Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads, and strips that assemble themselves #93

Merged
enricobuehler merged 10 commits from worktree-gamepad-ios-polish-2 into main 2026-08-07 14:33:19 +00:00
Owner

Everything reported from on-iPhone testing of #91, plus the two rounds of follow-ups it turned up. All verified on device.

The select

Linear, not a ring. Two field findings, one root cause. The ring showed the first option waiting to the right of the last — a promise the navigation doesn't keep, since left/right clamps. And on a 2-option ring the unselected item flipped sides on every step (the 60/120 Hz row), because ±1 are the same place on a ring of two. Positions are fixed now: option i has one home, the ends are the ends, A's wrap travels back across the list.

Long labels no longer overlap. Falls out of the same rework: options other than the facing one exist only while the drum is moving, so a resting row shows exactly its value.

Toggles ride the band too — On/Off and the pin picker's Off/Pinned, with Off left of On to match the left-off/right-on step direction.

Chrome and layout

  • The close ✕ is gone. A gamepad UI exits with B. A chromeless cancel keeps hardware Esc and the macOS sheet working, and the library's loading/error/empty states gained a zero-size B listener — without it a controller-only user hitting a fetch error was trapped once the X went away.
  • The heading is a real heading: leading-aligned on the 24 pt content inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18), across launcher, settings, add-host and library.
  • The selected tab pill is Liquid Glass, accent-tinted through the same consoleGlass the rows use.

The tray blur

Two rounds to get right, and the diagnosis is worth keeping: the blur "grew into place" on a push because its full-bleed came from .ignoresSafeArea(), which resolves a beat after insertion — outside any geometry group and outside the view's own transaction. Vertical stopped popping as soon as that reach became negative padding; the X axis kept popping until the side insets did too. It is now full-bleed by layout (a fixed 80 pt overhang, no ignoresSafeArea), with its shape pinned out of any animation. The bottom reach grew for the tray that sits over scrolling rows, and an ink.shade wash sinks the material's grey lift to black.

Card entrances

Host cards and library covers now assemble themselves: each card swings in on the drum — turned away, small, low, invisible — then rotates flat, grows and rises on a spring with a whisker of overshoot, delayed by its distance from the cursor so the strip fans open from where the eye already is. Implemented once in GamepadCarousel, so both strips inherit it. Reduce Motion drops every bit of travel for a plain unstaggered cross-fade.

Three defects were found and fixed getting there, each worth knowing about:

  1. A state flip inside onAppear lands in the insertion's transaction, where SwiftUI runs with animations disabled — so the entrance simply didn't play. The launcher got away with it; the library, whose strip mounts late when the fetch lands, lost every time.
  2. Opacity must not share the transform's curve. On one spring the card spent its whole rotation at near-zero alpha and only the last few degrees showed, which is why it read as a small slide. The fade now finishes in the first third of each card's window.
  3. A .scrollTransition derives its phase from the geometry of the view it wraps. The entrance was layered outside it, so it moved the very thing the transition measures: cards read as far from centre for the whole travel, phase pinned at fully-receded, and the centred card only collapsed into its focused look as the entrance ended — arriving as a jump. The card builder now hands each caller its CardEntrance and both strips apply it beneath their own transition, which composes on top instead of fighting it.

The entrance is one animated progress value driven through an Animatable modifier rather than per-card .animation modifiers, so nothing wraps a card in an animation that could capture its scroll transition — and if the animation is ever dropped, progress still lands at 1 and every card sits at exact identity rather than stranded.

The library also waits for its art: PosterImage reports when a cover settles (loaded, or every candidate exhausted), the coverflow counts the first few, and the entrance holds until then with a 700 ms backstop — so covers swing in carrying artwork instead of grey rectangles filling in afterwards. Art cross-fades now too, which the touch grid inherits.

The store chip

The coverflow's store/source chip only showed its background on the centred cover — the same law as the tray blur, one layer down: a card riding a scrollTransition is composited with opacity < 1 and a 3D rotation, and a material cannot sample a backdrop through an offscreen composite. The chip is a flat wash there now (StoreBadge(solid:)), which has nothing to sample and is simply always present. The touch grid keeps its material — its cards carry no transform.

Verification

  • swift build (macOS) — green
  • swift build --triple arm64-apple-ios17.0 — green
  • swift test — 208 passed, 5 skipped, 0 failures
  • Built, installed and exercised on the reporting iPhone; the reporter confirms the result
Everything reported from on-iPhone testing of #91, plus the two rounds of follow-ups it turned up. All verified on device. ## The select **Linear, not a ring.** Two field findings, one root cause. The ring showed the first option waiting to the right of the last — a promise the navigation doesn't keep, since left/right clamps. And on a **2-option ring** the unselected item flipped sides on every step (the 60/120 Hz row), because ±1 are the same place on a ring of two. Positions are fixed now: option *i* has one home, the ends are the ends, A's wrap travels back across the list. **Long labels no longer overlap.** Falls out of the same rework: options other than the facing one exist only while the drum is moving, so a resting row shows exactly its value. **Toggles ride the band too** — On/Off and the pin picker's Off/Pinned, with Off left of On to match the left-off/right-on step direction. ## Chrome and layout - **The close ✕ is gone.** A gamepad UI exits with B. A chromeless cancel keeps hardware Esc and the macOS sheet working, and the library's loading/error/empty states gained a zero-size B listener — without it a controller-only user hitting a fetch error was trapped once the X went away. - **The heading is a real heading**: leading-aligned on the 24 pt content inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18), across launcher, settings, add-host and library. - **The selected tab pill is Liquid Glass**, accent-tinted through the same `consoleGlass` the rows use. ## The tray blur Two rounds to get right, and the diagnosis is worth keeping: the blur "grew into place" on a push because its full-bleed came from `.ignoresSafeArea()`, which resolves a beat *after* insertion — outside any geometry group and outside the view's own transaction. Vertical stopped popping as soon as that reach became negative padding; the X axis kept popping until the side insets did too. It is now full-bleed **by layout** (a fixed 80 pt overhang, no `ignoresSafeArea`), with its shape pinned out of any animation. The bottom reach grew for the tray that sits over scrolling rows, and an `ink.shade` wash sinks the material's grey lift to black. ## Card entrances Host cards and library covers now assemble themselves: each card swings in on the drum — turned away, small, low, invisible — then rotates flat, grows and rises on a spring with a whisker of overshoot, delayed by its distance from the cursor so the strip fans open from where the eye already is. Implemented once in `GamepadCarousel`, so both strips inherit it. Reduce Motion drops every bit of travel for a plain unstaggered cross-fade. Three defects were found and fixed getting there, each worth knowing about: 1. **A state flip inside `onAppear` lands in the insertion's transaction, where SwiftUI runs with animations disabled** — so the entrance simply didn't play. The launcher got away with it; the library, whose strip mounts late when the fetch lands, lost every time. 2. **Opacity must not share the transform's curve.** On one spring the card spent its whole rotation at near-zero alpha and only the last few degrees showed, which is why it read as a small slide. The fade now finishes in the first third of each card's window. 3. **A `.scrollTransition` derives its phase from the geometry of the view it wraps.** The entrance was layered *outside* it, so it moved the very thing the transition measures: cards read as far from centre for the whole travel, phase pinned at fully-receded, and the centred card only collapsed into its focused look as the entrance ended — arriving as a jump. The card builder now hands each caller its `CardEntrance` and both strips apply it **beneath** their own transition, which composes on top instead of fighting it. The entrance is one animated progress value driven through an `Animatable` modifier rather than per-card `.animation` modifiers, so nothing wraps a card in an animation that could capture its scroll transition — and if the animation is ever dropped, progress still lands at 1 and every card sits at exact identity rather than stranded. **The library also waits for its art**: `PosterImage` reports when a cover settles (loaded, or every candidate exhausted), the coverflow counts the first few, and the entrance holds until then with a 700 ms backstop — so covers swing in carrying artwork instead of grey rectangles filling in afterwards. Art cross-fades now too, which the touch grid inherits. ## The store chip The coverflow's store/source chip only showed its background on the centred cover — the same law as the tray blur, one layer down: a card riding a `scrollTransition` is composited with `opacity < 1` and a 3D rotation, and **a material cannot sample a backdrop through an offscreen composite**. The chip is a flat wash there now (`StoreBadge(solid:)`), which has nothing to sample and is simply always present. The touch grid keeps its material — its cards carry no transform. ## Verification - `swift build` (macOS) — green - `swift build --triple arm64-apple-ios17.0` — green - `swift test` — 208 passed, 5 skipped, 0 failures - Built, installed and exercised on the reporting iPhone; the reporter confirms the result
enricobuehler added 1 commit 2026-08-07 12:36:09 +00:00
fix(client/apple): the gamepad UI answers its first field test
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m48s
ci / docs-site (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m25s
49fa344c92
Six findings from the on-device pass over #91, all iOS-facing:

- The tray blurs no longer grow into place on a push: the screen layer
  resolves its internal layout (safe-area trays, the scrims' full-bleed)
  in a geometryGroup BEFORE the insertion animates.

- The option band is LINEAR now, not a ring. A ring showed the first
  option waiting to the right of the last one - unreachable, since
  left/right clamps - and on a 2-option ring the unselected item flipped
  sides with every step (the 60/120 Hz row). Positions are fixed, the
  ends are the ends, and A's wrap travels back across the list. Options
  other than the facing one exist only while the drum is moving, so a
  long label never sits under a resting neighbour as overlapping text.

- Toggles (and the pin rows) ride the band too: Off left of On,
  matching the left-off/right-on step semantics.

- The close X is gone from settings, add-host and the library - a
  gamepad UI exits with B. A chromeless cancel button keeps hardware
  Esc and the macOS sheet working, and the library's loading/error/empty
  states gain a zero-size B listener so a controller-only user is never
  trapped where the coverflow (and its B) doesn't exist yet.

- The heading is a real heading: leading-aligned with the 24 pt content
  inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18) - launcher,
  settings, add-host and library alike. The launcher's hidden-mirror
  chip trick died with the centred title that needed it.

Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed.
enricobuehler added 1 commit 2026-08-07 12:45:26 +00:00
fix(client/apple): the tray blur keeps its shape, and the tab pill turns to glass
ci / bun-nix (pull_request) Successful in 27s
ci / docs-site (pull_request) Successful in 1m4s
ci / web (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m30s
apple / swift (pull_request) Successful in 1m35s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Canceled after 4m39s
f674a06a08
Two follow-ups from the second on-glass pass:

- The tray blur's pop-in survived the geometryGroup: the full-bleed
  growth (negative padding + safe-area expansion) rode the push's
  transaction, and safe-area resolution sits outside a geometry group.
  The scrim now pins its own geometry out of any animation - the layer
  fade/slide still carries it, only its SHAPE can never animate. The
  bottom overshoot grows 32 -> 72 pt (the tray sits over scrolling rows
  plus the detail line; the blur influence starts well above the legend
  now) and the mask holds strength longer before dissolving.

- The selected tab pill is a Liquid Glass surface (accent-tinted
  through consoleGlass, material fallback pre-26/tvOS) - the strip
  wears the same material language as the rows below it. The
  matched-geometry travel between pills is unchanged.
enricobuehler added 1 commit 2026-08-07 12:50:10 +00:00
fix(client/apple): the tray blur goes full-bleed by layout, and its frost sinks to black
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m6s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m54s
70de58f446
Round-3 field findings: the pop-in had retreated to the X axis alone -
the one growth still left to safe-area resolution (the landscape side
insets), which settles a beat after insertion, outside any geometry
group and outside the view's own transaction. The scrim now reaches
full-bleed purely by layout: a fixed 80 pt negative-padding overhang on
the outer edge and both sides replaces ignoresSafeArea entirely, so
every axis is deterministic from the first frame (and orientation no
longer changes the gradient's proportions). The mask's strong region
moves to 0.65 to account for the overhang leading the gradient.

And the frost reads black now, not grey: an ink.shade(0.35) wash inside
the mask sinks the material's luminance lift toward the palette's shade
- black on a dark field, palette-honest on a pale one.
enricobuehler added 1 commit 2026-08-07 13:04:19 +00:00
feat(client/apple): the strips assemble themselves, and the store chip stops flashing
ci / bun-nix (pull_request) Successful in 38s
ci / web (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m27s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m48s
ci / rust (pull_request) Canceled after 6m16s
cf68d33e2c
Two more from the on-glass pass:

- The coverflow's store/source chip only showed its background on the
  centred cover. Same mechanism as the tray blur: a card rides a
  scrollTransition that composites it with opacity < 1 and a 3D
  rotation, and a material cannot sample a backdrop through an offscreen
  composite - so the frost stayed blank everywhere except the one card
  sitting at exactly full opacity. The coverflow's chip is a flat wash
  now (StoreBadge gains `solid`), which has no backdrop to sample and
  is therefore simply always there. The touch grid keeps its material -
  its cards carry no transform, so its frost samples fine.

- Host cards and library covers now arrive with the strip instead of
  being there: each card rises out of a fade on a lightly overshooting
  spring, delayed by its distance from the cursor, so the strip
  assembles outward from where the eye already is. Implemented once in
  GamepadCarousel, so the launcher and the coverflow inherit it
  together. Transforms only - snapping, the callers' own
  scrollTransition and the tvOS focus engine are untouched - and Reduce
  Motion drops the travel for a plain unstaggered cross-fade.
enricobuehler added 1 commit 2026-08-07 13:10:40 +00:00
fix(client/apple): the library actually plays its entrance, and it swings in 3D
ci / bun-nix (pull_request) Successful in 54s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m56s
ci / rust (pull_request) Successful in 4m21s
2fb80073e6
The strip entrance never ran in the library, for two reasons:

- The trigger was lost. Flipping the state inside onAppear puts the
  change in the SAME transaction as the view's insertion, where SwiftUI
  runs with animations disabled. The launcher got away with it; the
  library's strip mounts late - only once the fetch lands - and lost
  every time. The flip now defers one runloop turn, so it is an ordinary
  animated state change.

- The art snapped in behind it. Covers hard-swapped from grey
  placeholder to image, so even a working entrance was followed by a run
  of cards popping to artwork after the strip had settled. PosterImage
  cross-fades now (the touch grid inherits it).

And the entrance is 3D: a card starts turned away on the drum, small,
low and invisible, then swings flat, grows and rises on an overshooting
spring. Cards left of the anchor hinge on their trailing edge and cards
right of it on their leading one, so the strip FANS OPEN from the cursor
instead of sweeping past it - the same hinge-and-perspective language
the coverflow's own recede speaks, so arriving and scrolling read as one
object. Reduce Motion still drops every bit of travel.
enricobuehler added 1 commit 2026-08-07 13:16:56 +00:00
fix(client/apple): the entrance waits for the art, and every card swings
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 59s
ci / docs-site (pull_request) Successful in 1m4s
ci / rust-arm64 (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 1m26s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 4m21s
769a8c049d
Three defects behind an entrance that read as a card sliding up:

- The centred card never rotated. The stagger fans out from an anchor,
  and the anchor was given side 0 = no rotation - but the anchor IS the
  card the eye is on, so the single most visible card only rose. Side is
  never 0 now; every card turns.

- The swing happened while the card was invisible. Opacity shared the
  transform's spring, so the card spent its whole rotation at near-zero
  alpha and only the last few degrees showed. The fade now runs on its
  own 0.22 s curve (a second .animation governs only the modifiers above
  it) while the transform springs over ~0.6 s. The travel is deeper too
  - 0.74 scale, 64 degrees, 58 pt - and the rotation sign now matches the
  coverflow's own recede, so a card unwinds INTO its resting angle
  instead of swinging against it.

- It fired before the art existed. Cards swung in as grey placeholders
  and filled with artwork afterwards. PosterImage reports when a cover
  settles (art loaded, or candidates exhausted), the coverflow counts
  the first few, and GamepadCarousel holds its entrance on a
  contentReady gate - with a 700 ms backstop so a slow or artless
  library still animates.
enricobuehler added 1 commit 2026-08-07 13:28:16 +00:00
fix(client/apple): the entrance stops dragging the scroll transition along with it
ci / bun-nix (pull_request) Successful in 23s
ci / web (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m59s
ci / docs-site (pull_request) Successful in 2m6s
ci / rust (pull_request) Successful in 5m50s
eaa87ca0cc
The strip entrance is one animated progress value now, not a Bool behind
per-card .animation modifiers. Those modifiers wrap the caller's card -
INCLUDING its .scrollTransition - so a delayed spring flipping while the
scroll view was still settling captured the transition's own per-frame
phase updates and stranded the centred card half-receded until the next
scroll re-drove it. That was the 'only navigating fixes it' report, and
the race with load speed was the same thing.

CardEntrance is now a ViewModifier + Animatable: it slices its own
window out of one master clock the carousel animates 0 -> 1, so every
transform is a pure function of an interpolated Double and no animation
modifier wraps a card at all. Benign failure mode too - progress
reaching 1 without animating leaves each card at exact identity rather
than stranded. The entrance also moved inside .frame(width:) so a scroll
target's geometry never depends on what its card is doing, and the
non-tvOS branch states its .id explicitly.

One leak remained after that: withAnimation sets its animation on the
whole TRANSACTION, so the scroll view's initial centring still inherited
the 1 s linear clock and the focused card only reached its correct look
as that clock ran out - arriving as a jump. The card subtree now clears
the inherited animation, so its phase lands per frame while the
entrance's own transforms (driven by animatableData, not by the
transaction) keep running.
enricobuehler added 1 commit 2026-08-07 13:35:10 +00:00
fix(client/apple): the entrance keeps every card inside its own footprint
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m7s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Canceled after 3m34s
ci / docs-site (pull_request) Canceled after 41s
ci / bun-nix (pull_request) Canceled after 29s
48339326ae
The focused card jumping into its correct state at the end of the
entrance was the entrance's own geometry. The caller's .scrollTransition
reads the geometry of the view underneath the entrance's transforms, so
a card shoved 58 pt down and hinged on its leading edge spent the whole
travel reported as far from centre - phase pinned at fully receded - and
only collapsed to identity as the card came home. That collapse IS the
jump, and it explains why it looked timing-dependent rather than simply
broken.

Now the rotation is about the card's centre (it turns in place instead
of swinging sideways out of position) and the rise is 16 pt, inside the
strip's own vertical slack, so nothing the entrance does moves a card
away from where the scroll view thinks it is. The entrance also waits a
couple of frames for real layout - the GeometryReader's first pass can
report no width, so there is nothing to centre on yet - and the
transaction override from the previous round is gone: it was not the
cause, and nil-ing inherited animation could have made navigation
snappier than intended.
enricobuehler added 1 commit 2026-08-07 13:38:51 +00:00
fix(client/apple): the entrance goes underneath the scroll transition
apple / swift (pull_request) Successful in 1m31s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m41s
ci / rust (pull_request) Canceled after 10m43s
9582da07b4
Structural, because tuning the transform values was treating a symptom.
A scroll transition derives its phase from the geometry of the view it
wraps, and the entrance was wrapping each card on the OUTSIDE - so it
moved the very thing the transition measures. Every card read as far
from centre for the whole travel, phase pinned at fully receded, and the
centred card only collapsed into its focused look as the entrance ended.
That collapse was the jump; shrinking the offset last round only made it
smaller.

The card builder now hands each caller its own CardEntrance and both the
launcher and the coverflow apply it BENEATH their .scrollTransition. The
transition measures a card that never moves and composes its scale and
rotation on top of the entrance's, so the two can no longer fight - and
the fuller travel is back (34 pt rise) now that the geometry constraint
that forced it down to 16 is gone.
enricobuehler changed title from Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads to Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads, and strips that assemble themselves 2026-08-07 13:39:55 +00:00
enricobuehler added 1 commit 2026-08-07 13:49:48 +00:00
fix(client/apple): the stats card's corner stops eating its own text
ci / bun-nix (pull_request) Successful in 28s
ci / web (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m39s
adf92939ec
Making the HUD concentric with the physical display corner had no upper
bound, so a modern phone (~62 pt of display radius) asked for a 48 pt
corner on a card whose lines sit 10 pt from the edge. A corner of radius
r pulls the edge inward by r - sqrt(r^2 - (r-y)^2) at distance y below
the top: at the first line that is ~19 pt, so the top and bottom lines
rendered INSIDE the arc.

Concentricity is only a virtue while the radius is small next to the
card. The radius is now capped at 28 (devices asking for less still get
a truly concentric corner) and the iOS content padding scales with it at
0.45*r, which leaves ~4.6 pt of arc against 12.6 pt of padding at the
cap. The card grows by under 3 pt a side; the compact pill is unchanged.
enricobuehler merged commit 9db2c06140 into main 2026-08-07 14:33:19 +00:00
enricobuehler deleted branch worktree-gamepad-ios-polish-2 2026-08-07 14:33:21 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#93