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
  • Vercel Blob

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 shaped most of what follows. Everything under it, from the schema to the token generation and the guard, is built and tested, but no letter has been sent yet.

Shape

Visitor
Hiring managerholds a link
Meadmin
↓
[lang]static
/letter/[token]per request, noindex
(letters)/admingated 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 personalize, so pages render on the server. Client components go at the leaf, only where an interaction needs one: the hero that types its answers, the header’s controls, the inspector.

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 are always visible, and animations do nothing under reduced motion. Every color, radius, and duration on this page resolves to a token.

A component library I own, not one I installed

ChosencksUI, in this repo

Built on shadcn’s patterns, with Radix for behavior and the source copied in. Every value is 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 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 isn’t authorization: GitHub will prove who any of its users is, but 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.

The trade-off: 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, because 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.

Screens from inside the product, behind a personal link

Most of the case studies are about work inside Vimocity’s app, and those screens aren’t this site’s to publish. So the files never enter the repository. They live in a private Blob store, and a hiring team sees them through a personal link: a signed token with an expiry, set as a cookie.

ChosenSigned links, checked per image

No database and no accounts. The image route verifies the token before reading the store, and answers anything else with a 404.

RejectedA shared password, or publishing the screens

A password can’t be revoked for one person. Publishing would put an employer’s product in a public repository.

Pages stay static: each gated figure renders both versions, and the theme script picks one before first paint. Without access, the frame keeps the screenshot’s size, with neutral shapes blurred past recognition and a request for access, so it reads as part of the case study rather than a gap.

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 anonymization. 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.
  • 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.