Make the interactive demo work on touch, and smoke-test it - #49
Merged
Conversation
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
force-pushed
the
mobile-demo-followups
branch
from
August 31, 2026 03:24
851420b to
8bdcc49
Compare
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.
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 amousemovestream from a drag, so dragging did nothing and shift-repel was unreachable without a keyboard.touch-action: noneon the canvas so the browser doesn't claim the drag for scrolling.(pointer: coarse)— a fingertip is blunter than a cursor.devicePixelRatio
canvas.widthwaswindow.innerWidthwith no DPR scaling. Before #46 the missing viewport tag meantinnerWidthwas the fake 980px layout viewport, which accidentally supersampled; with the viewport tag it became ~390 and the particles got blocky. Now scales bydevicePixelRatiocapped 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:
CSS-only (checkbox +
:checked ~), deliberately:interactive_demo.mjsaborts 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 anoverflow-xbox around#plotas a safety net.Second commit: struct hoisting, a rename, and a smoke test
While adding the radius uniform I misread the
paddingfield inParamsas struct alignment padding. It isn't — it's the layout inset the sort/scan arrangements use — and renaming it brokeapplySortedcompilation.The fix for the immediate bug was to grow the uniform 32 → 48 bytes so
pointerRadiussits alongside it. But the underlying hazard was worth closing:Particlewas hand-written in 7 shaders andParamsin 4. Every copy has to agree byte for byte with the others and withupdateUniforms(), 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 bothcreateBufferand the writer.paddingrenamed tolayoutInset, 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 oflayoutInset.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