The console screens read the ink they publish, so a pale palette stayed white on Apple TV #206

Merged
enricobuehler merged 1 commits from worktree-apple-tv-light-palette-ink into main 2026-08-13 19:18:57 +00:00
Owner

Field report: "with the bright background colors on Apple TV the text still stays white in many places".

The trap

A view's @Environment resolves against its parent. A screen that applies gamepadPaletteInk() to its own body is therefore above its own copy — the modifier covers the body's descendants, but not the body's own ink.… references. So every one of these screens read whatever was published above it.

On iOS that happened to be right: they are the shell's in-place layers, nested inside GamepadHomeView below its ink. On tvOS and macOS they are covers and sheets, attached outside the launcher's modifier — nothing is above them, so they got the bare GamepadInk.dark default.

The symptom is a screen split down the middle: the title, the tab pills, every row label and value white, and the focus wash still brand violet — while the child views on the same screen (hint bar, host tiles, ConsoleGlass frost) resolved the real palette and went correctly dark-on-pale.

This is the same trap the gamepadMetrics comment already documents, one environment key over.

Before / after — console Settings on Mint:

before after
white title, white tab labels, white row labels and values over pale glass; violet focus wash and selected pill palette ink throughout; the pill and focus wash are the palette's own accent

The fix

The six screens that publish the ink — and GamepadScreenBackground, mounted as their .background { } — resolve it from the stored ui_palette instead of the environment:

@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
private var ink: GamepadInk { .stored(paletteID) }

Deliberately not the fix gamepadMetrics uses (publish at the ContentView root): gamepadPaletteInk also publishes \.colorScheme, and forcing .light over a live session would flip StreamHUDView's .secondary/.tertiary to dark text over video. Resolving from storage also works no matter who mounts the screen, including the DEBUG shot harness, which mounts them with no ContentView above.

Three more of the same family (tvOS)

  • A tvOS fullScreenCover draws no background of its own. The pairing cover rendered the system's dark chrome straight over the launcher showing through it — the PIN prompt was white on the bright aurora. It gets the console field and the palette now, in the launcher's branch only; HomeView's route to the same PairSheet is the touch UI and still belongs to the system background.
  • A NavigationStack draws the navigation title, and it wraps LibraryView from outside that view's own ink — so the shelf's name stayed white over content that had already gone dark. Inked on the stack, on tvOS and on the macOS sheet (gated there: that sheet is both modes' library).
  • LibraryView's loading / error / empty states mounted no backdrop at all — only the coverflow did — so the spinner sat on the launcher's own aurora with the host tiles still visible behind it. Reported separately by the user while this was in flight.

Also: fg vs onAccent

A saved host's badge glyph took ink.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, and Graphite (accent luma ≈ 0.80) put white on light grey. It takes onAccent now, exactly like the selected settings tab in 08e462fe.

Verification

Built and run on the tvOS 26.5 simulator (Apple TV 4K), screenshotting each screen under Mint, Sunset (pale) and Violet, Graphite (dark): launcher, settings, add-host, pairing, and the library's loading state. Dark palettes are unchanged.

  • swift test — 288 passed, 6 skipped, 0 failures
  • swift build (macOS), Punktfunk-iOS and Punktfunk-tvOS all build — the #if os(iOS)-only (GamepadLibraryScreen) and os(iOS) || os(macOS)-only (GamepadPairView) branches are compiled out of the tvOS build, so both were typechecked separately.

⚠ Simulator only — not yet on real Apple TV hardware.

Field report: *"with the bright background colors on Apple TV the text still stays white in many places"*. ## The trap A view's `@Environment` resolves against its **parent**. A screen that applies `gamepadPaletteInk()` to its own body is therefore *above* its own copy — the modifier covers the body's descendants, but not the body's own `ink.…` references. So every one of these screens read whatever was published above it. On iOS that happened to be right: they are the shell's in-place layers, nested inside `GamepadHomeView` below its ink. On **tvOS and macOS they are covers and sheets**, attached outside the launcher's modifier — nothing is above them, so they got the bare `GamepadInk.dark` default. The symptom is a screen split down the middle: the title, the tab pills, every row label and value white, and the focus wash still brand violet — while the **child** views on the same screen (hint bar, host tiles, `ConsoleGlass` frost) resolved the real palette and went correctly dark-on-pale. This is the same trap the `gamepadMetrics` comment already documents, one environment key over. **Before / after — console Settings on Mint:** | before | after | | --- | --- | | white title, white tab labels, white row labels and values over pale glass; violet focus wash and selected pill | palette ink throughout; the pill and focus wash are the palette's own accent | ## The fix The six screens that publish the ink — and `GamepadScreenBackground`, mounted as their `.background { }` — resolve it from the stored `ui_palette` instead of the environment: ```swift @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" private var ink: GamepadInk { .stored(paletteID) } ``` Deliberately **not** the fix `gamepadMetrics` uses (publish at the ContentView root): `gamepadPaletteInk` also publishes `\.colorScheme`, and forcing `.light` over a live session would flip `StreamHUDView`'s `.secondary`/`.tertiary` to dark text over video. Resolving from storage also works no matter who mounts the screen, including the DEBUG shot harness, which mounts them with no ContentView above. ## Three more of the same family (tvOS) - **A tvOS `fullScreenCover` draws no background of its own.** The pairing cover rendered the system's dark chrome straight over the launcher showing through it — the PIN prompt was white on the bright aurora. It gets the console field and the palette now, in the launcher's branch only; `HomeView`'s route to the same `PairSheet` is the touch UI and still belongs to the system background. - **A `NavigationStack` draws the navigation title**, and it wraps `LibraryView` from outside that view's own ink — so the shelf's name stayed white over content that had already gone dark. Inked on the stack, on tvOS and on the macOS sheet (gated there: that sheet is both modes' library). - **`LibraryView`'s loading / error / empty states mounted no backdrop at all** — only the coverflow did — so the spinner sat on the launcher's own aurora with the host tiles still visible behind it. Reported separately by the user while this was in flight. ## Also: `fg` vs `onAccent` A saved host's badge glyph took `ink.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, and Graphite (accent luma ≈ 0.80) put white on light grey. It takes `onAccent` now, exactly like the selected settings tab in 08e462fe. ## Verification Built and run on the **tvOS 26.5 simulator** (Apple TV 4K), screenshotting each screen under **Mint**, **Sunset** (pale) and **Violet**, **Graphite** (dark): launcher, settings, add-host, pairing, and the library's loading state. Dark palettes are unchanged. - `swift test` — 288 passed, 6 skipped, 0 failures - `swift build` (macOS), `Punktfunk-iOS` and `Punktfunk-tvOS` all build — the `#if os(iOS)`-only (`GamepadLibraryScreen`) and `os(iOS) || os(macOS)`-only (`GamepadPairView`) branches are compiled out of the tvOS build, so both were typechecked separately. ⚠ Simulator only — not yet on real Apple TV hardware.
enricobuehler added 1 commit 2026-08-13 19:01:53 +00:00
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
9a52d725d5
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.
enricobuehler merged commit 4ab6a399e6 into main 2026-08-13 19:18:57 +00:00
enricobuehler deleted branch worktree-apple-tv-light-palette-ink 2026-08-13 19:19:01 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#206