From 9a52d725d5f9953c90c0e63aafd44bdb79924877 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 20:58:36 +0200 Subject: [PATCH] fix(apple): every console screen read the ink it publishes, so a pale palette stayed white on tvOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screen that applies `gamepadPaletteInk()` to its own body sits ABOVE its own copy of the environment: the modifier covers its descendants, never the body's own `ink.…` references. So each of these screens read whatever was published above it — and on tvOS, where they are presented as covers rather than nested in the iOS shell, that is nothing at all. They got the bare dark default while their CHILD views (the hint bar, the host tiles, the glass) resolved the real palette, which is why a pale field came out with a white title, white row labels and white values under correctly-pale glass, with the focus wash still brand violet instead of the palette's accent. The same trap the `gamepadMetrics` comment already documents, one environment key over. Resolve the ink from the stored `ui_palette` instead of the environment in the six screens that publish it, and in `GamepadScreenBackground` — mounted as their `.background { }`, so it was reading the parent's ink too and bleaching a pale field's scrim toward white. Three more of the same family, all tvOS-only: - the pairing cover drew the system's dark chrome straight over the launcher showing through it (a tvOS cover has no background of its own): the PIN prompt was white on the bright aurora. It gets the console field and the palette now, in the launcher's branch only — the touch route to the same sheet still belongs to the system background. - the library cover's navigation title is drawn by the NavigationStack, which wraps LibraryView from outside its own ink, so the shelf's name stayed white over content that had already gone dark. Fixed on tvOS and on the macOS sheet (gated there — that sheet is both modes'). - the library's loading / error / empty states mounted no backdrop at all; only the coverflow did. They now take the same field, so the spinner no longer sits on the launcher's own aurora with the host tiles still visible behind it. And a contrast bug the same screens made visible: a saved host's badge glyph took `fg`, which is chosen against the FIELD, while the badge it sits on IS the accent. The two disagree at both ends of the set — a pale palette put near-black on a deep accent, Graphite (accent luma 0.80) put white on light grey. It takes `onAccent` now, like the selected settings tab. Verified on the tvOS 26.5 simulator across Mint, Sunset, Violet and Graphite: launcher, settings, add-host, pairing and the library's loading state. `swift test` 288 passed / 6 skipped; iOS and tvOS both build. --- .../Sources/PunktfunkClient/ContentView.swift | 21 +++++++++++++ .../Home/GamepadAddHostView.swift | 5 +++- .../PunktfunkClient/Home/GamepadChrome.swift | 7 ++++- .../Home/GamepadHomeView.swift | 17 ++++++++--- .../PunktfunkClient/Home/GamepadInk.swift | 18 +++++++++++ .../Home/GamepadLibraryScreen.swift | 5 +++- .../Home/LibraryCoverflowView.swift | 5 +++- .../PunktfunkClient/Home/LibraryView.swift | 30 ++++++++++++++++--- .../Settings/GamepadSettingsView.swift | 5 +++- .../Trust/GamepadPairView.swift | 5 +++- 10 files changed, 104 insertions(+), 14 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index d2fa103f..d758bf5b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -453,6 +453,10 @@ struct ContentView: View { LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) }) } .frame(minWidth: 940, minHeight: 620) + // The stack draws the title, and it sits outside LibraryView's own ink — see the tvOS + // cover. Gated, because this sheet is BOTH modes' library on macOS and the touch + // grid's title belongs to the system background. + .gamepadPaletteInk(gamepadUIActive) } #else // iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one @@ -851,12 +855,29 @@ struct ContentView: View { .fullScreenCover(item: $pairingTarget) { host in PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) } .onExitCommand { pairingTarget = nil } + // A tvOS cover draws NO background of its own, and this one is attached + // outside the launcher's `gamepadPaletteInk` — so the pairing screen used + // to render the system's dark chrome directly over the launcher showing + // through it, which under a pale palette is white text on a bright field + // (the PIN prompt was all but invisible). Give it the console's own field + // and the palette's ink, like every other screen the launcher opens. Only + // this branch: `HomeView`'s route to the same sheet is the TOUCH UI, which + // sits on the system background and has no palette. + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { GamepadFormBackground() } + .gamepadPaletteInk() } .fullScreenCover(item: $libraryTarget) { shelf in NavigationStack { LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) }) } .onExitCommand { libraryTarget = nil } + // On the STACK, not just inside LibraryView: the navigation title is drawn by + // the stack, which wraps that view from outside its own `gamepadPaletteInk` — + // so the shelf's name stayed white over a pale field while the content below + // it had already gone dark. Unconditional here because this cover only exists + // in the launcher's branch, where the console UI is by definition drawing. + .gamepadPaletteInk() } #endif } else { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index fd032eec..fb69d279 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -12,7 +12,10 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) struct GamepadAddHostView: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } @Environment(\.gamepadMetrics) private var metrics @Environment(\.displayBottomInset) private var displayBottomInset @Environment(\.dismiss) private var dismiss diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 266f7500..156d4d02 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -430,7 +430,12 @@ private struct HintCellStyle: ButtonStyle { /// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from `paletteID` below rather than `\.gamepadInk`: this is mounted as a screen's + /// `.background { }`, which the screen attaches BEFORE its own `gamepadPaletteInk()`, so the + /// environment here is the screen's parent's — the dark default under a cover or a sheet. It + /// only feeds a pale palette's scrim, so the symptom was subtle: the field bleached toward + /// white instead of settling onto its own ink. (see `GamepadInk.stored`) + private var ink: GamepadInk { .stored(paletteID) } /// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora, /// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE /// it during a push/pop — the console does the same with its `bg_mix` — and every diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 5ddca868..0ef972db 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -64,7 +64,10 @@ private struct HomeTile: Identifiable { } struct GamepadHomeView: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } /// Published by ContentView at the app ROOT, so this reads its own window's tier — this screen /// applies `gamepadPaletteInk` itself and so sits above its own copy of the environment. @Environment(\.gamepadMetrics) private var metrics @@ -657,6 +660,12 @@ private struct GamepadHostTile: View { private var monogramBadge: some View { let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous) + // What the glyph is drawn ON: a filled badge IS the accent, so its mark takes `onAccent` + // — the colour picked by the accent's own luminance — exactly as the settings screen's + // selected tab pill does. It used to take `fg`, which is chosen against the FIELD, and the + // two disagree at both ends of the set: a pale palette put near-black on a deep accent, and + // Graphite (accent luma ≈ 0.80) put white on a light grey. + let glyph = tile.filled ? ink.onAccent : ink.accent return ZStack { shape.fill(tile.filled ? AnyShapeStyle(LinearGradient( @@ -664,7 +673,7 @@ private struct GamepadHostTile: View { startPoint: .top, endPoint: .bottom)) : AnyShapeStyle(ink.accent(0.16))) if tile.isConnecting { - ProgressView().tint(ink.fg) + ProgressView().tint(glyph) } else if let icon = tile.icon { Image(systemName: icon) .font(.system(size: Self.iconFont, weight: .semibold)) @@ -676,12 +685,12 @@ private struct GamepadHostTile: View { .resizable() .scaledToFit() .frame(width: Self.monogramFont, height: Self.monogramFont) - .foregroundStyle(tile.filled ? ink.fg : ink.accent) + .foregroundStyle(glyph) .accessibilityLabel(tile.osChain ?? "") } else { Text(monogram(tile.title)) .font(.geistFixed(Self.monogramFont, .bold)) - .foregroundStyle(tile.filled ? ink.fg : ink.accent) + .foregroundStyle(glyph) } } .frame(width: Self.badgeSide, height: Self.badgeSide) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift index 9d26aec0..07e93190 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -67,6 +67,24 @@ struct GamepadInk: Equatable, Sendable { /// The shipped dark look — what a preview or a test composition gets. static let dark = GamepadInk.of(GamepadPalette.named("violet")) + /// The ink for a stored `ui_palette` id, resolved WITHOUT the environment. + /// + /// For the screens that publish their own ink with `gamepadPaletteInk()`. A view's + /// `@Environment` resolves against its PARENT — the modifier a screen applies to its own body + /// covers its descendants, never the body's own `ink.…` references — so such a screen reads + /// whatever was published ABOVE it. Nested inside another gamepad screen (the iOS shell's + /// layers) that happens to be right; presented as a cover or a sheet (tvOS, macOS) there is + /// nothing above it and it gets the bare dark default. That is precisely how a pale palette + /// came out with a WHITE title, white row labels and a violet focus wash on an Apple TV, while + /// the child views in the same screen — the hint bar, the host tiles, the glass — were + /// correctly dark-on-pale. + /// + /// Declare it beside an `@AppStorage(DefaultsKey.uiPalette)`, which is what re-renders the + /// screen when the setting changes (`GamepadInkModifier` reads the same key). + static func stored(_ paletteID: String) -> GamepadInk { + .of(GamepadPalette.named(paletteID)) + } + /// The online pip — deliberately NOT palette-derived: a status colour must not change /// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim). static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 9d5fd493..3ab10a9c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -10,7 +10,10 @@ import SwiftUI #if os(iOS) struct GamepadLibraryScreen: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } @ObservedObject var store: HostStore let target: LibraryTarget let onLaunch: (String) -> Void diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 89a289f0..aad4d3fa 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -19,7 +19,10 @@ import SwiftUI import GameController struct LibraryCoverflowView: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } let games: [GameEntry] let artLoader: LibraryArtLoader? var onLaunch: ((String) -> Void)? diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index f54c6029..7c0c1746 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -94,6 +94,9 @@ struct LibraryView: View { gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled, mode: gamepadUIMode) } + /// True when the iOS shell already draws one persistent field behind its layers — mounting a + /// second would double the mesh (the same rule the coverflow and the settings screen follow). + @Environment(\.gamepadHostedInShell) private var hostedInShell #endif var body: some View { @@ -150,12 +153,13 @@ struct LibraryView: View { @ViewBuilder private var content: some View { if loading && games.isEmpty { - ProgressView("Loading library…") - .frame(maxWidth: .infinity, maxHeight: .infinity) + consoleField( + ProgressView("Loading library…") + .frame(maxWidth: .infinity, maxHeight: .infinity)) } else if let errorText, games.isEmpty { - errorState(errorText) + consoleField(errorState(errorText)) } else if games.isEmpty { - emptyState + consoleField(emptyState) } else { if gamepadUIActive { LibraryCoverflowView( @@ -168,6 +172,24 @@ struct LibraryView: View { } } + /// The console field behind the three states that are NOT the coverflow — loading, error, + /// empty. The coverflow mounts its own backdrop; these mounted nothing, so wherever this view + /// is a COVER over the launcher (tvOS, macOS) they drew straight onto it: the spinner and its + /// label sat on the launcher's own aurora with the host tiles still showing through. The same + /// field as the coverflow's (not the calmed form one), so nothing shifts under the content when + /// the titles land and the coverflow takes over. + /// + /// Only in gamepad mode: the plain grid's states belong on the system background, as before. + @ViewBuilder private func consoleField(_ view: some View) -> some View { + #if os(iOS) || os(macOS) || os(tvOS) + view.background { + if gamepadUIActive, !hostedInShell { GamepadScreenBackground() } + } + #else + view + #endif + } + private var grid: some View { // Design D4: launcher entries get their own section above the titles, never interleaved. // Both headers appear only when both groups exist, so a library without launcher entries diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index b3543630..81d280c1 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -45,7 +45,10 @@ enum GpSettingsTab: String, CaseIterable, Hashable { } struct GamepadSettingsView: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from `paletteID` below, NOT from `\.gamepadInk` — this screen publishes that value + /// itself and so sits above its own copy (see `GamepadInk.stored`). Reading the environment + /// here is what left the title, the tab pills and every row label white-on-pale on tvOS. + private var ink: GamepadInk { .stored(paletteID) } @Environment(\.gamepadMetrics) private var metrics @Environment(\.displayBottomInset) private var displayBottomInset @Environment(\.dismiss) private var dismiss diff --git a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift index 4d2054ee..5626a566 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift @@ -18,7 +18,10 @@ import SwiftUI #if os(iOS) || os(macOS) struct GamepadPairView: View { - @Environment(\.gamepadInk) private var ink + /// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that + /// value itself and so sits above its own copy (see `GamepadInk.stored`). + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } @Environment(\.gamepadMetrics) private var metrics @Environment(\.displayBottomInset) private var displayBottomInset @Environment(\.dismiss) private var dismiss