Homepage demo: the device answering three questions - #32
Open
hhff wants to merge 27 commits into
Open
Conversation
Replaces the static poster + CSS-perspective screen with the real model in three.js, so the device can be moved, lit and driven by script rather than re-rendered offline every time the framing changes. The lock screen is a CanvasTexture on the display mesh itself, which is what makes it survive a camera move: the depth buffer occludes it with the bezel instead of a hand-tuned mask. Two things worth knowing about the panel. It has no UVs (CAD export), so they are generated by projecting vertices onto their own plane, anchored to world up so the texture lands upright. And roughly a third of it sits behind the bezel, so the canvas is mapped to the measured aperture rather than the whole mesh: a ray is fired at the panel from in front at each step along its centre lines, and the uncovered run is the opening. Before that, anything drawn near an edge landed on hidden geometry. Removes components/DeviceScreen.tsx and its ~310 lines of CSS, which were a second copy of the same design and no longer referenced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
device-shell.png, device-screen.png and screen-mask.png existed to fake the device with a tinted render and a CSS-perspective screen. Nothing references them now. 264KB. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
One rAF clock with a pure stateAt(t), so the device's thinking indicator and the phone's typing bubble are read from the same number in the same frame. That ordering is the argument the demo makes; two timers would eventually blur it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
stateAt(t) returns the whole world at an instant: camera, explode, card position, thinking, labels, how much of the thread is showing. Nothing schedules itself, so seeking, replay and reduced-motion are all the same call with a different number. Thread entries carry their own beat tags, so demoState derives when each bubble is due rather than hardcoding a count per exchange. Adding a bubble to threadScript schedules it. Cue times are compared as absolutes. Relative comparison loses the boundary to floating point: 9.1 + 2.75 - 9.1 is 2.7499999999999996, so the typing bubble outlived its reply on exchange two and not on one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Presses play, the device assembles itself and steps back, the phone rises, and three questions get asked and answered in 24 seconds. Each answer is visibly produced: an artifact card slides up the device's own screen and is named by the same label system that names the hardware. The exploded hero turns the Orin back on. It was hidden as scenery; it is the point of the drawing. That meant restricting the aperture raycast to the enclosure, because including a CAD assembly of several thousand meshes in an 800-ray scan froze the main thread for seconds. Elapsed time is accumulated per frame and clamped, rather than read off the wall clock. A backgrounded tab stops firing rAF, and reading the wall clock on return would snap the demo to its end. MessageThread was 347 lines of static JSX. The copy is now data with beat tags, the bubbles are components, and the thread reveals a prefix of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The hydration mismatch was the serious one. SceneLabels seeded its state from the clock, whose idle label set depends on how long the scene has been up, so the server and the client disagreed on how many had arrived. React then regenerated the tree, which tore down the WebGL scene while it was still loading. Both SceneLabels and MessageThread now render the resting state and let subscribe() catch up on mount. The play control was invisible: it asked for var(--fi-white), which is not a token in this palette, so the declaration was dropped and black text sat on a black pill. Reduced motion gets the finished drawing rather than a build, compact viewports skip the exploded hero entirely (their labels are hidden, and an exploded device with nothing naming its parts is a broken machine), and neither the label overlay nor the phone holds a rAF loop open while the page is at rest. Reply attributions distinguish a citation from a receipt: from "GP summary" quotes a record, "Sent from your Gmail" reports an action, and rendering the second like the first read as nonsense. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Hovering part-closes the device; pressing play then re-opened it fully before assembling, because the intro always started from explode 1. The starting point is now an argument to stateAt rather than module state, so the function stays pure and the continuity is testable. Brings the design doc back in line with what got built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
An attribution can only appear under a reply already on screen. BEAT fixes that order, but nothing was holding it there, and the failure would render as a citation floating under nothing. resume() now refuses unless the clock was actually parked mid-run. From idle it would have started a play without recording whether the hover had already part-closed the device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Replay left the phone stranded on screen. It was positioned by a transform written every frame, and a value written per frame cannot be transitioned away from — when the loop stopped, the last frame's styles just stayed. The rise is now a class and a CSS transition, so it eases out as well as in, and one rAF loop goes away with it. The thread holds its last messages through the fade rather than emptying the phone the instant replay is pressed. Everything ran on one smoothstep before, which is symmetrical and reads as machinery: things left and arrived at the same rate. easing.ts adds the same cubic-beziers CSS takes, so a motion driven from the clock and one driven from a stylesheet can share a curve. The camera leaves fast and lands slowly, the device closes with weight at both ends, and cards accelerate away when they go. None of the curves leave 0..1 — an overshoot on a card rising up the device screen would leave a strip of lock screen showing beneath it, because the card is placed by its height. Labels were pure black hairlines over a render. Now near-black on a soft wash of the page colour, each with a dot terminating its leader line, fading in on its own beat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Removes "Delivered to the box in your kitchen" from the third exchange, and with it the Receipt bubble and the 'receipt' entry kind, which nothing else used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The device no longer comes apart. It sits assembled on the right with its parts called out, and pressing play drifts it back and further right over three and a half seconds on a curve that is soft at both ends. The old opening did two things at once — assembling itself while the camera moved — on a curve that left hard and arrived hard, which is what read as jerky. Removing the explosion also removes the hover reward, the per-part travel vectors and the framing that had to grow to contain them. The transcription under the first voice note was never right-aligned: ml-auto on a paragraph is dead here, because globals.css zeroes p margins outside any cascade layer and beats Tailwind's layered utilities. It is a flex container now, and the attribution line's top margin — silently doing nothing for the same reason — is padding. The phone is fixed rather than sticky. Sticky unpins as soon as its containing block scrolls past, which on a page this tall meant it drifted for a while and then stuck near the bottom. Its offset is the layout it replaces, not a guess: 80px page padding, 620px text column, 96px gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The screen texture was sampling at anisotropy 1. The panel is tilted away from the camera, so it is minified unevenly — measured 1.76x horizontally against 2.81x vertically — and isotropic filtering picks its mip level from the tightest axis, blurring both. Adding texels would not have helped: at 1070x800 against 608x284 device pixels there is already surplus resolution in each direction. The filtering was the limit, so the fix is 16x anisotropy, which the hardware here reports as supported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The idle frame is just the device now. Removing the callouts takes the staggered label arrival with it, and with that the clock's idle rAF, the markReady hook the stagger was timed from, the group-opacity fade and the compact flag that only ever existed to hide labels on narrow screens. idleState is a constant, so the render loop hands back a cached object rather than rebuilding one sixty times a second. Dragging over the right of the viewport now turns the device. The surface is a separate element rather than listeners on the canvas, which sits behind the page content and takes no pointer events; it covers only the side the device is on, so dragging across the About column still selects text. Above the breakpoint only — below it the device is a dim backdrop and swallowing drags there would break scrolling. The angle is an offset leaned on top of the script's framing, not an absolute pose, and it is faded out against the opening drift so letting go of the device and pressing play never fight over the same frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Adds an "ambient camera" section to the demo panel: the six values that make up the pose the demo opens on, a Copy that emits the HERO_POSE literal to paste back into timeline.ts, and a Reset to the committed numbers. Kept separate from the device panel's camera sliders on purpose. Those take the camera off the clock entirely so materials can be tuned at any angle; these change the pose the demo actually starts from and drifts away from, so moving one while scrubbed returns the clock to idle to show what changed. "Keep view" is the useful one: drag the device to an angle and make that the start, rather than hunting for the same view with three direction sliders. The scene publishes the direction it is rendering from, which is null until it has drawn a frame — clicking before then would otherwise have captured a placeholder and overwritten a good pose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The Copy button emitted a DEVICE_DEFAULTS literal with no cameraOverride field, so pasting it back would not compile. It was correct until that field was added and then quietly went stale. The panel's camera sliders also started from the stored defaults, which still held the framing the device used before the demo existed. Flipping Manual cam therefore jumped the device to a pose from an earlier design. They now seed from wherever the demo's camera actually is, so the switch picks up exactly where the scene already was, and the stored values say what they are for: a starting point for the override, never what you see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Takes the pasted values, and stops the panel offering camera numbers to commit in the first place. Its camera sliders are a viewing aid: they only bite while Manual cam is on, and the panel seeds them from the demo's live camera, so anything copied out of them is a number that looks committed and changes nothing. Copy now emits the four lines that do matter — the case colour and the grain — as lines to drop into DEVICE_DEFAULTS. The framing the demo opens on has its own control in the demo panel. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Applies the pose arrived at in the debug panel. Keep view captured only the direction, so a view found by changing the distance or the pan could not actually be kept — the angle came across and the rest silently stayed behind. It now takes all four values, which also means it works however the view was reached: dragging the device, or pushing the device panel's camera sliders with Manual cam on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Making the phone height-driven shrank its box to about 315px while everything inside kept its iOS point sizes. The status bar icons were squeezed out of the flex row, the transcription wrapped onto a third line, and the waveform pushed the timestamp off the voice note. It is laid out at 390x844 and scaled now, so every interior proportion is the one it was drawn at. The scale is computed in JS because scale() takes a unitless number and CSS cannot divide a length by a length to produce one — written as a calc() the declaration is silently invalid and the phone renders full size, which is what it was doing. Status bar glyphs also get shrink-0, and the wifi viewBox matches its height instead of being a point taller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
A small green pill in the flow rather than a button floating over the scene, using the play mark from the research page's audio player. The demo is the page's one action, so it belongs with the masthead. One button through all three states — Demo, Playing, Replay — rather than a play control that is replaced by a different replay control at the end, which would move the target out from under the pointer that just watched the whole thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Narrow viewports keep both the device and the phone off screen until the demo runs. Pressing Demo brings the phone up from the bottom edge at 80% of the viewport and the device into the top right corner. The device gets its own small box there rather than being panned across a full-viewport canvas, so the camera only has to fill the box it is given and CSS decides the corner. Panning instead would mean hand-tuned offsets — numbers that are right on the phone they were tuned on and wrong on every other aspect ratio. It also means one compact pose rather than a pair: there is nowhere on a phone to drift to. The phone is scaled to fit inside 80% of both dimensions, whichever binds first, so it stays phone-shaped instead of stretching to whatever the viewport is. Its dock is sized to the visual box so the scaled frame can be placed by ordinary means. Playing now reads "Playing…". The phase is published to the document as data-demo, which is what lets the stylesheet hold the device off screen until there is something to show. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The phone was being squeezed narrower than 390:844 on mobile. A second .phone-frame rule further down the stylesheet still carried max-width: 100% from the earlier sizing attempt, and being later it won — so the frame took its parent's width while keeping its full height. Measured 0.343 against a designed 0.462. Sizing moves into phoneFit.ts, where it is one function of the viewport rather than an expression inline in an effect. Returning a single scale is what keeps the aspect ratio correct by construction instead of by remembering to, and taking the smaller of the two constraints is what keeps the whole phone on screen whichever one runs out first. Tested across fourteen viewports, from an iPhone SE to a desktop display, plus a collapsed one. Measured against the visual viewport where there is one. A phone's layout viewport can be taller than what you can see, with a collapsing toolbar over the difference, and the phone is docked to exactly that edge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The flicker was the resize itself. setSize reallocates the drawing buffer, which leaves the canvas blank until something renders into it, and doing that straight from the ResizeObserver lets the browser paint the empty buffer before the next animation frame arrives. Dragging a window edge fires the observer continuously, so that empty frame is what was flashing. The observer now only raises a flag and the reallocation happens in the frame that draws, so there is never an empty one to see. The phone sits 20px off the bottom rather than welded to it — on a phone the very bottom belongs to the home indicator and the browser's chrome. The gap comes out of the height it is fitted into, so the inset cannot push its top off the other edge. While the demo runs the page steps back to the name and the device: the copy fades out and the control dims to 0.2. It comes back on hover and focus, and in full once the demo is done, because Replay still has to be findable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The step-back treatment is now scoped to narrow viewports. On a phone the demo lands on top of the copy so the page has to get out of its own way; wide, the phone and the device have their own half of the screen and fading the text there just makes the page look broken. The phone also waited 2.2s before it began rising, which read as it holding back until the device had finished getting out of the way. It now starts the instant play is pressed, alongside the camera, and takes 1.4s rather than 0.7 so it is still arriving while the camera is still drifting — one movement rather than a slide followed by a pan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The device's corner box goes from 58vw x 34dvh to 76vw x 42dvh, and the camera moves in from 1.18 to 0.88. The framing fits the model's bounding sphere, which includes its depth, so a distance of 1 already leaves a margin all round — most of the size comes from spending that. Kept at 76vw rather than pushed wider: a box that spans the screen centres the device rather than leaving it in the corner it belongs in. The page no longer scrolls while the demo runs. The copy behind it is at zero opacity, so there is nothing to scroll to, and scrolling would only drag the wordmark and the control off the top. Tapping anywhere off the phone stops the demo. The catcher sits above the page but below the phone, which takes its own pointer events back on mobile so tapping the conversation does not dismiss it. It covers the control too, which is harmless: the control's own action while the demo runs is this same reset. All three are narrow-viewport only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
Its screen was behind the conversation on every phone. The device is centred in a box pinned to the top right, and at 42dvh that box's middle sat below the phone's top edge — 177px against 165 on an iPhone 14, and the same on every other size checked. So the part of the device you most need to see was the part covered. The box is 68vw x 26dvh now, whose middle clears the phone with room to spare, and the camera comes in to 0.85 so the device is no smaller for it. Only its base crosses the phone's edge, which reads as depth. phoneTopFor() and COMPACT_DEVICE_BOX_H pin the relationship, because it is not visible in either file on its own: one number lives in the stylesheet and the other falls out of how the phone is fitted. The test fails on the old 42dvh for all six phones checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The phone's bezel is a quarter thinner and takes the wordmark's off-black. Pure black beside #313131 reads as a different palette; the screen's own UI keeps true black, because that is screen content rather than the device. The whole masthead fades on mobile now — wordmark and control as well as the copy — for the screen space. Nothing is stranded by that: a tap anywhere off the phone already stops the demo and brings it all back. The lock screen's title fades to a fifth while an artifact is up, in step with the card rather than on a timer of its own, so whose device it is stays legible without competing with what it is showing. The first question comes 1.2s sooner. Two tests asserted the camera had formally finished drifting by then; both now assert what actually matters, which is that it has visibly stopped — GLIDE eases out hard enough that the last stretch covers almost no ground. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
The first question now lands at 2.0s. The opening drift is still running at that point — 83% of the distance covered — which is deliberate on both counts: the drift is slow on purpose and the conversation starts early on purpose. The tests say so rather than asserting the camera has formally finished: nearly home by the first question, done before the second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Press Demo under the wordmark and the device drifts back, the phone rises alongside it, and three questions get asked and answered in 26 seconds. Each answer is visibly produced by the box: an artifact card slides up its screen, named by a leader-line label that cites the same source the phone's reply does.
Script:
docs/homepage-demo-script.md. Design:docs/superpowers/specs/2026-08-28-homepage-demo-design.md.How it works
stateAt(t)is a pure function returning the whole world at an instant — camera, card position, thinking, labels, how much of the thread is showing. Nothing schedules itself.That is what keeps the load-bearing beat honest: the device starts thinking 400ms before the phone shows its typing bubble, and that ordering is the argument the demo makes. Read from the same number in the same frame, it cannot drift. It also makes seeking, replay and reduced-motion the same call with a different number.
React renders about two dozen times across the run rather than ~1,600: continuous values go straight to the WebGL loop, and the clock only notifies subscribers when something discrete changes.
Worth a reviewer's attention
setSizereallocates the drawing buffer; doing it from theResizeObserverlets the browser paint the empty buffer first, which is what made dragging a window edge flicker.phoneFit.tsreturns one scale, which is what keeps 390:844 correct by construction, and takes the smaller constraint, which is what keeps the whole phone on screen.MessageThreadwas 347 lines of static JSX. The copy is now data carrying beat tags, the bubbles are components, and adding a bubble schedules it — no count to update anywhere else.DeviceScreen.tsx, ~310 lines of CSS and 264KB of poster renders: a second copy of the same design, unreferenced since the scene went live.Debug
?debug=truegives a stage scrubber (jump to any beat), an ambient-camera section with a Keep view that turns whatever you are looking at into the pose the demo opens on, and the device panel for case colour and grain.Verification
npm run buildclean ·npm test168 passing (72 new) ·tsc --noEmitclean ·eslintclean on every file touched — 8 errors remain repo-wide in files this branch does not touch.The tests cover the parts that would fail silently: every cue boundary a frame either side, the think-before-typing ordering, monotonic reveal, no attribution before its reply, the easing curves staying inside 0..1, and the phone fitting whole at its own aspect across fourteen viewports.
Not verified by me: motion in real time, and how any of it feels under a real pointer. The automated browser reports
visibilityState: hidden, so rAF only fires when a frame is captured and CSS transitions never advance — every timing claim here rests on the unit tests and on seeking, not on watching it run. The orbit drag sensitivity and the 3.4s opening drift in particular want a human eye.Open questions
fam-api/fixtures/seeds? They are inventions.upsis loaded but never drawn — a battery callout would be nearly free if the labels ever come back.🤖 Generated with Claude Code
https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo