Zum Inhalt

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 — add HUDContent type, hud/hintVisible state + timers, wire into apply/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 HUDContent type — at the top of AmbientController.swift, after the imports (before the AmbientController doc 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 init signature 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 whole apply method:
    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 inside tick, after the existing if 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 overlay if block 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 .onMoveCommand to the root VStack (the one wrapping Spacer() + 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
Expected: BUILD SUCCEEDED (all #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/launch to ATV 70720F26-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.