Skip to content

Appearance

Theme
Mode

Style guide: tokens, type, and contrast →

Colophon · Caleb Smith

How this site is built, and why

A stack list says what I installed. This says what the alternatives were, and what each choice cost.

  • Next.js 16
  • React 19
  • TypeScript
  • Tailwind v4
  • Drizzle
  • Neon Postgres
  • Auth.js
  • Vercel

Three surfaces

AnyonePublic portfolioFast, static, indexable

Live

MeAdminAuthenticated, write-heavy

Live

One hiring manager, by linkCover lettersPrivate, unguessable, one per opening

Built, not yet in use

The third one shaped most of what follows, and is the one still waiting on a first real use. Everything under it — the schema, the token generation, the guard — is built and tested; no letter has been sent yet.

Shape

Visitor
Hiring managerholds a link
Meadmin
(site)static
/letter/[token]per request, noindex
(admin)gated per route
lib/repositories/every query lives here — nothing above writes SQL, nothing below knows what a route is
Neon Postgresover HTTP
GitHub OAuthidentity only
Vercelhosting

Decisions

Server Components by default

Nearly every page is a read of content the visitor didn’t personalise. The landing and sign-in pages ship zero application JavaScript. Client components go at the leaf, when an interaction needs one.

Tailwind v4, configured in CSS

Tokens and the utility namespace sit in one file, twelve lines apart, so they can’t drift. Dark mode is defined per token, not retrofitted. Focus rings always visible; animations no-op under reduced motion. This page uses the same tokens.

Every color, radius, and duration resolves to a token. A hex value in a component is a reviewable error.

A component library I own, not one I installed

ChosencksUI, in this repo

Built on shadcn's patterns — Radix for behavior, source copied in — with every value rewritten onto these tokens.

RejectedAn installed UI library

It would ship someone else's UI and run a second design system alongside this one.

Radix stays a dependency, and a justified one: focus management, keyboard interaction, and ARIA are where a subtle mistake is invisible until it reaches someone using a screen reader.

cksUI stands in the same relation to this site that VimUI does to the product I work on. The portfolio is an example of the claim rather than a description of it.

Neon over Supabase

ChosenNeon + Drizzle

Scales to zero, resumes instantly. Speaks Postgres over HTTP, so there's no connection pool to exhaust.

RejectedSupabase

Its edge is Auth + row-level security — unused here, since auth is Auth.js. Complexity stays, payoff doesn't.

The decider was practical: free-tier Supabase pauses after about a week idle, and the worst moment for that is a hiring manager opening a letter three days after I sent it. Cost: no table-editing UI, no file storage.

Drizzle over Prisma — lighter runtime, no codegen in the deploy path, migrations as plain SQL reviewable in a PR. One file knows the database is Neon.

OAuth, where a password would have done

For one user, a hashed password in an env var would be adequate security in eighty lines. I chose OAuth because OIDC is how modern auth actually works and every enterprise SSO product is the same flow with a different issuer — I wanted to build it, not read about it.

ChosenAuth.js + database sessions

Every leg of the code exchange leaves an inspectable row. Deleting a session row ends it immediately.

RejectedClerk, and JWT sessions

Clerk's value proposition is hiding the mechanics. A signed JWT stays valid until it expires.

Authentication is not authorization. GitHub will prove the identity of any of its users; it has no opinion on who may edit my portfolio.

So a sign-in callback checks the login against an allowlist. No proxy-level guard: Next’s docs say that layer suits optimistic checks and real authorization belongs next to the data, so every admin route calls the guard itself.

Deleting six tables when the requirement changed

The schema was built for “tailored CVs” — positions, bullets, skills, tags, and case studies, joined per opening with overrides. It was a misreading that survived long enough to become six tables.

The actual need was cover letters. A CV is assembled from parts; a letter is prose addressed to one reader. Nothing to select, nothing to reorder.

ChosenDelete the six tables

Case studies are files and the experience page is structured data, so none of them had a reader left.

RejectedKeep them for later

Unused schema is a claim about the future that has to be maintained and explained.

The most useful thing I did to this database was take most of it out.

The URL is the credential

No login, because asking a hiring manager to register to read a cover letter is a good way to not have it read. The boundary is token entropy: 32 bytes from a CSPRNG, generated in the repository layer, never accepted as an argument.

Accepted honestly: anyone with the link can read and forward it. It’s a letter written to be handed to someone I have never met, so exposure is bounded by what it already is. It is also HTML only — a downloadable copy would be a second artifact of the same content, stale the moment a sentence changes. Mitigations: revoke, rotate, expire, noindex, and an identical 404 for revoked, expired, draft, and nonexistent alike, so a recipient can’t learn a link was turned off — or that a company was ever sent one.

View logging, minimal on purpose

“Did they open it” is useful, but the visitor is a counterparty in a hiring process who consented to nothing. No cookies, no fingerprinting, no third-party analytics. IP stored only as a salted hash; rotating the salt severs the link to everything logged — which is why the salt is secret, since hashing an IP isn’t strong anonymisation. The write happens after the response is sent and swallows its errors.

Deliberately not built

Stated explicitly, because unfinished and overlooked look identical from outside.

  • Admin editing screensSchema, repositories, and the gate exist. Each screen is a form wired to a server action.
  • Public case study routesRead layer exists. The site is still a landing page on purpose.
  • Cache ComponentsOff until DB-backed routes exist and can be verified against it. The code is already written in the shape it wants.
Per-decision records live in docs/decisions/. The rules the codebase is held to live in CLAUDE.md — which exists so the standard is applied by default rather than corrected after the fact.