Generate, Evaluate, Refine: Design Judgment in AI-Assisted Product Design
AI can generate more design options faster than any designer working alone. What it can't do is know which options belong in your product.
I built this design system in Figma, designed my site with it, and evolved it as the site grew. This page is the system as it actually ships.
The site itself is built with Claude Code. I don't write the CSS. Every color and
type value is defined once, as a CSS custom property in styles.css, and
Tailwind's @theme inline maps each utility (text-h1,
bg-ember) back to those same variables. A utility isn't a copy of a
token. It's a pointer to one.
I built this page to understand and visualize that. Three components below exist in both forms: as component classes, and composed from Tailwind utilities. Reading them side by side is how variables and utilities stopped being abstract to me.
Colors change with theme. Type changes with viewport. Spacing, radii and shadows change with neither, so they're written as fixed tokens rather than live variables. The indirection only earns its keep where something has to move.
On desktop, resize the browser. The type scale is fluid, so sizes interpolate continuously instead of snapping at breakpoints.
Nine tokens, three themes. Every swatch is painted with a real
bg-* utility that resolves to a live var(). There
isn't a hard-coded hex on this page. Switch themes and the swatch and its
value change together.
Five brand accents sit alongside the core nine in styles.css:
--accent-primer, --accent-butcherbox,
--accent-nara, --accent-blueapron,
--accent-grapevine. Each one is the fill its project card animates
to on hover. They belong to a single project apiece, so they're named here
rather than shown as part of the shared palette.
Six heading sizes, then the body scale. All Libre Franklin, all rendered with
real text-* utilities bridged to the tokens, each carrying its own
line-height. Rows marked fluid scale with the viewport via
clamp, 375 to 1440px. Each body size is paired with its
font-semibold (600) twin. The headings already carry their own
weight, so they have no separate variant.
text-h148 → 80 / 1.0 · 800fluidtext-h244 → 64 / 1.0 · 700fluidtext-h336 → 48 / 1.0 · 700fluidtext-h432 → 40 / 1.1 · 600fluidtext-h528 → 32 / 1.40 · 600fluidtext-h624 / 1.30 · 600text-body-lg22 → 24 / 1.6fluidtext-body-lg font-semibold22 → 24 / 1.6 · 600 · Hero taglines (.cs2-tagline)text-body-md20 → 22 / 1.6fluidtext-body-md font-semibold20 → 22 / 1.6 · 600 · Block lead-ins (.article-body-semibold)text-body18 / 1.6text-body font-semibold18 / 1.6 · 600 · Metric values, inline emphasistext-body-sm16 / 1.6text-body-sm font-semibold16 / 1.6 · 600 · Metric labelstext-body-xs14 / 1.6text-body-xs font-semibold14 / 1.6 · 600text-eyebrow14 / 1.6 · 700 · caps · 1.12px trackingtext-caption14 / 1.5text-tooltip12 / 1.6
The site sits on a fixed lattice: 1px lines every 32px, painted at
--grid-line at 5% ink, inverted per theme so it reads on light
and dark alike. It's position: fixed with
pointer-events: none at z-index: 0. Content scrolls,
the grid holds still. A --plaster-to-transparent fade keeps the
header clear, and on desktop the cursor lights the nearest lines in ember.
--grid-line token and 32px step as the live background behind this page.
One scale on a 4px base (--spacing: 0.25rem), with an 8px rhythm
carrying most gaps and padding. Tailwind's p-*,
gap-*, and m-* all step in 4px units, so
gap-4 is 16px. Each bar below is drawn at
calc(var(--spacing) * n), so you're measuring the live token, not
a picture of it.
4pxgap-1 · p-18pxgap-2 · p-212pxgap-3 · p-316pxgap-4 · p-420pxgap-5 · p-524pxgap-6 · p-632pxgap-8 · p-840pxgap-10 · p-1048pxgap-12 · p-1264pxgap-16 · p-1680pxgap-20 · p-2080px 192px 0, most sections use 160px sides, collapsing to 16px on mobile.
Four radii carry the system, bridged as rounded-* utilities. The
larger the radius, the softer and more prominent the surface reads. Swatches
use the real utilities wherever they exist.
rounded-tag · 4pxTags, tooltips, CTAs, close buttonsrounded-well · 16pxImage wells, metric & history cardsrounded-panel · 24pxMeta & hero-image cardsrounded-card · 32pxProject, article & problem cards
Shadows are dark-on-light, reserved for surfaces that lift off the page.
shadow-card-hover handles the card lift; the heavier
shadow-media and shadow-video sit under hero imagery.
Shown here on a light surface, which is where they're built to sit.
Flip the theme above to see the shadows.
shadow-card-hover0 12px 40px · 8% inkshadow-media0 16px 48px · 16% blackshadow-video0 16px 48px · 25% blacklayeredPhoto drop, and a left-facing panel shadow for the slide-out drawer.
Every icon is a 24×24 inline SVG that paints with currentColor,
so it inherits the text color and flips to ember on hover without a second
asset. Two families share the frame: solid brand marks (fill)
and line UI icons (2px stroke, round caps). Hover a tile to see
the currentColor swap.
One treatment for every image caption on the site: text-caption
at text-flint, centred, capped at
720px, and sitting 8px below the media. The 8px is the rule
— it is the gap on the container that holds the image and caption
(.cs2-figure, .cs2-media, .cs-image-caption-group,
.cs2-hero-image-card), or a matching margin-top
on .article-caption.
typetext-caption · 400 — see Type scalecolorvar(--flint)measuremax-width 720px, centredgap8px below the media
8px sits between the image and this caption.
The metadata pill. bg-plaster chip, border-shale
hairline, rounded-tag (4px), px-2 py-1,
text-body-sm (16px). Container is flex flex-wrap gap-4.
Built twice, real .project-tag vs utilities. They match.
The interactive text and button atoms, built from the real classes. They
share one hover language: the accent shifts to ember over a quick
0.2s ease. Hover each to see it.
.text-linkThe convention for any link inside body copy: a 1px underline in the text’s own colour, both going ember on hover..text-linkThe colour is inherit, not a fixed token — so the same class reads pitch in a body paragraph and flint in a muted one, and the underline rides along.
Seattle to Portland bike ride, July 2025. are worth a photo..photo-noteAn inline term that reveals a captioned photo. Built on the .project-card box — same fill, stroke, 32px radius and padding — but with a square image and a caption instead of a title and tags. The trigger is a <button> with a dotted underline rather than the solid one .text-link uses: it reveals something in place instead of navigating, and the two affordances should not look alike. Hover on pointer devices, tap to toggle on touch; Escape or a click outside dismisses. JS keeps the card inside the viewport and flips it below the line when there is no room above..panel-linkUnderline wipes ember in from the right; the arrow bounces once on hover..projects-cta-btnChalk fill, shale border → ember border + ember text on hover.
The project card component, built twice, class-for-class. Real
styles.css left, Tailwind utilities right. Title is
text-h6, body is text-body, both bridged to the
same tokens as the Original, so they render identically.
The writing-index card. Same gradient-stroke border and
rounded-card radius as the project card (unified, no drift).
The image well sits desaturated at brightness-[0.62] and snaps
to full color on card hover via group-hover:brightness-100.
The rule of thumb: the smaller the change, the faster it resolves. A color
swap lands in 0.2s, the nav sheet takes 0.32s.
Most of the site runs on plain ease. The nav is the deliberate
exception — opening uses cubic-bezier(.16, 1, .3, 1), a
fast-out, long-settle curve shared by the sheet and the hamburger so the
two read as a single gesture. Closing runs a shorter ease-in instead:
arriving can take its time, leaving should get out of the way. Hover any
stage below to run it.
hover0.2s easetransform0.3s ease--shadow-card-hover.fade0.5s ease / ease-out.fade-in.underlineWipe0.25s easepanelEnter0.32s cubic-bezier(.16, 1, .3, 1)panelClose0.22s cubic-bezier(.4, 0, 1, 1)navToggleMorph0.3s cubic-bezier(.16, 1, .3, 1) · 0.16s easethemeToggleSpin0.6s easetocIndicator0.35s transform, 0.3s opacitytransition: transform .3s ease, box-shadow .3s ease;
:hover { transform: translateY(-4px);
box-shadow: var(--shadow-card-hover); }
::after { right: 0; width: 0; height: 2px;
background: var(--ember);
transition: width .25s ease; }
:hover::after { width: 100%; left: 0; right: auto; }
.fade-in { opacity: 0; transform: translateY(16px); }
.fade-in.visible { opacity: 1; transform: none; }
/* js/case-study.js adds .visible on scroll */
/* closed */ transform: translateY(-100%);
transition: transform .22s cubic-bezier(.4, 0, 1, 1);
/* open */ transform: none;
transition: transform .32s cubic-bezier(.16, 1, .3, 1);
.bar { height: 3px; width: 24px; top: 0 / 6.5px / 13px; }
transition: transform .3s cubic-bezier(.16, 1, .3, 1),
top .3s cubic-bezier(.16, 1, .3, 1),
opacity .16s ease;
[aria-expanded="true"] .bar:nth-child(1) { top: 6.5px; transform: rotate(45deg); }
[aria-expanded="true"] .bar:nth-child(2) { opacity: 0; }
[aria-expanded="true"] .bar:nth-child(3) { top: 6.5px; transform: rotate(-45deg); }
svg { transition: transform .6s ease; }
:hover svg { transform: rotate(360deg); }
transition: color .2s ease, border-color .2s ease;
:hover { color: var(--ember);
border-color: var(--ember); }