tvOS T1 UX-Iteration Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add transient feedback HUD (cycle on/off + preset name), a bottom→up sliding overlay with swipe-down dismiss, and a startup hint to the tvOS T1 "Physarum Ambient" app.
Architecture: All feedback/hint state lives in the @Observable AmbientController (the single ControlIntent sink), expiring via its existing tick(dt) driver — host-testable on macOS. tvOS SwiftUI views (TVHUDView, TVHintView) merely reflect that state. Engine/Renderer/WGSL untouched.
Tech Stack: Swift 5, SwiftUI (tvOS), @Observable, XCTest (macOS host), XcodeGen.
Spec: docs/superpowers/specs/2026-06-16-tvos-t1-ux-iteration-design.md
File Structure¶
- Modify
apple/PhysarumApp/Sources/Ambient/AmbientController.swift— addHUDContenttype,hud/hintVisiblestate + timers, wire intoapply/tick. - Create
apple/PhysarumApp/Sources/TV/TVHUDView.swift— transient top-center HUD. - Create
apple/PhysarumApp/Sources/TV/TVHintView.swift— bottom startup hint. - Modify
apple/PhysarumApp/Sources/TV/TVPlayerView.swift— mount HUD+hint, slide transition. - Modify
apple/PhysarumApp/Sources/TV/TVOverlayView.swift— swipe-down dismiss. - Modify
apple/PhysarumApp/Tests/AmbientControllerTests.swift— feedback/hint tests.
Task 1: AmbientController feedback + hint state¶
Files:
- Modify: apple/PhysarumApp/Sources/Ambient/AmbientController.swift
- Test: apple/PhysarumApp/Tests/AmbientControllerTests.swift
- [ ] Step 1: Write the failing tests (append to
AmbientControllerTests.swift, before the closing brace)
// MARK: - Feedback HUD + startup hint (Inc 12b)
func testTogglePlaylistShowsCycleHUD() {
let (_, _, c) = make()
XCTAssertNil(c.hud)
c.apply(.togglePlaylist) // cycling true → false
XCTAssertEqual(c.hud, .cycle(false))
c.apply(.togglePlaylist) // → true
XCTAssertEqual(c.hud, .cycle(true))
}
func testManualNextShowsPresetNameHUD() {
let (_, presets, c) = make()
c.apply(.next)
XCTAssertEqual(c.hud, .preset(presets[1].name))
}
func testManualPreviousWrapsAndShowsPresetNameHUD() {
let (_, presets, c) = make()
c.apply(.previous)
XCTAssertEqual(c.hud, .preset(presets[presets.count - 1].name))
}
func testJumpToShowsPresetNameHUD() {
let (_, presets, c) = make()
let target = presets[2]
c.apply(.jumpTo(target.id))
XCTAssertEqual(c.hud, .preset(target.name))
}
func testHUDAutoClearsAfterDuration() {
let (_, _, c) = make() // hudDuration default 2.0
c.apply(.togglePlaylist)
XCTAssertNotNil(c.hud)
c.tick(2.1)
XCTAssertNil(c.hud)
}
func testAutoAdvanceRaisesNoHUD() {
let (state, presets, c) = make() // dwell 2.0, morph 1.0
c.tick(2.0) // dwell reached → auto-advance (not via apply)
XCTAssertNil(c.hud)
c.tick(1.1) // complete morph
XCTAssertEqual(state.selectedID, presets[1].id)
XCTAssertNil(c.hud)
}
func testHintVisibleInitiallyThenTimesOut() {
let (_, _, c) = make() // hintDuration default 5.0
XCTAssertTrue(c.hintVisible)
c.tick(5.1)
XCTAssertFalse(c.hintVisible)
}
func testHintClearsOnFirstIntent() {
let (_, _, c) = make()
XCTAssertTrue(c.hintVisible)
c.apply(.showOverlay)
XCTAssertFalse(c.hintVisible)
}
- [ ] Step 2: Run tests to verify they fail
Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/AmbientControllerTests
Expected: compile failure — value of type 'AmbientController' has no member 'hud' / 'hintVisible' / no type HUDContent.
- [ ] Step 3: Add the
HUDContenttype — at the top ofAmbientController.swift, after the imports (before theAmbientControllerdoc comment):
/// What the transient ambient HUD is currently showing. Owned by `AmbientController`
/// (the intent sink that knows *when* and *what*); the tvOS view only reflects it.
enum HUDContent: Equatable {
case cycle(Bool) // true = now auto-cycling, false = pinned/paused
case preset(String) // display name of the target preset (manual switch only)
}
- [ ] Step 4: Add state + timing properties — inside the class, next to
isCycling/overlayVisible:
private(set) var hud: HUDContent?
private var hudElapsed: Double = 0
let hudDuration: Double
private(set) var hintVisible: Bool = true
private var hintElapsed: Double = 0
let hintDuration: Double
- [ ] Step 5: Extend the initializer — replace the existing
initsignature and body:
init(appState: AppState, playlist: [Preset], morphDuration: Double = 4.0, dwell: Double = 60.0,
hudDuration: Double = 2.0, hintDuration: Double = 5.0) {
self.appState = appState
self.playlist = playlist
self.morphDuration = morphDuration
self.dwell = dwell
self.hudDuration = hudDuration
self.hintDuration = hintDuration
}
- [ ] Step 6: Wire feedback into
apply(_:)— replace the wholeapplymethod:
func apply(_ intent: ControlIntent) {
switch intent {
case .next:
let i = PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1)
beginMorph(toIndex: i)
if !playlist.isEmpty { setHUD(.preset(playlist[i].name)) }
case .previous:
let i = PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: -1)
beginMorph(toIndex: i)
if !playlist.isEmpty { setHUD(.preset(playlist[i].name)) }
case .togglePlaylist:
isCycling.toggle()
dwellElapsed = 0
setHUD(.cycle(isCycling))
case .jumpTo(let id):
if let i = playlist.firstIndex(where: { $0.id == id }) {
beginMorph(toIndex: i)
setHUD(.preset(playlist[i].name))
}
case .showOverlay:
overlayVisible = true
overlayIdle = 0
case .dismissOverlay:
overlayVisible = false
}
if overlayVisible { overlayIdle = 0 } // any command keeps the overlay awake
hintVisible = false // first interaction retires the startup hint
}
private func setHUD(_ content: HUDContent) { hud = content; hudElapsed = 0 }
- [ ] Step 7: Expire HUD + hint in
tick(_:)— append insidetick, after the existingif overlayVisible { … }block (still inside the method):
if hud != nil {
hudElapsed += dt
if hudElapsed >= hudDuration { hud = nil }
}
if hintVisible {
hintElapsed += dt
if hintElapsed >= hintDuration { hintVisible = false }
}
- [ ] Step 8: Run tests to verify they pass
Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/AmbientControllerTests
Expected: PASS (all AmbientController tests, old + 8 new).
- [ ] Step 9: Commit
git add apple/PhysarumApp/Sources/Ambient/AmbientController.swift apple/PhysarumApp/Tests/AmbientControllerTests.swift
git commit -m "feat(tvos): ambient feedback HUD + startup hint state in AmbientController"
Task 2: TVHUDView (transient top-center HUD)¶
Files:
- Create: apple/PhysarumApp/Sources/TV/TVHUDView.swift
No unit test — SwiftUI tvOS view, verified via build + screenshot + device (project convention; the HUD logic is already covered by Task 1).
- [ ] Step 1: Create the file
#if os(tvOS)
import SwiftUI
import PhysarumEngine
/// Transient top-center HUD reflecting `AmbientController.hud`: cycle on/off (play/pause)
/// or the new preset name (manual swipe). Non-interactive; the controller owns the ~2s
/// lifetime, this view only fades the current content in and out.
struct TVHUDView: View {
let controller: AmbientController
var body: some View {
VStack {
if let hud = controller.hud {
content(for: hud)
.padding(.horizontal, 28)
.padding(.vertical, 14)
.background(.black.opacity(0.55), in: Capsule())
.foregroundStyle(.white)
.padding(.top, 40)
.transition(.opacity)
}
Spacer()
}
.allowsHitTesting(false)
.animation(.easeInOut(duration: 0.25), value: controller.hud)
}
@ViewBuilder private func content(for hud: HUDContent) -> some View {
switch hud {
case .cycle(let on):
Label(on ? "Cycle On" : "Cycle Off",
systemImage: on ? "arrow.triangle.2.circlepath" : "pause.fill")
.font(.title2.weight(.semibold))
case .preset(let name):
Text(name).font(.title2.weight(.semibold))
}
}
}
#endif
- [ ] Step 2: Verify it compiles (deferred to Task 4's tvOS-sim build — the view isn't mounted yet). Commit:
git add apple/PhysarumApp/Sources/TV/TVHUDView.swift
git commit -m "feat(tvos): transient top-center feedback HUD view"
Task 3: TVHintView (startup hint)¶
Files:
- Create: apple/PhysarumApp/Sources/TV/TVHintView.swift
- [ ] Step 1: Create the file
#if os(tvOS)
import SwiftUI
/// Bottom startup hint nudging that the preset overlay lives below and is summoned by
/// swiping up. Driven entirely by `AmbientController.hintVisible` (shown on cold launch,
/// auto-fades ~5s or clears on first interaction). Hidden while the overlay is open.
struct TVHintView: View {
let controller: AmbientController
var body: some View {
VStack {
Spacer()
if controller.hintVisible && !controller.overlayVisible {
Label("Swipe up for presets", systemImage: "chevron.up")
.font(.headline)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(.black.opacity(0.45), in: Capsule())
.foregroundStyle(.white.opacity(0.9))
.padding(.bottom, 60)
.transition(.opacity)
}
}
.allowsHitTesting(false)
.animation(.easeInOut(duration: 0.3), value: controller.hintVisible)
.animation(.easeInOut(duration: 0.3), value: controller.overlayVisible)
}
}
#endif
- [ ] Step 2: Commit
git add apple/PhysarumApp/Sources/TV/TVHintView.swift
git commit -m "feat(tvos): startup hint view (swipe up for presets)"
Task 4: Mount views + overlay slide + swipe-down dismiss¶
Files:
- Modify: apple/PhysarumApp/Sources/TV/TVPlayerView.swift
- Modify: apple/PhysarumApp/Sources/TV/TVOverlayView.swift
- [ ] Step 1: Mount HUD+hint and slide the overlay — in
TVPlayerView.body, replace the overlayifblock and add the two overlays. The ZStack becomes:
ZStack {
MetalView(
initialField: FieldSize(width: 1280, height: 720),
selectedPreset: appState.selectedPreset,
editedParams: appState.edited,
cover: true,
format: .auto,
tier: .tv,
paused: scenePhase != .active
)
.focusable(!controller.overlayVisible)
.focused($focused)
.onMoveCommand { dir in
guard !controller.overlayVisible else { return }
if let intent = remoteMoveIntent(dir) { withAnimation(.easeInOut) { controller.apply(intent) } }
}
.onPlayPauseCommand { if !controller.overlayVisible { controller.apply(.togglePlaylist) } }
if controller.overlayVisible {
TVOverlayView(appState: appState, controller: controller)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
TVHUDView(controller: controller)
TVHintView(controller: controller)
}
- [ ] Step 2: Swipe-down dismiss in the overlay — in
TVOverlayView.body, add.onMoveCommandto the rootVStack(the one wrappingSpacer()+ content). Insert it directly before the existing.onAppear:
.onMoveCommand { direction in
if direction == .down { withAnimation { controller.apply(.dismissOverlay) } }
}
.onAppear { focusedCard = appState.selectedID }
- [ ] Step 3: Regenerate project + tvOS-sim build
Run:
xcodegen generate --spec apple/PhysarumApp/project.yml --project apple/PhysarumApp
xcodebuild -project apple/PhysarumApp/Physarum.xcodeproj -scheme Physarum \
-destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' build
#if os(tvOS) views compile + are referenced).
- [ ] Step 4: Commit
git add apple/PhysarumApp/Sources/TV/TVPlayerView.swift apple/PhysarumApp/Sources/TV/TVOverlayView.swift
git commit -m "feat(tvos): mount feedback HUD + hint, slide overlay from bottom, swipe-down dismiss"
Task 5: Full gates + device deploy¶
- [ ] Step 1: Host test suite —
cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS'. Expected: all App tests green (174 + 8 new = 182). - [ ] Step 2: tvOS-sim screenshot — boot the ATV-4K sim, install, launch, screenshot; confirm HUD top, hint bottom, overlay present. (Sim doesn't drive the sim — static frame only; motion/focus = device.)
- [ ] Step 3: Device deploy — Release build for
platform=tvOS,id=c2f1eba2a1841970a35e5e333fb1ba5dd1985bd9(with-allowProvisioningUpdates),devicectl install/launchto ATV70720F26-DC9C-52A0-9E4E-A436977EE205. - [ ] Step 4: Hand to Johannes for remote sign-off (HUD on play/pause + swipe; overlay slides up/down; swipe-down closes; hint on launch).
Self-Review¶
Spec coverage: G1 HUD → Tasks 1+2 (cycle + preset content, top-center, 2s expiry). G2 overlay slide + swipe-down → Task 4. G3 startup hint → Tasks 1+3. Non-goal "no HUD on auto-advance" → testAutoAdvanceRaisesNoHUD. All covered.
Placeholder scan: none — every code step is complete Swift.
Type consistency: HUDContent.cycle(Bool)/.preset(String) used identically in Task 1 (definition + tests), Task 2 (view switch). hud/hintVisible/hudDuration/hintDuration names consistent across controller, tests, and views. controller.apply(.dismissOverlay) matches the existing ControlIntent case. Device IDs match the earlier successful deploy.
Risk (carried from spec): swipe-down dismiss relies on tvOS focus-bubbling of an unconsumed .down from the focused carousel card to the overlay container's onMoveCommand. Empirically verified at Task 5; Menu-dismiss remains as fallback, so no functional loss if it needs adjustment.