Skip to content

Apple-design audit: fluid globe drag, interruptible fly-to, press feedback, OS transparency/contrast settings - #1133

Merged
zkWizard merged 5 commits into
mainfrom
claude/apple-design-audit
Sep 24, 2026
Merged

zkWizard merged 5 commits into
mainfrom
claude/apple-design-audit

Conversation

@zkWizard

Copy link
Copy Markdown
Owner

An audit of https://roamingeye.org against the apple-design skill (Apple's Designing Fluid Interfaces and related WWDC guidance, adapted for the web). This PR fixes the findings that could ship without changing product decisions or HUD geometry. The rest are listed at the bottom with the reason each was deferred.

What changes

1. The globe tracks the pointer 1:1 and a flick has real momentum (§2, §5, §6, §11)

controls.enableDamping = true looked like inertia but acted as lag. Each frame it paid out 8% of the drag still owed, so:

  • the ground trailed the cursor by about 200 ms at 60 Hz while dragging;
  • a flick went no further than a slow drag of the same length, because speed was ignored and only distance counted;
  • it was counted per frame, so a 120 Hz display settled twice as fast as a 60 Hz one.

Damping is now off, and the new src/scene/GlobeMomentum.ts provides the momentum:

  • The release velocity comes from the pointer's own event history, including coalesced events, over the last 100 ms. It is anchored on one earlier sample so it still works when events arrive once a frame. Pointer timestamps are accurate at any frame rate. A frame-sampled version I tried first read most flicks as a stop under 9 fps SwiftShader.
  • Pixels are converted to radians with OrbitControls' own rule (2π · px · rotateSpeed / height), so the throw continues the drag's actual speed at any zoom.
  • The coast uses Apple's scroll-view decay: 0.995 per ms, integrated exactly so the path is the same at any frame rate. It has the same 200 ms time constant the old damping had at 60 Hz, so the globe feels as heavy as before. The difference is that a flick now carries it further than a slow drag.
  • A pointer that came to rest before release doesn't coast. Grabbing stops a coast instantly. A pinch never throws. Speed is capped at one turn per second. Reduced motion gets no coast.

2. A search fly-to can be interrupted (§3: input is never locked during a transition)

CameraFlyer set controls.enabled = false for the full 1.4 s flight, so pressing, scrolling or arrowing the globe mid-flight did nothing. Now any of those takes the camera back from its current position, with no jump, and the gesture that interrupted it continues. The listeners use the capture phase on window, so they run before OrbitControls ignores the event.

It also fixes a small pre-existing bug. When a flight ended while Draw region was armed, it set controls.enabled = true and handed the drag back to OrbitControls, so the globe rotated under the box being drawn. Ending a flight now keeps the drawer in control of the drag.

3. Buttons respond on pointer-down (§1)

The only :active rule in the app was the globe's grab cursor. Every <button> now scales to 0.96 while pressed and eases back over 100 ms. It uses the individual scale property, so it combines with existing transforms and never reflows. Dragging off a pressed button still cancels it. Under reduced motion the press dims to 0.75 opacity instead.

4. Reduce Transparency and Increase Contrast are honoured (§12, §14)

The app has 21 backdrop-filter surfaces and handled neither setting. Both are now handled on the tokens:

  • Reduce transparency: panels become solid and the blur is removed.
  • Increase contrast: panels become near-solid, control borders go from 11–14% to 45–50% opacity, and --muted moves toward the foreground colour. Applies in both themes.

5. Size-specific tracking on the wordmark (§15)

The only large text in the app, the 22–32 px wordmark, had +0.02em letter-spacing. It is now −0.02em. The line gets narrower but not taller, so nothing below it moves.

Evidence

Before/after, scripted Playwright on the live site vs this branch. Chromium with SwiftShader at 9 fps, 1280×720; the view is read back from the URL hash.

Gesture roamingeye.org (main) this branch
Drag 130 px, hold still, release still creeping 4 s after release (−119.21 → −119.24), landing at 29.24° stops at 29.25° immediately
Same drag, released while moving 29.24°, the same as the held drag: release speed ignored 29.25° + a coast (33.6° for this environment's slow scripted "flick")
Search "Paris", press the globe 300 ms into the flight press ignored, lands on Paris (48.86, 2.32) stops mid-flight (14.75, −76.71)

Checked in the browser against the live vite build:

  • Holding a toolbar button gives scale: 0.96; releasing gives none; dragging off cancels (aria-pressed unchanged).
  • With prefers-contrast: more, panels are at 0.95 opacity, borders at 0.5, --muted is #35425a (light theme).
  • With prefers-reduced-transparency: reduce, the toolbar, search and share panels are solid with backdrop-filter: none.
  • With reduced motion, a press gives opacity 0.75 and no scale.

Local gates:

  • npm run verify passes (typecheck, lint, format, unit, catalog, build, bundle budget). The main chunk is 55.1 kB of its 60 kB budget.
  • 13 new GlobeMomentum tests cover: the projection formula; frame-rate independence at 60 Hz, 120 Hz and in one step; a 9 fps event cadence; rest-before-release; grab mid-coast; reduced motion; drawer-held controls; pinch; the speed cap; coalesced events.
  • 2 new CameraFlyer.cancel tests.

Deliberately not in this PR: recommendations for the owner

These came up in the audit but are either product decisions or would move HUD geometry that about 30 e2e specs measure.

  1. The chrome buries the globe (§16 Simplicity). At 375×812 the header chips, the expanded HUD, the attribution line and the toolbar cover about 75% of the screen. At 1440×900 the expanded HUD covers the globe's southern ~40%. Suggestion: collapse the HUD by default on phones, and move the secondary actions (Save PNG, Imagery URL, Compare, Share) into one overflow menu. This needs its own PR because of the HUD-geometry specs.
  2. "Fleet status" and "Find software" are primary header actions for every visitor (§16 Purpose). The fleet dashboard is internal tooling. These belong in the footer or an About sheet.
  3. Jargon in the HUD (§16 plain language). The raw product ID MODIS_Terra_L3_NDVI_Monthly · Aug 2026 sits under the timeline, and the legend carries a three-line caveat paragraph at about 11 px in muted grey on translucent glass. That runs against the caveat budget, and the wording is a science-owner call.
  4. Panels pop in instead of materializing (§7, §12). The layer dropdown should grow out of its pill (transform-origin at the trigger), and sheets should fade and blur in. Deferred because a scale-in makes getBoundingClientRect measurements taken right after opening flaky. The safe version is opacity plus backdrop blur only, with no geometry change.
  5. One material weight everywhere (§12). Every surface uses the same blur(10px) over 0.7-alpha glass, so small chips and the large HUD carry equal visual weight. Suggestion: two material tiers with a saturate() vibrancy boost. Profile first, because blur is recomputed every frame over a WebGL canvas that is always redrawing.
  6. Boot layering. The search field and toolbar paint above the "Loading Earth…" scrim, while the header underneath is dimmed.

Test plan

  • CI: lint, typecheck, unit, build, E2E smoke (WebGL)
  • On a trackpad and on a phone: a slow drag stops where you let go; a flick carries on and slows to a stop; touching a coasting globe stops it
  • Search a place and grab the globe mid-flight: it stops where it was and follows your drag
  • Arm Draw region, search a place, then drag: it draws a box and the globe doesn't turn
  • macOS: turn on Reduce Transparency, then Increase Contrast: the panels go solid, then high-contrast

🤖 Generated with Claude Code

zkWizard and others added 5 commits September 24, 2026 01:19
OrbitControls' damping was lag, not inertia: each frame paid out 8% of the
drag still owed, so the ground trailed the cursor by ~200 ms and a flick
travelled no further than a slow drag of the same length (and settled twice
as fast at 120 Hz). Damping is now off, so the drag tracks the pointer 1:1,
and GlobeMomentum supplies real momentum. On release it reads the pointer's
velocity from its own event history (exact at any frame rate, including
9 fps SwiftShader), maps it through OrbitControls' pixels-to-radians rule,
and coasts with Apple's scroll-view decay (0.995/ms, the same 200 ms time
constant as before). A pointer that rested before letting go doesn't coast.
Reduced motion gets no coast.

A search fly-to held the controls off for its full 1.4 s. A press, wheel or
arrow key on the globe now takes the camera back from its live position
mid-flight. A flight that ends also re-asserts an armed region drawer's hold
on the drag; before, landing handed it back to OrbitControls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the globe's grab cursor responded to pointer-down; every button waited
for the click. Buttons now scale to 0.96 on :active and ease back over
100 ms. The scale uses the individual `scale` property so it composes with
existing transforms, and it never reflows, so the HUD geometry the e2e
suite measures is untouched. Dragging off a pressed button still cancels
it natively. Under reduced motion the press dims instead of moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every panel is a translucent layer blurred over a globe that never stops
moving, and the app ignored the two OS settings that ask for less of that.
Both are now handled on the tokens, so every panel that paints --panel-bg
follows without per-panel rules:

- prefers-reduced-transparency: reduce makes the panels solid and removes
  the backdrop blur (!important, because a user setting outranks the
  per-panel backdrop-filter declarations).
- prefers-contrast: more makes the panels near-solid, raises control
  borders from 11-14% to 45-50% opacity, and lifts --muted toward the
  foreground, in both themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tracking should depend on size: display type wants it slightly negative,
because large letters read as spaced apart. The 22-32px wordmark was the
only large text in the app, and it was set wide at +0.02em. It's now
-0.02em. The line gets narrower but no taller, so nothing below it moves.
Small labels keep their positive tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zkWizard
zkWizard merged commit e0bcd76 into main Sep 24, 2026
13 checks passed
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