Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';

const caseStudies = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/case-studies' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishDate: z.coerce.date(),
draft: z.boolean().default(false),
}),
});

export const collections = { caseStudies };
46 changes: 46 additions & 0 deletions src/content/case-studies/design-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: 'the sky is the palette'
description: 'How youthphase.dev computes its design from your actual sky: the why, the accessibility engine underneath, and the craft in between.'
publishDate: 2026-07-24
draft: true
---

<!-- Working draft. draft: true keeps this off /case-studies until it's ready. -->

Most personal sites pick a palette. Mine computes one.

The background of youthphase.dev is your actual sky. Sun and moon positions are calculated for your location, the seventy brightest stars are placed for your latitude and the hour, and every color the interface wears is derived from that sky at runtime. Visit at golden hour and the page is pink and peach. Visit at midnight and it is deep midnight blue with real constellations. The line I want a visitor to remember is not "nice site." It is "the site was showing me my own sky."

## why a sky

I had two audiences and one minute: hiring managers deciding fast from a resume link, and fellow engineers who notice details. Both have seen a thousand dev portfolios, and that template (hero, skills grid, project cards, testimonial slider) was my anti-reference, along with corporate polish and the over-animated showreel. I wanted one person's living artifact instead, and the most alive thing a screen can do is reflect the actual world outside your window. Not a dark mode toggle. Weather.

That framing came with a rule that sounds absurd until you commit to it: nothing on this site is themed, so almost nothing gets a fixed color. Colors are never picked. They are computed.

## the constraint that designed the system

A palette that changes every frame has an obvious problem: contrast. A designer normally checks text against a known background once, at design time. I had every sun altitude from midnight through noon. So accessibility became the engine of the design rather than a checklist at the end. Body ink is chosen per frame to hold at least 4.5:1 against the gradient band it sits on. Card surfaces are computed to guarantee 7:1, so anything on them is AAA before it loads. And a script sweeps every sun altitude in CI and fails the build if any pairing drops below AAA. Not a warning. A failed build.

The constraint was not limiting the design; it was the design. The pretty part, the gradient itself, was almost free by comparison.

## real, not rendered

The sky's honesty is load-bearing, so the engineering keeps it honest. Location comes from the geolocation headers my host already attaches to every request, echoed back by a tiny serverless function: no third-party lookup, no tracking. When that fails, the site shows San Francisco and the status line says "borrowed sky" instead of "your sky," honest about whose sky you are looking at. The stars are the real brightest seventy for your coordinates and the hour. Everything advances in quarter-hour hops, a sundial tick rather than imperceptible drift.

And because delight should be discovered, not performed: every tap on open sky lands a small sparkle, shooting stars visit on clear nights, and three quick taps play a secret show that fits the moment, the moon running through its month of phases (terminator accurate) or a sudden shower and a rainbow by day. Every animation has a `prefers-reduced-motion` alternative. All of it is static files plus that one function; the sky is computed in your browser, not on a server.

## objects under the sky

One family of things refuses to participate in the weather: photographs. Photos render as physical prints on warm ivory stock, and a real print does not change color at dusk, so ivory is the system's one named exception. Everything else tracks the sky.

The prints get two mountings, on purpose. On the about page they are polaroids: caption written in the bottom margin, and on a phone they swipe like a print in your hand, drag-follow with resistance and a settle-back. On the contact page, the photo of my grandfather's dinghy (the site's namesake) is the same ivory stock but taped into the page at all four corners, a scrapbook gesture. Polaroids are my life now; the taped print is an inherited memory. Same paper, different verb.

## one source of truth

Even a computed system has fixed values: the ivory, the moonlight family, the type scale, the radii. Those started as raw color values copied into whichever component needed them, the ivory living in three files at once. Now they live in a single theme block (in oklch), flow from the same source the written design doc describes, and are usable as utility classes or CSS variables. A redesign edits the numbers once. The boundary matters as much as the tokens: the sky's own colors stay a runtime layer that can never be static, and the docs say so. A design system is not just what you systematize; it is knowing what you must not.

The proof is a live, unlisted page rendering every token, type style, and rule from the real site under the real sky: [/design-system](/design-system). This case study links there instead of embedding screenshots, because screenshots of this site are out of date by definition. The sky moved.

## why it's public

Every rule above is written down with its reason attached, because the test of a design system is not whether it looks coherent today but whether a change made months from now comes out coherent by default. I have spent years doing this work inside companies, behind NDAs. This one runs in the open: public repo, enforced accessibility, visual regression on every PR, inspectable down to the commit history. That is the point of it.
34 changes: 34 additions & 0 deletions src/pages/case-studies/[...slug].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
import Layout from '../../layouts/Layout.astro';
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
const pieces = await getCollection('caseStudies', ({ data }) => !data.draft);
return pieces.map((piece) => ({ params: { slug: piece.id }, props: { piece } }));
}

const { piece } = Astro.props;
const { Content } = await render(piece);

const dateFmt = (d: Date) => d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
---

<Layout title={`${piece.data.title} — Amy Lam`} description={piece.data.description}>
<main class="relative mx-auto flex min-h-dvh max-w-2xl flex-col gap-6 px-page-gutter pt-20 pb-32">
<a
href="/case-studies"
class="link-squiggle sky-top-ink relative self-start text-[19px] font-bold lowercase tracking-wide transition-transform duration-300 after:absolute after:-inset-3 after:content-[''] hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
← case studies
</a>

<article class="sky-card rounded-card px-6 py-8 sm:px-10 sm:py-10">
<p class="text-xs lowercase tracking-widest opacity-70">{dateFmt(piece.data.publishDate)}</p>
<h1 class="mt-2 text-headline font-medium tracking-tight sm:text-5xl">{piece.data.title}</h1>

<div class="prose prose-lg mt-8 max-w-none">
<Content />
</div>
</article>
</main>
</Layout>
56 changes: 56 additions & 0 deletions src/pages/case-studies/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';

const pieces = (await getCollection('caseStudies', ({ data }) => !data.draft)).sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf(),
);
---

<Layout
title="case studies — Amy Lam"
description="Case studies and project writeups from Amy Lam."
>
<main class="relative mx-auto flex min-h-dvh max-w-2xl flex-col gap-8 px-page-gutter pt-20 pb-32">
<a
href="/"
class="link-squiggle sky-top-ink relative self-start text-[19px] font-bold lowercase tracking-wide transition-transform duration-300 after:absolute after:-inset-3 after:content-[''] hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
← home
</a>

<h1 class="text-headline font-medium lowercase sm:text-5xl">case studies</h1>

{
pieces.length === 0 && (
<p class="sky-card rounded-card px-5 py-4 text-sm lowercase tracking-wide">
nothing here yet — check back soon.
</p>
)
}

<ul class="flex flex-col gap-5">
{
pieces.map((piece) => (
<li>
<a
href={`/case-studies/${piece.id}`}
class="sky-card group block rounded-card px-6 py-6 transition-transform duration-300 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current sm:p-8"
>
<p class="text-xs lowercase tracking-widest opacity-70">
{piece.data.publishDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
})}
</p>
<h2 class="mt-1 text-2xl font-medium tracking-tight group-hover:underline group-hover:underline-offset-4">
{piece.data.title}
</h2>
<p class="mt-2 text-sm opacity-80 sm:text-base">{piece.data.description}</p>
</a>
</li>
))
}
</ul>
</main>
</Layout>
17 changes: 1 addition & 16 deletions src/pages/contact.astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,7 @@ import youthPhase from '../assets/contact/youth-phase-print.png';
</p>
</div>

<!-- The handoff line: to the code, then to the form. Sentence case
and body-sized — it's prose, not chrome. The link carries the
full system treatment: squiggle underline plus the hover lift
(inline-block so the transform applies). -->
<p class="mt-8 text-base sm:text-lg">
Read about how this site is built on <a
href="https://github.com/amyrlam/youthphase"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub (opens in new tab)"
class="link-squiggle inline-block transition-transform duration-300 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>GitHub</a
>.
</p>

<p class="mt-4 text-base sm:text-lg">Want to get in touch? Send me a message below!</p>
<p class="mt-8 text-base sm:text-lg">Want to get in touch? Send me a message below!</p>
</section>

<section class="sky-card rounded-card px-6 py-8 sm:p-10">
Expand Down
6 changes: 6 additions & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ import Layout from '../layouts/Layout.astro';
>
about
</a>
<a
href="/case-studies"
class="link-squiggle sky-caption relative text-sm lowercase tracking-widest transition-transform duration-300 after:absolute after:-inset-3 after:content-[''] hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
>
case studies
</a>
<a
href="/contact"
class="link-squiggle sky-caption relative text-sm lowercase tracking-widest transition-transform duration-300 after:absolute after:-inset-3 after:content-[''] hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
Expand Down