Two of Pouya's rulings of 2026-08-31, committed as one tree at his instruction
("as-is") because AGENTS.md Change Log entry (ai) covers both and splitting it
would mean rewriting the record rather than moving it. This is a deliberate
departure from one-logical-change-per-commit, recorded here rather than left to
be inferred.
THE EYEBROW. `--text-eyebrow: 0.8125rem` added to tokens.css; `.eyebrow` in
global.css retargeted to it. One edit site, which is what the design system
claimed. 13px is not a rung on the modular ladder — it sits between xs and sm
deliberately, because uppercase mono at 0.18em tracking reads smaller than it
measures. `--text-2xs`'s comment stopped calling itself the eyebrow floor.
The rendered sweep (22 pages × 2 widths, 838 mono elements measured over CDP,
not grepped) found exactly one escape and one deliberate override:
- h2.footer-heading, 176 instances — an ESCAPED EYEBROW. Five declarations
byte-identical to `.eyebrow`, differing only in colour. Consolidated to
`class="eyebrow footer-heading"`; the scoped rule is now colour + margin.
The colour is load-bearing, not decorative: `.eyebrow`'s own `--text-meta`
on ink is 3.07:1 and fails.
- span.eyebrow.brand-tagline, 21 instances — HELD at `--text-2xs`. Measured:
at 13px the header grows 81 → 83.4px while `--header-h` is pinned at 81 and
drives `scroll-padding-top`; and with a seventh nav item the CTA lands past
`.header-inner`'s content edge by 42px at 1216, 18px at 1240, 26px at 1280
and 1440. Document overflow is 0 in all of those, so no page-level check
can see it. Insights is that seventh item.
Everything else mono-uppercase is a genuinely different component and was left:
the 0.06em `--tracking-wide` family, the 14px mixed-case designation strip, the
/bio/ print sheet, /contact/'s form labels.
Measured after: contrast unchanged on all 817 instances (11.09 / 8.11 / 5.47 /
5.01:1, all pass at 13px, which is still normal text and needs 4.5:1). Zero
document overflow and identical header geometry at 15 widths. Two eyebrows gain
a line below 414px — /'s hero, already wrapping at 320px before this, and
/insights/'s empty state at 320px only. Accepted, not re-tuned.
docs/02's type spec moved 11–12px → 13px and now enumerates the three 11px
carve-outs instead of implying there are none. The /type-scale/ proof sheet
(d) asked for no longer exists — deleted at build step 2 — so the spec prose is
the proof sheet now.
THE TTL. backend/intake/handler.mjs CONFIRMED to match `AttributeName=ttl`: it
writes `ttl` as a Number, in epoch seconds, at RETENTION_MONTHS = 24. Nothing
needed changing for the enable command.
Removed `|| 0` from the TTL computation. DynamoDB does not expire an item whose
TTL is more than five years past, so `ttl: 0` meant RETAINED FOREVER while
/legal/privacy/ promises deletion — a fallback whose failure mode was the exact
inverse of the claim it was protecting. Unreachable in practice, which is why
it would never have been noticed. A bad value now fails the write.
§7 records TTL as DISABLED at first verification, so the privacy policy's
automatic-deletion promise was unbacked from the moment it was written. §7 is
the only place that status lives; docs/05 and docs/06 carry the constraint and
cite §7, because round 2 of review caught this change set reproducing the SES
DKIM defect — five copies of a status that is about to be re-stamped.
Added, and these are the gate: `TODO(pouya)` on /legal/privacy/'s retention
section, §9 Q60, §12 R19. The page does not publish a period, it asserts a
MECHANISM — deleted by the database rather than by someone remembering — and
nothing in the toolchain can see that. check:claims is frozen with no pattern
for it and deploy does not read docs/06. The copy was NOT softened: it is about
to be true, and weakening a privacy commitment to make it defensible is the
move the rules forbid. What was missing was the gate, not the caveat.
Reviewed by adversarial-reviewer, two rounds (D19 cap), 15 findings, all
accepted, none declined. claims-auditor did not run — D20. Round 1's findings
were almost entirely in prose written that session, and round 2's blocking
finding plus its sharpest should-fix were both defects in round 1's own fixes.
public/pouya-lajevardi-bio.pdf is deliberately NOT in this commit. It was
regenerated and reverted: /bio/'s eyebrow sits inside `.no-print`, so the sheet
has no eyebrow at all, and `cmp -l` showed exactly 10 differing bytes, all in
/CreationDate and /ModDate.
Gates, every one read as an exit status and none through a pipe: check 0,
build 0 (22 pages), check:claims 0, og:proof 0, check:intake 0, lint 0,
minifier tripwire clean, TODO in dist 0. Lighthouse run twice with identical
output: perf 99 on / and 100 on the other 21, a11y 100, best practices 100,
SEO 100 on every indexable page, CLS 0.000, LCP 1.50–2.03s.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
20 KiB
02 — Design system
Authority: AGENTS.md §3 D7 — keep the palette and the infinity mark;
modernize the execution. The look is not up for redesign. What follows is the
system that preserves it while fixing what the old build got wrong.
What carries over unchanged
- The palette: cream, ink, maroon, gold.
- The infinity mark — SML Company Ltd.'s actual logo, and the metaphor holds:
a dispute is a loop, and the work is redrawing the loop into a line.
It is a shaded ribbon, not a stroked curve: a band of variable width that
twists in three dimensions, maroon flowing into champagne, passing over itself
at the crossing. Ink bounding box 2668 × 1704 = 1.5657:1
[verified 2026-08-26 — measured against the master]. Source of truth:src/assets/brand/sml-infinity-mark.png; provenance indocs/reference/brand-assets.md. - The type pairing: Instrument Serif for display, Geist for text, Geist Mono for eyebrows and labels.
- The editorial register — generous whitespace, restrained colour, serif display type at large sizes.
What changes
| Was | Is | Why |
|---|---|---|
| Google Fonts at runtime | Self-hosted, subset, preloaded | Removes a render-blocking third-party round trip from a page that collects legal inquiries |
| Fixed px type sizes | Fluid clamp() scale |
One scale from 360 px to 1600 px with no breakpoint jumps |
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
| Gold used as a text colour on cream | Gold restricted to decorative and on-dark | It fails WCAG AA at 2.10:1. Measured, not assumed |
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind prefers-reduced-motion |
Motion that reads as confident rather than decorative; accessible by default |
2.2 MB single file, ~1 MB of base64 logos (both figures under review — AGENTS.md Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed) |
Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
Colour
Tokens live in src/styles/tokens.css. Never write a raw hex value in a
component.
| Token | Value | Use |
|---|---|---|
--cream |
#faf7f2 |
Page background |
--cream-2 |
#f3ede0 |
Alternating section background |
--cream-3 |
#ebe3d1 |
Cards and insets on cream |
--ink |
#1a1614 |
Body text; dark section backgrounds |
--ink-soft |
#3a322c |
Secondary text |
--muted |
#6e6359 |
Metadata, captions — on cream only |
--maroon |
#5a1a1c |
Primary action, accents, dark panels |
--maroon-d |
#3d1112 |
Hover on maroon |
--maroon-l |
#7a2a2c |
Links on cream |
--gold |
#c9a876 |
Rules, dividers, on-dark accent — never text on cream |
--gold-d |
#a88858 |
Large decorative text on cream only |
--gold-l |
#e2c89a |
Text on ink or maroon |
Contrast — measured, 2026-08-25
Against --cream #faf7f2:
| Colour | Ratio | AA body (4.5) | AA large (3.0) |
|---|---|---|---|
--ink #1a1614 |
16.81 | pass | pass |
--ink-soft #3a322c |
11.75 | pass | pass |
--maroon #5a1a1c |
12.29 | pass | pass |
--maroon-l #7a2a2c |
8.95 | pass | pass |
--muted #6e6359 |
5.47 | pass | pass |
--gold-d #a88858 |
3.11 | FAIL | pass |
--gold #c9a876 |
2.10 | FAIL | FAIL |
Against --maroon #5a1a1c: cream 12.29 · gold-l 8.11 · gold 5.84 — all pass.
Against --ink #1a1614: cream 16.81 · gold-l 11.09 · gold 8.00 — all pass.
--muted on ink measures 3.07 and fails; use --gold-l or cream at reduced
opacity for secondary text on dark.
Hard rules.
--goldis never a text colour on cream. Rules, borders, dividers, icon strokes, and on-dark text only.--gold-don cream only at 24 px+ / 19 px bold, and only for decorative display text — never for anything a reader must parse.- Body text on cream is
--inkor--ink-soft. Metadata may use--muted. - Secondary text on dark is
--gold-lor--creamat ≥ 70% opacity. - No dark mode. A single committed light identity is the right call for a legal practice, and halving the surface area halves the ways contrast breaks.
Typography
Faces. Instrument Serif (display) · Geist 300/400/500/600 (text) · Geist Mono 400/500 (eyebrows, labels, data).
Self-host all three. Subset to Latin + Latin Extended-A. font-display: swap.
Preload only the two faces used above the fold — Instrument Serif regular and
Geist 400.
Scale. Fluid, clamp(), 1.25 ratio at the small end widening to 1.333 at
the display end. Tokens --text-2xs through --text-6xl in tokens.css, plus
--text-eyebrow at 13 px — see the eyebrow rule below for why it is not a rung.
Rules.
- Display type (
--font-serif) at--text-4xland above only. It has almost no hinting at small sizes and looks weak below 32 px. - Display line-height
0.95–1.05; letter-spacing-0.02em. - Body line-height
1.6. Measure capped at68ch— the old site ran full-bleed paragraphs at 1400 px, which is unreadable. - Eyebrows: mono, 13 px (
--text-eyebrow),0.18emtracking, uppercase, always paired with a real heading. 13 px is not a rung on the scale — it sits between--text-xsand--text-smbecause uppercase mono at this tracking reads smaller than it measures, and at 12 px it was being lost against the display type (Pouya, 2026-08-31; it read 11–12 px until then). There is one definition,.eyebrowinglobal.css; the footer's column headings carry that class rather than repeating it. An eyebrow is not a heading and never carries the<h*>— a real heading may carry the class. "One definition" means one definition of the 13 px eyebrow. The four 11 px blocks in the next bullet repeat the mono/0.18em/uppercase treatment deliberately and are not covered by it. --text-2xs(11 px) is the compressed-label floor, not the eyebrow floor. Three carve-outs, and they are deliberate rather than incidental — do not read this line as licence for an 11 px label anywhere else. (a) The header tagline, and it is load-bearing today, not at some future step: 13 px takes the header to 83.4 px while--header-his 81 px, whichglobal.cssfeeds intoscroll-padding-top. A seventh nav item is the second reason and that one does arrive with Insights. Both measurements are inSiteHeader.astro. (b) The/bio/print sheet, whichnpm run bio:pdfholds to one page. (c) On/contact/, both the<label>/<legend>set and the direct-contact<dl>terms. ⚠️ (c) is the weak one and is flagged as such. 11 px uppercase mono measures 5.47 : 1 and breaches no floor in this document or in WCAG, but it now sits two steps below the eyebrow on the page that collects inquiries, for no reason beyond it having been written that way. Raised with Pouya 2026-08-31; it is an accepted size, not a ratified one.- Italic display (
.it) is the one flourish the design allows. One italic phrase per headline, at most. - Never skip a heading level.
<h1>once per page.
Spacing and layout
8 px base: --space-1 4px · -2 8 · -3 12 · -4 16 · -5 24 ·
-6 32 · -7 48 · -8 64 · -9 96 · -10 128 · -11 160.
Content width 1280px; prose measure 68ch; wide media 1440px.
Gutters: 24px mobile, 48px desktop.
Section rhythm: --space-9 (96 px) mobile, --space-11 (160 px) desktop —
--section-y in tokens.css. Corrected 2026-08-26: the curve was
6vw + 2rem, which reaches 160 px only at a 2133 px viewport, so the
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
128 px at 1600 px). It is now 9vw + 1rem, which reaches 160 px at 1600 px. If
you change the curve, re-measure — a clamp() whose upper bound is unreachable
reads exactly like one that works.
Grid: 12 columns desktop, 6 tablet, 4 mobile, --space-5 gutter.
Motion
The old build animated nearly everything on scroll. The replacement is deliberate and quiet.
- Reveal on section entry only — not on every child element. Sub-element stagger is limited to card grids, and capped at six children.
- Duration
600ms, easingcubic-bezier(.2,.7,.2,1). Transform and opacity only — never layout properties. - Implement with
animation-timeline: view(), behind@supports. Amended 2026-08-26: theIntersectionObserveralternative this line used to offer first is now ruled out, not merely second choice. It has to run inline in<head>to avoid a flash, and05-backend-spec.mdspecifiesscript-src 'self'with nounsafe-inline— so the only script on the site would have been the one thing the site's own CSP refuses to execute, and a per-build hash drifts from the policy pinning it. The CSS route ships zero JavaScript. Not a framework, not a library, not a script. - Content is visible without the feature. The
@supportsgate is load-bearing, not defensive: without it, a browser that ignoresanimation-timelineruns the animation once against the document timeline at load; with it, that browser gets no animation and fully visible content. The old build had this exactly backwards and shipped a blank page. - And without a print timeline. A scroll-driven animation has no timeline
when printing, so a revealed element renders at its
fromstate —opacity: 0. Measured 2026-08-26: before the print override existed, printing a page to PDF dropped four card headings from the output entirely./about/is written to be printed by people evaluating an appointment. - Hover transitions
250ms.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Nothing animates infinitely. Nothing autoplays. Nothing moves on page load except the reveal of the hero.
Components
| Component | Notes |
|---|---|
InfinityMark |
⚠️ Currently a raster — a documented, temporary exception to this rule (AGENTS.md Q38, R13). The mark is gradient-mesh artwork rather than flat vector paths. An SVG is held and it renders faithfully — and it renders faithfully because it IS the raster: 257,278 bytes wrapping seven embedded base64 PNGs, against 3,063 B for the AVIF a Retina browser takes in the header — 84×. Figures re-stated 2026-08-27 because a second, larger call site now exists and the single number had become misleading: the mark ships at two intrinsic widths, 64 px (header, footer) and 232 px (the home page's approach section, which renders at 225.5 px). At 232 px a DPR-2 device takes 14,555 B and DPR-3 22,639 B, so the ratio against the SVG there is ~11×, not 84×. The full ladders are in docs/reference/brand-assets.md; do not quote one number as if it covered both. Inlining it would breach CLAUDE.md's no-base64-images rule. Pouya settled the characterisation on 2026-08-27: a first draft called the file "a raster in a vector wrapper", a later draft withdrew that as unfair, and the withdrawal went too far. Both halves are true — the fidelity is real, and it is bought with embedded raster, which is exactly why fidelity was never the question. The exception is about payload and composition. It renders AVIF/WebP with a PNG fallback; a Retina device takes 3,063 B of AVIF, alt="" when decorative, aspect ratio pinned to 667 / 426. Restore this rule the moment the commissioned vector master lands. Until then the rule stands unchanged for every other mark or icon: inline SVG, currentColor, aria-hidden when decorative, never a PNG |
SiteHeader |
Sticky from 66 rem (1056 px) up; static below it. Practice dropdown as CSS-only <details>. (a) The one-row header holds the brand, seven nav items and the CTA — Insights is the seventh, arriving on its own at build step 7. Binary search on the built page puts the true fit at 1047 px; 66 rem is the clean token above it. Below 66 rem the nav takes its own row and the header measures 141 px at tablet widths and 189 px at 320–375 px. (b) "Condenses on scroll" is a hairline rule and a shadow, not a size change — a position: sticky header stays in normal flow, so shrinking its padding lifts every page below it, against the CLS < 0.05 budget. Longhands only; see the component on what the minifier does to the animation shorthand. (This row has been wrong twice, instructively. It first said 60 rem / "~115 px", a height the header never took. It then said 64 rem "with 32 px of clearance" — that 32 px was .header-inner's own column-gap mistaken for slack; the real figure at 1024 px with seven items was −21.6 px, and nothing overflowed only because flexbox crushed the logo inside the brand block. Measure slack, not gaps.) |
SiteFooter |
Three-column sitemap, contact block, designations, entity line |
Eyebrow |
Mono label with optional maroon dot |
SectionHeading |
Eyebrow + display heading + optional lede, one measure |
Button |
Variants primary (maroon) · ghost (outlined) · gold (ink bg, gold-l text). Renders <a> or <button> correctly |
Pill |
Small bordered label for designations and sector chips |
CredentialRow |
Three or four credential slots. Never matter counts — AGENTS.md §4 |
PracticeCard |
Sector chip, heading, one paragraph, arrow link |
ProcessStep |
Numbered step, timing, body |
ArticleCard |
Title, description, date, topic pills, reading time |
Prose |
Long-form wrapper. Owns all typographic defaults for MDX |
SEO |
Title, description, canonical, OG, Twitter, JSON-LD — see 04-seo-spec.md |
Focus states. Every interactive element gets a visible focus ring:
outline: 2px solid var(--maroon); outline-offset: 3px. Use :focus-visible.
Never outline: none without a replacement — the old build removed it globally.
Accessibility floor
Not a polish pass. A build requirement.
-
One
<h1>per page; heading levels never skipped. -
Landmarks:
<header>,<nav>,<main>,<footer>. Skip-to-content link first in tab order. -
Every image has
alt. Decorative images getalt="". -
Colour never carries meaning alone.
-
All functionality reachable by keyboard; focus order matches visual order.
-
Forms: real
<label>elements,aria-describedbyfor hints, errors announced withrole="alert"and tied to their field. -
Touch targets ≥ 44 × 44 px.
-
Test at 200% zoom and at 320 px width. Both measured 2026-08-27 on
/: document overflow 0 at 320, 360, 390, 414, 640, 768, 900, 1024, 1056, 1200, 1216, 1280, 1440 and 1920 CSS px, with zero elements extending past the viewport. Page zoom at 200% of 1280 is the 640 column and at 400% is the 320 column, so WCAG 1.4.4 and 1.4.10 are both covered by that sweep.A stricter case is not fully clean, and it is recorded rather than left to be discovered. With the reader's default font size at 200% — root at 32 px, a real accessibility setting and not page zoom —
/measured 234 px of overflow at 390. Brought down in three measured steps:Fix 390 px 320 px as first built 234 px 304 px minmax(min(Nrem, 100%), 1fr)on three grids83 px 153 px .credentialsmade explicitrepeat(2, minmax(0, 1fr));.featurepadding clamped andoverflow-wrap: anywhereon its title;.contact-actionflex: 0 1 auto+min-inline-size: 03 px 63 px 65 pxcorrected to63 pxon 2026-08-28, re-measured independently on the same page and setting. Two pixels, and it is recorded because a table that reads as the site-wide record has to be re-measurable rather than remembered./about/added 2026-08-28 — step 3, and the first page to be measured against this table rather than establishing it:Page and fix 390 px 360 px 320 px /about/as first built38 px 68 px 108 px .designation-partwhite-space: nowrapremoved (the separator is held by an NBSP instead) andoverflow-wrap: anywhereon.hero-h0 px 23 px 63 px Step 4's three pages added 2026-08-28, and they needed no fix of their own — the guards this table produced were applied while writing them rather than after measuring:
Page 390 px 360 px 320 px /mediation/3 px 23 px 63 px /arbitration/3 px 23 px 63 px /med-arb/0 px 23 px 63 px At the default root size all three are 0 px at every width measured. The element-level sweep at 320/root-32, predicate
right > clientWidth, names threePracticeCardchips on/mediation/— Construction, Technology, Shareholder — plus the header and footer brand elements, which appear on all five pages. (This read "six" for one pass. Six is the count ofarticle.cardwithscrollWidth > clientWidth, a different predicate on a different element;adversarial-reviewerre-ran it and no predicate yields six chips. A number in this table has to be re-runnable, which is the whole reason the table exists.) All three are byte-identical to/'s — same widths, same offsets, same text — so step 4 introduced no new element-level overflow.Two findings from that page specifically. The
nowrapwas introduced as a fix for an orphaned·at the end of a wrapped line, and it made the whole designation item unbreakable — a fix that created a reflow regression, caught only because this table existed to regress against. And the type scale is rem-based, so an<h1>at--text-5xlcomputes to 88 px at root 32 and a single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content box at 320 px;overflow-wrap: anywhereis the only remedy that reduces min-content size./about/now measures equal to or better than/at all three widths, and its 320/360 residual is the same header decision.Command, so the numbers are re-runnable rather than quoted: headless Chrome over the built
dist,document.documentElement.style.fontSize = '32px', thendocumentElement.scrollWidth - documentElement.clientWidth, plus an enumeration of every element wider thanclientWidthto name the offender.Two things worth keeping.
overflow-wrap: break-wordpermits a break at layout time but does not reduce min-content size —anywheredoes, and that distinction was the whole of one of those fixes. And 1280 px stays over, from the header's deliberateflex-wrap: nowrapabove 66 rem pluswhite-space: nowrapon the brand name — 602 px on/and 944 px on every other page, becauseSiteHeadergates the tagline on!isHome, so the masthead is wider everywhere except home. (This read a flat "602 px" until 2026-08-28. That figure was measured on/and generalised;/about/was already 944 when it was added to this table, and step 4's three pages are 944 too. Found byadversarial-reviewer.) The 320 px residual is the same header plus the display headline's 104 px floor. Undoing either re-opens the measured step-1 header decision, so they stand. All of this is beyond what this floor requires — page zoom is clean — so it is a robustness margin rather than a failure. Revisit if a real reader hits it. -
Measure the elements, not only the page. A document-level overflow check passes while a flex child absorbs the deficit by being crushed — that is how step 1 shipped a logo at aspect 0.891 under a green "0 overflow at every width". Assert the rendered geometry of the thing you care about. On
/every infinity-mark instance measures 1.5654–1.5657 against the master's 1.5657. -
A touch-target measurement of the wrong box is not a finding. The eight cards on
/report 26–39 px-tall<a>elements and are fine: each card's whole box is the link's hit area via::after { inset: 0 }, verified by hit-testing nine points per card at three widths (24 cards, 9/9). Hit-test before enlarging anything. WCAG 2.5.8's inline exception also applies to a link sitting mid-sentence, and one such link on/is deliberately left at 164 x 21. -
Every page must be readable and navigable with JavaScript disabled.