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>
|
||||
+97
-20
@@ -12,12 +12,30 @@ export const SITE = {
|
||||
url: 'https://adr.smlcompany.ca',
|
||||
locale: 'en_CA',
|
||||
/**
|
||||
* TODO(pouya): AGENTS.md Q30 — §4 verifies "Operator of SML Company Ltd."
|
||||
* but nothing verifies the company's jurisdiction of incorporation, and this
|
||||
* string is destined for the public footer. Jurisdiction dropped until
|
||||
* confirmed; the operator fact itself is verified and stays.
|
||||
* The footer's copyright line, in full. Q30 is CLOSED.
|
||||
*
|
||||
* Two facts were being conflated in the string this replaces
|
||||
* (`'SML Company Ltd. · Ontario, Canada'`), which read as a jurisdiction of
|
||||
* incorporation and named the wrong one:
|
||||
*
|
||||
* - Jurisdiction of incorporation — **federal, under the CBCA**
|
||||
* `[verified 2026-08-26 — Pouya]`, recorded in AGENTS.md §4.
|
||||
* - Place of business — Toronto, Ontario. That is `CONTACT.location`,
|
||||
* and it belongs in the contact block, not in the entity line.
|
||||
*
|
||||
* **Neither appears in the footer.** Pouya's direction, 2026-08-26: the line
|
||||
* is `© <year> SML Company Ltd` and nothing else. The incorporation fact is
|
||||
* verified and available — it is simply not published. Do not "complete" this
|
||||
* line by adding it back.
|
||||
*
|
||||
* No corporation number: we do not have one and the line does not need one.
|
||||
*
|
||||
* Spelling note so it does not read as a typo and get "fixed": AGENTS.md §4
|
||||
* writes *SML Company Ltd.* with a terminal period. Pouya specified the
|
||||
* rendered footer string twice, both times without it. His wording governs
|
||||
* what ships.
|
||||
*/
|
||||
entity: 'SML Company Ltd.',
|
||||
entity: 'SML Company Ltd',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -38,38 +56,97 @@ export const CREDENTIALS = {
|
||||
'Stitt Feld Handy — negotiation and ADR workshop series',
|
||||
],
|
||||
languages: ['English', 'Farsi'],
|
||||
/** [verified 2026-08-26 — Pouya, AGENTS.md Q28] — and FOR NOW: OBA section
|
||||
* membership renews yearly, so this is a fact with a shelf life (AGENTS.md
|
||||
* R10). Re-confirm at renewal. NOT OCNI (lapsed) and NOT the Law Society —
|
||||
* listing the LSO implies licensure, which D13 bars. Do not add either. */
|
||||
/** [verified 2026-08-26 — Pouya, AGENTS.md Q28 and the CTF addition of the
|
||||
* same date] — and FOR NOW. Both the OBA sections and the Canadian Tax
|
||||
* Foundation renew yearly, so every line below is a fact with a shelf life
|
||||
* (AGENTS.md R10). Re-confirm at renewal, and before any page that lists
|
||||
* memberships ships. NOT OCNI (lapsed) and NOT the Law Society — listing the
|
||||
* LSO implies licensure, which D13 bars. Do not add either. */
|
||||
memberships: [
|
||||
'ADR Institute of Canada (ADRIC)',
|
||||
'ADR Institute of Ontario (ADRIO)',
|
||||
'Ontario Bar Association — Construction & Infrastructure, ADR, and Civil Litigation sections',
|
||||
'Canadian Tax Foundation',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** The three credential slots. Never matter counts — AGENTS.md §4. */
|
||||
export const CREDENTIAL_ROW = [
|
||||
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
|
||||
{ value: 'JD + ML', label: 'Law and engineering' },
|
||||
{ value: 'EN · FA', label: 'Bilingual practice' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The Toronto boutique is NEVER named — AGENTS.md D16. Use this string.
|
||||
* Do not infer a name from an email domain or anywhere else.
|
||||
*/
|
||||
export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
|
||||
|
||||
/**
|
||||
* The boutique role and the litigation line — the two highest-risk strings on
|
||||
* the site, so they live here rather than being typed into a page.
|
||||
*
|
||||
* Neither had a constant until 2026-08-26, which meant `/about/` (step 3) and
|
||||
* the `Person` JSON-LD (docs/04) were both going to hand-type them. A claim
|
||||
* written by hand in a component is a claim nobody re-checks against §4 — and
|
||||
* these are the two where the wording IS the compliance.
|
||||
*/
|
||||
export const ROLE = {
|
||||
/** §4 verbatim. docs/04: this is `jobTitle` in the Person JSON-LD, and
|
||||
* `worksFor` is OMITTED — populating it either names the boutique (D16) or
|
||||
* misstates the employer. */
|
||||
title: 'Director of Firm Operations', // [verified 2026-08-25 — strategy brief §I]
|
||||
/** Always rendered with BOUTIQUE, never with a firm name (D16). */
|
||||
at: BOUTIQUE,
|
||||
/**
|
||||
* D13's approved phrasing, and the only approved phrasing. The alternative
|
||||
* he approved is 'involvement in litigation and ADR matters'.
|
||||
*
|
||||
* NEVER "practice" in this context — that is the exact word D13 bars in the
|
||||
* exact context it bars it, and §4 records that this register itself once
|
||||
* carried the wrong word here while quoting the strategy brief verbatim.
|
||||
* "Practice" describing Pouya's OWN ADR practice is correct and unaffected.
|
||||
*
|
||||
* Explicitly interim — AGENTS.md R1. Raise it; do not let it settle in.
|
||||
*/
|
||||
litigationLine: 'active litigation exposure', // [verified 2026-08-26 — D13]
|
||||
/** The matter types behind that exposure. §4 verbatim; do not extend this
|
||||
* list without a §4 row to extend it from. */
|
||||
litigationAreas: [
|
||||
'personal injury',
|
||||
'construction',
|
||||
'regulatory (POA)',
|
||||
'insurance (SABS)',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** The three credential slots. Never matter counts — AGENTS.md §4. */
|
||||
export const CREDENTIAL_ROW = [
|
||||
{ value: 'Q.Med', label: 'ADRIC / ADRIO designation' },
|
||||
/**
|
||||
* TODO(pouya): AGENTS.md Q37 — is "Law and engineering" the right label?
|
||||
*
|
||||
* §4's substitution principle supplies the three VALUES (`Q.Med / JD + ML /
|
||||
* EN · FA`); the labels come from docs/03 and are not themselves registered.
|
||||
* `claims-auditor` reads this one as a D13 implication risk: "engineering" is
|
||||
* shorthand for a verified *practising* claim, so the parallel invites "Law"
|
||||
* to be read the same way — and D13 bars implication as hard as assertion.
|
||||
*
|
||||
* Left as written, because it is docs/03's approved copy and changing
|
||||
* approved copy is not an implementer's call. Nothing renders it yet. Answer
|
||||
* before step 2 builds the credential row.
|
||||
*/
|
||||
{ value: 'JD + ML', label: 'Law and engineering' },
|
||||
{ value: 'EN · FA', label: 'Bilingual practice' },
|
||||
] as const;
|
||||
|
||||
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
||||
export const ANALYTICS = {
|
||||
/**
|
||||
* TODO(pouya): AGENTS.md Q31 — D15 records the choice as "Plausible **or**
|
||||
* Fathom", i.e. undecided. `'plausible'` was a guessed value, which is what
|
||||
* the header of this file tells you not to do. Null until you pick one.
|
||||
* Q31 CLOSED — **Plausible**, decided rather than defaulted. Pouya checked
|
||||
* 2026-08-26: Fathom is Canadian-owned but stores non-EU traffic on US
|
||||
* servers, isolating in the EU only for EU visitors; Plausible keeps all data
|
||||
* in the EU. For a practice whose privacy posture is part of the offer,
|
||||
* EU-only beats US-hosted. D15 amended to match.
|
||||
*
|
||||
* The union type stays — `/legal/privacy/` has to name the processor, and a
|
||||
* change of processor is a copy change on that page, not just a config edit.
|
||||
*/
|
||||
provider: null as 'plausible' | 'fathom' | null,
|
||||
provider: 'plausible' as 'plausible' | 'fathom',
|
||||
domain: 'adr.smlcompany.ca',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
/**
|
||||
* The page shell. Every route renders through this.
|
||||
*
|
||||
* Metadata is not optional and not a prop this layout can default: it forwards
|
||||
* whatever it is given straight to SEO.astro, which throws if the title or
|
||||
* description is out of the range docs/04-seo-spec.md sets.
|
||||
*/
|
||||
import '../styles/global.css';
|
||||
import type { Props as SeoProps } from '../components/SEO.astro';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import SiteHeader from '../components/SiteHeader.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
import { SITE } from '../data/site';
|
||||
|
||||
export type Props = SeoProps;
|
||||
|
||||
// SITE.locale is `en_CA` — Open Graph's underscore form. The lang attribute
|
||||
// takes the BCP 47 hyphen form. One source, two spellings, no second constant.
|
||||
const lang = SITE.locale.replace('_', '-');
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<SEO {...Astro.props} />
|
||||
|
||||
{
|
||||
/* .ico first for clients that ignore the SVG, and because some crawlers
|
||||
request /favicon.ico at the root regardless of what is declared here. */
|
||||
}
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
{
|
||||
/* Only the two faces used above the fold, per docs/02. Fonts are always
|
||||
fetched in CORS mode, so a preload without `crossorigin` is a second,
|
||||
wasted request rather than a warmed cache. */
|
||||
}
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/instrument-serif-latin-400-normal.woff2?v=1"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/geist-latin-wght-normal.woff2?v=1"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
{
|
||||
/* No script tag. Not "no framework", not "minimal JS" — none.
|
||||
|
||||
The reveal used to be an inline IntersectionObserver here. It ran before
|
||||
first paint so nothing flashed, and it took its own class back off if
|
||||
anything threw. It was still wrong: docs/05-backend-spec.md specifies
|
||||
`script-src 'self'` with no `unsafe-inline`, so the single script on the
|
||||
site was the one thing the site's own CSP would refuse to execute — and
|
||||
a per-build hash drifts from the policy that is supposed to pin it.
|
||||
|
||||
`animation-timeline: view()` in global.css does the same job in CSS. See
|
||||
the Reveal block there for why the @supports gate is load-bearing. */
|
||||
}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<SiteHeader />
|
||||
<main id="main" tabindex="-1">
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,652 @@
|
||||
---
|
||||
/**
|
||||
* TEMPORARY — build step 1 only. Delete when step 2 ships `/`.
|
||||
*
|
||||
* A proof sheet for the design system. It exists so the type scale, the
|
||||
* palette, the spacing rhythm, the focus states, and the header and footer can
|
||||
* be looked at in a browser before nineteen real pages are built on top of them.
|
||||
*
|
||||
* It carries `noindex` and is excluded from the sitemap in astro.config.mjs.
|
||||
* Remove that filter entry when you remove this page.
|
||||
*
|
||||
* WHAT THIS PAGE MAY CONTAIN: body copy about CSS, and nothing else. The page
|
||||
* is published — `noindex` suppresses indexing, not access — so AGENTS.md §4
|
||||
* governs every string on it exactly as it governs `/about/`.
|
||||
*
|
||||
* An earlier version of this comment read "NOTHING HERE ASSERTS A FACT ABOUT
|
||||
* POUYA." It was false when written, and it is recorded here as the caution:
|
||||
* the page was at that moment carrying a four-stage service timeline with day
|
||||
* counts and two unverified assertions about the previous build, and the claims
|
||||
* audit found them *because* a comment had certified the file clean and nobody
|
||||
* re-read it. A certification is not a check — CLAUDE.md, "a sweep is a
|
||||
* command, not a claim". Audit the page; do not trust this paragraph.
|
||||
*
|
||||
* The contrast numbers are COMPUTED, not transcribed. The frontmatter reads
|
||||
* src/styles/tokens.css, pulls the hex values out of it, and runs the WCAG 2.x
|
||||
* relative-luminance formula over them. Restating docs/02's measured table here
|
||||
* would have created a second copy of a fact — and the copy that goes stale is
|
||||
* the one nobody re-reads. This one cannot drift from the tokens, because it is
|
||||
* derived from them.
|
||||
*/
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import Button from '../../components/Button.astro';
|
||||
import InfinityMark from '../../components/InfinityMark.astro';
|
||||
|
||||
// `?raw` rather than fs.readFileSync: `import.meta.url` is rewritten to the
|
||||
// emitted chunk's own location during the build, so a relative path resolves
|
||||
// against dist/.prerender/chunks/ and throws ENOENT. Vite resolves this at
|
||||
// bundle time, in dev and in build alike.
|
||||
import tokensCss from '../../styles/tokens.css?raw';
|
||||
|
||||
const hex: Record<string, string> = {};
|
||||
for (const match of tokensCss.matchAll(
|
||||
/--([a-z0-9-]+):\s*(#[0-9a-f]{6})\b/gi,
|
||||
)) {
|
||||
hex[match[1]] = match[2].toLowerCase();
|
||||
}
|
||||
|
||||
// The parser above only understands 6-digit hex. Move a palette token to
|
||||
// `oklch()`, `color-mix()` or 3-digit hex and the lookup silently yields
|
||||
// undefined; the luminance maths then throws an unnamed TypeError with no clue
|
||||
// which token did it. Fail here instead, naming them — the posture SEO.astro
|
||||
// takes with an out-of-range title.
|
||||
const REQUIRED_TOKENS = [
|
||||
'cream',
|
||||
'ink',
|
||||
'ink-soft',
|
||||
'muted',
|
||||
'maroon',
|
||||
'maroon-l',
|
||||
'gold',
|
||||
'gold-d',
|
||||
'gold-l',
|
||||
];
|
||||
const unparsed = REQUIRED_TOKENS.filter((token) => !hex[token]);
|
||||
if (unparsed.length > 0) {
|
||||
throw new Error(
|
||||
'/type-scale/ could not read these colour tokens as 6-digit hex from ' +
|
||||
`src/styles/tokens.css: ${unparsed.map((t) => `--${t}`).join(', ')}. ` +
|
||||
'The contrast table on this page is computed from them. If the palette ' +
|
||||
'moved to another colour syntax, teach this parser that syntax — do not ' +
|
||||
'hardcode the values back in.',
|
||||
);
|
||||
}
|
||||
|
||||
const typeScale: Array<{ token: string; value: string }> = [];
|
||||
for (const match of tokensCss.matchAll(
|
||||
/--(text-(?:xs|sm|base|lg|xl|\dxl)):\s*([^;]+);/g,
|
||||
)) {
|
||||
typeScale.push({ token: `--${match[1]}`, value: match[2].trim() });
|
||||
}
|
||||
|
||||
/** WCAG 2.x relative luminance and contrast ratio. */
|
||||
const channel = (v: number) =>
|
||||
v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
const luminance = (value: string) => {
|
||||
const n = Number.parseInt(value.slice(1), 16);
|
||||
return (
|
||||
0.2126 * channel(((n >> 16) & 255) / 255) +
|
||||
0.7152 * channel(((n >> 8) & 255) / 255) +
|
||||
0.0722 * channel((n & 255) / 255)
|
||||
);
|
||||
};
|
||||
const contrast = (a: string, b: string) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
const verdict = (ratio: number) =>
|
||||
ratio >= 4.5 ? 'AA body' : ratio >= 3 ? 'AA large only' : 'fails both';
|
||||
|
||||
const foregrounds = [
|
||||
'ink',
|
||||
'ink-soft',
|
||||
'maroon',
|
||||
'maroon-l',
|
||||
'muted',
|
||||
'gold-d',
|
||||
'gold',
|
||||
];
|
||||
const onCream = foregrounds.map((token) => ({
|
||||
token,
|
||||
value: hex[token],
|
||||
ratio: contrast(hex[token], hex.cream),
|
||||
}));
|
||||
const onInk = ['cream', 'gold-l', 'gold', 'muted'].map((token) => ({
|
||||
token,
|
||||
value: hex[token],
|
||||
ratio: contrast(hex[token], hex.ink),
|
||||
}));
|
||||
|
||||
const spacing: Array<{ token: string; value: string }> = [];
|
||||
for (const match of tokensCss.matchAll(/--(space-\d+):\s*([^;]+);/g)) {
|
||||
spacing.push({ token: `--${match[1]}`, value: match[2].trim() });
|
||||
}
|
||||
|
||||
const title = 'Type scale and colour proof sheet · Pouya Lajevardi';
|
||||
const description =
|
||||
'A temporary reference page rendering the design system end to end: the fluid type scale, the palette with computed contrast ratios, spacing, and motion.';
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description} noindex>
|
||||
<div class="wrap">
|
||||
<section class="section">
|
||||
<p class="eyebrow">
|
||||
<span class="dot"></span>Build step 1 · temporary page
|
||||
</p>
|
||||
<h1 class="display size-5xl">
|
||||
The system, <span class="it">rendered</span>.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Everything below is read out of <code>src/styles/tokens.css</code> at build
|
||||
time. The contrast ratios are computed from the token values, not copied from
|
||||
the spec — if a token changes, this page changes with it.
|
||||
</p>
|
||||
<p class="note">
|
||||
This page is <strong>temporary</strong>. It is <code>noindex</code>, it
|
||||
is excluded from the sitemap, and build step 2 deletes it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<hr class="rule-gold" />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Display type</p>
|
||||
<h2 class="display size-3xl">
|
||||
Instrument Serif, <span class="it">4xl and up</span>
|
||||
</h2>
|
||||
<p class="note">
|
||||
docs/02: display type is used at <code>--text-4xl</code> and above only. It
|
||||
has almost no hinting at small sizes and looks weak below 32 px.
|
||||
</p>
|
||||
<div class="stack">
|
||||
<p class="display size-6xl">Redraw the loop</p>
|
||||
<p class="display size-5xl">Redraw the loop into a line</p>
|
||||
<p class="display size-4xl">Redraw the loop into a line</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Type scale</p>
|
||||
<h2 class="display size-3xl">Fluid, 360 px to 1600 px</h2>
|
||||
<p class="note">
|
||||
Resize the window. Every step interpolates; there are no breakpoint
|
||||
jumps. The declaration beside each line is the token itself.
|
||||
</p>
|
||||
<dl class="scale-list">
|
||||
{
|
||||
typeScale.map((step) => (
|
||||
<div class="scale-row">
|
||||
<dt>
|
||||
{/* A neutral specimen, deliberately. This was
|
||||
"Mediation · Arbitration · Toronto" — hand-typed rather than
|
||||
read from SITE.tagline, and rendered ten times at up to 96px
|
||||
on a published page. Two defects in one string: a claim
|
||||
typed inline is a claim nobody re-checks against §4 (the
|
||||
rule SiteFooter states), and `Arbitration` unqualified at
|
||||
display size is the open question AGENTS.md Q33 names. A
|
||||
type specimen needs letterforms, not assertions. */}
|
||||
<span class="sample" style={`font-size: var(${step.token})`}>
|
||||
Handgloves & quartz — 1234567890
|
||||
</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{step.token}</code>
|
||||
<span class="scale-value">{step.value}</span>
|
||||
</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Colour on cream</p>
|
||||
<h2 class="display size-3xl">Measured, not assumed</h2>
|
||||
<p class="note">
|
||||
Ratios computed at build time against <code>--cream</code>
|
||||
{' '}{hex.cream}. AA is 4.5:1 for body text and 3:1 for large text.
|
||||
</p>
|
||||
<ul class="swatches" role="list">
|
||||
{
|
||||
onCream.map((entry) => (
|
||||
<li class="swatch">
|
||||
<span class="chip" style={`background: ${entry.value}`} />
|
||||
<span class="swatch-meta">
|
||||
<code>--{entry.token}</code>
|
||||
<span class="swatch-value">{entry.value}</span>
|
||||
<span class="swatch-ratio">
|
||||
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="gold-demo">
|
||||
<p class="note">
|
||||
<strong>The hard rule, shown without breaking it.</strong> Gold is a rule,
|
||||
a border, and an icon stroke. The identical hue fails at
|
||||
{contrast(hex.gold, hex.cream).toFixed(2)}:1 as text on cream and
|
||||
passes at {contrast(hex.gold, hex.ink).toFixed(2)}:1 on ink — the
|
||||
colour is not the problem, the pairing is. Both blocks below are the
|
||||
same <code>--gold</code>.
|
||||
</p>
|
||||
{
|
||||
/* Shown as blocks, not as sentences. An earlier version set a real
|
||||
paragraph in --gold on --bg-raised, which measures 1.76:1 — worse
|
||||
than the 2.10:1 the rule exists to prevent. A reader with low vision
|
||||
got an unreadable paragraph and no alternative, and the one artefact
|
||||
documenting the rule was the one artefact breaking it. */
|
||||
}
|
||||
<div class="gold-pair">
|
||||
<div class="gold-block on-cream">
|
||||
<span class="gold-band"></span>
|
||||
<span class="gold-caption">
|
||||
<code>--gold</code> on <code>--cream</code>
|
||||
<span class="swatch-ratio">
|
||||
{contrast(hex.gold, hex.cream).toFixed(2)}:1 · never as text
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gold-block on-ink">
|
||||
<span class="gold-band"></span>
|
||||
<span class="gold-caption">
|
||||
<code>--gold</code> on <code>--ink</code>
|
||||
<span class="swatch-ratio">
|
||||
{contrast(hex.gold, hex.ink).toFixed(2)}:1 · text is fine here
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Components</p>
|
||||
<h2 class="display size-3xl">Buttons, mark, focus</h2>
|
||||
<p class="note">
|
||||
Tab through this section. Every interactive element takes a visible
|
||||
focus ring — 2 px maroon, 3 px offset, on
|
||||
<code>:focus-visible</code>. Never <code>outline: none</code> without a replacement.
|
||||
</p>
|
||||
<div class="row">
|
||||
<Button href="/contact/" variant="primary"
|
||||
>Request a consultation</Button
|
||||
>
|
||||
<Button href="/process/" variant="ghost">How I work</Button>
|
||||
<Button href="/about/" variant="gold">Read the record</Button>
|
||||
<Button type="button">A real <button></Button>
|
||||
</div>
|
||||
<div class="row marks">
|
||||
<InfinityMark size="4rem" />
|
||||
<InfinityMark size="2.5rem" />
|
||||
<InfinityMark size="1.5rem" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Measure</p>
|
||||
<h2 class="display size-3xl">Sixty-eight characters</h2>
|
||||
{
|
||||
/* `.prose` is opted into, not inherited. global.css no longer caps every
|
||||
<p> on the site — long-form asks for the measure, and this block is
|
||||
demonstrating the very thing it uses. */
|
||||
}
|
||||
<div class="prose">
|
||||
<p>
|
||||
Body copy is capped at <code>--width-prose</code>, 68 ch. Past
|
||||
roughly 75 characters the eye starts losing the beginning of the next
|
||||
line on the return sweep, and long-form copy stops being read. This
|
||||
paragraph sits at the cap. It is the single largest lever on whether a
|
||||
page of prose is actually finished, and it costs nothing to set.
|
||||
</p>
|
||||
<p>
|
||||
Line height is 1.6 for body and 0.98 for display. Headings balance
|
||||
with
|
||||
<code>text-wrap: balance</code>, so a two-line headline breaks evenly
|
||||
rather than leaving one word stranded.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Spacing</p>
|
||||
<h2 class="display size-3xl">Eight-pixel base</h2>
|
||||
<ul class="spacing-list" role="list">
|
||||
{
|
||||
spacing.map((step) => (
|
||||
<li class="spacing-row">
|
||||
<code>{step.token}</code>
|
||||
<span
|
||||
class="spacing-bar"
|
||||
style={`inline-size: var(${step.token})`}
|
||||
/>
|
||||
<span class="spacing-value">{step.value}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="section section-inverse">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow"><span class="dot"></span>Colour on ink</p>
|
||||
<h2 class="display size-3xl">The dark panel</h2>
|
||||
<p class="note-inverse">
|
||||
<code>--muted</code> is the trap: it passes on cream at 5.47:1 and fails on
|
||||
ink. Secondary text on dark is <code>--gold-l</code> or cream.
|
||||
</p>
|
||||
<ul class="swatches" role="list">
|
||||
{
|
||||
onInk.map((entry) => (
|
||||
<li class="swatch">
|
||||
<span class="chip" style={`background: ${entry.value}`} />
|
||||
<span class="swatch-meta">
|
||||
<code>--{entry.token}</code>
|
||||
<span class="swatch-value">{entry.value}</span>
|
||||
<span class="swatch-ratio">
|
||||
{entry.ratio.toFixed(2)}:1 · {verdict(entry.ratio)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="wrap">
|
||||
<section class="section">
|
||||
<p class="eyebrow"><span class="dot"></span>Motion</p>
|
||||
<h2 class="display size-3xl">Reveal on section entry</h2>
|
||||
<p class="note">
|
||||
The four cards below carry <code>.reveal-stagger</code>. Scroll them
|
||||
into view. With JavaScript disabled, or with reduced motion requested,
|
||||
they are simply present — the animation is layered onto a page that
|
||||
already reads.
|
||||
</p>
|
||||
{
|
||||
/* Deliberately contentless. These four cards previously carried the
|
||||
engagement timeline — which IS specified, at docs/01-architecture.md
|
||||
§/process/, for the `/process/` page. It does not belong on a proof
|
||||
sheet for a CSS stagger, where nothing qualifies it and no reader is
|
||||
looking for it. The stagger demonstrates just as well with nothing to
|
||||
say. */
|
||||
}
|
||||
<ul class="cards reveal-stagger" role="list">
|
||||
<li class="card"><h3>First card</h3><p>Stagger step 1</p></li>
|
||||
<li class="card"><h3>Second card</h3><p>Stagger step 2</p></li>
|
||||
<li class="card"><h3>Third card</h3><p>Stagger step 3</p></li>
|
||||
<li class="card"><h3>Fourth card</h3><p>Stagger step 4</p></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.size-6xl {
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
.size-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
.size-4xl {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
.size-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-start: var(--space-5);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.note,
|
||||
.note-inverse {
|
||||
max-inline-size: var(--width-prose);
|
||||
margin-block-start: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.note {
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.note-inverse {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
h2.display {
|
||||
margin-block-start: var(--space-3);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
/* No `max-inline-size: none` escape hatch any more — there is nothing left to
|
||||
escape from now that the measure is opt-in. */
|
||||
|
||||
/* --- Type scale ---------------------------------------------------------- */
|
||||
|
||||
.scale-list {
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.scale-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
border-block-start: 1px solid var(--border);
|
||||
}
|
||||
.scale-row dt,
|
||||
.scale-row dd {
|
||||
/* A flex item's default `min-width: auto` is its min-content width, which
|
||||
for a 96px specimen is one whole word. */
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.scale-row dd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: end;
|
||||
}
|
||||
.scale-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.sample {
|
||||
line-height: var(--leading-tight);
|
||||
/* At --text-6xl a single word is ~500px wide, so at 320px it has to break
|
||||
mid-word or push the page out. Measured: 8px of overflow before this,
|
||||
which now has no `body { overflow-x: hidden }` to hide it — which is
|
||||
exactly why that clamp was removed. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* --- Colour -------------------------------------------------------------- */
|
||||
|
||||
.swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.chip {
|
||||
inline-size: 3.5rem;
|
||||
block-size: 3.5rem;
|
||||
flex: none;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.swatch-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.swatch-value,
|
||||
.swatch-ratio {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
.section-inverse .swatch-value,
|
||||
.section-inverse .swatch-ratio,
|
||||
.section-inverse code {
|
||||
color: var(--text-inverse-2);
|
||||
}
|
||||
.section-inverse .chip {
|
||||
border-color: var(--line-dark);
|
||||
}
|
||||
|
||||
.gold-demo {
|
||||
margin-block-start: var(--space-7);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.gold-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-5);
|
||||
}
|
||||
.gold-block {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.gold-block.on-cream {
|
||||
background: var(--cream);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.gold-block.on-ink {
|
||||
background: var(--ink);
|
||||
}
|
||||
/* The colour itself, carried by a block rather than by glyphs. */
|
||||
.gold-band {
|
||||
display: block;
|
||||
block-size: var(--space-6);
|
||||
background: var(--gold);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.gold-caption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-block-start: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.gold-block.on-cream .gold-caption {
|
||||
color: var(--text);
|
||||
}
|
||||
.gold-block.on-ink .gold-caption,
|
||||
.gold-block.on-ink code,
|
||||
.gold-block.on-ink .swatch-ratio {
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* --- Rows ---------------------------------------------------------------- */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
}
|
||||
.marks {
|
||||
color: var(--accent);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* --- Spacing ------------------------------------------------------------- */
|
||||
|
||||
.spacing-list {
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.spacing-row {
|
||||
display: grid;
|
||||
/* minmax(0, …) on every track. A grid item's default `min-width: auto` is
|
||||
its min-content size, so the 160px bar below forced the row wider than
|
||||
the viewport — 72px of overflow at 320px, with three cells pushed off
|
||||
screen and unreachable now that body{overflow-x:hidden} is gone from
|
||||
global.css. WCAG 1.4.10 Reflow. */
|
||||
grid-template-columns: minmax(0, 6rem) minmax(0, 1fr) minmax(0, 3.5rem);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
.spacing-bar {
|
||||
block-size: 0.75rem;
|
||||
max-inline-size: 100%;
|
||||
background: var(--rule);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.spacing-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-meta);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
/* --- Cards --------------------------------------------------------------- */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-block-start: var(--space-6);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.card {
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.card h3 {
|
||||
font-size: var(--text-lg);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
}
|
||||
.card p {
|
||||
margin-block-start: var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-meta);
|
||||
}
|
||||
</style>
|
||||
+190
-65
@@ -5,41 +5,98 @@
|
||||
@import './tokens.css';
|
||||
|
||||
/* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ----
|
||||
TODO(claude-code): place subset woff2 files in /public/fonts/ and preload
|
||||
Instrument Serif 400 and Geist 400 in BaseLayout — they are the only two
|
||||
faces used above the fold. */
|
||||
Files and their provenance: docs/reference/fonts-provenance.md.
|
||||
Filenames are stable on purpose — a preload needs a path that does not change
|
||||
between builds, which rules out Astro's hashed asset pipeline.
|
||||
|
||||
`?v=1` IS LOAD-BEARING. scripts/deploy-local.sh serves /fonts/* with
|
||||
`max-age=31536000, immutable`, so a returning visitor holds these bytes for a
|
||||
year and a CloudFront invalidation cannot reach their browser cache. Bump the
|
||||
query when a file's contents change — here AND on the preload in
|
||||
BaseLayout.astro, which must match byte for byte or the preload is a second,
|
||||
wasted request instead of a warmed cache.
|
||||
|
||||
The `latin` cut of each face is listed FIRST and the `latin-ext` cut second.
|
||||
Order matters: where two @font-face rules for one family both match a
|
||||
codepoint, the last wins. Latin-ext is the wider, heavier file; putting it
|
||||
last would hand it every ASCII character on the page. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
src: url('/fonts/instrument-serif-400.woff2') format('woff2');
|
||||
src: url('/fonts/instrument-serif-latin-400-normal.woff2?v=1') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
src: url('/fonts/instrument-serif-400-italic.woff2') format('woff2');
|
||||
src: url('/fonts/instrument-serif-latin-ext-400-normal.woff2?v=1')
|
||||
format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
|
||||
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Italic is the one flourish the design allows (docs/02) — a phrase inside a
|
||||
headline, never a paragraph. Latin only; there is no latin-ext italic file. */
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
src: url('/fonts/instrument-serif-latin-400-italic.woff2?v=1') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Geist and Geist Mono are variable fonts: one file spans the whole weight
|
||||
axis, so 300/400/500/600 cost nothing extra. `font-weight: 100 900` declares
|
||||
the axis range the file actually carries — narrowing it here would make the
|
||||
browser synthesise weights it already has. */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('/fonts/geist-latin-wght-normal.woff2?v=1')
|
||||
format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('/fonts/geist-variable.woff2') format('woff2-variations');
|
||||
font-weight: 300 600;
|
||||
src: url('/fonts/geist-latin-ext-wght-normal.woff2?v=1')
|
||||
format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+0100-017F, U+2000-206F, U+2190-21BB;
|
||||
unicode-range:
|
||||
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
|
||||
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('/fonts/geist-mono-variable.woff2') format('woff2-variations');
|
||||
font-weight: 400 500;
|
||||
src: url('/fonts/geist-mono-latin-wght-normal.woff2?v=1')
|
||||
format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range: U+0000-00FF, U+2000-206F;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* --- Reset ---------------------------------------------------------------- */
|
||||
@@ -56,7 +113,19 @@
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: var(--space-8);
|
||||
/* No offset by default: below 64rem the header is not sticky, so nothing is
|
||||
covering the target. See the media query below. */
|
||||
scroll-padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* The header is sticky from 64rem up, and `scroll-padding-top` has to clear it
|
||||
or "Skip to content" drops the reader behind it — the one control that exists
|
||||
specifically for keyboard users, landing them on content they cannot see.
|
||||
--header-h is defined in tokens.css beside the value it has to match. */
|
||||
@media (min-width: 64rem) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--header-h) + var(--space-4));
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -69,8 +138,12 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
/* No `overflow-x: hidden`. It was here, and it was hiding a real defect: at
|
||||
320px the page overflowed by 72px and three cells sat outside the viewport
|
||||
with no scrollbar to reach them — WCAG 1.4.10 content loss, silently
|
||||
masked. A global overflow clamp turns every future layout bug on nineteen
|
||||
pages into an invisible one. Fix the overflow; do not hide it. */
|
||||
}
|
||||
|
||||
img,
|
||||
@@ -146,9 +219,10 @@ h4 {
|
||||
vertical-align: 0.15em;
|
||||
}
|
||||
|
||||
p {
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
/* NO GLOBAL `p { max-inline-size }`. It was here, and it capped every paragraph
|
||||
on the site — inside cards, footers, and form hints — so components had to
|
||||
opt back out one by one, and it made `.prose` below a class with no effect,
|
||||
since every <p> was already capped. Long-form opts IN. */
|
||||
|
||||
a {
|
||||
color: var(--link);
|
||||
@@ -202,7 +276,11 @@ a:hover {
|
||||
.wrap-wide {
|
||||
max-inline-size: var(--width-wide);
|
||||
}
|
||||
.prose {
|
||||
/* The reading measure, opted into. docs/02 caps body copy at 68ch; the old
|
||||
build ran full-bleed paragraphs at 1400px. Wrap long-form in `.prose`, and
|
||||
let the MDX `Prose` component own it for articles. */
|
||||
.prose,
|
||||
.prose p {
|
||||
max-inline-size: var(--width-prose);
|
||||
}
|
||||
.section {
|
||||
@@ -241,53 +319,81 @@ hr {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* --- Reveal ---------------------------------------------------------------
|
||||
Progressive enhancement, not a dependency. Content is rendered and visible
|
||||
in the HTML; `.reveal` only takes effect once JS adds `js-reveal` to <html>.
|
||||
If the observer never runs, every page reads normally. The previous build
|
||||
had this backwards and shipped a blank page to anything without JS. */
|
||||
/* --- Reveal ----------------------------------------------------------------
|
||||
Scroll-driven CSS. There is NO JavaScript on this site, and this block is
|
||||
why: the reveal used to be an inline IntersectionObserver in <head>, which
|
||||
collided with the Content-Security-Policy docs/05-backend-spec.md specifies
|
||||
(`script-src 'self'`, no `unsafe-inline`, "use a hash or nonce for the reveal
|
||||
script"). A per-build hash is a moving target and drifts from the policy.
|
||||
`animation-timeline: view()` is what docs/02 §Motion offers as the
|
||||
alternative, and it removes the script — and the problem — entirely.
|
||||
|
||||
.js-reveal .reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
The @supports gate is load-bearing, not defensive. Without it a browser that
|
||||
ignores `animation-timeline` would run the animation once against the
|
||||
document timeline at load; with it, that browser gets no animation and fully
|
||||
visible content. Content is never hidden behind a feature that might not
|
||||
arrive. The previous build had this backwards and shipped a blank page to
|
||||
anything without JavaScript. */
|
||||
|
||||
@supports (animation-timeline: view()) {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* LONGHANDS ONLY. `animation: reveal-in linear both` beside
|
||||
`animation-timeline: view()` is folded by Lightning CSS on minify into
|
||||
`animation: linear both reveal-in view()`, which is invalid — `view()` is
|
||||
not a component of the shorthand — so the whole declaration is thrown
|
||||
away. It works in `npm run dev` and is dead in `npm run build`. This is
|
||||
the same defect the header's condense had; it was found there first and
|
||||
written straight back into the fix for it. Grep dist for it (Phase 5). */
|
||||
.reveal {
|
||||
animation-name: reveal-in;
|
||||
animation-duration: 1ms;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% cover 22%;
|
||||
}
|
||||
.reveal-stagger > * {
|
||||
animation-name: reveal-in;
|
||||
animation-duration: 1ms;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: view();
|
||||
}
|
||||
/* Stagger is expressed as timeline range, not delay: a scroll-driven
|
||||
animation has no wall clock to delay against. Each child completes a
|
||||
little further into the scroll than the one before. Six children by
|
||||
design (docs/02) — a seventh simply lands with the sixth. */
|
||||
.reveal-stagger > *:nth-child(1) {
|
||||
animation-range: entry 0% cover 18%;
|
||||
}
|
||||
.reveal-stagger > *:nth-child(2) {
|
||||
animation-range: entry 0% cover 22%;
|
||||
}
|
||||
.reveal-stagger > *:nth-child(3) {
|
||||
animation-range: entry 0% cover 26%;
|
||||
}
|
||||
.reveal-stagger > *:nth-child(4) {
|
||||
animation-range: entry 0% cover 30%;
|
||||
}
|
||||
.reveal-stagger > *:nth-child(5) {
|
||||
animation-range: entry 0% cover 34%;
|
||||
}
|
||||
.reveal-stagger > *:nth-child(6) {
|
||||
animation-range: entry 0% cover 38%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.js-reveal .reveal.is-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity var(--dur-reveal) var(--ease),
|
||||
transform var(--dur-reveal) var(--ease);
|
||||
|
||||
@keyframes reveal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.js-reveal .reveal-stagger > * {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > * {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition:
|
||||
opacity var(--dur-reveal) var(--ease),
|
||||
transform var(--dur-reveal) var(--ease);
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(1) {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(2) {
|
||||
transition-delay: 70ms;
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(3) {
|
||||
transition-delay: 140ms;
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(4) {
|
||||
transition-delay: 210ms;
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(5) {
|
||||
transition-delay: 280ms;
|
||||
}
|
||||
.js-reveal .reveal-stagger.is-in > *:nth-child(6) {
|
||||
transition-delay: 350ms;
|
||||
}
|
||||
/* Stagger caps at six children by design. */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
@@ -301,8 +407,27 @@ hr {
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.js-reveal .reveal,
|
||||
.js-reveal .reveal-stagger > * {
|
||||
/* Belt and braces. The @supports block above is already gated on
|
||||
no-preference, so nothing here should be animating at all — this keeps the
|
||||
guarantee true even if a later rule forgets the gate. */
|
||||
.reveal,
|
||||
.reveal-stagger > * {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* A scroll-driven animation has no timeline when printing, so every revealed
|
||||
element would render at its `from` state — which is `opacity: 0`. Measured
|
||||
before this block existed: printing the page to PDF dropped four card
|
||||
headings from the output entirely. `/about/` is written to be printed by
|
||||
people evaluating an appointment; content that vanishes at Cmd-P is not a
|
||||
cosmetic problem. */
|
||||
@media print {
|
||||
.reveal,
|
||||
.reveal-stagger > * {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
+15
-1
@@ -102,10 +102,24 @@
|
||||
--space-10: 8rem; /* 128 */
|
||||
--space-11: 10rem; /* 160 */
|
||||
|
||||
--section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11));
|
||||
/* docs/02: "Section rhythm: --space-9 (96px) mobile, --space-11 (160px)
|
||||
desktop." The previous curve was `6vw + 2rem`, which reaches 160px only at
|
||||
a 2133px viewport — measured 128px at 1600px, 108.8px at 1280px. The upper
|
||||
bound was unreachable on any real screen, so the token read as if it
|
||||
delivered a rhythm it never delivered. `9vw + 1rem` hits 160px at 1600px
|
||||
and still clamps to 96px on a phone. [measured 2026-08-26] */
|
||||
--section-y: clamp(var(--space-9), 9vw + 1rem, var(--space-11));
|
||||
|
||||
/* --- Layout ------------------------------------------------------------ */
|
||||
|
||||
/* Sticky-header height at >= 64rem, where the header IS sticky. global.css
|
||||
drives `scroll-padding-top` off this, so the skip link does not drop the
|
||||
reader behind the header. If SiteHeader's padding or nav sizing changes,
|
||||
re-measure and change this with it — one fact living in two files.
|
||||
[measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six
|
||||
nav items and with a seventh injected: 77px at every one] */
|
||||
--header-h: 4.8125rem; /* 77 — measured, not chosen */
|
||||
|
||||
--width-content: 80rem; /* 1280 */
|
||||
--width-wide: 90rem; /* 1440 */
|
||||
--width-prose: 68ch; /* reading measure — never exceed for body copy */
|
||||
|
||||
Reference in New Issue
Block a user