8.4 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.
- 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 | Optimized SVG mark, AVIF/WebP photography | The mark is geometry; it should be vector, not a 470 KB PNG |
| 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-xs through --text-6xl in tokens.css.
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, 11–12 px,
0.18emtracking, uppercase, always paired with a real heading. An eyebrow is not a heading and never carries the<h*>. - 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 (96px) mobile, --space-11 (160px) desktop.
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
IntersectionObserverin one tiny inline script, oranimation-timeline: view()where supported. Not a framework, not a library. - Content is visible without JavaScript. The reveal is an enhancement layered on top of already-rendered HTML. If the observer never runs, the page reads normally. The old build had this exactly backwards.
- 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 |
Inline SVG, currentColor, aria-hidden when decorative. Never a PNG |
SiteHeader |
Sticky, condenses on scroll. Practice dropdown as CSS-only <details> |
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.
- Every page must be readable and navigable with JavaScript disabled.