Files
adr-sml/docs/02-design-system.md
T
Pouya Lajevardi 19f7226661
Build and deploy / build-and-deploy (push) Failing after 5s
chore: project scaffold, specs, and working record
2026-08-26 08:51:16 -04:00

8.4 KiB
Raw Blame History

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.

  1. --gold is never a text colour on cream. Rules, borders, dividers, icon strokes, and on-dark text only.
  2. --gold-d on cream only at 24 px+ / 19 px bold, and only for decorative display text — never for anything a reader must parse.
  3. Body text on cream is --ink or --ink-soft. Metadata may use --muted.
  4. Secondary text on dark is --gold-l or --cream at ≥ 70% opacity.
  5. 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-4xl and above only. It has almost no hinting at small sizes and looks weak below 32 px.
  • Display line-height 0.951.05; letter-spacing -0.02em.
  • Body line-height 1.6. Measure capped at 68ch — the old site ran full-bleed paragraphs at 1400 px, which is unreadable.
  • Eyebrows: mono, 1112 px, 0.18em tracking, 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, easing cubic-bezier(.2,.7,.2,1). Transform and opacity only — never layout properties.
  • Implement with IntersectionObserver in one tiny inline script, or animation-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 countsAGENTS.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 get alt="".
  • Colour never carries meaning alone.
  • All functionality reachable by keyboard; focus order matches visual order.
  • Forms: real <label> elements, aria-describedby for hints, errors announced with role="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.