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:
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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 themselves2026-08-07 13:39:55 +00:00
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
consoleGlassthe 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, noignoresSafeArea), with its shape pinned out of any animation. The bottom reach grew for the tray that sits over scrolling rows, and anink.shadewash 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:
onAppearlands 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..scrollTransitionderives 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 itsCardEntranceand 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
Animatablemodifier rather than per-card.animationmodifiers, 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:
PosterImagereports 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
scrollTransitionis composited withopacity < 1and 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) — greenswift build --triple arm64-apple-ios17.0— greenswift test— 208 passed, 5 skipped, 0 failuresGamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leadsto Gamepad UI (iOS): the field pass — a linear drum, no close chrome, a heading that leads, and strips that assemble themselves