Case Study

Building a Portfolio That Feels Like Home

Personal portfolio Updated 2026 Astro + Cloudflare Pages

Most portfolio sites either shout or disappear. I wanted something quieter: a place where case studies, notes, and small experiments read like an editorial publication rather than a marketing page. The project was less about proving I know a stack and more about designing a space where the work, and the writing about the work, could breathe.

Under the hood it’s a fairly simple Astro + Cloudflare Pages site. The interesting part is how typography, spacing, the content model, and a few chosen interactions line up behind one goal: a site that’s calm, consistent, and cheap to maintain.


One Design, All Viewports

There is no separate mobile or desktop layout. There is one design that scales.

Instead of juggling breakpoint‑specific rules, the viewport is treated as a continuous range between a minimum and a maximum width. A single breakpoint value maps that range to a 0–1 scale, and every major token — spacing, font size, layout padding — derives from it. All the layout and typography decisions flow from that one idea.

The effect is easy to feel. Headings, body text, and section spacing scale together, so the page reads as the same design on a 360px phone and a 1280px desktop. There are no awkward widths where the layout feels caught between two breakpoints. And when I want to adjust the vertical rhythm, I change a handful of tokens instead of chasing overrides across components.

This is also why there’s no Tailwind CSS here, even though I use it for many client projects. This site needed a continuous, math‑driven system more than a utility‑class layer. Recreating the same fluid relationships with a discrete spacing scale and breakpoint utilities would have meant either a lot of custom CSS anyway or giving up the precision I wanted.


Design Decisions Live in One File

Every visual decision lives in a single tokens file: colors, typography, spacing, layout widths, radii, shadows. The rest of the CSS imports those tokens; there are almost no raw hex values or magic numbers scattered across components.

This buys consistency by default — if buttons, cards, and headings look aligned, it’s because they share the same source values. Changing the reading width or paragraph spacing is one edit, not a hunt through templates. And someone else can open the tokens file and understand the system without reverse‑engineering component styles.

The semantic color system follows the same logic. There are dedicated tokens for the reading experience — surface, text, links, borders, callouts — that differ from “card surfaces” and “brand accents.” Dark mode isn’t a new theme so much as a dimmed version of the same palette, tuned for comfortable low‑light reading instead of dramatic contrast.


The Kindle Comment

A few weeks after launch, someone close to me spent time reading through the notes and case studies. Their comment was short: “It feels soothing, like reading on a Kindle.”

That line captured what I was aiming for better than any metric. The cream background instead of pure white. The slightly generous line height. The control of line length, the muted link colors, the absence of flickering animations around the content. All of it exists to make long‑form reading feel easy.

Technically it comes from a dedicated “reading” set of tokens, font sizes, line heights, and max widths, kept separate from UI text. Paragraphs, lists, and headings share the same vertical rhythm variables. Nothing jumps layout at a random breakpoint while you’re halfway through a paragraph.

If you notice the spacing system consciously, something has probably gone wrong. It’s supposed to disappear into the experience.


A Contact Form That Respects You

The contact form is the only truly interactive surface on the homepage, so it had to behave like it respects the person using it.

The core idea is managing validation state explicitly instead of trusting the browser’s defaults. The form tracks two things:

const formState = {
  hasSubmitted: false,
  touchedFields: new Set(),
};

Every input listens for two events. On blur, the field is marked as touched and validated. On input, if the field has been touched or a submit was already attempted, any error clears and the field optionally re‑validates. The resulting behavior is deliberate: you never see an error before interacting with a field, an error clears as soon as you fix the value, and after a failed submit you get immediate feedback as you correct each field.

Errors are announced through small, dedicated elements with ARIA wiring. The form submits to a third‑party endpoint configured via environment variables, and success and failure are handled in a single place: disabling the button, swapping its label, showing a message, and resetting state when appropriate.

It’s not a complex state machine. It’s also more intentional than throwing required on the inputs and hoping for the best.


The Logo Sets the Tone

The logo is a single inline SVG that draws itself once when the page loads. It traces the shapes, then fades in the fills. On hover it scales slightly and nudges toward the brand color. It doesn’t loop, pulse, or compete with the content.

The animation isn’t the point. What it implies about the rest of the site is. Animations appear where they have a job, on the logo, on card hovers, on the occasional arrow. Nothing needed for understanding — navigation, copy, content — depends on motion. The same easing and subtle scaling show up on project cards and primary links, so the interaction vocabulary stays consistent.

The first impression is a site that’s alive without being attention‑seeking.


Content That Can’t Be Wrong

Content lives in Astro content collections with Zod schemas for type safety. Notes and case studies are MDX files with typed frontmatter. A note without a title or a valid date simply cannot build; the schema rejects it. Missing fields surface as build errors, queries through the content API are predictable, and evolving the content model — adding an “updated” field or a draft flag — is a schema edit rather than an ad‑hoc convention.

On the layout side there’s a single base layout that imports the global styles, header, footer, and a small amount of analytics wiring, all guarded by environment variables. The main script bundle is a lightweight module that only runs what each page actually needs.

Most visitors will never know there’s a fluid spacing system or a validation state object behind the contact form. They’ll just feel that the site is easy to read and gently interactive. That’s the kind of technical I aim for: the kind that makes things feel solid without standing in the spotlight.