From 49fa344c920254e110a642bed4d08f1d28edb78b Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 14:35:47 +0200 Subject: [PATCH 01/10] fix(client/apple): the gamepad UI answers its first field test 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. --- .../Home/GamepadAddHostView.swift | 44 ++++----- .../PunktfunkClient/Home/GamepadChrome.swift | 11 +-- .../Home/GamepadHomeView.swift | 28 +++--- .../Home/GamepadLibraryScreen.swift | 32 +++---- .../PunktfunkClient/Home/LibraryView.swift | 35 +++++++ .../Settings/GamepadOptionBand.swift | 95 +++++++++---------- .../Settings/GamepadSettingsView.swift | 54 +++++------ 7 files changed, 155 insertions(+), 144 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 300bc243..b6b09ab0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -53,26 +53,24 @@ struct GamepadAddHostView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like every gamepad heading — and no close chrome (B is the exit). Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity) - // On the title row itself (not the header block) so it rides the title's own - // top padding — the same anchoring the settings screen's close button uses. - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) .foregroundStyle(ink.fg(0.55)) - .multilineTextAlignment(.center) - .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) + .multilineTextAlignment(.leading) + .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading) } } + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -95,6 +93,18 @@ struct GamepadAddHostView: View { .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Cancel") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif #if os(tvOS) // tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom // tray — the remote and the pad both drive it natively. Same `editing` state as the @@ -161,24 +171,6 @@ struct GamepadAddHostView: View { if let close { close() } else { dismiss() } } - /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc - /// rides the cancel action. - private var closeButton: some View { - Button { performClose() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Cancel") - } - // MARK: - Rows private struct Row: Identifiable { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 995abd16..8391a3b1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -32,7 +32,7 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat { #elseif os(tvOS) 24 #else - compact ? 10 : 18 + compact ? 18 : 28 #endif } @@ -57,12 +57,13 @@ func gamepadHeaderSpacing(compact: Bool) -> CGFloat { } /// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the -/// in-hand compact-aware sizes elsewhere. +/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict +/// on the smaller first cut was "way too small" once the title moved off-centre. func gamepadTitleSize(compact: Bool) -> CGFloat { #if os(tvOS) 44 #else - compact ? 20 : 30 + compact ? 24 : 34 #endif } @@ -82,8 +83,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 18 static let rowMaxWidth: CGFloat = 920 static let detailFont: CGFloat = 19 - static let closeFont: CGFloat = 20 - static let closeSide: CGFloat = 48 static let bandWidth: CGFloat = 380 #else static let headerFont: CGFloat = 12 @@ -97,8 +96,6 @@ enum GamepadFormMetrics { static let rowCorner: CGFloat = 14 static let rowMaxWidth: CGFloat = 620 static let detailFont: CGFloat = 13 - static let closeFont: CGFloat = 14 - static let closeSide: CGFloat = 34 /// The option band's (GamepadOptionBand) fixed stage inside a choice row. static let bandWidth: CGFloat = 240 #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 370d20d9..8c7a6eef 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -124,6 +124,11 @@ struct GamepadHomeView: View { #if os(iOS) if let screen = topScreen { screenLayer(screen) + // Resolve the screen's internal layout (safe-area trays, the scrims' + // `.ignoresSafeArea()` full-bleed) BEFORE the insertion animates: without + // this the tray blurs entered at their content bounds and visibly grew to + // the screen edges mid-push. + .geometryGroup() .zIndex(1) .id(screen.id) .transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) @@ -322,32 +327,27 @@ struct GamepadHomeView: View { // MARK: - Chrome private var titleBar: some View { - // The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone - // it sat directly on top of the centred title ("Select a Host" ran straight into the pad - // name). Laying it out as a row with a hidden mirror on the leading side keeps the title - // optically centred AND clear of the chip at every width; the title shrinks a little - // before it would ever truncate. + // Leading title (a console heading, not a floating label — field ask), chip trailing. + // The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip; + // a leading title needs none of it — the flexible frame keeps the two apart, and the + // title shrinks a little before it would ever truncate. HStack(spacing: 12) { - statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - statusChip(hidden: false) + .frame(maxWidth: .infinity, alignment: .leading) + statusChip } - .padding(.horizontal, 20) + .padding(.horizontal, 24) } /// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a - /// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same - /// chip purely as a width reserve. - @ViewBuilder private func statusChip(hidden: Bool) -> some View { + /// compact-height phone gives the pixels to the carousel instead. + @ViewBuilder private var statusChip: some View { if !compact, let active = gamepads.active { ControllerStatusChip(controller: active) - .opacity(hidden ? 0 : 1) - .accessibilityHidden(hidden) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 67ba4a44..8402e890 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -27,35 +27,29 @@ struct GamepadLibraryScreen: View { store: store, host: host, onLaunch: onLaunch, onClose: close, controllerActive: controllerActive) .safeAreaInset(edge: .top, spacing: 0) { + // Leading, like every gamepad heading — no close chrome, B is the exit (the + // coverflow's, or LibraryView's own back-catcher before the coverflow exists). Text("\(host.displayName) — Library") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .background { GamepadTrayScrim(edge: .top) } } + // A hardware keyboard's Esc still closes, without chrome. + .background { + Button("Close") { close() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } .gamepadPaletteInk() } - - /// Touch/click fallback for closing — the controller path is B (the coverflow's onDismiss), - /// and it also covers the loading/error/empty states, which the coverflow (and its B) never - /// mounts under. A hardware keyboard's Esc rides the cancel action. - private var closeButton: some View { - Button { close() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .keyboardShortcut(.cancelAction) - .accessibilityLabel("Close library") - } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index f02b2544..13efdf4e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -65,6 +65,17 @@ struct LibraryView: View { imageSession?.finishTasksAndInvalidate() imageSession = nil } + #if os(iOS) || os(macOS) + // B closes the library even before the coverflow exists (loading / error / empty): + // the coverflow's carousel owns B once games render; until then this zero-size + // listener does — without it a controller-only user is trapped on an error screen + // (the gamepad screens carry no close chrome). + .background { + if gamepadUIActive && games.isEmpty { + LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() } + } + } + #endif } @ViewBuilder private var content: some View { @@ -210,6 +221,30 @@ struct LibraryView: View { } } +#if os(iOS) || os(macOS) +/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same +/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows +/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up. +private struct LibraryBackCatcher: View { + let active: Bool + let onBack: () -> Void + @State private var input = GamepadMenuInput(manager: .shared) + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { + input.onBack = onBack + if active { input.start() } + } + .onChange(of: active) { _, nowActive in + if nowActive { input.start() } else { input.stop() } + } + .onDisappear { input.stop() } + } +} +#endif + /// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs /// (portrait → header → hero) and finally a text placeholder. private struct GameCard: View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift index b4397d32..ad43ad23 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift @@ -1,10 +1,19 @@ -// The gamepad settings' "select" value as a REAL band: every option sits on a drum rotating -// about a vertical axis — the current one faces you flat, its neighbours curve away with -// perspective, shrinking and fading toward the edges. The old presentation animated a single -// Text keyed by its value (an old-out/new-in crossfade that merely implied motion), which fell -// apart under fast repeated steps: each press restarted the fade. Here the drum's position is -// one continuous value driven by a spring, and SwiftUI's spring retargeting preserves velocity — -// rapid presses accumulate into one accelerating spin instead of five restarted crossfades. +// The gamepad settings' "select" value as a REAL band: the options sit side by side on a drum +// segment curving about a vertical axis — the current one faces you flat, and a step rotates the +// next one in with perspective. The old presentation animated a single Text keyed by its value +// (an old-out/new-in crossfade that merely implied motion), which fell apart under fast repeated +// steps: each press restarted the fade. Here the drum's position is one continuous value driven +// by a spring, and SwiftUI's spring retargeting preserves velocity — rapid presses accumulate +// into one accelerating travel instead of five restarted crossfades. +// +// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first +// option waiting to the right of the last one, which left/right can't reach (adjust clamps) — +// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped +// sides with every step. So positions are fixed: option i sits i steps from the start, the ends +// are the ends, and A's wrap from the last option travels BACK across the list to the first. +// Options other than the facing one exist only while the drum is actually moving — at rest a row +// shows exactly its value (a resting neighbour under a long label rendered as overlapping, +// unreadable text). // // The band is purely presentational: stepping semantics (left/right clamps with a boundary thud, // A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row @@ -28,9 +37,8 @@ struct GamepadOptionBand: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Where the drum rests, in option steps — UNBOUNDED: a forward wrap keeps adding 1, never - /// modded back, so the ring distance below is what brings option 0 around from the right. - /// Rendering only ever reads it modulo `options.count`. + /// Where the drum rests, in option steps — always chasing `Double(selection)`; only the + /// spring's interpolation ever puts it between integers. @State private var drumPosition: Double init(options: [String], selection: Int, focused: Bool, width: CGFloat) { @@ -56,7 +64,6 @@ struct GamepadOptionBand: View { Drum( options: options, rotation: drumPosition, - neighborGate: focused ? 1 : 0, target: drumPosition, // Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge. radius: width * 0.72) @@ -77,8 +84,8 @@ struct GamepadOptionBand: View { } .onChange(of: selection) { old, new in step(from: old, to: new) } // The options list itself can mutate under the drum (a custom resolution appears, a - // controller connects, the buffer options re-derive from a new refresh rate) — the ring - // math is only valid while drumPosition ≡ selection (mod count), so re-seat without a spin. + // controller connects, the buffer options re-derive from a new refresh rate) — re-seat + // without a travel. .onChange(of: options.count) { _, _ in snap() } // One element to VoiceOver — the neighbour texts are rendering, not content. .accessibilityElement(children: .ignore) @@ -89,22 +96,15 @@ struct GamepadOptionBand: View { options.indices.contains(selection) ? options[selection] : "" } - /// One step spins the drum; anything else (an external write from the touch settings, a - /// re-derived options list) re-seats it — a spin to a value the user didn't step to would - /// read as the UI acting on its own. + /// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs + /// the drum; anything else (an external write from the touch settings, a re-derived options + /// list) re-seats it — a travel to a value the user didn't step to would read as the UI + /// acting on its own. private func step(from old: Int, to new: Int) { - let n = options.count - let raw = new - old - let delta: Int? = if n > 1 && old == n - 1 && new == 0 { - 1 // A's wrap from the last option: keep spinning FORWARD, the way the thumb pressed. - } else if abs(raw) == 1 { - raw - } else { - nil - } - guard let delta, !reduceMotion else { return snap() } + let wrapped = options.count > 1 && old == options.count - 1 && new == 0 + guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() } withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { - drumPosition += Double(delta) + drumPosition = Double(new) } } @@ -117,51 +117,44 @@ struct GamepadOptionBand: View { /// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED /// rotation every frame of the spring — each option's offset/scale/opacity follows the real arc, -/// and options more than one step away genuinely enter and leave mid-spin. (A plain `.animation` -/// on independent modifiers can't do that: each modifier would lerp its own endpoints and the +/// and options along the travel genuinely enter and leave mid-flight. (A plain `.animation` on +/// independent modifiers can't do that: each modifier would lerp its own endpoints and the /// in-between options would never appear.) private struct Drum: View, Animatable { let options: [String] /// The interpolated drum position, in option steps. var rotation: Double - /// 1 while the row is focused — the resting drum shows its neighbours only under focus (an - /// unfocused row is one flat Text, visually and costwise what it was before the band). - var neighborGate: Double /// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The - /// distance between them is "how mid-flight are we" — it keeps the neighbours visible while - /// an unfocused drum finishes settling, fading them continuously as it lands. + /// distance between them is "how mid-flight are we" — the neighbours exist exactly as long + /// as the drum is moving, fading continuously as it lands, so a resting row is one flat + /// Text and a long label never sits under a resting neighbour. let target: Double /// Drum radius in points (from the band width — see the caller). let radius: Double - var animatableData: AnimatablePair { - get { AnimatablePair(rotation, neighborGate) } - set { - rotation = newValue.first - neighborGate = newValue.second - } + var animatableData: Double { + get { rotation } + set { rotation = newValue } } /// Angular pitch between adjacent options on the drum. private static let stepAngle = 34.0 * .pi / 180.0 var body: some View { - let n = options.count let flight = min(1, abs(rotation - target) * 3) let content = ZStack { - ForEach(0.. 0.001 && abs(d) <= 2.5) { + option(i, distance: d, gate: flight) } } } #if os(tvOS) - // Flatten the transform stack while spinning — the 10-foot GPU already made these rows - // drop Liquid Glass, and five projected texts per step is the same class of cost. + // Flatten the transform stack while travelling — the 10-foot GPU already made these + // rows drop Liquid Glass, and five projected texts per step is the same class of cost. content.drawingGroup() #else content @@ -171,7 +164,7 @@ private struct Drum: View, Animatable { @ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View { let angle = d * Self.stepAngle let depth = cos(angle) - // The facing option never gates: an unfocused row still shows its value. + // The facing option never gates: a resting row still shows its value. let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) Text(options[i]) .lineLimit(1) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 6727577e..b044af86 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -144,12 +144,14 @@ struct GamepadSettingsView: View { } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: gamepadHeaderSpacing(compact: compact)) { + VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) { + // Leading, like a console section heading — centred read as a floating label, + // and a gamepad UI needs no close chrome next to it (B is the exit). Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) .foregroundStyle(ink.fg) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) // The picker is one layer deeper — its rows aren't sections of anything, so the // strip would be a control that does nothing while it's up. if pinTarget == nil { tabStrip } @@ -191,6 +193,18 @@ struct GamepadSettingsView: View { gamepads.startDiscovery() } .onDisappear { gamepads.stopDiscovery() } + #if !os(tvOS) + // The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware + // keyboard's Esc and the macOS sheet's cancel working without chrome. + .background { + Button("Close") { performClose() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + #endif } /// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to @@ -294,24 +308,6 @@ struct GamepadSettingsView: View { if let close { close() } else { dismiss() } } - /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc - /// rides the cancel action. - private var closeButton: some View { - Button { performClose() } label: { - Image(systemName: "xmark") - .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(ink.fg) - .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) - .consoleGlassBackground(Circle(), interactive: true) - .contentShape(Circle()) - } - .buttonStyle(.plain) - #if !os(tvOS) - .keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there) - #endif - .accessibilityLabel("Close settings") - } - /// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which /// layer the row list currently is. private var title: String { @@ -391,12 +387,10 @@ struct GamepadSettingsView: View { .font(.geist(m.valueFont, .medium, relativeTo: .callout)) .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) } else { - // Toggles and the flat rows keep the quiet slip: keyed by the value so - // a change slides the new string in following the user's motion (a - // right-step enters from the right), crossfading over ~14 pt. The - // ZStack is the stable home the removed/inserted texts transition - // within. (A two-position switch on a drum would read as a coin flip — - // both sibling clients keep toggles a different control, too.) + // The flat rows (profile pin counts, placeholders) keep the quiet slip: + // keyed by the value so a change slides the new string in following the + // user's motion, crossfading over ~14 pt. The ZStack is the stable home + // the removed/inserted texts transition within. let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14 ZStack { Text(row.value) @@ -771,6 +765,8 @@ struct GamepadSettingsView: View { value: pinned ? "Pinned" : "Off", detail: "A pinned profile appears as its own card on the host — one press " + "connects with it.", + optionLabels: ["Off", "Pinned"], + selectedIndex: pinned ? 1 : 0, adjust: { delta in let target = delta > 0 guard pinned != target else { return false } @@ -868,6 +864,10 @@ struct GamepadSettingsView: View { id: id, tab: tab, icon: icon, label: label, value: value.wrappedValue ? "On" : "Off", detail: detail, + // Toggles ride the band too (field ask): Off sits left of On, matching the + // directional semantics below, so a right-step slides On in from the right. + optionLabels: ["Off", "On"], + selectedIndex: value.wrappedValue ? 1 : 0, enabled: enabled, adjust: { delta in // Directional semantics: left = off, right = on; a no-op reads as a boundary. -- 2.54.0 From f674a06a082ae48bc4fa31aa75738817e10c9cce Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 14:45:15 +0200 Subject: [PATCH 02/10] fix(client/apple): the tray blur keeps its shape, and the tab pill turns to glass 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. --- .../PunktfunkClient/Home/GamepadChrome.swift | 14 +++++++++++--- .../PunktfunkClient/Home/GamepadHomeView.swift | 8 ++++---- .../Settings/GamepadSettingsView.swift | 8 +++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 8391a3b1..44334423 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -385,15 +385,23 @@ struct GamepadTrayScrim: View { LinearGradient( stops: [ .init(color: .black, location: 0), - .init(color: .black.opacity(0.9), location: 0.5), + .init(color: .black.opacity(0.92), location: 0.55), .init(color: .clear, location: 1), ], startPoint: fromEdge, endPoint: toContent) } // Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own - // text always sits on the strong part, rows blur out before they reach it. - .padding(edge == .top ? .bottom : .top, -32) + // text always sits on the strong part, rows blur out before they reach it. The bottom + // gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and + // the field verdict on the short reach was rows colliding visibly with the legend. + .padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72) .ignoresSafeArea() + // This view's geometry must NEVER animate: mounted inside a pushed shell layer, the + // full-bleed growth above (the negative padding and the safe-area expansion) rode the + // push's transaction, so the blur visibly entered at content bounds and grew into + // place — and `.geometryGroup()` on the layer does not reach safe-area resolution. + // The layer's own fade/slide still carries the scrim; only its SHAPE is pinned. + .transaction { $0.animation = nil } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 8c7a6eef..8a826ad1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -124,10 +124,10 @@ struct GamepadHomeView: View { #if os(iOS) if let screen = topScreen { screenLayer(screen) - // Resolve the screen's internal layout (safe-area trays, the scrims' - // `.ignoresSafeArea()` full-bleed) BEFORE the insertion animates: without - // this the tray blurs entered at their content bounds and visibly grew to - // the screen edges mid-push. + // Settle the screen's internal layout before the insertion animates, so + // descendants never lerp from a half-resolved first frame. (Not sufficient + // for the tray blurs on its own — safe-area expansion resolves outside a + // geometry group; GamepadTrayScrim pins its own geometry too.) .geometryGroup() .zIndex(1) .id(screen.id) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index b044af86..4358cddc 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -257,10 +257,12 @@ struct GamepadSettingsView: View { .padding(.vertical, 7) .background { // One shared capsule that MOVES between pills, rather than one per pill fading - // in and out — the highlight travels the way the press did. + // in and out — the highlight travels the way the press did. A Liquid Glass + // surface (accent-tinted through consoleGlass), so the strip wears the same + // material language as the rows it sits above. if selected { - Capsule() - .fill(ink.accent(0.85)) + Color.clear + .consoleGlass(Capsule(), tint: ink.accent(0.85)) .matchedGeometryEffect(id: "tab", in: tabHighlight) } } -- 2.54.0 From 70de58f4463fb7d90c96b96c8e6d8d7247c558bf Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 14:50:01 +0200 Subject: [PATCH 03/10] fix(client/apple): the tray blur goes full-bleed by layout, and its frost sinks to black 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. --- .../PunktfunkClient/Home/GamepadChrome.swift | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 44334423..b35097c0 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -380,12 +380,18 @@ struct GamepadTrayScrim: View { // to keep the pinned title legible, so it has to frost dark under white ink and // light under dark ink. .environment(\.colorScheme, ink.isLight ? .light : .dark) - // Fade the whole blur out toward the content so it dissolves rather than ending on a line. + // Sink the material's grey luminance lift toward the palette's shade (black on a + // dark field — field ask: the frost read GREY over the aurora). Inside the mask, so + // the tint dissolves with the blur. + .overlay(ink.shade(0.35)) + // Fade the whole blur out toward the content so it dissolves rather than ending on a + // line. The strong region sits deep (0.65) because the first stretch of the gradient + // now runs over the fixed 80 pt outer overhang below. .mask { LinearGradient( stops: [ .init(color: .black, location: 0), - .init(color: .black.opacity(0.92), location: 0.55), + .init(color: .black.opacity(0.92), location: 0.65), .init(color: .clear, location: 1), ], startPoint: fromEdge, endPoint: toContent) @@ -395,12 +401,17 @@ struct GamepadTrayScrim: View { // gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and // the field verdict on the short reach was rows colliding visibly with the legend. .padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72) - .ignoresSafeArea() - // This view's geometry must NEVER animate: mounted inside a pushed shell layer, the - // full-bleed growth above (the negative padding and the safe-area expansion) rode the - // push's transaction, so the blur visibly entered at content bounds and grew into - // place — and `.geometryGroup()` on the layer does not reach safe-area resolution. - // The layer's own fade/slide still carries the scrim; only its SHAPE is pinned. + // Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a + // beat after insertion (outside any geometry group and outside this view's own + // transaction), which is exactly the pop the field kept seeing — vertically first, + // then, once the vertical runway became padding, on the X axis alone (the landscape + // side insets). 80 pt clears every inset on every device; backgrounds never clip, + // so the overhang simply draws. + .padding(edge == .top ? .top : .bottom, -80) + .padding(.horizontal, -80) + // And the shape must NEVER animate: mounted inside a pushed shell layer, any late + // geometry would ride the push's transaction and visibly grow into place. The + // layer's own fade/slide still carries the scrim; only its SHAPE is pinned. .transaction { $0.animation = nil } } } -- 2.54.0 From cf68d33e2c4717bce7177d71c69a43fc52c34eb2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:03:54 +0200 Subject: [PATCH 04/10] feat(client/apple): the strips assemble themselves, and the store chip stops flashing 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. --- .../Home/GamepadCarousel.swift | 60 ++++++++++++++++++- .../Home/LibraryCoverflowView.swift | 4 +- .../PunktfunkClient/Home/LibraryWidgets.swift | 21 +++++-- 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 64369f6d..5e51d648 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -83,6 +83,16 @@ struct GamepadCarousel: View where Item.ID: Hash /// confirm and end-stop events (moves trigger on `cursor`). @State private var activateTick = 0 @State private var boundaryTick = 0 + /// The strip's entrance (see `CardEntrance`): false for exactly one frame after mount, then + /// the cards rise in. Never reset — a strip that re-played its entrance every time a screen + /// popped off the top of it would be noise, and the shell's push/pop carries that motion + /// already. So it plays when a screen is entered: the launcher when the gamepad UI comes up, + /// the coverflow each time the library opens (its layer mounts fresh). + @State private var appeared = false + /// Which card the entrance fans out from — the cursor as it stood at mount, so a restored + /// selection assembles around where the eye already is instead of sweeping in from the left. + @State private var entranceAnchor = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Read-back from a touch drag is honoured only once the gamepad has been quiet this long /// (longer than a move animation, so overlapping held-stick moves never let it through). @@ -94,7 +104,9 @@ struct GamepadCarousel: View where Item.ID: Hash ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: spacing) { - ForEach(items) { item in + // Enumerated for the entrance stagger only — identity stays `item.id`, + // which is what `.scrollTargetLayout()` and `scrollPosition` key on. + ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in #if os(tvOS) // A focusable Button per card: the focus engine does the navigating // (remote swipes and pad dpad alike), select activates. The bare style @@ -103,6 +115,7 @@ struct GamepadCarousel: View where Item.ID: Hash Button { activate(item) } label: { card(item) .frame(width: itemWidth) + .modifier(entrance(idx)) } .buttonStyle(ConsoleBareButtonStyle()) .focused($focusedID, equals: item.id) @@ -110,6 +123,7 @@ struct GamepadCarousel: View where Item.ID: Hash #else card(item) .frame(width: itemWidth) + .modifier(entrance(idx)) .contentShape(Rectangle()) .onTapGesture { tap(item) } #endif @@ -165,6 +179,9 @@ struct GamepadCarousel: View where Item.ID: Hash reconcile() wire() if isActive { input.start() } + // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. + entranceAnchor = cursor + appeared = true } .onDisappear { input.stop() @@ -203,6 +220,19 @@ struct GamepadCarousel: View where Item.ID: Hash } } + // MARK: - Entrance + + /// The card's share of the strip's entrance: it rises into place out of a fade, the anchored + /// card landing first and its neighbours following outward. + private func entrance(_ idx: Int) -> CardEntrance { + CardEntrance( + shown: appeared, + // Capped so a several-hundred-title library never queues a card behind a visibly long + // wait — everything past the cap lands together, well off-screen anyway. + delay: min(0.3, Double(abs(idx - entranceAnchor)) * 0.05), + reduceMotion: reduceMotion) + } + // MARK: - Input wiring private func wire() { @@ -346,4 +376,32 @@ struct GamepadCarousel: View where Item.ID: Hash withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 } } } + +/// How a card arrives when its strip does: it rises a little, growing out of a fade, on a spring +/// soft enough to overshoot by a hair — so the launcher's hosts and the library's covers assemble +/// themselves around the cursor instead of simply being there. Each card carries its own delay +/// (see `entrance(_:)`), which is what makes the strip read as one gesture rather than a +/// simultaneous flash. +/// +/// Transforms only — nothing here touches layout, so the scroll view's snapping, the caller's +/// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus +/// engine are all untouched. Reduce Motion drops the travel entirely for a plain, unstaggered +/// cross-fade. +private struct CardEntrance: ViewModifier { + let shown: Bool + let delay: Double + let reduceMotion: Bool + + func body(content: Content) -> some View { + content + .opacity(shown ? 1 : 0) + .scaleEffect(shown || reduceMotion ? 1 : 0.88) + .offset(y: shown || reduceMotion ? 0 : 34) + .animation( + reduceMotion + ? .easeOut(duration: 0.25) + : .spring(response: 0.52, dampingFraction: 0.78).delay(delay), + value: shown) + } +} #endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index bff15cce..5c5e06a3 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -107,7 +107,9 @@ struct LibraryCoverflowView: View { .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(alignment: .topLeading) { - StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + // `solid`: a frosted chip can't sample a backdrop through this card's own + // composited transform, so it would only show up on the centred card. + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher, solid: true) } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 79d2219d..37f5be75 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -17,16 +17,29 @@ struct StoreBadge: View { /// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size /// without reading the title. var isLauncher: Bool = false + /// Fill the chip with a flat wash instead of a frosted material. + /// + /// The coverflow MUST pass true. Its cards ride a `.scrollTransition` that composites them + /// with `opacity < 1` and a 3D rotation, and a material cannot sample a backdrop through an + /// offscreen composite — so the frost stayed blank on every card and only appeared on the one + /// card sitting at exactly full opacity in the centre, reading as a flash on focus. A flat + /// wash has no backdrop to sample: it is simply always there. (Deliberately black, not + /// palette ink: the chip sits on cover art, whose colours the palette has no business + /// fighting.) + var solid: Bool = false + + private var fill: AnyShapeStyle { + if isLauncher { return AnyShapeStyle(Color.brand) } + return solid ? AnyShapeStyle(Color.black.opacity(0.58)) : AnyShapeStyle(.ultraThinMaterial) + } var body: some View { Text(label) .font(.geist(11, .semibold, relativeTo: .caption2)) - .foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .foregroundStyle(isLauncher || solid ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .padding(.horizontal, 6) .padding(.vertical, 3) - .background( - isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial), - in: Capsule()) + .background(fill, in: Capsule()) .padding(6) } } -- 2.54.0 From 2fb80073e6ae104e67a099a4485ad018db38c1ea Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:10:33 +0200 Subject: [PATCH 05/10] fix(client/apple): the library actually plays its entrance, and it swings in 3D 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. --- .../Home/GamepadCarousel.swift | 44 +++++++++++++------ .../PunktfunkClient/Home/LibraryWidgets.swift | 7 +++ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 5e51d648..f8808760 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -181,7 +181,12 @@ struct GamepadCarousel: View where Item.ID: Hash if isActive { input.start() } // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. entranceAnchor = cursor - appeared = true + // Deferred one runloop turn ON PURPOSE. A state change made inside `onAppear` lands + // in the same transaction as the insertion, where SwiftUI runs animations disabled — + // so the cards just WERE there, no entrance (the library, whose strip mounts late + // when the fetch lands, showed this every time). One hop later it is an ordinary + // state change and every card's `.animation(_:value:)` picks it up. + DispatchQueue.main.async { appeared = true } } .onDisappear { input.stop() @@ -222,14 +227,15 @@ struct GamepadCarousel: View where Item.ID: Hash // MARK: - Entrance - /// The card's share of the strip's entrance: it rises into place out of a fade, the anchored - /// card landing first and its neighbours following outward. + /// The card's share of the strip's entrance: it swings in on the drum, the anchored card + /// landing first and its neighbours fanning outward to either side. private func entrance(_ idx: Int) -> CardEntrance { CardEntrance( shown: appeared, // Capped so a several-hundred-title library never queues a card behind a visibly long // wait — everything past the cap lands together, well off-screen anyway. - delay: min(0.3, Double(abs(idx - entranceAnchor)) * 0.05), + delay: min(0.36, Double(abs(idx - entranceAnchor)) * 0.055), + side: idx == entranceAnchor ? 0 : (idx < entranceAnchor ? -1 : 1), reduceMotion: reduceMotion) } @@ -377,30 +383,42 @@ struct GamepadCarousel: View where Item.ID: Hash } } -/// How a card arrives when its strip does: it rises a little, growing out of a fade, on a spring -/// soft enough to overshoot by a hair — so the launcher's hosts and the library's covers assemble -/// themselves around the cursor instead of simply being there. Each card carries its own delay -/// (see `entrance(_:)`), which is what makes the strip read as one gesture rather than a -/// simultaneous flash. +/// How a card arrives when its strip does: turned away on the drum, small, low and invisible — +/// then it swings flat, grows and rises into place on a spring soft enough to overshoot. Cards to +/// the left of the anchor hinge on their trailing edge and cards to its right on their leading +/// one, so the strip FANS OPEN from the cursor rather than sweeping past it; the anchor card +/// itself only grows, since it is already facing you. Each card carries its own delay (see +/// `entrance(_:)`) — that stagger is what makes the strip read as one gesture instead of a +/// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede +/// speaks, so the arrival and the scrolling feel like one object. /// /// Transforms only — nothing here touches layout, so the scroll view's snapping, the caller's /// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus -/// engine are all untouched. Reduce Motion drops the travel entirely for a plain, unstaggered +/// engine are all untouched. Reduce Motion drops every bit of travel for a plain, unstaggered /// cross-fade. private struct CardEntrance: ViewModifier { let shown: Bool let delay: Double + /// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor), + /// +1 hinged on its leading edge (right of it), 0 for the anchor card, which never turns. + let side: Double let reduceMotion: Bool func body(content: Content) -> some View { + let away = !shown && !reduceMotion content + .scaleEffect(away ? 0.82 : 1) + .rotation3DEffect( + .degrees(away ? side * 72 : 0), + axis: (x: 0, y: 1, z: 0), + anchor: side < 0 ? .trailing : .leading, + perspective: 0.6) + .offset(y: away ? 44 : 0) .opacity(shown ? 1 : 0) - .scaleEffect(shown || reduceMotion ? 1 : 0.88) - .offset(y: shown || reduceMotion ? 0 : 34) .animation( reduceMotion ? .easeOut(duration: 0.25) - : .spring(response: 0.52, dampingFraction: 0.78).delay(delay), + : .spring(response: 0.58, dampingFraction: 0.74).delay(delay), value: shown) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 37f5be75..a82e824b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -80,12 +80,19 @@ struct PosterImage: View { Image(platformImage: image) .resizable() .scaledToFill() + .transition(.opacity) } else if index < candidates.count { ZStack { placeholder; ProgressView() } + .transition(.opacity) } else { placeholder + .transition(.opacity) } } + // Art crosses over its placeholder instead of replacing it between two frames. Cover + // fetches land one by one, so without this a freshly opened library is a run of cards + // visibly snapping from grey to artwork after the strip has already settled. + .animation(.easeOut(duration: 0.3), value: image != nil) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() .task(id: index) { await loadCurrent() } -- 2.54.0 From 769a8c049d476f449afc08b121840868e472926d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:16:49 +0200 Subject: [PATCH 06/10] fix(client/apple): the entrance waits for the art, and every card swings 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. --- .../Home/GamepadCarousel.swift | 73 +++++++++++++------ .../Home/LibraryCoverflowView.swift | 24 +++++- .../PunktfunkClient/Home/LibraryWidgets.swift | 11 ++- 3 files changed, 84 insertions(+), 24 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index f8808760..d92861be 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -55,6 +55,10 @@ struct GamepadCarousel: View where Item.ID: Hash /// otherwise poll the SAME controller at once — driving both. The parent sets this false while /// something is presented on top so only the front-most carousel consumes the gamepad. var isActive: Bool = true + /// Whether the cards are worth showing off yet — the entrance holds until this is true. The + /// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything + /// whose cards are ready the moment they mount leaves it alone. + var contentReady: Bool = true @ViewBuilder let card: (Item) -> Card @State private var input = GamepadMenuInput(manager: .shared) @@ -89,9 +93,12 @@ struct GamepadCarousel: View where Item.ID: Hash /// already. So it plays when a screen is entered: the launcher when the gamepad UI comes up, /// the coverflow each time the library opens (its layer mounts fresh). @State private var appeared = false - /// Which card the entrance fans out from — the cursor as it stood at mount, so a restored - /// selection assembles around where the eye already is instead of sweeping in from the left. + /// Which card the entrance fans out from — the cursor as it stood when the strip was armed, + /// so a restored selection assembles around where the eye already is instead of sweeping in + /// from the left. @State private var entranceAnchor = 0 + /// The entrance has been scheduled; it plays exactly once per mount. + @State private var entranceArmed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Read-back from a touch drag is honoured only once the gamepad has been quiet this long @@ -179,15 +186,10 @@ struct GamepadCarousel: View where Item.ID: Hash reconcile() wire() if isActive { input.start() } - // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. - entranceAnchor = cursor - // Deferred one runloop turn ON PURPOSE. A state change made inside `onAppear` lands - // in the same transaction as the insertion, where SwiftUI runs animations disabled — - // so the cards just WERE there, no entrance (the library, whose strip mounts late - // when the fetch lands, showed this every time). One hop later it is an ordinary - // state change and every card's `.animation(_:value:)` picks it up. - DispatchQueue.main.async { appeared = true } + armEntrance() } + // The cards became worth showing (the library's covers got their art) — play now. + .onChange(of: contentReady) { _, _ in armEntrance() } .onDisappear { input.stop() haptics.stop() @@ -227,6 +229,19 @@ struct GamepadCarousel: View where Item.ID: Hash // MARK: - Entrance + /// Fire the entrance, once, as soon as the strip is mounted AND its cards are worth showing. + /// + /// The flip is deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` + /// lands in the same transaction as the view's insertion, where SwiftUI runs with animations + /// disabled — so the cards would simply BE there. + private func armEntrance() { + guard !entranceArmed, contentReady else { return } + entranceArmed = true + // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. + entranceAnchor = cursor + DispatchQueue.main.async { appeared = true } + } + /// The card's share of the strip's entrance: it swings in on the drum, the anchored card /// landing first and its neighbours fanning outward to either side. private func entrance(_ idx: Int) -> CardEntrance { @@ -234,8 +249,10 @@ struct GamepadCarousel: View where Item.ID: Hash shown: appeared, // Capped so a several-hundred-title library never queues a card behind a visibly long // wait — everything past the cap lands together, well off-screen anyway. - delay: min(0.36, Double(abs(idx - entranceAnchor)) * 0.055), - side: idx == entranceAnchor ? 0 : (idx < entranceAnchor ? -1 : 1), + delay: min(0.42, Double(abs(idx - entranceAnchor)) * 0.07), + // Never zero: the anchor is the card the eye is ON, so it must swing like the rest — + // giving it "no rotation" left the one card you actually watch merely sliding up. + side: idx < entranceAnchor ? -1 : 1, reduceMotion: reduceMotion) } @@ -400,25 +417,39 @@ private struct CardEntrance: ViewModifier { let shown: Bool let delay: Double /// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor), - /// +1 hinged on its leading edge (right of it), 0 for the anchor card, which never turns. + /// +1 hinged on its leading edge (right of it). Never 0 — every card turns, including the + /// centred one. let side: Double let reduceMotion: Bool func body(content: Content) -> some View { let away = !shown && !reduceMotion content - .scaleEffect(away ? 0.82 : 1) - .rotation3DEffect( - .degrees(away ? side * 72 : 0), - axis: (x: 0, y: 1, z: 0), - anchor: side < 0 ? .trailing : .leading, - perspective: 0.6) - .offset(y: away ? 44 : 0) + // The fade runs on its OWN, much faster curve (this `.animation` only governs the + // modifiers above it). One shared curve meant the card spent the whole swing at + // near-zero opacity and only the last few degrees were visible — which is exactly + // why the entrance read as a small slide. .opacity(shown ? 1 : 0) .animation( reduceMotion ? .easeOut(duration: 0.25) - : .spring(response: 0.58, dampingFraction: 0.74).delay(delay), + : .easeOut(duration: 0.22).delay(delay), + value: shown) + // Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. + // The sign matches the coverflow's own recede (right of centre turns negative about + // its leading edge), so the arrival deepens the turn the card already wears at rest + // and unwinds into it, instead of swinging the opposite way. + .scaleEffect(away ? 0.74 : 1) + .rotation3DEffect( + .degrees(away ? side * -64 : 0), + axis: (x: 0, y: 1, z: 0), + anchor: side < 0 ? .trailing : .leading, + perspective: 0.65) + .offset(y: away ? 58 : 0) + .animation( + reduceMotion + ? nil + : .spring(response: 0.62, dampingFraction: 0.72).delay(delay), value: shown) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 5c5e06a3..0d0b55be 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -41,6 +41,18 @@ struct LibraryCoverflowView: View { private let compact = false // no size classes on macOS #endif @State private var selection: String? + /// How many covers have settled (art loaded, or every candidate exhausted). + @State private var artSettled = 0 + /// The backstop below has fired: play the entrance regardless of what the art is doing. + @State private var artWaitOver = false + + /// Whether the strip may play its entrance yet. Cards swinging in as grey placeholders and + /// then filling with artwork afterwards is the whole effect wasted, so the entrance waits for + /// the first few covers — every poster is fetched in parallel, so those land together and + /// cover the visible strip. The wait is capped: a slow or artless library still animates. + private var contentReady: Bool { + artWaitOver || artSettled >= min(4, games.count) + } var body: some View { GeometryReader { geo in @@ -59,6 +71,11 @@ struct LibraryCoverflowView: View { // Publish the palette's ink to this screen (text, glass, accent, scrims) — a // pale palette flips all of them, and no leaf should have to read the setting. .gamepadPaletteInk() + // The entrance's backstop (see `contentReady`). + .task { + try? await Task.sleep(for: .milliseconds(700)) + artWaitOver = true + } } @ViewBuilder private func content(for size: CGSize) -> some View { @@ -91,7 +108,8 @@ struct LibraryCoverflowView: View { onActivate: { onLaunch?($0.id) }, onBack: { onDismiss?() }, shoulderJump: 5, - isActive: controllerActive + isActive: controllerActive, + contentReady: contentReady ) { game in cover(game, width: coverWidth, height: coverHeight) } @@ -103,7 +121,9 @@ struct LibraryCoverflowView: View { /// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the /// scale/rotation/opacity ramp already makes the centered cover prominent. private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View { - PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession) + PosterImage( + candidates: game.art.posterCandidates, title: game.title, session: imageSession, + onLoaded: { artSettled += 1 }) .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(alignment: .topLeading) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index a82e824b..86d577ac 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -71,6 +71,10 @@ struct PosterImage: View { let candidates: [URL] let title: String let session: URLSession? + /// Fires once this poster has settled — art loaded, or every candidate exhausted and the + /// placeholder is what it will be. The gamepad coverflow waits on a few of these before + /// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles. + var onLoaded: (() -> Void)? @State private var index = 0 @State private var image: PlatformImage? @@ -99,7 +103,11 @@ struct PosterImage: View { } private func loadCurrent() async { - guard index < candidates.count else { return } + // Past the end: the placeholder IS the final look, so this poster has settled. + guard index < candidates.count else { + onLoaded?() + return + } guard let session, let data = try? await session.data(from: candidates[index]).0, let loaded = PlatformImage(data: data) else { @@ -107,6 +115,7 @@ struct PosterImage: View { return } image = loaded + onLoaded?() } private var placeholder: some View { -- 2.54.0 From eaa87ca0cc17ff6b0d503075847099804048f52a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:28:09 +0200 Subject: [PATCH 07/10] fix(client/apple): the entrance stops dragging the scroll transition along with it 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. --- .../Home/GamepadCarousel.swift | 146 ++++++++++++------ 1 file changed, 100 insertions(+), 46 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index d92861be..16cf3506 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -87,12 +87,19 @@ struct GamepadCarousel: View where Item.ID: Hash /// confirm and end-stop events (moves trigger on `cursor`). @State private var activateTick = 0 @State private var boundaryTick = 0 - /// The strip's entrance (see `CardEntrance`): false for exactly one frame after mount, then - /// the cards rise in. Never reset — a strip that re-played its entrance every time a screen - /// popped off the top of it would be noise, and the shell's push/pop carries that motion - /// already. So it plays when a screen is entered: the launcher when the gamepad UI comes up, - /// the coverflow each time the library opens (its layer mounts fresh). - @State private var appeared = false + /// The strip's entrance, as ONE timeline: 0 = every card still away, 1 = every card landed + /// (see `CardEntrance`, which slices its own window out of this). Animated exactly once per + /// mount — a strip that re-played its entrance every time a screen popped off the top of it + /// would be noise, and the shell's push/pop carries that motion already. So it plays when a + /// screen is entered: the launcher when the gamepad UI comes up, the coverflow each time the + /// library opens (its layer mounts fresh). + /// + /// One animated Double rather than a Bool behind per-card `.animation(_:value:)` modifiers, + /// because those modifiers wrap the caller's card — INCLUDING its `.scrollTransition` — and a + /// delayed spring flipping while the scroll view was still settling captured the transition's + /// own per-frame phase updates, stranding the centred card in a half-receded state until the + /// next scroll re-drove it. Nothing here wraps the card in an animation at all. + @State private var entranceProgress: Double = 0 /// Which card the entrance fans out from — the cursor as it stood when the strip was armed, /// so a restored selection assembles around where the eye already is instead of sweeping in /// from the left. @@ -120,19 +127,22 @@ struct GamepadCarousel: View where Item.ID: Hash // below keeps the tile's own look — the `.scrollTransition` center pop // is the focus treatment, since focus and center track each other. Button { activate(item) } label: { + // Entrance INSIDE the fixed frame: the scroll target's own + // geometry stays exactly `itemWidth`, whatever the card is doing. card(item) - .frame(width: itemWidth) .modifier(entrance(idx)) + .frame(width: itemWidth) } .buttonStyle(ConsoleBareButtonStyle()) .focused($focusedID, equals: item.id) .id(item.id) #else card(item) - .frame(width: itemWidth) .modifier(entrance(idx)) + .frame(width: itemWidth) .contentShape(Rectangle()) .onTapGesture { tap(item) } + .id(item.id) // explicit scroll-target identity for scrollPosition #endif } } @@ -224,32 +234,44 @@ struct GamepadCarousel: View where Item.ID: Hash .onChange(of: items.map(\.id)) { _, _ in reconcile() wire() + // A strip that mounted empty (its content arrived after) still gets its entrance. + armEntrance() } } // MARK: - Entrance - /// Fire the entrance, once, as soon as the strip is mounted AND its cards are worth showing. + /// Run the entrance, once, as soon as the strip is mounted AND its cards are worth showing. /// - /// The flip is deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` - /// lands in the same transaction as the view's insertion, where SwiftUI runs with animations - /// disabled — so the cards would simply BE there. + /// Deferred one runloop turn ON PURPOSE: a state change made inside `onAppear` lands in the + /// same transaction as the view's insertion, where SwiftUI runs with animations disabled — so + /// the cards would simply BE there. Note the failure mode is benign either way: progress + /// reaching 1 without animating leaves every card at exact identity, never stranded. private func armEntrance() { - guard !entranceArmed, contentReady else { return } + guard !entranceArmed, contentReady, !items.isEmpty else { return } entranceArmed = true // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. entranceAnchor = cursor - DispatchQueue.main.async { appeared = true } + DispatchQueue.main.async { + // Linear on purpose: the master timeline is a clock, and each card eases its OWN + // slice of it (see `CardEntrance`) — a spring here would warp every card's curve. + withAnimation( + reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total) + ) { + entranceProgress = 1 + } + } } /// The card's share of the strip's entrance: it swings in on the drum, the anchored card /// landing first and its neighbours fanning outward to either side. private func entrance(_ idx: Int) -> CardEntrance { - CardEntrance( - shown: appeared, - // Capped so a several-hundred-title library never queues a card behind a visibly long - // wait — everything past the cap lands together, well off-screen anyway. - delay: min(0.42, Double(abs(idx - entranceAnchor)) * 0.07), + // Capped so a several-hundred-title library never queues a card behind a visibly long + // wait — everything past the cap lands together, well off-screen anyway. + let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07) + return CardEntrance( + progress: entranceProgress, + start: delay / CardEntrance.total, // Never zero: the anchor is the card the eye is ON, so it must swing like the rest — // giving it "no rotation" left the one card you actually watch merely sliding up. side: idx < entranceAnchor ? -1 : 1, @@ -413,44 +435,76 @@ struct GamepadCarousel: View where Item.ID: Hash /// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus /// engine are all untouched. Reduce Motion drops every bit of travel for a plain, unstaggered /// cross-fade. -private struct CardEntrance: ViewModifier { - let shown: Bool - let delay: Double +private struct CardEntrance: ViewModifier, Animatable { + /// How long ONE card takes to travel, and the most any card waits before it starts. + static let perCard: Double = 0.6 + static let maxDelay: Double = 0.42 + /// The master timeline the carousel animates 0 → 1. + static var total: Double { perCard + maxDelay } + + /// The interpolated master progress. `Animatable` is the whole point: SwiftUI hands this + /// modifier a fresh value every frame and re-runs `body`, so the card's transforms are a pure + /// FUNCTION of the clock. No `.animation` modifier wraps the card, so nothing here can catch + /// the caller's `.scrollTransition` mid-scroll and strand it. + var progress: Double + /// Where this card's window opens on that timeline, 0…1. + let start: Double /// Which way the card swings in: -1 hinged on its trailing edge (it sits left of the anchor), /// +1 hinged on its leading edge (right of it). Never 0 — every card turns, including the /// centred one. let side: Double let reduceMotion: Bool + var animatableData: Double { + get { progress } + set { progress = newValue } + } + func body(content: Content) -> some View { - let away = !shown && !reduceMotion - content - // The fade runs on its OWN, much faster curve (this `.animation` only governs the - // modifiers above it). One shared curve meant the card spent the whole swing at - // near-zero opacity and only the last few degrees were visible — which is exactly - // why the entrance read as a small slide. - .opacity(shown ? 1 : 0) - .animation( - reduceMotion - ? .easeOut(duration: 0.25) - : .easeOut(duration: 0.22).delay(delay), - value: shown) - // Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. - // The sign matches the coverflow's own recede (right of centre turns negative about - // its leading edge), so the arrival deepens the turn the card already wears at rest - // and unwinds into it, instead of swinging the opposite way. - .scaleEffect(away ? 0.74 : 1) + // This card's own 0…1, sliced out of the master clock. + let span = Self.perCard / Self.total + let raw = min(max((progress - start) / span, 0), 1) + // The travel eases out with a whisker of overshoot, so a card settles rather than stops. + let travel = Self.easeOutBack(raw) + // The fade is FAR quicker than the travel — it finishes in the first third of the window. + // Sharing one curve meant the card spent its whole swing at near-zero opacity and only + // the last few degrees ever showed, which is why this read as a small slide. + let fade = Self.easeOut(min(raw / 0.34, 1)) + // Deep turn, well down, well shrunk — the card is genuinely edge-on and travelling. The + // sign matches the coverflow's own recede (right of centre turns negative about its + // leading edge), so the arrival deepens the turn the card wears at rest and unwinds into + // it instead of swinging the opposite way. + let away = reduceMotion ? 0 : 1 - travel + return content + // The card's own updates must NEVER inherit the entrance's clock. `withAnimation` + // sets its animation on the whole transaction, so the caller's `.scrollTransition` — + // which is geometry-driven and expects to land per frame — got swept into the 1 s + // linear timeline while the scroll view settled, and the centred card only reached + // its focused look as that timeline ran out, arriving as a jump. Clearing the + // inherited animation here leaves the transforms below (pure functions of the + // interpolated `progress`) untouched, and explicit `.animation`s inside the card + // (the poster's cross-fade) still set their own. + .transaction { $0.animation = nil } + .opacity(reduceMotion ? raw : fade) + .scaleEffect(1 - 0.26 * away) .rotation3DEffect( - .degrees(away ? side * -64 : 0), + .degrees(side * -64 * away), axis: (x: 0, y: 1, z: 0), anchor: side < 0 ? .trailing : .leading, perspective: 0.65) - .offset(y: away ? 58 : 0) - .animation( - reduceMotion - ? nil - : .spring(response: 0.62, dampingFraction: 0.72).delay(delay), - value: shown) + .offset(y: 58 * away) + } + + /// `1 - (1-t)³`, with a small overshoot past 1 before it settles. + private static func easeOutBack(_ t: Double) -> Double { + let c1 = 1.2, c3 = c1 + 1 + let u = t - 1 + return 1 + c3 * u * u * u + c1 * u * u + } + + private static func easeOut(_ t: Double) -> Double { + let u = 1 - t + return 1 - u * u * u } } #endif -- 2.54.0 From 48339326ae4e5e12ba3444bec61fe56698203ff1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:34:56 +0200 Subject: [PATCH 08/10] fix(client/apple): the entrance keeps every card inside its own footprint 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. --- .../Home/GamepadCarousel.swift | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 16cf3506..23a3de33 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -252,7 +252,12 @@ struct GamepadCarousel: View where Item.ID: Hash entranceArmed = true // After `reconcile`, so the fan-out anchors on the seeded/restored cursor. entranceAnchor = cursor - DispatchQueue.main.async { + // Not just the next runloop turn (a change made inside `onAppear` lands in the + // insertion's transaction, where animations are disabled) but a couple of frames: the + // GeometryReader's first pass can report no width at all, so the strip has to lay out + // for real and the scroll view has to centre itself on the cursor before this starts. + // Cards are invisible until then (progress 0 ⇒ opacity 0), so the wait never shows. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { // Linear on purpose: the master timeline is a clock, and each card eases its OWN // slice of it (see `CardEntrance`) — a spring here would warp every card's curve. withAnimation( @@ -476,23 +481,22 @@ private struct CardEntrance: ViewModifier, Animatable { // it instead of swinging the opposite way. let away = reduceMotion ? 0 : 1 - travel return content - // The card's own updates must NEVER inherit the entrance's clock. `withAnimation` - // sets its animation on the whole transaction, so the caller's `.scrollTransition` — - // which is geometry-driven and expects to land per frame — got swept into the 1 s - // linear timeline while the scroll view settled, and the centred card only reached - // its focused look as that timeline ran out, arriving as a jump. Clearing the - // inherited animation here leaves the transforms below (pure functions of the - // interpolated `progress`) untouched, and explicit `.animation`s inside the card - // (the poster's cross-fade) still set their own. - .transaction { $0.animation = nil } .opacity(reduceMotion ? raw : fade) + // EVERY transform here stays inside the card's own footprint, and that is a + // correctness requirement, not taste: the caller's `.scrollTransition` reads the + // geometry of the view underneath these, so a card shoved 58 pt down and hinged on + // its leading edge spent the travel reported as "far from centre" — its phase pinned + // at fully-receded — and only collapsed to identity as the card came home. That was + // the focused card jumping into its correct state at the end. Hence: rotation about + // the CENTRE (the card turns in place rather than swinging sideways) and a rise small + // enough to stay within the strip's own slack. .scaleEffect(1 - 0.26 * away) .rotation3DEffect( .degrees(side * -64 * away), axis: (x: 0, y: 1, z: 0), - anchor: side < 0 ? .trailing : .leading, + anchor: .center, perspective: 0.65) - .offset(y: 58 * away) + .offset(y: 16 * away) } /// `1 - (1-t)³`, with a small overshoot past 1 before it settles. -- 2.54.0 From 9582da07b48c208113e0ae9cec23e39e16243203 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:38:38 +0200 Subject: [PATCH 09/10] fix(client/apple): the entrance goes underneath the scroll transition 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. --- .../Home/GamepadCarousel.swift | 39 +++++++++---------- .../Home/GamepadHomeView.swift | 10 +++-- .../Home/LibraryCoverflowView.swift | 10 +++-- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 23a3de33..ab4e7d13 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -59,7 +59,10 @@ struct GamepadCarousel: View where Item.ID: Hash /// library passes "the first covers have their artwork" (see LibraryCoverflowView); anything /// whose cards are ready the moment they mount leaves it alone. var contentReady: Bool = true - @ViewBuilder let card: (Item) -> Card + /// Builds one card. The `CardEntrance` handed along is the card's share of the strip's + /// arrival, and the caller MUST apply it (`.modifier(entrance)`) *underneath* its own + /// `.scrollTransition` — see `CardEntrance` for why that placement is load-bearing. + @ViewBuilder let card: (Item, CardEntrance) -> Card @State private var input = GamepadMenuInput(manager: .shared) @State private var haptics = MenuHaptics(manager: .shared) @@ -127,18 +130,14 @@ struct GamepadCarousel: View where Item.ID: Hash // below keeps the tile's own look — the `.scrollTransition` center pop // is the focus treatment, since focus and center track each other. Button { activate(item) } label: { - // Entrance INSIDE the fixed frame: the scroll target's own - // geometry stays exactly `itemWidth`, whatever the card is doing. - card(item) - .modifier(entrance(idx)) + card(item, entrance(idx)) .frame(width: itemWidth) } .buttonStyle(ConsoleBareButtonStyle()) .focused($focusedID, equals: item.id) .id(item.id) #else - card(item) - .modifier(entrance(idx)) + card(item, entrance(idx)) .frame(width: itemWidth) .contentShape(Rectangle()) .onTapGesture { tap(item) } @@ -436,11 +435,17 @@ struct GamepadCarousel: View where Item.ID: Hash /// simultaneous flash, and it is the same hinge/perspective language the coverflow's own recede /// speaks, so the arrival and the scrolling feel like one object. /// -/// Transforms only — nothing here touches layout, so the scroll view's snapping, the caller's -/// `.scrollTransition` (whose scale/rotation simply multiply with these) and the tvOS focus -/// engine are all untouched. Reduce Motion drops every bit of travel for a plain, unstaggered -/// cross-fade. -private struct CardEntrance: ViewModifier, Animatable { +/// ⚠️ APPLY THIS UNDERNEATH THE CARD'S OWN `.scrollTransition`, never around it. A scroll +/// transition derives its phase from the geometry of the view it wraps, so an entrance layered +/// on the OUTSIDE moves the very thing the transition is measuring: every card read as far from +/// centre for the whole travel, its phase pinned at fully-receded, and the centred card only +/// collapsed into its focused look as the entrance ended — arriving as a jump. Underneath, the +/// transition measures a card that never moves and simply composes its own scale/rotation on top. +/// +/// Transforms only — nothing here touches layout, so the scroll view's snapping and the tvOS +/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain, +/// unstaggered cross-fade. +struct CardEntrance: ViewModifier, Animatable { /// How long ONE card takes to travel, and the most any card waits before it starts. static let perCard: Double = 0.6 static let maxDelay: Double = 0.42 @@ -482,21 +487,13 @@ private struct CardEntrance: ViewModifier, Animatable { let away = reduceMotion ? 0 : 1 - travel return content .opacity(reduceMotion ? raw : fade) - // EVERY transform here stays inside the card's own footprint, and that is a - // correctness requirement, not taste: the caller's `.scrollTransition` reads the - // geometry of the view underneath these, so a card shoved 58 pt down and hinged on - // its leading edge spent the travel reported as "far from centre" — its phase pinned - // at fully-receded — and only collapsed to identity as the card came home. That was - // the focused card jumping into its correct state at the end. Hence: rotation about - // the CENTRE (the card turns in place rather than swinging sideways) and a rise small - // enough to stay within the strip's own slack. .scaleEffect(1 - 0.26 * away) .rotation3DEffect( .degrees(side * -64 * away), axis: (x: 0, y: 1, z: 0), anchor: .center, perspective: 0.65) - .offset(y: 16 * away) + .offset(y: 34 * away) } /// `1 - (1-t)³`, with a small overshoot past 1 before it settles. diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 8a826ad1..94d3bab4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -371,8 +371,8 @@ struct GamepadHomeView: View { onSecondary: { openLibraryForSelected() }, onTertiary: { showSettings = true }, isActive: homeOwnsController - ) { tile in - hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) + ) { tile, entrance in + hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance) } .frame(height: cardHeight + 40) } @@ -381,8 +381,12 @@ struct GamepadHomeView: View { /// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen /// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale + /// brightness/saturation + a depth blur on the recessed neighbors. - private func hostCard(_ tile: HomeTile, size: CGSize) -> some View { + private func hostCard( + _ tile: HomeTile, size: CGSize, entrance: CardEntrance + ) -> some View { GamepadHostTile(tile: tile, size: size) + // Beneath the scroll transition, never around it — see CardEntrance. + .modifier(entrance) .scrollTransition { content, phase in let d = CGFloat(min(abs(phase.value), 1)) let scale = 1 - d * 0.12 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 0d0b55be..509d3fc8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -110,8 +110,8 @@ struct LibraryCoverflowView: View { shoulderJump: 5, isActive: controllerActive, contentReady: contentReady - ) { game in - cover(game, width: coverWidth, height: coverHeight) + ) { game, entrance in + cover(game, width: coverWidth, height: coverHeight, entrance: entrance) } .frame(height: coverHeight + 44) } @@ -120,7 +120,9 @@ struct LibraryCoverflowView: View { /// per-frame `phase` (real distance-from-centered), so the tilt tracks what's actually on screen /// mid-scroll. `.shadow` isn't a `VisualEffect`, so it's baked constant into the card; the /// scale/rotation/opacity ramp already makes the centered cover prominent. - private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat) -> some View { + private func cover( + _ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance + ) -> some View { PosterImage( candidates: game.art.posterCandidates, title: game.title, session: imageSession, onLoaded: { artSettled += 1 }) @@ -136,6 +138,8 @@ struct LibraryCoverflowView: View { .strokeBorder(ink.fg(0.12), lineWidth: 1) } .shadow(color: ink.shadow(0.5), radius: 16, y: 12) + // Beneath the scroll transition, never around it — see CardEntrance. + .modifier(entrance) .scrollTransition { content, phase in let v = phase.value let d = CGFloat(min(abs(v), 1)) -- 2.54.0 From adf92939ec5ff238d4ef824b895b80b06223260a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:49:37 +0200 Subject: [PATCH 10/10] fix(client/apple): the stats card's corner stops eating its own text 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. --- .../Session/StreamHUDView.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 41606725..9e06fdb3 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -224,9 +224,18 @@ struct StreamHUDView: View { /// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the /// couch (relative system styles), so the card's chrome must keep pace or it reads cramped. + /// + /// On iOS it also has to CLEAR THE CORNER. A rounded corner of radius `r` pulls the card's + /// edge inward by `r − √(r² − (r−y)²)` at a distance `y` below the top, so the first and last + /// lines of a padded stack sit inside the arc unless the padding keeps pace with the radius. + /// At `0.45 · r` that intrusion stays well inside the padding across the whole range this + /// card can wear (≈4.6 pt of arc against 12.6 pt of padding at the 28 pt cap), so no line + /// ever runs into the curve. private var cardPadding: CGFloat { #if os(tvOS) return 16 + #elseif os(iOS) + return max(10, cardCornerRadius * 0.45) #else return 10 #endif @@ -246,13 +255,20 @@ struct StreamHUDView: View { #endif } - /// The card's corner radius. On iOS it's concentric with the physical display corner — - /// `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform right around the - /// corner instead of a small-radius card cutting into the very rounded glass. Clamped so a - /// flat-cornered device (or a hidden radius) still gets a sensibly rounded card. + /// The card's corner radius. On iOS it aims to be concentric with the physical display + /// corner — `displayCornerRadius − edgeInset`, so the gap to the screen edge stays uniform + /// right around the corner instead of a small-radius card cutting into the very rounded + /// glass — but that aim is BOUNDED by what a card this small can actually carry. + /// + /// Unbounded, 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: the arc reaches ~19 pt inward at the first line, so + /// the top and bottom lines rendered INSIDE the curve. Concentricity is only a virtue while + /// the radius is small next to the card; past that it is just a blob eating its own text. + /// 28 pt is the most this card's stack can wear (with `cardPadding` scaling alongside), and + /// devices whose display radius asks for less than that still get a truly concentric corner. private var cardCornerRadius: CGFloat { #if os(iOS) - return max(12, DeviceMetrics.displayCornerRadius - edgeInset) + return min(28, max(12, DeviceMetrics.displayCornerRadius - edgeInset)) #elseif os(tvOS) return 16 // scales with the roomier padding #else -- 2.54.0