Most portfolio sites either shout or disappear. I wanted something quieter: a place where case studies, notes, and small experiments read more like an editorial publication 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 the typography, spacing, content model, and a few carefully chosen interactions all align around one goal: make the site feel calm, consistent, and easy 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, I built a fluid system that treats the viewport as a continuous range between a minimum and 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 impact is simple but noticeable:
- headings, body text, and section spacing scale together, so the page feels like the same design on a 360px phone and a 1280px desktop
- there are no “awkward” widths where the layout feels like it’s between two breakpoints
- maintenance is cheaper – to adjust the vertical rhythm, I change a handful of tokens rather than chase overrides across components
It also made one decision easy: I didn’t use Tailwind CSS here. I use Tailwind for many client projects, but 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 either mean a lot of custom CSS anyway or giving up on the precision I wanted.
Treating Design Decisions as Data
All the visual decisions live in a single tokens file: colors, typography, spacing, layout widths, radii, and shadows. The rest of the CSS imports those tokens and uses them; there are almost no raw hex values or magic numbers scattered across components.
That structure buys a few things:
- consistency by default – if buttons, cards, and headings look aligned, it’s because they literally share the same source values
- small, safe changes – changing the reading width or paragraph spacing is one edit, not a hunt through templates
- a portable mental model – someone else can open the tokens file and understand the system without reverse‑engineering component styles
The semantic color system is built the same way. There are dedicated tokens for the “reading experience” (surface, text, links, borders, callouts) that differ from “card surfaces” and “brand accents”. Dark mode is not a new theme so much as a dimmed version of the same palette, tuned for comfortable low‑light reading instead of dramatic contrast.
Making the Reading Experience Feel Like Kindle
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 careful control of line length, the muted link colors, the absence of flickering animations around the content – all of that was there to make long‑form reading feel easy.
Technically, this comes from:
- using a dedicated “reading” set of tokens (font sizes, line heights, max widths) separate from UI text
- letting paragraphs, lists, and headings share the same vertical rhythm variables
- avoiding layout surprises – no elements suddenly jump layout at random breakpoints while you’re halfway through a paragraph
If you notice the spacing system consciously, something has probably gone wrong; the goal is for it to disappear into the experience.
A Small Form with Grown‑Up UX
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 to manage validation state explicitly instead of relying on the browser’s default behavior. The form tracks two pieces of state:
const formState = {
hasSubmitted: false,
touchedFields: new Set(),
};
Every input listens for two events:
blur: mark the field as “touched” and validate itinput: if the field has been touched or the user has already tried to submit, clear any error and optionally re‑validate
This leads to a few deliberate UX properties:
- you never see an error before interacting with a field
- once you do see an error, it clears as soon as you fix the value
- 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 it handles success and failure states 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, but it is more intentional than “throw required on the inputs and hope for the best”, and it’s exactly the kind of detail that makes a small site feel considered.
A Logo That Sets Expectations
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 the brand color, but it doesn’t loop, pulse, or compete with the content.
The value here is not the animation by itself. It’s what it implies about the rest of the work:
- animations appear where they have a job (on the logo, on card hovers, on the occasional arrow), not everywhere
- nothing that’s needed for understanding (navigation, copy, content) depends on motion
- the interaction vocabulary is consistent – the same easing and subtle scaling show up on project cards and primary links
The result is a first impression that the site is alive, but not attention‑seeking.
Content as a First‑Class Citizen
Content lives in Astro content collections with Zod schemas for type safety. Notes and case studies live as MDX files with typed frontmatter. A note without a title or a valid date simply cannot build; the schema rejects it.
Practically, this gives:
- compile‑time safety for content (missing fields surface as build errors)
- predictable queries via the content API
- an easy path to evolving the content model – adding an “updated” field or a “draft” flag is a schema edit, not an ad‑hoc convention
On the layout side, there is 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 is actually needed on each page.
What This Says About How I Work
This portfolio is deliberately modest. It doesn’t exist to show off a new framework or squeeze in every tool I like. Instead, it’s a small, opinionated system that tries to get a few things right:
- reading first – tokens, layout, and color are all biased toward long‑form comfort
- explicit structure – design tokens, content schemas, and scripts live where you’d expect, and changes are routed through those layers rather than patched per‑page
- progressive enhancement – JavaScript is used where it adds clear UX value (form behavior, small interactions), not as the default rendering strategy
- pragmatic tradeoffs – no Tailwind here, even though I use it elsewhere; no custom backend, because a form service is enough for this job
Most visitors won’t know there is 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 part that makes things feel solid without needing to stand in the spotlight.