# Building a portfolio design system with Subframe and Claude Code

> How I integrated a production design system into my live portfolio as a solo design engineer — without breaking the typography I already loved.

- Author: Alejandro Haydar
- Published: 2026-05-20
- Taxonomy: Design Systems, Design Engineering, AI, Workflows, Case Study
- Canonical: https://alejandroastroport.netlify.app/case-studies/subframe-design-system/

---
import { Image } from "astro:assets";
import brandTokensLight from "../../assets/images/subframe/brand-tokens-light.png";
import brandTokensDark from "../../assets/images/subframe/brand-tokens-dark.png";

> **The outcome:** I integrated Subframe — a production-grade design system tool — into my live Astro portfolio over a single weekend. Custom fonts preserved, brand cyan synced across light and dark modes, Badge migrated across four real consumers with no visual regressions, and the next component already staged. The whole flow was driven by Claude Code as a pair-programmer.

**Role:** Solo design engineer
**Team:** Just me + Claude Code (Opus)
**Timeline:** One weekend, May 2026
**Stack:** Astro 5, Tailwind, Subframe, shadcn/ui, Claude Code
**Skills spotlighted:** Design systems thinking, token architecture, migration strategy

---

## Challenge

My portfolio had outgrown vibe-coding. The components I want to build next — a cinematic hero gallery, a feed navigator with prev/next siblings, a hire-mode footer — aren't things I can confidently describe to an LLM in plain English. I need to see them, push pixels, develop taste. But I didn't want to retreat to Figma either, because every Figma round-trip means re-implementing the design by hand. The interesting question wasn't *"which tool do I use to design?"* — it was *"can the design tool and the codebase agree on the same source of truth?"*

**Constraints I gave myself:**
- Don't lose Erode and Satoshi. They are my type system; they are not negotiable.
- Don't pay to unblock the integration until I know it's worth paying for.
- Don't replace shadcn/ui wholesale. Migrate component-by-component, only when there's a reason.
- The code in the repo is the source of truth. The design tool is the planning surface.

## Process

### Three phases, one weekend

I structured the work the way I'd structure a real platform migration: **install**, **theme**, **pilot**. Each phase ended with a working build. No phase started until the previous one was green.

**Phase 1 — Install.** Subframe's CLI generated `src/subframe/` with the components and a Tailwind preset; I wired it into `tailwind.config.cjs` by hand because the CLI doesn't auto-edit `.cjs` files, and installed the 14 Radix peer deps Subframe expects but doesn't bundle. Boring, mechanical, done in twenty minutes.

**Phase 2 — Theme.** This is where the interesting decision lived.

### The font problem (and the decision behind the fix)

Subframe's free tier wouldn't accept my custom Satoshi-Variable and Erode-Variable uploads. The obvious choices were: (a) pay for the upgrade, (b) switch to a Google Font Subframe already supported, or (c) override Subframe's font tokens at the Tailwind config layer so the project config wins.

I chose (c), and the reasoning is the spine of this whole project. The most interesting components I need to build can't happen with just vibe-coding — I need a place to design them. The code will always be the source of truth; the design tool is the planning surface I think through taste on.

The font wall wasn't a blocker — it was a distraction pretending to be a blocker. Paying to fix it would have anchored me to Subframe's pricing model before I knew if the tool was worth it. Switching to a Google Font would have erased the type voice that defines the portfolio. Overriding at the Tailwind layer cost me one config edit, kept code as the source of truth, and let me keep moving toward the components I actually wanted to design.

The first mapping wasn't quite right — I mapped Erode to body and Satoshi only to headings, which made every button label render in serif. The revised mapping reads almost as a manifesto: **Satoshi for everything except reading.** Erode survives in `monospace-body` and in `.prose p` across feed, showcase, and case-study pages, so blog reading still feels like a paperback. Everything else — buttons, badges, captions — snaps to Satoshi.

### Brand tokens, not brand colors

For color, I derived the full Subframe ramp (50→950) from my existing `hsl(153 100% 50%)` brand cyan, pasted it into Subframe's Theme UI, and re-ran sync. Subframe emitted `src/subframe/theme.css` — a clean set of CSS variables for light and dark modes — and I added one import line to `global.css` so the variables actually load. From that point on, Subframe components paint with `--color-brand-500: rgb(0 255 156)` in both modes automatically.

<div class="breakout grid grid-cols-1 md:grid-cols-2 gap-4 my-8">
  <figure>
    <Image src={brandTokensLight} alt="The Subframe Button pilot in light mode: brand-primary filled with cyan, its label near-black, set in Satoshi" />
    <figcaption class="text-sm text-neutral-500 mt-2">Light — label <code>rgb(10 10 10)</code></figcaption>
  </figure>
  <figure>
    <Image src={brandTokensDark} alt="The same Subframe Button pilot in dark mode: the identical cyan brand fill, label flipped to near-white" />
    <figcaption class="text-sm text-neutral-500 mt-2">Dark — label <code>rgb(250 250 250)</code></figcaption>
  </figure>
</div>

The check that matters is the pair above, not either half of it. `--color-brand-500` resolves to `rgb(0 255 156)`, and brand-primary paints `rgb(0 255 140)` — a neighbouring step of the same derived ramp — **identically in both themes**. What moves is the label: `rgb(10 10 10)` on light, `rgb(250 250 250)` on dark, decided by the token rather than by me. A brand colour that had to be hand-picked per theme would have drifted the first time I touched either one.

Both are screenshots of `/workshop/hero-gallery` as it renders today, not mockups. The page's own caption claimed the opposite — *"Default Subframe theme — orange brand, Manrope"* — for long enough that I believed it while writing this case study. It had been stale since the theme sync landed. Writing this section is what caught it.

### Phase 3 — Pilot, then swap

I built a private gallery at `/workshop/hero-gallery` showing every Subframe Tier 1 component side-by-side with its shadcn counterpart. Same page, same lighting — the only way to make an honest decision about which library wins each slot.

**Real swaps after the gallery:**
- **Progress** was set up as a drop-in — one import line in `SkillsMatrix.tsx`, track gray, indicator cyan. The catch I only caught later: a follow-up audit found I'd staged the swap but not actually flipped the import. The migration is one PR away, and the audit habit — between every migration, not just at the end — is the part of the workflow worth documenting.
- **Badge** needed a variant translation — shadcn's `secondary` and `outline` both mapped cleanly to Subframe's `neutral` across four consumers (post heroes + the Thought Industries case study tag).
- **Avatar** I deliberately skipped. It's used rarely; the migration cost wasn't worth the consistency win yet.
- **Button** is paused on me — Subframe's default variants don't match shadcn's `default / outline / ghost`, so I have to rebuild them in the design tool before the swap is safe.

The one small UX bug worth remembering: Subframe's Badge renders as a `<div>`, which means block-level full-width when it lands inside a flex column. The fix is one class on the parent (`flex justify-center`). The lesson is structural — every cross-library migration ships at least one of these.

## What shipped

- A working dual-library setup — Subframe and shadcn coexist, neither owns the codebase.
- A theme override pattern that survives every Subframe re-sync (project config beats preset).
- A pilot page (`/workshop/hero-gallery`) that doubles as visual regression coverage on every sync.
- One real component swap (Badge across four consumers) in production with zero hand-tuning afterward, plus the next swap staged.
- A merged PR (`#16`) for the foundation, plus the open question for the next round: which components actually have a live surface that asks for migration, and which are queue-thinking pretending to be roadmap.

The override layer lives in `tailwind.config.cjs`, the Subframe-emitted vars live in `src/subframe/theme.css`, and the bridge is a single import line in `global.css`. Three files, one direction of dependency, zero ambiguity about which one wins when they disagree.

## Impact

**Quantitative (so far):**
- 1 production component migrated across 4 consumer sites (Badge in post heroes + the Thought Industries case study tag); Progress staged but flip pending
- 1 weekend, 1 PR merged
- Verified by side-by-side comparison on the pilot gallery — no regressions surfaced
- 14 peer deps added to `package.json`; build still green

**Qualitative:**
- I now have a design surface for components I couldn't vibe-code. The next round (hero gallery variants, feed navigator) starts in Subframe instead of starting in code.
- The "code is the source of truth" principle held under stress. Every time the design tool tried to overwrite the codebase's intent (fonts, colors, semantics), I had a one-file patch to reassert it.

The pattern — override layer + pilot gallery + sync round-trip — is the asset here. The component counts matter less than the loop being reproducible across however many you eventually pull through it.

**What I'd do differently:**
- Audit between migrations, not just at the end. The Progress swap looked done at the time and only proved otherwise on the next read-through — exactly the drift this whole workflow is supposed to prevent.
- Build the variant rebuild plan *before* migrating any consumer. I assumed Button would be a drop-in; it isn't. A 10-minute variant audit would have caught that and held the migration for the design tool.
- Version-control the Subframe project ID and theme export alongside the code. Right now the Subframe-side state lives in their cloud; if I lose access tomorrow, the re-sync flow breaks even though the codebase is intact.

## Reflection

This project clarified something I'd been circling for months: I'm not a designer who codes, and I'm not an engineer who designs. This is what design engineering looks like to me — refusing to pick a single source of truth between the design tool and the repo. Both have to agree, and when they disagree, the code wins. Subframe is the first tool I've used that takes that constraint seriously — its sync flow makes the design canvas a *plan*, not a deliverable, and the deliverable is always the code.

The deeper lesson is about momentum. The font wall could have eaten the whole weekend if I'd treated it as a real problem. Treating it as a distraction — fixable in one config line, worth zero further thought — let me get to the work that actually matters: building a place where I can design the components my portfolio is still missing.

The other lesson lands later: a design system isn't just what you build, it's what you verify ships. The Progress swap looked finished at every step until I checked the import on a second pass. That gap — between *planned* and *shipped* — is why this kind of system needs an audit loop running alongside the roadmap, not after it.

---

## Closing

I went into this weekend with a design system I couldn't extend and a tool I didn't trust. I came out with a working hybrid library, the first component swap in production, a brand theme that survives re-syncs, and — most importantly — a place to design the next round of components. The code is still the source of truth. Subframe is where I plan. The rest is just disciplined translation between the two.

→ **[See the living design system](/design-system/)** — every component rendered from the same source as this site, assembled by one React component, `src/components/design-system/DesignSystemGrid.tsx`. It no longer imports from Subframe; the update below explains why.

---

## Update, September 2026: why I outgrew it

I moved the portfolio's components off Subframe and onto [Fluid Functionalism](https://www.fluidfunctionalism.com/), a shadcn-registry library I install as source I own. The reason is speed, and it's specific to my situation: I'm the only person who touches this design system.

Subframe's loop is design in the canvas, sync to code, and never hand-edit what it generates. That loop is the point when a team shares the canvas. For me it became a round trip on every change. Button stayed paused, waiting on a variant rebuild in the design tool. The Reading Helper needed an escape hatch (`useCustomTrack`) because a synced component couldn't loop over an article's real sections. Each time, the code was ready before the canvas was.

I still think it's an excellent design system, especially for teams that don't run Storybook. Most small teams never stand one up, or let it rot. Subframe gives them the thing Storybook is supposed to be — a living, browsable surface where every component is real React — and a designer can change it without opening the repo.

What I kept: the brand ramp, the ReadingGuide and dock designs (their markup now lives in my components), and the habit of treating the canvas as a plan rather than a deliverable. What changed is that one person no longer needs two places to agree before shipping.