Skip to content

Homepage demo: the device answering three questions - #32

Open
hhff wants to merge 27 commits into
mainfrom
homepage-demo
Open

Homepage demo: the device answering three questions#32
hhff wants to merge 27 commits into
mainfrom
homepage-demo

Conversation

@hhff

@hhff hhff commented Aug 29, 2026

Copy link
Copy Markdown
Member

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

  • The screen is a CanvasTexture on the display mesh, so the depth buffer occludes it with the bezel for free. The panel has no UVs (CAD export) and about a third of it sits behind the bezel, so the canvas is mapped to the measured aperture: a ray is fired at the panel from in front at each step along its centre lines, and the uncovered run is the opening.
  • Anisotropic filtering on that texture. The panel is tilted, so it minifies unevenly — 1.76x horizontally against 2.81x vertically — and isotropic sampling picks its mip level from the tightest axis and blurs both.
  • Resizing happens in the frame that draws. setSize reallocates the drawing buffer; doing it from the ResizeObserver lets the browser paint the empty buffer first, which is what made dragging a window edge flicker.
  • The phone is scaled, never reflowed. Everything inside is in iOS points, so narrowing the box squeezes the status bar icons out and rewraps the copy. phoneFit.ts returns 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.
  • Mobile gives the 3D layer its own corner box rather than panning the device across a full-viewport canvas. The camera only has to fill the box it is handed, so the placement holds on any phone instead of depending on offsets hand-tuned to one aspect ratio.
  • MessageThread was 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.
  • Removed 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=true gives 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 build clean · npm test 168 passing (72 new) · tsc --noEmit clean · eslint clean 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

  1. Are Boland and Kavanagh real in fam-api/fixtures/seeds? They are inventions.
  2. ups is loaded but never drawn — a battery callout would be nearly free if the labels ever come back.
  3. On mobile the artifact cards are decorative: the device is small and its labels are hidden.

🤖 Generated with Claude Code

https://claude.ai/code/session_0121UKuyfSWGudtkFRTzNqLo

hhff and others added 23 commits August 28, 2026 20:13
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
@hhff hhff changed the title Homepage demo: labelled hero, then three answered questions Homepage demo: the device answering three questions Aug 29, 2026
hhff and others added 4 commits August 29, 2026 12:08
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant