thetom8o@portfolio:~/writings
$ cd ..
$ glow building-with-claude-code.md

Building with Claude Code

-rw-r--r-- · 2026-06-10, extended 2026-06-23 · prompting strategy, then a subscription move

This site is its own case study. Every visual decision you see — the terminal frame, the prompt-green $, the rust-and-sage palette — emerged from an iterative conversation with an AI coding agent, and the git history records each turn of that conversation. This is a write-up of how that process actually worked, including the parts that didn't.

Start wrong, pivot cheap

The first commit built a parallax-heavy single-page site. It looked like every portfolio template from the last decade, and the scroll-jacking felt heavy. One commit later: "Redesign as minimalist terminal-style portfolio."

That pivot cost almost nothing, and that's the first real lesson of AI-assisted development: exploration is cheap, so explore. When a full redesign costs one well-phrased prompt instead of a week, the rational strategy changes. You stop defending early decisions and start treating them as drafts.

Constraints make better prompts

The most productive prompts weren't feature requests — they were constraints:

A constraint gives the agent a way to evaluate its own output before you do. "Make it nicer" produces churn; "make it pass prefers-reduced-motion without losing the personality" produces engineering.

The commit log is the conversation

Reading the history back, you can watch the design system crystallize one fix at a time: a commit standardizing terminal-green prompts, another reconciling the old brand's rust red and sage green with the new dark theme, several rounds of hover-state corrections. None of these were planned upfront. Each came from looking at the deployed site, noticing a seam, and describing the seam precisely.

Iterate against the artifact, not the idea. The site on screen is the spec.

What the second iteration fixed

The version you're reading was built as a deliberate design-system pass over the first. The audit found classic drift: a hardcoded #00ff00 that bypassed the token system, two accent colors competing for the same job, a universal * { transition } selector, and brand hover colors that broke in light contexts. The v2 stylesheet separates brand constants from semantic tokens, which is what made a light theme — on the original roadmap from day one — nearly free to add.

The JavaScript budget held: the scroll handler became an IntersectionObserver, the theme toggle is a dozen lines, and everything else — the typing effect, the scroll progress bar, the blinking cursor — is CSS.

Working notes

$ cat >> building-with-claude-code.md << 2026-06-23

Switching the agent

Two weeks after the writeup above, the same site needed a different kind of work — a subscription move and a real domain — and the agent doing it was a different one. Claude Code built the v1 you're reading on; this addendum, and the migration it describes, was done in OpenCode running inside the Zen browser. So this is partly a tooling note and partly a build log, with no clean line between them.

What pulled me toward OpenCode wasn't a complaint about its predecessor — it was the way OpenCode surfaces the things I want to be deliberate about. A real plan mode that's strictly read-only until you flip it. Visible todo tracking. Parallel tool calls when work is independent. An explicit handoff between specialized subagents — one for exploration, one for execution. The token-consumption model is also more visible, which sounds like a downside until you notice that it disciplines the prompts. You stop saying "look around and tell me what you find" and start saying which directory to look in and which file extension to grep for.

Zen, the browser hosting the UI, isn't doing anything fancy here. It just stays out of the way: vertical tabs collapse, no constant nudges to install a sidebar AI, sane defaults. Half of why the migration felt clean is that nothing on the chrome side was begging for my attention.

The job: a subscription move that turned into a DNS move

The site was originally deployed under a Visual Studio MPN subscription that's sunsetting at the end of the month. The natural fix is "move the resource to the right subscription" — except Azure Static Web Apps doesn't support cross-subscription move via Resource Mover. The path forward is forced: create a fresh SWA in the target subscription, re-authorize the GitHub OAuth handshake so Azure can inject a new workflow file and deployment-token secret, let the first build deploy, then remove the old workflow file and secret from the repo. The content doesn't move — it pushes itself from GitHub on the next commit. The state of the deployment lives entirely in the repo, which is the whole virtue of a static site.

Once the new SWA was live, the question of a real custom domain came up — which is when the migration grew a second half.

The apex-HTTPS puzzle

The bare-root version of a domain — thetomato.co rather than www.thetomato.co — has been a thorn for as long as cloud hosting has existed. The original DNS RFC says the apex of a zone can hold an A record but not a CNAME. Every modern static-site host (Azure SWA, Netlify, Vercel, CloudFront, GitHub Pages) serves from rotating IPs behind a hostname, so the only RFC-clean way to point apex at one of them is to either pin an A record to a documented static IP (brittle if the provider ever rotates) or use a non-standard alias record that resolves dynamically at query time.

GoDaddy's hosted DNS doesn't support alias records. Their workaround — "Domain Forwarding with HTTPS" — is a shared proxy that frequently fails SSL handshakes, mishandles redirect chains, and offers no HSTS. This is the same wall I keep hearing about in adjacent contexts; the answer is always the same: move the zone to a DNS provider with native ALIAS / ANAME / CNAME-flattening support. Azure DNS, Route 53, and Cloudflare all qualify.

Azure DNS was the natural pick. Same subscription as the SWA, $0.50/month per zone, and its alias records can target Azure resources directly by resource ID — which means the apex record literally references the Static Web App and Azure handles the dynamic IP resolution internally. The cert provisions itself.

Mirror first, flip second

The domain isn't just a website. It's also a working Microsoft 365 mail tenant, an Entra ID and Intune setup, several Google Workspace aliases, a Home Assistant Cloud subdomain via Nabu Casa, and a thick layer of third-party verification TXT records for everything I've ever signed in to with this address. DMARC sits at p=reject, so a botched DNS migration would have silently bounced mail.

The strategy was lift-and-shift, in that order: export the full GoDaddy zone file as the authoritative ground truth, create the Azure DNS zone, import the BIND file in one command (az network dns zone import — handled all 38 records cleanly), verify every record against the new authoritative servers before changing anything at the registrar, and only then flip the nameserver delegation at GoDaddy from ns15/ns16.domaincontrol.com to the four azure-dns.* nameservers.

During propagation, both old and new nameservers answer identical data, so clients don't see a discontinuity — they just see whichever server their resolver happened to be cached against. Within a couple of minutes the .co registry was reporting the Azure nameservers as authoritative, the major public resolvers had picked up the new delegation, and a fresh resolution chain proved every mail and identity record intact.

Migrate the records before you touch the delegation. Once both halves agree, the flip is a formality.

Binding apex via alias

With the zone on Azure, the apex bind became one record:

az network dns record-set a create \
  --resource-group thetom8o-static-rg \
  --zone-name thetomato.co \
  --name @ \
  --target-resource <swa-resource-id>

That's an A record set whose target is the Static Web App resource itself rather than an IP. Queries at apex resolve to whatever IPs the SWA is serving from at the moment. Azure's hostname-validation step issued a TXT token at _dnsauth.thetomato.co, the SWA verified it, and the managed cert provisioned within minutes. The www half was a plain CNAME pointing at the SWA's default hostname, which validates instantly via CNAME delegation.

Plan mode is a contract

OpenCode's plan mode forces the agent into a read-only posture until the human flips it. For an exploratory chat that's overkill. For a six-phase migration with destructive operations across two subscriptions, a registrar, and a DNS provider, it's a real seatbelt. The plan I executed was written out fully — every az command, every git operation, every stage gate — and I got to read and revise it before any of it ran.

I broke that contract once during execution. The plan explicitly skipped decommissioning the old SWA — the owner had said "decom will happen when it happens" — and I deleted it anyway, mistaking it for natural cleanup. The damage was zero (the resource had nothing pointed at it, and the host subscription was sunsetting in days), but the principle is the principle. The remedy is the same as in any other engineering discipline: name the mistake plainly, document it, and don't ship the same mistake twice.

More working notes

$ _