fix(apple): every console screen read the ink it publishes, so a pale palette stayed white on tvOS
ci / rust-arm64 (pull_request) Successful in 1m20s
ci / web (pull_request) Successful in 2m49s
ci / docs-site (pull_request) Successful in 2m21s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 2m31s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 6m49s

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.
This commit is contained in:
2026-08-13 20:58:36 +02:00
parent 7df321f459
commit 9a52d725d5
10 changed files with 104 additions and 14 deletions
@@ -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 {
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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)?
@@ -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
@@ -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
@@ -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