Button
Primary
Secondary
Ghost
44px minimum target · px-6 py-3 · 12px radius · 500 at 15px. Disabled uses aria-disabled="true" plus opacity — never color alone.
One accent, one typeface, fourteen components. Every token, scale, and rule documented below is the one styling what you are reading — the styleguide and its own acceptance test in a single file.
One brand ramp, five inks, four surfaces, two lines. No second accent, no gradient. Contrast is a token property, not a QA step — every approved pair ships with its measured ratio, and a pair that isn’t in the table isn’t approved.
Tinted section background
Borders and dividers on tint
Large text ≥24px, icons, rules. Never body-size white text on it.
Buttons, links, focus ring
Hover / active; links on tint and alt
Headings, primary body
Secondary body, long prose
Captions, supporting copy
Meta, eyebrows — smallest approved gray, white surface only
Text on ink-900, brand-600, brand-700
Default page
Alternating sections
Hero, callouts
Footer only
Card borders, dividers
Internal rules
| Text token | surface | surface-alt | surface-tint |
|---|---|---|---|
| ink-900 | 12.92 | 12.05 | 11.93 |
| ink-700 | 8.45 | 7.88 | 7.81 |
| ink-500 | 5.41 | 5.05 | 5.00 |
| ink-400 | 4.68 | 4.37 | 4.33 |
| brand-700 | 6.64 | 6.19 | 6.13 |
| brand-600 | 4.69 | 4.38 | 4.34 |
| brand-500 | 3.39 | 3.17 | 3.14 |
✓ passes AA at body size · ◐ AA large text only · ✕ fails body size. ink-400 and brand-600 text are approved on surface only; on alt and tint sections, meta steps up to ink-500 and links to brand-700.
Roboto only. One family, differentiated by weight, size, and tracking — no mono, no display face. Self-hosted, latin subset, 400 and 900 preloaded because they’re the only two faces above the fold.
Hero and section ledes sit between heading and body, carrying the one sentence a skimmer actually reads.
Body copy is set at 17px with a 1.65 line height and capped at 68 characters. The previous case-study body at 14.4px is retired — prose is for reading, and reading needs room.
Captions, figure notes, and footer copy.
004 · Eyebrow label
Wordmark role line, light weight
Roboto has no 600. The previous site asked for it on nav links, so the browser synthesized a faux-bold and the type smeared. Restricting to real weights fixed it. Headings use text-wrap: balance, body uses text-wrap: pretty, and indices lock to tabular-nums so 001, 002, 003 stay aligned in a column without a monospace face.
Tailwind’s default 0.25rem scale, unchanged. The only additions are the rhythms that should breathe: section padding and gutter are both clamp(), so the page scales between breakpoints instead of jumping at them.
| Token | Value | Applied to |
|---|---|---|
--container-max | 1200px | Page container |
--container-measure | 68ch | Prose column |
--spacing-gutter | clamp(1.25rem, 4vw, 2.5rem) | Horizontal padding |
--spacing-section | clamp(4rem, 10vw, 8rem) | Vertical rhythm · py-section |
--radius-sm / radius / radius-lg | 6 / 12 / 20px | Inputs, tags / cards, buttons / media frames |
| Breakpoints | sm 40 · md 48 · lg 64 · xl 80rem | Tailwind v4 defaults. Mobile-first. No custom breakpoints. |
Small inventory on purpose. Everything else is composition. Flowbite supplies markup and accessibility wiring for five of them; every visual default is stripped and replaced with tokens. If a component still looks like Flowbite, it isn’t done.
Primary
Secondary
Ghost
44px minimum target · px-6 py-3 · 12px radius · 500 at 15px. Disabled uses aria-disabled="true" plus opacity — never color alone.
Inline
Nav
Footer
Current nav state is carried by a 2px underline plus aria-current="page", not color. Prose links stay underlined at rest.
Desktop · scrolled
Mobile · closed
Live at the top of this page. Sticky, 92% white with backdrop blur, bottom border appears once scrolled. Below 48rem it collapses — Escape closes the panel and focus is trapped while open, neither of which Flowbite Collapse provides.
Live at the bottom of this page. surface-deep with inverse ink; three columns at md, stacked below. Every social link is icon + sr-only label — the previous site had fourteen icon-only links with no accessible name.
Featured · focus-within
Whole card is one link. Media 16:10 → index → title → org · product → role · year. No blurb paragraph; index and meta carry it. Media shown is a placeholder frame.
Number at h3 size, weight 900, tabular. Three per row at md. At least one tile must be a real outcome, not tenure — the third slot is a placeholder until content supplies one.
13px · 500 · uppercase · 0.14em · ink-400 on white, ink-500 on alt and tint.
bg-brand-50 text-brand-700 · 6.13:1 · 13px. Non-interactive unless it filters.
Canonical order: Org · Product · Role · Year. Separators are generated, not typed.
Grid-2
Half
20px radius · 1px line · shadow-3. Every image ships width, height, srcset at 800/1200/1600, lazy below the fold, and real alt text.
Tint background, 3px brand-600 left rule, right corners rounded only, p-6.
Decisions is the section that separates a case study from a gallery. It names the tradeoff, the option not taken, and why — with links underlined at rest and a measure that never exceeds 68 characters.
Case-study body order: Problem · Role · Process · Decisions · Outcome · Reflections.
py-section. Sections alternate — two adjacent sections never share a background. This page runs tint → default → alt → default → alt → default → alt → default → tint.
dl › div.faq__item › dt + dd. Cards are not links, so they take no hover, focus or lift state — that vocabulary belongs to the Project card. Fill surface-card on an alt section, radius 12px, no border and no shadow. The question is text-body at weight 700, one step below card__title, so the grid stays quieter than the work cards above it. One column, two from 48rem, three from 64rem with the first card spanning two. Visible answers must match the FAQPage JSON-LD on the page that uses it.
One curve, one duration band, three approved behaviors. Motion signals state or connects two views; nothing moves to decorate.
| Behavior | Spec | Why it exists |
|---|---|---|
--ease-brand | cubic-bezier(.22,.61,.36,1) | Single curve, whole site |
| Duration band | 150 / 240 / 420ms | fast / default / slow — nothing outside it |
| Hover & focus | fast · color, shadow, 1px press | Confirms the target is interactive |
| Scroll reveal | 12px rise + fade · slow · 60ms stagger | Directs reading order on first pass; fires once via IntersectionObserver |
| Mobile nav | Collapse open / close | Connects the toggle to the panel it controls |
Everything is gated behind prefers-reduced-motion. With it on, the site is fully static and every element is visible. Reveals only arm when JavaScript runs, so content never depends on motion — or on scripts — to be reachable.
WCAG 2.2 AA, every page, every build. Not a checklist run at the end — most of these are decided by tokens before a page exists.
| Area | Decision |
|---|---|
| Contrast | Body ink 12.9:1. Every text/background pair appears in the measured table; unlisted pairs are unapproved. |
| Focus | 3px brand-600 ring, 2px offset, on everything focusable. Never outline: none without a replacement. |
| Targets | 44×44px minimum on every interactive element. |
| Structure | lang="en", skip link first in tab order, header › nav › main#main › footer, one h1, no skipped levels. |
| Images & icons | Every image has alt or a deliberate alt="". Every icon-only control has an accessible name. |
| Meaning | Color never carries it alone: current nav is underlined, disabled uses aria-disabled, prose links stay underlined. |
| Keyboard | Fully operable. Mobile nav closes on Escape and traps focus only while open. |
| Flowbite wiring | aria-expanded, aria-controls, aria-labelledby kept intact. Removing them to simplify markup is a regression. |
| Reflow | Works at 320px and 200% zoom without horizontal scroll. |
A design system described in slides is a claim. One that renders its own documentation is evidence. This page is the styleguide, the acceptance test for the system, and a case study at the same time — if a token is wrong, it’s wrong here first, where anyone can open the inspector on it.
The swatch grid was never the interesting part. The decisions about what to leave out are: one accent instead of a palette, one typeface instead of three, fourteen components instead of a library, and contrast measured before a pair is allowed to exist.
Those limits are what keep a system applied the same way on the tenth page as on the first. Building this page is also how the two open issues above were found — which is the argument for publishing it.