Brett Bullock
← All work

This site

This site is a project too, built to be fun to poke at. The background moves with your cursor and things drift past now and then. A small 3D guide answers questions about me out loud, and you can make it a bull, a lion, a giraffe or an otter, then pick its colour, fur, name and voice. Underneath, it's plain HTML, CSS and JavaScript with a few serverless functions.

ProblemMost sites are static

Most portfolios are a static page of links. That feels closer to the early web than to what's possible now.

SolutionA love letter to the internet

Something fun and interactive: demos that run right on the page, a guide you can talk to, and a few things hidden around.

ValueSomething to try, not just read

You can try a demo or ask a question in a few seconds, and there's usually something to poke at.

By the numbers1 day

from no code to the first version. It's about 6,000 lines of HTML, CSS and JavaScript now, no framework, and Claude Code wrote most of them.

Built with

Tap any piece of the stack to see what it does here.

Why I built it

I wanted a place to show the things I've built, and to have some fun doing it.

One of the things I loved about my time at Shopify was watching the team behind Shopify Editions flex on the web. They thought of the Editions pages they built and released as love letters to the internet: fun, interactive and exciting, not the static pages you so often see, which feel closer to the beginning of the web than to what's possible now.

So this site is my own small love letter. The demos run right on the page, there's a guide you can talk to, and there are a few things hidden around.

Try it

  1. 01Ask the guide something about my experience. It's in the corner.
  2. 02Make it yours. Pick a bull, a lion, a giraffe or an otter, then its colour, fur, name and voice. Press and hold it to move it anywhere, or flick it to spin it.
  3. 03Flip a project card on the home page to run its demo in a phone.
  4. 04Look around. A few things on the site aren't on any menu. The guide might give you a hint.

How it fits together

Decisions, and why

DecisionWhy
Make it funMost people only skim a portfolio. Something to play with keeps them around a little longer, and it was more fun to build.
No frameworkPlain pages load fast, there's nothing to upgrade, and the code is easy to read.
The guide waits its turnIt loads only after the page settles and never covers text, so the fun never slows down someone skimming.
Answers only from one fact sheetA guide that speaks for me must not make things up. If it isn't in the file, it says so.
Limits with free fallbacksPublic access to paid APIs needs a ceiling. Falling back to built-in answers and the browser's voice means the site never just breaks.
Premade voices onlyA visitor can never pick a voice cloned on the account.
Own analytics, alongside Vercel'sVercel counts totals. A few lines on Redis show a visit's pages, time and clicks in order, with no IP addresses stored.
A web mock-up for DempInA streamed Android emulator's free tier is about 100 minutes a month across all visitors. A mock-up with sample data is always on.
Measure before tuningProfiling showed the guide was the main cost, so that's where the work went.

A guide that never gets in the way

A chatbot on a portfolio is easy to get wrong, so I set some rules before building it. It shows up only after the page has loaded, it's small, and you can tuck it away for good. It follows your cursor only in the right-hand margin, on a wide screen with a mouse; otherwise it stays in the corner, away from the text. It offers at most three tips a visit, 35 seconds apart, and stops if you dismiss one.

It only knows what's in one fact sheet in the repo, and when something isn't in there, it says so. Under every reply it shows which model answered, how long it took and how many tokens it used. And it tells you I can read the questions, because I can.

Analytics, kept small

Vercel's analytics give me totals. I also wanted to see a single visit: which pages someone read, for how long, and what they clicked or asked. So each event is a small record in one Redis list, and the stats page does the math in the browser.

It keeps only the city and country Vercel reports and the device type, never IP addresses. Link previews and crawlers don't run JavaScript, so they never count as visits.

Scanners that run a real browser are harder. So each visit gets a score for how likely it is to be a person: did it ever touch the page, and does it look like a data centre (an address in a cloud provider's published ranges, graphics drawn without a GPU, a clock that doesn't match the location)? Only those signs are kept, like "AWS" or "no GPU", and the stats page shows the score with its reasons.

Spending money safely

Three parts of the site cost money every time they're used: the guide's answers, its voice, and Claude reading a receipt photo in the DempIn demo. Anyone on the internet can use them, so they need limits.

Each one has an hourly limit per visitor and a daily limit for the whole site, both counted in Redis. Past a limit, or if a key is missing, the guide falls back to a short list of built-in answers and the browser's own voice instead of breaking. The keys live only in Vercel's settings; the browser never sees one. And every answer is logged with where it came from, which model, how long it took and how many tokens, so I can trace a strange reply.

Fast on purpose

When the site started to feel slow, I measured before changing anything. The guide was the main cost: about 125 draw calls a frame for a character 124 pixels wide.

  • Fewer fur layers, merged shapes, and 30 frames a second while it idles.
  • The dot field behind every page batched from about 1,600 colour changes a frame to 16.
  • Demos pause whenever they're off screen.

The DempIn page went from 7 to 16 frames a second in a deliberately slow test, and the Andromeda page from 15 to 60 once its demo is scrolled away.

Later, Lighthouse's phone test gave the home page 37 out of 100 for performance. Serving the fonts from the site, letting the scripts load without holding up the page, and starting the guide a couple of seconds later brought it to 96.