Skip to content

Some Tweaks for Mobile - #46

Merged
jowens merged 2 commits into
gridwise-webgpu:mainfrom
greggman:mobile-demo
Aug 31, 2026
Merged

Some Tweaks for Mobile#46
jowens merged 2 commits into
gridwise-webgpu:mainfrom
greggman:mobile-demo

Conversation

@greggman

Copy link
Copy Markdown
Collaborator

Make the examples more mobile friendly

* add meta tag for mobile
* remove / on closing tag. HTML is not XML
* shrink controls on small screens
* set `<canvas>` to `display: block` rather than using `overflow: hidden`
  to hide the fact that `<canvas>` defaults to `display: inline`
* use `height: 100%` instead of `height: 100vh` so it handles
  mobile hiding/removing bars. `100dvh` would also work.
@greggman
greggman requested a review from jowens August 31, 2026 02:42
@jowens

jowens commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Reviewed this with Claude Code. Merging — the viewport tag alone justifies the PR, and the 100vh/100vwheight: 100% + overscroll-behavior swap is the right modern pattern.

The following are Claude's suggestions, not blockers. Recording them here so they don't get lost; I'll split them into follow-up issues.

1. The @media block shrinks touch targets, which is backwards for a mobile PR

@media (height < 600px) or (width < 600px) {
  #controls { gap: 5px;
    .button-group { gap: 0px;
      button { padding: 5px; } } }
}

padding: 10px 20pxpadding: 5px with a 0px gap gives roughly 24px-tall buttons stacked flush against each other. That's the floor of WCAG 2.5.8 (24px) with no separation, and well under Apple's HIG target (44pt). Sort / Scan / Reduce each kick off a different slow GPU operation, so a mis-tap is expensive.

Suggested instead: keep the button height, and shrink the panel via a horizontal button row, smaller panel padding, a smaller min-width (currently 180px), or making the panel collapsible.

2. devicePixelRatio — this PR silently lowers the demo's render resolution on mobile

demos/interactive_demo.mjs:43 does canvas.width = window.innerWidth with no DPR scaling.

Today, with no viewport tag, innerWidth on a phone is the fake 980 layout viewport, and that canvas gets scaled down to ~390pt — accidentally supersampling at ~2.5x, which is why the starfield looks sharp. After this PR innerWidth is ~390, so we render 390 device pixels stretched across a 3x screen and the particles get visibly blockier.

Not a reason to hold the PR (it also makes rendering ~6x cheaper, which a phone GPU will appreciate), but worth pairing with:

const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(window.innerWidth * dpr);
canvas.height = Math.round(window.innerHeight * dpr);

and scaling mouseX/mouseY by the same factor, since raw clientX/clientY are fed into canvas-pixel space at interactive_demo.mjs:979-982.

3. The demo still has no touch input

Interaction is bound to mousemove, mousedown, mouseup, mouseleave only (interactive_demo.mjs:979-1008). Mobile browsers synthesize a click from a tap but not a mousemove stream from a drag, so "Click: attract" barely responds and dragging does nothing. "Shift: repel" is unreachable without a keyboard.

So after this PR the demo looks right on a phone and still doesn't do anything on one. Suggested fix: switch those four listeners to pointerdown/pointermove/pointerup, add #canvas { touch-action: none }, and replace the shift-key repel with a mode toggle button.

4. Back-port user-scalable=yes to the Jekyll docs

docs/_includes/head.html:4 currently ships maximum-scale=1, user-scalable=no, which disables pinch-zoom — an accessibility anti-pattern (iOS Safari ignores it now; Android Chrome doesn't). This PR's width=device-width, initial-scale=1.0, user-scalable=yes is the better convention and should replace it.

5. Heads-up: the perf pages will now scroll horizontally

benchmarking.mjs:491 renders Observable Plot output at a hardcoded width: 1280. On scan_sort_perf.html, reduce_perf.html, and benchmarking.html that will overflow a ~390px viewport and require sideways scrolling.

Strictly better than the status quo — at 980px those plots already overflowed, they were just also unreadable — but making the plot width responsive would finish the job. examples/gridwise.css uses only max-width, so the other example pages are already fluid and should look good.

@jowens
jowens merged commit 4bed4b3 into gridwise-webgpu:main Aug 31, 2026
2 checks passed
@greggman

Copy link
Copy Markdown
Collaborator Author

you can probably ask claude to fix the mobile input.

As for it's advice on the UI getting smaller, as it was, the padding made the UI fill a 1/4th of the screen on mobile. Adding a collapse bar might solve that. Just making the buttons smaller also solved it. The UI is not that complex so maybe small buttons are fine.

@greggman
greggman deleted the mobile-demo branch August 31, 2026 02:57
@jowens

jowens commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

@greggman thanks — you were right on the UI size, and I've taken the collapse-bar route. Reporting back on all of it. (Analysis and implementation by Claude, reviewed by me.)

On the panel size: your objection was correct

Claude's first pass kept the buttons at full size but spanned the panel edge-to-edge, which landed at the same ~1/4 of the screen you were complaining about — i.e. it would have reintroduced the problem your change was actually solving, while lecturing you about tap targets. Not a good trade.

So: collapse bar, as you suggested. The panel folds behind a single button on small screens, which fixes the footprint without shrinking anything.

Measured at 390x844:

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

Collapsed is a 44x44 button in the corner. That's a better footprint than the shrunk-button version and keeps full-size targets, so it's not a compromise between the two.

It's 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.

Mobile input: done

  • pointerdown/pointermove/pointerup/pointercancel with pointer capture, replacing the four mouse handlers.
  • touch-action: none on the canvas so the browser doesn't take the drag for scrolling.
  • A Mode button toggling pull/push, since shift is unreachable without a keyboard. 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: done, and it needed more than the two lines I suggested

Math.min(devicePixelRatio, 2), with pointer coordinates scaled by the same factor.

The part I'd missed when I wrote the original suggestion: the influence radius is in backing-store pixels too, so it needed to scale as well or the interaction would shrink on high-DPI screens. That needed a new uniform field — and while adding it I misread the existing padding field in Params as struct alignment padding. It isn't; it's the layout inset the sort/scan arrangements use. Renaming it broke applySorted compilation.

Caught it, fixed it properly (uniform grew 32 → 48 bytes so pointerRadius sits alongside), and then fixed the underlying hazard: Particle was hand-written in 7 shaders and Params in 4, all of which must agree byte for byte with each other and with updateUniforms(), with nothing enforcing it. Both are now declared once and interpolated. padding is renamed layoutInset, because that name is what caused the mistake.

Test coverage for the demo

The demo isn't in the regression suite and isn't shipped in the npm package, so nothing would have caught any of this. Added a shallow smoke test to misc/run_headless_tests.js (which CI already runs): loads the page, asserts every shader compiled and no error surfaced, checks the controls exist, and clicks Sort/Scan/Reduce — Sort and Scan specifically because they're the only readers of layoutInset.

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

Verified both directions: green on the current tree, and red with the field-rename bug reintroduced, reporting both the shader error and the inert mode button.

Docs (#47)

Fixed separately. Root cause there was worse than the surface read: the article text wasn't just cramped, it was rendered entirely off-screen.docs-container never switched to flex-direction: column, so the full-width sidebar took the viewport and the content column was laid out beside it at x=410, then clipped by overflow-x: hidden. Details in #47.

Your user-scalable=yes convention from this PR is back-ported to the Jekyll docs there, replacing maximum-scale=1, user-scalable=no.

jowens added a commit that referenced this pull request Aug 31, 2026
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>
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.

2 participants