Skip to content

Make the interactive demo work on touch, and smoke-test it - #49

Merged
jowens merged 2 commits into
mainfrom
mobile-demo-followups
Aug 31, 2026
Merged

Make the interactive demo work on touch, and smoke-test it#49
jowens merged 2 commits into
mainfrom
mobile-demo-followups

Conversation

@jowens

@jowens jowens commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Follow-ups to #46, which made the interactive demo render correctly on a phone but left it non-functional there. Two commits: the mobile fixes, then a robustness pass plus the test that would have caught a bug I hit along the way.

Touch input

Interaction was bound to mousemove/mousedown/mouseup/mouseleave. Mobile browsers synthesize a click from a tap but never a mousemove stream from a drag, so dragging did nothing and shift-repel was unreachable without a keyboard.

  • Pointer events with pointer capture, replacing all four mouse handlers.
  • touch-action: none on the canvas so the browser doesn't claim the drag for scrolling.
  • A Mode button toggling pull/push. Shift-drag still inverts it for mouse users.
  • Influence radius gets a 1.5x bump under (pointer: coarse) — a fingertip is blunter than a cursor.

devicePixelRatio

canvas.width was window.innerWidth with no DPR scaling. Before #46 the missing viewport tag meant innerWidth was the fake 980px layout viewport, which accidentally supersampled; with the viewport tag it became ~390 and the particles got blocky. Now scales by devicePixelRatio capped at 2, with pointer coordinates and the influence radius scaled by the same factor since the simulation works in backing-store pixels.

Controls panel

#46 shrank the buttons to ~24px with a 0px gap to stop the panel eating a quarter of a phone screen. That fixed the footprint but made three buttons that each launch a slow GPU operation easy to mis-tap. @greggman suggested a collapse bar; this does that instead, which solves the footprint without shrinking anything.

Measured at 390x844:

before #46 #46 this PR
panel footprint ~25% of screen small 1.1% collapsed, 21.1% open
tap targets 40px ~24px, 0px gap 44px

CSS-only (checkbox + :checked ~), deliberately: interactive_demo.mjs aborts early when WebGPU is missing, and the panel should still fold away on the error screen.

Benchmark plots

Observable Plot was hardcoded to width: 1280, which overflows a phone. It now measures its container and clamps, falling back to 1280 where there's no DOM to measure. Added an overflow-x box around #plot as a safety net.

Second commit: struct hoisting, a rename, and a smoke test

While adding the radius uniform I misread the padding field in Params as struct alignment padding. It isn't — it's the layout inset the sort/scan arrangements use — and renaming it broke applySorted compilation.

The fix for the immediate bug was to grow the uniform 32 → 48 bytes so pointerRadius sits alongside it. But the underlying hazard was worth closing:

  • Particle was hand-written in 7 shaders and Params in 4. Every copy has to agree byte for byte with the others and with updateUniforms(), with nothing enforcing it. Both are now declared once and interpolated.
  • updateUniforms() writes through a named slot table rather than bare indices, and the buffer size is one constant used by both createBuffer and the writer.
  • padding renamed to layoutInset, since that name is what caused the mistake. The real alignment padding is now explicitly _pad0..2.

Smoke test added to misc/run_headless_tests.js, which CI already runs. The demo isn't in the regression suite and isn't shipped in the npm package, so it can rot silently. Deliberately shallow: loads the page, asserts every shader compiled and no error surfaced, checks the controls exist, clicks Sort/Scan/Reduce — Sort and Scan specifically because they're the only readers of layoutInset.

It also probes that the module finished initializing, via the mode button's label changing on click. Worth knowing why: the first draft of this test passed on a broken tree, because a shader failure aborts the module before listeners are registered, so the clicks did nothing and every check passed vacuously.

Verification

Headless Chrome with WebGPU at 390x844 and 1440x900: no shader or console errors, canvas backing store 780x1688 and 2880x1800 respectively, zero page overflow, mode toggle works, render loop live across a synthesized touch drag, desktop layout unchanged.

Smoke test verified in both directions — green on this tree, red with the field-rename bug reintroduced, reporting both the shader error and the inert mode button. Regression suite still 31/31.

🤖 Generated with Claude Code

jowens and others added 2 commits August 30, 2026 20:23
Follow-ups to #46, which made the demo render correctly on a phone but
left it non-functional there.

Touch input. Interaction was bound to mousemove/mousedown/mouseup/
mouseleave. Mobile browsers synthesize a click from a tap but never a
mousemove stream from a drag, so dragging did nothing and the shift-key
repel was unreachable without a keyboard. Switched to pointer events
with pointer capture, added touch-action:none to the canvas so the
browser doesn't take the drag for scrolling, and added a Mode button
that toggles pull/push. Shift-drag still inverts the mode for mouse
users.

devicePixelRatio. canvas.width was window.innerWidth with no DPR
scaling. Before #46 the missing viewport tag meant innerWidth was the
fake 980px layout viewport, which accidentally supersampled; with the
viewport tag it became ~390 and the particles got blocky. Now scales by
devicePixelRatio capped at 2. Pointer coordinates and the influence
radius scale by the same factor, since the simulation works in
backing-store pixels.

The radius needed a new uniform. Note that the existing `padding` field
in Params is not struct padding - it is the layout inset used by the
sort and scan arrangements - so the uniform grew from 32 to 48 bytes to
carry pointerRadius alongside it. The inset now scales with dpr too.

Controls panel. #46 shrank the buttons to ~24px with a 0px gap to stop
the panel eating a quarter of a phone screen. That fixed the footprint
but made three buttons that each launch a slow GPU operation easy to
mis-tap. Collapsed the panel behind a disclosure toggle instead, so the
footprint problem is solved without shrinking anything: measured at
390x844, the panel is 1.1% of the screen collapsed and 21.1% open, with
every tap target at 44px. CSS-only, because interactive_demo.mjs aborts
early when WebGPU is missing and the panel should still fold away on
the error screen.

Benchmark plots. Observable Plot was hardcoded to width:1280, which
overflows a phone. It now measures its container and clamps, falling
back to 1280 where there's no DOM to measure. Added an overflow-x box
around #plot as a safety net for anything still too wide.

Verified in headless Chrome with WebGPU at 390x844 and 1440x900: no
shader or console errors, canvas backing store 780x1688 and 2880x1800
respectively, zero page overflow, mode toggle works, render loop live
across a synthesized touch drag, and the desktop layout unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two robustness fixes to demos/interactive_demo.mjs plus the test that
would have caught the bug they address.

Hoist the struct declarations. Particle (the particle storage buffer
layout) was written out by hand in 7 shaders and Params (the
simulationUniformBuffer layout) in 4. Every copy has to agree byte for
byte with the others and with updateUniforms(), and nothing enforced
that: renaming a field in the declarations but missing a shader body
that used it is exactly how the previous commit broke applySorted. Both
structs are now declared once and interpolated, so the copies cannot
drift. updateUniforms() writes through a named slot table (P) rather
than bare indices, and the buffer size is a named constant used by both
createBuffer and the writer.

Rename Params.padding to layoutInset. It is not struct alignment
padding - it is the margin the sort and scan arrangements keep clear
around the canvas edge - and reading it as padding is what caused the
bug. The actual alignment padding is now explicitly _pad0.._pad2.

Add a demo smoke test to misc/run_headless_tests.js, which CI already
runs. The demo is not in the regression suite and not shipped in the
npm package, so it can rot silently. The test is deliberately shallow:
it loads the page, checks every shader compiled and no error surfaced,
checks the controls exist, and clicks Sort, Scan and Reduce. Sort and
Scan matter specifically because they are the only readers of
Params.layoutInset.

It also probes that the module finished initializing, by checking the
mode button's label changes when clicked. Without that, a shader
failure aborts the module before the listeners are registered and every
later check passes vacuously - which is what happened on the first
draft of this test.

Verified both ways: green on the current tree, and red with the
original field-rename bug reintroduced, reporting both the shader error
and the inert mode button. Regression suite still 31/31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jowens
jowens force-pushed the mobile-demo-followups branch from 851420b to 8bdcc49 Compare August 31, 2026 03:24
@jowens
jowens merged commit 5104b6e into main Aug 31, 2026
2 checks passed
@jowens
jowens deleted the mobile-demo-followups branch August 31, 2026 03:53
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