This Site: A Design System, Shipped
The site you're reading. A token-driven Tailwind design system with light and dark theming, WCAG 2.2 AA checked by an axe-core suite, and a Lighthouse budget that fails the build when a score slips. Designed and built end to end.
- Client
- Personal project, live
- Role
- Designer & Front-End Developer
- Year
- 2026
- Duration
- Ongoing
Five token layers
src/app/globals.css · 35 color tokens
fg-*
text only
flips in darkink-*
backgrounds only
never flipssurface-*
cards & hairlines
flips in darkbrand-*
one hue, 50–950
never flipson-dark-*
always-dark panels
never flips
What I did
- Design system: 35 color tokens, light/dark theming, shared primitives
- Visual, layout and interaction design for every page
- Front-end build in Next.js, Tailwind CSS v4 and TypeScript
- Accessibility, end-to-end and Lighthouse test gates
- Built with Claude Code as a coding assistant
A portfolio usually shows you the output of the work. This one is the work. Every screen is built from a documented token system, themed light and dark from one source of truth, and checked against WCAG 2.2 AA by tests that run before anything ships. It's the clearest evidence I can give you of how I actually work, mostly because you can go read the code and check.
Key decisions
Split the palette into text tokens that flip in dark mode and background tokens that never do.
Instead of re-pointing one navy for dark mode, which inverted the whole page.
Accessibility checks that fail the build.
Instead of an accessibility review step before launch.
Any value the system doesn't have yet becomes a token.
Instead of hardcoded hex and one-off arbitrary values.
Gate LCP against Lighthouse's simulated figure, with a ratchet.
Instead of claiming a 2.5-second pass the gate hasn't earned.
Results
- Accessibility budget
- 100
- Design tokens
- 35
- WCAG 2.2 AA scans
- 6
The problem with a two-layer color model
Dark mode breaks in a predictable way: a color that's text in one theme is a background in the other. I walked straight into it. The dark navy in this palette is both the light-mode text color and the dark-mode page background, so re-pointing it for dark mode inverted the entire page. The fix was to split the palette in two. An ink layer that never flips, for backgrounds, and an fg layer that does, for text only. One utility, text-fg-muted, now reads correctly in both themes with no per-component dark overrides. That one rule killed off most of the theme bugs I'd been chasing.
One value, two jobs
#0b1220 is light-mode text AND the dark-mode page background.
light
Heading text
Supporting body copy sits here.
Primary
text-fg → #0b1220
bg → #ffffff
brand-700 fill
dark
Heading text
Supporting body copy sits here.
Primary
text-fg → #f1f5f9
bg → ink #0b1220
same utility, no override
✓ text-fg-muted
one utility, correct in both themes
✗ text-ink dark:text-slate-400
the pattern this system replaced
Tokens as the contract
35 color tokens live in one CSS file, with light values in Tailwind v4's @theme block and dark re-points in a .dark block, across five layers: foreground text, fixed ink, theme-aware surfaces, brand, and a fixed on-dark set for panels that stay dark in both themes. Hardcoded hex and arbitrary values are banned. If a value doesn't exist yet it becomes a token, not a one-off. Above that sit the shared primitives: Button, Badge, Container, Section, ProjectCard, the motion components. Building a new card means reaching for a recipe that already exists instead of writing fresh inline styles. The rules live in a DESIGN.md that ships with the code and gets versioned with it, so they can't quietly drift out of sync with what's actually implemented.
Contrast audit · WCAG 2.2 AA
body text must clear 4.5:1
fg on surface
18.7:1
AAfg-soft on surface
14.6:1
AAfg-muted on surface
7.6:1
AAfg-faint on surface
4.8:1
AAbrand-700 on white
5.5:1
AAbrand-600 on white
3.8:1
FAIL→ brand-700
The failing pair was the site's primary action color. Fixed site-wide by stepping brand-600 → brand-700.
Accessibility as a build gate, not a review step
An axe-core suite scans the site against WCAG 2.2 AA: home page, representative case studies, the 404, and the contact form in its error state. A keyboard test checks that submitting the form moves focus to the confirmation, so keyboard and screen-reader users aren't stranded on a button that no longer exists. Getting those scans trustworthy took some digging. They kept flaking, and the reason was that axe was sampling contrast mid-animation. It read a badge at 3.2:1 that actually settles at 5.2:1. Emulating prefers-reduced-motion and freezing CSS transitions before the scan fixed it. Along the way the scans caught a real failure I'd missed by eye: the brand green behind primary buttons was too light against white. One shade darker fixed it site-wide.
The build gate
scripts/lh-gate.mjs · exits 1 on regression
100
Accessibility
100
SEO
95
Best practices
90
Performance
LCP (simulated)
< 3.2s ratchet
CLS
< 0.1
TBT
< 200ms
axe-core · WCAG 2.2 AA scans
- home
- case studies
- 404
- contact form errors
- keyboard focus
+ Playwright journeys · responsive at 360 / 768 / 1280 · SEO metadata
Holding the line
A Lighthouse gate runs against a production build and exits non-zero if anything drops below budget: accessibility and SEO at 100, performance 90, best practices 95, CLS under 0.1, TBT under 200ms. LCP needed a judgment call. Lighthouse reports about 3.0s, which fails the 2.5s target. But its LCP-element audit comes back empty, which is the tell that the number is simulated from 4x CPU throttling rather than actually observed. Measured with a PerformanceObserver against the same build, real LCP lands under 200ms on the hero h1. So the gate ratchets against the simulated figure, which still catches a genuine render-blocking regression, and I don't claim a 2.5s pass I haven't earned. Chasing that number did turn up a real bug: the h1 was animating from opacity 0, and LCP only records once text hits final opacity, so my own entrance fade was pushing the metric back. It now paints opaque and animates transform only. Playwright covers the user journeys, three breakpoints, and SEO metadata. Motion honors prefers-reduced-motion twice over, through a CSS media query and useReducedMotion() in JS, because framer-motion animates in JavaScript and ignores the CSS one.
Why this is the case study
It's a small project and I won't pretend otherwise. No team, no stakeholders, no user research. But the rules held up against a real codebase, accessibility was a requirement rather than a cleanup pass, and the decisions are written down well enough that someone else could build against them. I work the same way on bigger things. The difference is that here you can open the repo and check.
Want to talk about this work?
I'm open to UX/UI and product design roles, and happy to walk through any of this in more depth. I'll usually reply within a day or two.