This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
v4 swaps the node graph for a slot machine: three vertical reels where the left one picks a category, the middle one an option inside it, and the right one the write-up behind that option. Same data as the graph, a very different way to wander through it, plus a spin button for when you don’t know what you’re looking for.
The graph in v3 was great at showing how everything connects, but it asked the visitor to do the exploring. A slot machine flips that: it hands you one concrete combination at a time, and the Spin button turns “I don’t know where to start” into a feature. Land somewhere random, read the result bar, open it or spin again. The playful frame also earns the dependent-selection model: nobody questions why the middle wheel changes when the left one does, because that’s just how these machines work.
Reel 1 is the category: an Apps bucket for the features, a résumé bucket, then one bucket per write-up category. Reel 2 holds that category’s options, and reel 3 the write-up behind the selected option. All of it is built by buildSlots() from the same FEATURES and THOUGHTS arrays the graph reads, including the same bridge rule: a feature only pulls its write-up into reel 3 when that write-up is still active. Add a feature or a note and the machine picks it up without anyone touching v4 code, and the graph and the slots can never drift apart.
A machine that only surfaced feature write-ups would strand the standalone ones, so every write-up category from the /thoughts index is its own reel-1 entry. These categories have no app to open, though, so their middle reel is a single greyed-out Write-up only marker, disabled and skipped by the spin, and the write-ups themselves stack in reel 3, where they belong. Pick Build & Tooling and the middle greys out while deployment, bundlers, and the rest fill the right-hand reel. Deprecated notes get the same treatment through a trailing Deprecated category, each wearing a small amber tag so the state is obvious. There are two things called “Features” on the category reel, and that’s deliberate: the Apps bucket holds the features themselves, while the Features write-up category holds the notes about building them. When an app has no write-up at all, reel 3 shows a friendly empty state with a link to browse everything instead of a blank wheel.
Spin picks a random valid combination first, then, like a real machine, all three reels start turning at once and lock in left to right: reel 1 lands, then reel 2 within the landed category, then reel 3. A column that hasn’t reached its turn free-wheels on fast, even steps so the reels to the right of whatever is landing are always visibly moving, and every still-turning column wears a vertical motion blur that snaps sharp the instant it stops. The landing itself steps through a handful of rows on timeouts with widening gaps, so it reads as a wheel losing momentum rather than a value snapping into place. The trick that keeps it clean is that reels 2 and 3 lock their contents to the chosen target the moment the pull starts, so while they spin they cycle their own frozen options rather than thrashing through every category reel 1 passes on the way down. It all stays fast, and input is off mid-spin so a stray click can’t desync the three indices. Under prefers-reduced-motion all of that is skipped: the reels jump straight to the target with no stepping and no transitions. The tested logic in the motion path is a pair of tiny pure helpers, wrapIndex and shortestDelta, that treat every list as a wrapping wheel.
Underneath the chrome each reel is a plain listbox: focusable, labelled, with aria-activedescendant pointing at the selected row and every row a real option. Up and Down move the selection within a reel (wrapping), Left and Right hop focus between the three columns and skip any greyed-out reel, Home and End jump, and Enter opens whatever the focused reel points at, including the category reel, which opens the landed destination so the leftmost column is never a dead end. A polite live region announces the combination after every spin or selection, and ⌘K opens a command palette for jumping straight to any page or note. The result bar under the machine is still the primary navigation: ordinary links stating exactly where they go, so nobody has to operate the novelty UI to get anywhere. Selection is never colour alone: the landed row sits large and sharp under a glass magnifier bar while its neighbours soften, and once a column settles a small hand-drawn arrow draws itself in to name the result in place of a static header. Everything focusable has a visible ring.
The payout animation from the list below has since shipped: line three reels up on a feature and its write-up and the window fills with a fall of party confetti, colour-matched to what you landed on. It looked great on my laptop and stuttered on my phone, and the story of chasing that stutter is worth keeping, because it ends in a compromise rather than a clever fix.
The first pass just cut the piece count, and it still stuttered, because count wasn’t the whole cost. Each piece carried a translucent foil sheen, and a phone re-blends that alpha across dozens of overlapping, tumbling layers every single frame. So the second pass went after the real work: drop the sheen for a solid fill on mobile, thin the burst further, and promote every piece with will-change: transform up front so the layers don’t all get allocated at burst start, which was the jump on the first frame.
I wanted to measure that before trusting it, so I benched the real confetti in headless and headed Chromium at an iPhone-class viewport, CPU throttled four to six times, device-pixel-ratio pushed from 3 all the way to 14. Every run held a locked 60fps — which sounds like a pass but is really the bench failing to reproduce the bug. A transform-only animation runs entirely on the compositor thread, so CPU throttling never touches it, and a GPU trace confirmed it: under a millisecond of raster across the whole burst, because nothing repaints, it only re-composites. The cost is fill-rate, blending translucent pixels, and a desktop GPU has so much headroom for that it never breaks a sweat. That headroom is the whole reason the stutter only ever showed up on the actual phone. The lesson worth writing down: emulating a phone’s screen is not emulating a phone’s GPU.
What I could measure was the work the second pass removed — about a 72% cut in alpha-blended pixels per frame, from roughly 99,000 device pixels down to 28,000 at a 3x DPR. So I shipped it to a preview build and tried it on the real phone. It still froze and lagged. That was the deciding data point: the approach itself, a fall of dozens of independently tumbling layers, is more than a phone GPU will carry, and shaving the per-piece cost only moves the threshold, it doesn’t cross it. Rather than keep chasing a smooth version that may not exist on mid-range hardware, I made the call to disable the confetti on mobile entirely.
The compromise is easier to accept once you notice the confetti was never load-bearing. It’s decoration layered behind the reels; a win already reads without it, through the reels locking into place, the result bar naming what you landed on, and the jingle. So on a phone the celebration is those three things, and the confetti is desktop-only, where the GPU has the room for it and it still falls in full with its foil sheen. When the choice is a janky effect or no effect, no effect wins — a dropped-frame stutter reads as the whole page being broken, which is a worse first impression than a clean win with no paper. The desktop-only threshold lives behind one useIsMobile check, so if I ever build a genuinely cheap mobile celebration — a handful of pieces, or a CSS-only flash — it’s a small, honest place to add it.
A weighted spin that favours things the visitor hasn’t landed on yet, and a genuinely cheap mobile win celebration — a handful of pieces or a CSS-only flash — to replace the confetti a phone GPU couldn’t carry.
Two things nagged at me once the machine had been live for a while. The first: every other page carries the little theme dropdown — system, light, dark, plus a Settings link tucked inside — and the landing didn’t. The signed-in hub had quietly grown a bare Settings button sitting next to Log out instead, which is exactly the kind of one-off that the shared menu exists to prevent. So I pulled the same HeaderMenu the rest of the site uses into a small LandingActions cluster and dropped it into both the guest and signed-in variants. Settings now lives only inside the menu, and the standalone button is gone.
The second was a report that the landing looked “zoomed in” on a phone — you had to pinch out to see the whole thing. My first instinct was a viewport bug, but the meta tag is the ordinary width=device-width, initial-scale=1 and the visual viewport measured a scale of exactly 1. Nothing was zooming. The machine was just taller than a short phone screen: at 390×640 the result caption and the bottom nav sat 147px past the fold, so the part you actually click was the part you had to scroll to find. The reels carry the spin animation’s pixel math, so I left their geometry alone and reclaimed the space from everything around them — top and bottom padding, the data line, the spin button, the caption — all trimmed on mobile and restored to their old sizing at the sm breakpoint. It fits in one screen now, which is the only honest way to answer “are we zooming or does it just not fit”: it wasn’t zooming, and now it fits.