Skip to content

iPhone Duo layout: fold-aware player, queue beside it, swipe-down, and fixes - #5

Merged
tsvb merged 15 commits into
mainfrom
iphone-duo
Nov 6, 2026
Merged

tsvb merged 15 commits into
mainfrom
iphone-duo

Conversation

@tsvb

@tsvb tsvb commented Nov 6, 2026

Copy link
Copy Markdown
Owner

Adapts the iOS app to iPhone Duo (Apple's foldable) and fixes several bugs the work uncovered on every iPhone.

iPhone Duo

  • Player beside its queue on the inner display. NowPlayingArrangement hosts the full-screen player: iOS 27.1 ArrangementView (player + Signal/Up Next) when the inner display is flat, explicit columns when it's upright, the player alone everywhere else (every other iPhone, the outer display, Split View halves). The player's queue button is hidden only while the queue is beside it. Verified folding and unfolding with the player open (state kept, button returns).
  • Pill sheds controls by measured width. On the outer display the tab bar goes vertical and the accessory is ~290pt while still .expanded; the title had collapsed to "Aw…". Now ±15/30 then previous drop out to keep a 90pt title (all five kept on a normal iPhone).

Fixes on every iPhone

  • The player no longer overflows short canvases. The placeholder art (tracks without covers) was a rigid 280pt tile: on the Duo it pushed the dismiss and queue rows off screen, and on a portrait iPhone it overflowed ~15pt at each end and truncated the venue. Same fix for Click Wheel, whose pad also never actually shrank below 330pt.
  • Swipe down to close the player. It was a plain fullScreenCover, so only the chevron ever closed it. A drag on the player (never the sliders, buttons, or queue column) now follows the finger and closes past a threshold.
  • Playback errors stay visible in the queue column.

CI

Moves to the xcode-27 runner, pinned to Xcode_27.1.app, with a step that fails loudly if that Xcode is missing — the 27.1 SDK symbols can't compile on 26.6 even behind #available. This PR is the workflow's first run on that runner. The iOS deployment floor stays 26.1.

Verification

  • Gate: both schemes warning-free under SWIFT_STRICT_CONCURRENCY=complete; AppleNugsTests 175 passing.
  • iPhone 17 Pro / iOS 26.5: pixel-identical player except where it was overflowing before.
  • iPhone Duo simulator (Duo-only iOS 27.1 runtime 24A94425): all five themes, flat / upright / folded.
  • Real iPhone Duo (iOS 27.2): side-by-side with real art, upright, Split View, pill panel. Swipe-down and the upright queue column were installed for hands-on checking.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN

Tim and others added 15 commits November 5, 2026 20:56
The spec, plan and probe screenshots live in the gitignored
docs/superpowers/ inside this worktree, so the decisions behind them
(scope, 26.1 floor, CI on Xcode 27.1+, player-alone upright) and the
Phase 0 findings go here, where a worktree removal can't take them.
Also corrects the toolchain note: xcode-select is now Xcode 27.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
The iPhone Duo work adopts ArrangementView behind
`if #available(iOS 27.1, *)`. A missing symbol is a property of the SDK,
not the deployment target, so #available does not help on macos-26's
Xcode 26.6. Move to the `xcode-27` runner (the only GitHub-hosted label
with Xcode 27.x; still a preview image, actions/runner-images#14404) and
pin Xcode_27.1.app via DEVELOPER_DIR (GA 27A9275 on every image version;
Xcode_27.2.app is still the RC on part of the fleet). A new assertion
step fails the job up front if the image ever drops or downgrades it.
The iOS deployment floor stays 26.1.

Unverified until the first push.
Source: https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…ement

On iPhone Duo's outer display the 27.1+ SDK lays the tab bar out
vertically, so tabViewBottomAccessory is ~290pt wide while its placement
still reads .expanded. All five transport controls plus the chevron then
left the title 34pt ("Aw…"). PillLayout chose controls by Slot alone and
never used textBudget to shed any.

PillLayout.controls(slot:width:hasLeadingSlot:trailingReserve:) now drops
the skip intervals together, then previous, until the text column has
minTextWidth (90pt); play/pause and next are never shed. An unmeasured
width returns the full set, so the iPhone 17 Pro's 360pt pill (104pt of
text) keeps all five. NowPlayingPill measures its outer width with
onGeometryChange and never animates a control in or out.

9 new PillLayoutTests (140 total, all passing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
StandardNowPlayingScreen's no-art branch was a rigid 280×280
MonogramTile. On iPhone Duo's landscape inner display (~951×669pt) it
pushed the header (dismiss) and bottom row (AirPlay, queue, more) off
screen in Soundboard, Tape Room and Shoebox. It also overflowed on a
portrait iPhone: the stack spilled ~15pt past its vertical padding at
each end and truncated the venue line to one row.

The monogram now takes min(width, height) of its box, capped at the old
280pt (ideal 280 for unconstrained proposals) — the same flexibility the
real-artwork Image already had. On iPhone 17 Pro the placeholder now
lays out exactly where real artwork does (verified pixel-for-pixel
against a real CDN cover); on the Duo inner display the whole player
fits, art ~104-128pt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Same overflow as 8ea1533, in the Click Wheel theme: a rigid 260pt
MonogramTile plus a fixed 330×260 satellite cluster pushed the header
off the top of iPhone Duo's landscape inner display and cut the ±15/30
satellites off the bottom. On a portrait iPhone 17 Pro it overflowed
too, by ~16pt at each end (header pinned to the safe-area edge).

- The monogram scales with its box, capped at its old 260pt.
- The control pad scales as a unit to the available width via the new
  pure NowPlayingFit.scale(available:natural:) — scaleEffect plus a
  matching frame, since scaleEffect alone keeps the 330pt footprint.
  Proportions never reflow. Scale is 1 on iPhone and the Duo today; it
  exists for split columns / Split View narrower than 330pt.

NowPlayingFit is Foundation-only and compiled into AppleNugsTests
(6 new tests, 146 total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
- nowPlayingQueueBeside (environment, default false): when true, each
  full-screen player swaps its queue button for a same-size, hidden
  placeholder — never removed, so the fixed-geometry rows keep their
  shape. The sheet stays attached.
- DashboardPanel(showsNowPlaying:) (default true): beside the player the
  panel's "Now" section is redundant. Static per instance; every
  existing call site is unchanged.
- nowPlayingReadableWidth(): on iOS at regular width the player's
  content caps at 520pt, centered, background still full-bleed. Chosen
  by value, not if/else, so a size-class flip can't reset the players'
  @State. A no-op on macOS (whose Now Playing window shares these views)
  and on compact iPhones.

No behavior change until the next commit sets the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…ueue

The full-screen cover now hosts NowPlayingArrangement: on iOS 27.1+ an
ArrangementView with the player primary and DashboardPanel (Signal + Up
Next, no "Now" section) secondary, split horizontally only. Where it
can't split — every other iPhone, the Duo's compact outer display, the
inner display held upright — the SDK shows the player alone, exactly as
before. Older iOS takes the bare player (the floor stays 26.1).

Measured on the Duo simulator, unfolded flat: an even 50/50 split of the
safe area, two 433.5×635pt columns, each reporting a compact horizontal
size class. The player's own queue button hides when the queue column
appears (derived from the secondary's onAppear — the SDK has no split
visibility signal). iPhone 17 Pro / iOS 26.5 output is pixel-identical.

Unverified: whether the secondary is removed (vs. kept at zero size)
when the inner display can't split — needs a fold or an upright pose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
- A playback error stayed invisible on the Duo's split player: on iOS
  the error only rendered in DashboardPanel's "Now" section, which the
  queue column drops, and the player's queue button (the old route to
  it) is hidden there. The panel now keeps the error label on its own
  when showsNowPlaying is false.
- The queue-beside flag counts live secondary instances instead of
  flipping one Bool, so a rebuilt secondary whose onAppear lands before
  the old one's onDisappear can't leave the flag false with the column
  on screen.
- PillLayout tests pin the inclusive 90pt floor at its exact edges (346
  vs 345 with the chevron, 314 vs 313 without) and the no-leading-slot
  case. 149 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Pick-up-here now lists every commit on the branch, the measured split
geometry, and exactly what is still unverified (outer-display pill,
fold/unfold, upright, book pose, Split View, real art on a real Duo,
CI). Conventions gain the three rules that fail silently if broken:
now-playing art yields height, the pill sheds by measured width, and
the queue-beside placeholder contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
…can't split

Tim folded and unfolded the Duo simulator with the split player open:
the outer display shows the player alone with its queue button back
(state kept), unfolding restores the split, and the folded tab root's
pill reads "Away From th…" with prev/play/next instead of "Aw…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Developer Mode, building against the specific device id with
-allowProvisioningDeviceRegistration (a generic build never registers a
new phone), and a pending Program License Agreement that blocks
registration until accepted. devicectl, not xctrace, is the reliable
view of the phone's state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
The player has always been a plain fullScreenCover, which iOS doesn't
let you swipe away — only the header chevron closed it, on every
iPhone. A vertical drag on the PLAYER (never the queue column, whose
ScrollView keeps scrolling) now moves the whole arrangement down 1:1
and closes it past 120pt or on a fast flick, else springs back.

- Policy is pure and unit-tested (NowPlayingDismissDrag, 16 tests): a
  drag is judged once on its first sample, so a horizontal scrub never
  moves the player and a tracked drag never snaps back mid-gesture.
- It's a plain .gesture, so child gestures win: the sliders, every
  button, The Receiver's volume ladder (recognizes on touch-down).
- The cover gets a clear presentation background so the app shows
  through as the player slides. Offset content does NOT paint the parts
  of its backgrounds that reach into the safe area (measured: the 62pt
  top band vanished), so the moving layer ignores the system safe area
  and carries the same insets as its own safeAreaPadding.
- VoiceOver: escape (two-finger Z) closes it.

At rest the player is pixel-identical on iPhone 17 Pro / 26.5. 165
tests. Unverified on hardware: drags that start on a slider thumb or a
button, and the Duo split under the new safe-area wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
Tim reversed "player alone when upright". ArrangementView's
horizontal-only split refuses a taller-than-wide canvas, so the pure
NowPlayingColumns policy picks the layout: compact → player alone;
regular and wider than tall → the verified ArrangementView; regular
and upright → an explicit HStack, queue clamp(width − 300, 220…260)pt
so the player never gets less than 300pt (sim upright: 409 + 260).
Both columns report a compact size class, as ArrangementView's do.
Compact still routes through ArrangementView's primary-only path, so a
fold keeps the player's state as the fold test verified; switching
between the HStack and ArrangementView (rotating with the cover open)
rebuilds the player — documented trade-off.

Every player now fits a 300pt column (verified with a forced-300pt
build): nowPlayingFitWidth(margin:) scales the Standard transport row
(~308pt natural) and The Receiver's knurled row (364pt) as units, scale
1 on every iPhone. Also fixes Click Wheel's pad never shrinking below
330pt: a max-only frame reports max(child, proposal), so the container
needed minWidth: 0.

8 new tests (173). iPhone 17 Pro / 26.5 byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
… real-Duo pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
- The pre-27.1 path chose between "player alone" and "player + queue"
  with a switch; each case of a switch in a view builder is its own
  branch, so crossing it rebuilt the player and dropped its state. One
  call with an optional queue width now keeps the player's identity.
- close() dismisses directly when the arrangement is already off-screen:
  an animation that changes nothing isn't guaranteed to run its
  completion, which would leave a clear, touch-blocking cover.
- The cover's clear presentation background keeps the TabView live
  behind the player, so the arrangement is now an accessibility modal
  and VoiceOver can't wander onto Home behind it.
- Tests pin the inclusive 120pt / 300pt thresholds and that only a
  tracking drag can close (175 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN
@tsvb
tsvb merged commit 805d766 into main Nov 6, 2026
1 check 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