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