Zum Inhalt

tvOS „Physarum Ambient" (T1) 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: Ship a standalone, Siri-Remote-operable tvOS Ambiance app that renders the existing Metal engine full-bleed and auto-cycles a curated preset playlist with smooth morph transitions.

Architecture: A single multiplatform target gains a #if os(tvOS) lean-back SwiftUI shell (TV/*) over the unchanged engine + RenderCoordinator + AppState. All ambient logic (playlist scheduling, control-intent state machine, structure-pinned morph) lives in a platform-agnostic Ambient/* layer that is unit-tested on the macOS host. The iOS/macOS UI layer is excluded from the tvOS build via whole-file #if !os(tvOS) guards. A single AmbientController.apply(_ intent: ControlIntent) sink is the seam the later T2 phone controller plugs into.

Tech Stack: Swift 5 mode, SwiftUI, MetalKit, XcodeGen, XCTest. Build via xcodebuild; pure logic tested on macOS, tvOS UI verified by simulator build + launch (animation/thermal = device sign-off).


Reference: build & test commands

# Regenerate the Xcode project after any project.yml change
cd apple/PhysarumApp && xcodegen generate

# tvOS Simulator BUILD (compile gate — SDK present, runtime installed)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -25

# macOS BUILD + pure-logic TESTS (regression gate + where Ambient/* is tested)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -15
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -15

# iOS Simulator BUILD (regression gate)
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -15

# Run a single test class on macOS
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15

# Engine smoke gate (Xcode-free)
cd apple/PhysarumEngine && swift run EngineCheck 2>&1 | tail -5

Commit convention (AGENTS.md): Conventional Commits, no emoji, English. Never --no-verify. Direct commits to main are authorized (trunk-based). Do not push (Codeberg→GitHub auto-mirror; push only on Johannes' direct command).


File Structure

New — apple/PhysarumApp/Sources/Ambient/ (platform-agnostic, compiled everywhere, unit-tested on macOS): - ControlIntent.swift — the ControlIntent enum + pure remoteMoveIntent(_:) mapping. - PlaylistDirector.swift — pure auto-cycle scheduling (dwell + wrap-around index). - AmbientController.swift — @Observable playback state machine: intent sink, time driver (tick), structure-pinned morph; drives AppState.

New — apple/PhysarumApp/Sources/TV/ (#if os(tvOS) only): - TVRootView.swift — scene root: owns AppState + AmbientController, idle-timer suppression. - TVPlayerView.swift — full-bleed MetalView + Siri-Remote gestures + 30 Hz morph tick + scene-phase pause. - TVOverlayView.swift — summon-able overlay: preset name, focusable preset carousel, pin/resume toggle.

Modified: - Sources/Model/DeviceIdiom.swift — add .tv idiom. - Sources/Model/SimFormat.swift — add FieldTier.tv + map .tv idiom. - Sources/App/PhysarumApp.swift — tvOS scene branch → TVRootView. - Sources/Audio/AudioInput.swift — compile inert on tvOS (no mic route). - The iOS/macOS UI layer (RootView, Editor/, Library/, Capture UI + Photos) — whole-file #if !os(tvOS) guards (exact set finalized by the tvOS build loop in Task 4). - apple/PhysarumApp/project.yml — add tvOS to the test target (Task 7).

New tests — apple/PhysarumApp/Tests/: - FieldTierTVTests.swift, PlaylistDirectorTests.swift, ControlIntentTests.swift, AmbientControllerTests.swift.


Task 1: Model — .tv device idiom + field tier

Files: - Modify: apple/PhysarumApp/Sources/Model/DeviceIdiom.swift - Modify: apple/PhysarumApp/Sources/Model/SimFormat.swift - Test: apple/PhysarumApp/Tests/FieldTierTVTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/FieldTierTVTests.swift:

import XCTest
@testable import Physarum

final class FieldTierTVTests: XCTestCase {
    func testTVTierIsConservativeAndDistinct() {
        // Apple TV 4K is mains-powered but fanless — start at the conservative
        // phone/tablet workload; the seam (distinct id) lets us raise it on-device.
        XCTAssertEqual(FieldTier.tv.base, 512)
        XCTAssertEqual(FieldTier.tv.maxLong, 1280)
        XCTAssertEqual(FieldTier.tv.agentCap, 250_000)
        XCTAssertNotEqual(FieldTier.tv, FieldTier.tablet)
        XCTAssertNotEqual(FieldTier.tv, FieldTier.phone)
    }

    func testFieldTierMapsTVIdiom() {
        XCTAssertEqual(fieldTier(for: .tv), .tv)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -15 Expected: FAIL — .tv is not a member of DeviceIdiom / FieldTier.

  • [ ] Step 3: Add the .tv idiom

In Sources/Model/DeviceIdiom.swift, extend the enum and the computed var:

/// Device class for live-path workload + capture-ceiling decisions.
enum DeviceIdiom: Equatable { case phone, pad, mac, tv }

/// The running device's class. iPad is its own class (not "desktop").
var currentDeviceIdiom: DeviceIdiom {
    #if os(macOS)
    return .mac
    #elseif os(tvOS)
    return .tv
    #else
    return UIDevice.current.userInterfaceIdiom == .pad ? .pad : .phone
    #endif
}
  • [ ] Step 4: Add the .tv field tier + idiom mapping

In Sources/Model/SimFormat.swift, add the static tier next to the existing ones:

    static let tv = FieldTier(base: 512, maxLong: 1280, agentCap: 250_000, id: 3)

And add the .tv case to the existing fieldTier(for:) switch (it currently maps phone/pad/mac):

func fieldTier(for idiom: DeviceIdiom) -> FieldTier {
    switch idiom {
    case .phone: return .phone
    case .pad:   return .tablet
    case .mac:   return .desktop
    case .tv:    return .tv
    }
}
  • [ ] Step 5: Run test to verify it passes

Run: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -15 Expected: PASS (2 tests).

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/Model/DeviceIdiom.swift apple/PhysarumApp/Sources/Model/SimFormat.swift apple/PhysarumApp/Tests/FieldTierTVTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add .tv device idiom and conservative field tier"

Task 2: PlaylistDirector (pure auto-cycle scheduling)

Files: - Create: apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift - Test: apple/PhysarumApp/Tests/PlaylistDirectorTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/PlaylistDirectorTests.swift:

import XCTest
@testable import Physarum

final class PlaylistDirectorTests: XCTestCase {
    func testShouldAdvanceAtOrAfterDwell() {
        XCTAssertFalse(PlaylistDirector.shouldAdvance(dwellElapsed: 59, dwell: 60))
        XCTAssertTrue(PlaylistDirector.shouldAdvance(dwellElapsed: 60, dwell: 60))
        XCTAssertTrue(PlaylistDirector.shouldAdvance(dwellElapsed: 61, dwell: 60))
    }

    func testNextIndexWraps() {
        XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 3, step: 1), 1)
        XCTAssertEqual(PlaylistDirector.nextIndex(current: 2, count: 3, step: 1), 0)
        XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 3, step: -1), 2)
        XCTAssertEqual(PlaylistDirector.nextIndex(current: 1, count: 3, step: -1), 0)
    }

    func testNextIndexEmptyListIsSafe() {
        XCTAssertEqual(PlaylistDirector.nextIndex(current: 0, count: 0, step: 1), 0)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15 Expected: FAIL — no such type PlaylistDirector.

  • [ ] Step 3: Write the implementation

Create apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift:

import Foundation

/// Pure auto-cycle scheduling for the tvOS ambient playlist. Holds no state itself —
/// callers pass elapsed dwell time and the current index; the director decides when to
/// advance and to which index. Platform-agnostic so it is fully unit-testable on the
/// macOS host (the tvOS view layer just feeds it time).
enum PlaylistDirector {
    /// Whether the current preset has been on screen long enough to advance.
    static func shouldAdvance(dwellElapsed: Double, dwell: Double) -> Bool {
        dwellElapsed >= dwell
    }

    /// Next index with wrap-around. `step` may be negative (previous) or positive (next).
    /// Returns `current` unchanged when the list is empty.
    static func nextIndex(current: Int, count: Int, step: Int) -> Int {
        guard count > 0 else { return current }
        return ((current + step) % count + count) % count
    }
}
  • [ ] Step 4: Run test to verify it passes

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PlaylistDirectorTests 2>&1 | tail -15 Expected: PASS (3 tests). (xcodegen generate picks up the new source file.)

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Ambient/PlaylistDirector.swift apple/PhysarumApp/Tests/PlaylistDirectorTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add PlaylistDirector auto-cycle scheduling"

Task 3: ControlIntent + remote mapping + AmbientController

Files: - Create: apple/PhysarumApp/Sources/Ambient/ControlIntent.swift - Create: apple/PhysarumApp/Sources/Ambient/AmbientController.swift - Test: apple/PhysarumApp/Tests/ControlIntentTests.swift - Test: apple/PhysarumApp/Tests/AmbientControllerTests.swift

  • [ ] Step 1: Write the ControlIntent + mapping test

Create apple/PhysarumApp/Tests/ControlIntentTests.swift:

import XCTest
import SwiftUI
@testable import Physarum

final class ControlIntentTests: XCTestCase {
    func testMoveMapping() {
        XCTAssertEqual(remoteMoveIntent(.left), .previous)
        XCTAssertEqual(remoteMoveIntent(.right), .next)
        XCTAssertEqual(remoteMoveIntent(.up), .showOverlay)
        XCTAssertEqual(remoteMoveIntent(.down), .dismissOverlay)
    }
}
  • [ ] Step 2: Write ControlIntent + mapping

Create apple/PhysarumApp/Sources/Ambient/ControlIntent.swift:

import SwiftUI

/// A single command the ambient player understands, regardless of source. Today the
/// Siri Remote (T1) feeds these; the T2 phone controller will decode network messages
/// into the SAME intents, so the player's command surface never forks.
enum ControlIntent: Equatable {
    case next
    case previous
    case togglePlaylist      // pin the current preset ↔ resume auto-cycling
    case jumpTo(String)      // preset id
    case showOverlay
    case dismissOverlay
}

/// Pure mapping from a SwiftUI move command (Siri Remote swipe / directional click) to
/// an intent. Lives here (not in the view) so it is unit-testable on the macOS host.
/// `MoveCommandDirection` is a struct with static members (not an enum), so compare with `==`.
func remoteMoveIntent(_ direction: MoveCommandDirection) -> ControlIntent? {
    if direction == .left  { return .previous }
    if direction == .right { return .next }
    if direction == .up    { return .showOverlay }
    if direction == .down  { return .dismissOverlay }
    return nil
}
  • [ ] Step 3: Run the mapping test (verify pass)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ControlIntentTests 2>&1 | tail -15 Expected: PASS (1 test).

  • [ ] Step 4: Write the AmbientController test

Create apple/PhysarumApp/Tests/AmbientControllerTests.swift:

import XCTest
@testable import Physarum

final class AmbientControllerTests: XCTestCase {
    private func make() -> (AppState, [Preset], AmbientController) {
        let presets = PresetCatalog.loadAll()
        let state = AppState()
        state.select(id: presets[0].id)
        let controller = AmbientController(appState: state, playlist: presets, morphDuration: 1.0, dwell: 2.0)
        return (state, presets, controller)
    }

    func testTogglePlaylistFlipsCycling() {
        let (_, _, c) = make()
        XCTAssertTrue(c.isCycling)
        c.apply(.togglePlaylist)
        XCTAssertFalse(c.isCycling)
    }

    func testPinnedMorphHoldsStructure() {
        let (_, presets, c) = make()
        let a = presets[0].params, b = presets[1].params
        let mid = c.pinnedMorph(from: a, to: b, t: 0.5)
        XCTAssertEqual(mid.agentCount, a.agentCount)
        XCTAssertEqual(mid.spawnPattern, a.spawnPattern)
        for i in 0..<3 { XCTAssertEqual(mid.species[i].populationShare, a.species[i].populationShare) }
    }

    func testManualNextMorphsThenSelectsTarget() {
        let (state, presets, c) = make()
        c.apply(.next)
        XCTAssertEqual(state.selectedID, presets[0].id)   // morph not yet complete
        c.tick(0.5)
        XCTAssertEqual(state.selectedID, presets[0].id)   // mid-morph, still old
        c.tick(0.6)                                       // crosses t=1
        XCTAssertEqual(state.selectedID, presets[1].id)   // adopted target
    }

    func testAutoCycleAdvancesAfterDwell() {
        let (state, presets, c) = make()    // dwell 2.0, morphDuration 1.0
        c.tick(2.0)                          // dwell reached → begins morph
        c.tick(1.1)                          // completes morph
        XCTAssertEqual(state.selectedID, presets[1].id)
    }

    func testOverlayAutoDismisses() {
        let (_, _, c) = make()
        c.apply(.showOverlay)
        XCTAssertTrue(c.overlayVisible)
        c.tick(7)                            // > overlayTimeout
        XCTAssertFalse(c.overlayVisible)
    }
}
  • [ ] Step 5: Run the AmbientController test (verify it fails)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/AmbientControllerTests 2>&1 | tail -15 Expected: FAIL — no such type AmbientController.

  • [ ] Step 6: Write AmbientController

Create apple/PhysarumApp/Sources/Ambient/AmbientController.swift:

import SwiftUI
import PhysarumEngine

/// Owns the tvOS ambient playback: which preset is showing, whether the playlist is
/// auto-cycling, and any in-progress morph between presets. It is the single sink for
/// `ControlIntent`s (Siri Remote now, phone controller later) and the single driver of
/// `AppState` (the model SSOT the MetalView renders from). It never touches the
/// RenderCoordinator directly — it mutates `AppState.edited`/selection exactly like the
/// live editor does, so the existing MetalView → RenderCoordinator path applies everything.
///
/// Transitions are a *structure-pinned* param morph: scalar look params (decay, bloom,
/// colours, matrix, …) interpolate via `morphParams`, but agentCount / spawnPattern /
/// populationShare stay pinned to the outgoing preset so the live `setParams` path does
/// NOT thrash its debounced respawn every frame. At t=1 we `select` the target, which
/// re-seeds the sim once into the new structure — the look already matches, so it reads
/// as a soft refresh rather than a cut.
@Observable
final class AmbientController {
    private let appState: AppState
    private let playlist: [Preset]
    let morphDuration: Double
    let dwell: Double

    private(set) var isCycling: Bool = true
    private(set) var overlayVisible: Bool = false

    private struct Morph { let from: PresetParams; let to: PresetParams; let toID: String; var elapsed: Double }
    private var morph: Morph?
    private var dwellElapsed: Double = 0
    private var overlayIdle: Double = 0
    private let overlayTimeout: Double = 6

    init(appState: AppState, playlist: [Preset], morphDuration: Double = 4.0, dwell: Double = 60.0) {
        self.appState = appState
        self.playlist = playlist
        self.morphDuration = morphDuration
        self.dwell = dwell
    }

    private var currentIndex: Int {
        playlist.firstIndex { $0.id == appState.selectedID } ?? 0
    }

    // MARK: Intent sink (Siri Remote now, phone later — same entry point)

    func apply(_ intent: ControlIntent) {
        switch intent {
        case .next:
            beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1))
        case .previous:
            beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: -1))
        case .togglePlaylist:
            isCycling.toggle()
            dwellElapsed = 0
        case .jumpTo(let id):
            if let i = playlist.firstIndex(where: { $0.id == id }) { beginMorph(toIndex: i) }
        case .showOverlay:
            overlayVisible = true
            overlayIdle = 0
        case .dismissOverlay:
            overlayVisible = false
        }
        if overlayVisible { overlayIdle = 0 }   // any command keeps the overlay awake
    }

    // MARK: Time driver — called ~30 Hz by the view

    func tick(_ dt: Double) {
        if var m = morph {
            m.elapsed += dt
            let t = min(1, m.elapsed / morphDuration)
            appState.edited = pinnedMorph(from: m.from, to: m.to, t: t)
            if t >= 1 {
                appState.select(id: m.toID)   // adopt full structure; one re-seed
                morph = nil
                dwellElapsed = 0
            } else {
                morph = m
            }
        } else if isCycling {
            dwellElapsed += dt
            if PlaylistDirector.shouldAdvance(dwellElapsed: dwellElapsed, dwell: dwell) {
                beginMorph(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1))
            }
        }
        if overlayVisible {
            overlayIdle += dt
            if overlayIdle >= overlayTimeout { overlayVisible = false }
        }
    }

    // MARK: Internals

    private func beginMorph(toIndex index: Int) {
        guard !playlist.isEmpty else { return }
        let target = playlist[index]
        guard target.id != appState.selectedID || morph != nil else { return }   // already here & idle
        morph = Morph(from: appState.edited, to: target.params, toID: target.id, elapsed: 0)
    }

    /// `morphParams` with structural fields pinned to the outgoing preset so the live
    /// `setParams` path never schedules a respawn mid-transition. Internal (not private)
    /// for unit testing.
    func pinnedMorph(from: PresetParams, to: PresetParams, t: Double) -> PresetParams {
        var m = morphParams(from, to, t: t)
        m.agentCount = from.agentCount
        m.spawnPattern = from.spawnPattern
        for i in 0..<3 { m.species[i].populationShare = from.species[i].populationShare }
        return m
    }
}
  • [ ] Step 7: Run the AmbientController test (verify it passes)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/AmbientControllerTests 2>&1 | tail -15 Expected: PASS (5 tests). If PresetCatalog.loadAll() returns < 2 presets in the test host, the bundle's Resources/Presets folder is not loading — confirm the macOS test host bundles the preset folder (it does in the existing preset-decode tests).

  • [ ] Step 8: Commit
git add apple/PhysarumApp/Sources/Ambient/ControlIntent.swift apple/PhysarumApp/Sources/Ambient/AmbientController.swift apple/PhysarumApp/Tests/ControlIntentTests.swift apple/PhysarumApp/Tests/AmbientControllerTests.swift apple/PhysarumApp/project.yml
git commit -m "feat(tvos): add ControlIntent sink and AmbientController playback state machine"

Task 4: tvOS build-enablement (guard iOS/macOS UI, minimal TV shell)

This is an iterative compile-fix task. The whole iOS/macOS UI layer is compiled into the multiplatform target and uses APIs absent on tvOS (.inspector, presentationDetents, \.horizontalSizeClass, .fileExporter, Photos). Wrap each offending file whole in #if !os(tvOS) … #endif until the tvOS build is green. macOS/iOS builds keep every guarded file (no regression).

Files: - Create: apple/PhysarumApp/Sources/TV/TVRootView.swift - Modify: apple/PhysarumApp/Sources/App/PhysarumApp.swift - Modify: apple/PhysarumApp/Sources/Audio/AudioInput.swift - Modify (whole-file #if !os(tvOS) guards, confirm/extend via the build loop): Sources/App/RootView.swift, Sources/App/EditorPresentation.swift, Sources/Editor/EditorView.swift, Sources/Editor/ParamSlider.swift, Sources/Editor/Sections.swift, Sources/Editor/AudioReactiveSection.swift, Sources/Editor/MorphSheet.swift, Sources/Editor/SliderPrecision.swift, Sources/Library/LibraryView.swift, Sources/Library/SaveDialog.swift, Sources/Library/RenameDialog.swift, Sources/Capture/CaptureSheet.swift, Sources/Capture/CaptureSaver.swift, and any UI/* file the build flags.

  • [ ] Step 1: Add the minimal tvOS root view

Create apple/PhysarumApp/Sources/TV/TVRootView.swift:

#if os(tvOS)
import SwiftUI
import PhysarumEngine

/// Minimal tvOS entry — renders the engine full-bleed for the selected preset.
/// Auto-cycle, Siri-Remote control, the overlay and lifecycle are layered on in
/// Tasks 5–6. Kept tiny here so the build-enablement task has a compile target.
struct TVRootView: View {
    @State private var appState = AppState()

    var body: some View {
        MetalView(
            initialField: FieldSize(width: 1280, height: 720),
            selectedPreset: appState.selectedPreset,
            editedParams: appState.edited,
            cover: true,
            format: .auto,
            tier: .tv
        )
        .ignoresSafeArea()
    }
}
#endif
  • [ ] Step 2: Branch the app scene for tvOS

In Sources/App/PhysarumApp.swift, extend the scene's platform branch:

        WindowGroup {
            #if os(macOS)
            // Three columns at their minimums need ~1000pt before the wider inspector ideal kicks in.
            RootView()
                .frame(minWidth: 1000, minHeight: 600)
            #elseif os(tvOS)
            TVRootView()
            #else
            RootView()
            #endif
        }
  • [ ] Step 3: Make AudioInput compile inert on tvOS

Open Sources/Audio/AudioInput.swift. Apple TV has no microphone. Ensure the file compiles on tvOS with running == false always: wrap the iOS AVAudioSession and macOS AVCaptureDevice permission/start bodies so tvOS hits a no-op branch. The minimal correct shape for each platform-specific block:

#if os(iOS)
    // existing iOS AVAudioSession + permission code
#elseif os(macOS)
    // existing macOS AVCaptureDevice permission code
#else
    // tvOS: no microphone — stay inert (running remains false)
#endif

Do not set RenderCoordinator.audioInput on tvOS (the tvOS shell never constructs an AudioInput), so applyAudio()'s let input = audioInput, input.running guard keeps the engine audio-neutral.

  • [ ] Step 4: First tvOS build — collect errors

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -40 Expected: FAIL with … is unavailable in tvOS / cannot find … in scope / no such module 'Photos' errors pointing at the iOS/macOS UI + capture files.

  • [ ] Step 5: Guard each offending file, loop until green

For every file named in a tvOS build error that is iOS/macOS-only UI or Photos code, wrap its entire contents between #if !os(tvOS) and #endif (one guard pair at the very top and very bottom of the file — these files are only consumed by RootView, which is itself guarded). Start with the candidate list in this task's Files block, then re-run the build and add any stragglers the compiler reports.

Repeat: edit → xcodegen generate → xcodebuild … 'generic/platform=tvOS Simulator' build → fix, until: Expected: ** BUILD SUCCEEDED ** for the tvOS Simulator.

If the build reports a missing tvOS App Icon / Brand Assets set (ASSETCATALOG_COMPILER_APPICON_NAME), add an "Apple TV App Icon & Top Shelf Image" set named AppIcon to Resources/Assets.xcassets (a flat placeholder layer is fine for the simulator; final parallax art is a device/store follow-up) and rebuild.

  • [ ] Step 6: Verify no regression on macOS + iOS

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -8
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -12
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -8
Expected: macOS build + all tests PASS; iOS Simulator build SUCCEEDS. (Guarded files are unchanged for non-tvOS platforms.)

  • [ ] Step 7: Commit
git add -A apple/PhysarumApp
git commit -m "feat(tvos): compile-enable the multiplatform target for tvOS

Guard the iOS/macOS UI + Photos layer behind #if !os(tvOS), make AudioInput
inert on tvOS, branch the app scene to a minimal TVRootView. tvOS Simulator
builds; macOS/iOS unchanged."

Task 5: Wire AmbientController + Siri Remote + lifecycle into the tvOS shell

Files: - Modify: apple/PhysarumApp/Sources/TV/TVRootView.swift - Create: apple/PhysarumApp/Sources/TV/TVPlayerView.swift

  • [ ] Step 1: Replace TVRootView with the controller-owning root

Overwrite Sources/TV/TVRootView.swift:

#if os(tvOS)
import SwiftUI
import PhysarumEngine

/// Scene root for tvOS: owns the model (`AppState`) and the playback brain
/// (`AmbientController`), and suppresses the idle/Aerial screensaver so the ambient
/// visual keeps running.
struct TVRootView: View {
    @State private var appState: AppState
    @State private var controller: AmbientController

    init() {
        let state = AppState()
        _appState = State(initialValue: state)
        _controller = State(initialValue: AmbientController(appState: state, playlist: state.presets))
    }

    var body: some View {
        TVPlayerView(appState: appState, controller: controller)
            .ignoresSafeArea()
            .onAppear { UIApplication.shared.isIdleTimerDisabled = true }
    }
}
#endif
  • [ ] Step 2: Create the player view (render + remote + tick + pause)

Create apple/PhysarumApp/Sources/TV/TVPlayerView.swift:

#if os(tvOS)
import SwiftUI
import PhysarumEngine

/// Full-bleed render + Siri-Remote control + the 30 Hz morph tick. Pauses the MTKView
/// when the scene is inactive/background (tvOS rejects GPU work in the background).
struct TVPlayerView: View {
    let appState: AppState
    let controller: AmbientController

    @Environment(\.scenePhase) private var scenePhase
    @FocusState private var focused: Bool
    @State private var lastTick = Date()

    private let ticker = Timer.publish(every: 1.0 / 30.0, on: .main, in: .common).autoconnect()

    var body: some View {
        ZStack {
            MetalView(
                initialField: FieldSize(width: 1280, height: 720),
                selectedPreset: appState.selectedPreset,
                editedParams: appState.edited,
                cover: true,
                format: .auto,
                tier: .tv,
                paused: scenePhase != .active
            )
            if controller.overlayVisible {
                TVOverlayView(appState: appState, controller: controller)
                    .transition(.opacity)
            }
        }
        .focusable(true)
        .focused($focused)
        .onAppear { focused = true; lastTick = Date() }
        .onMoveCommand { dir in
            if let intent = remoteMoveIntent(dir) { withAnimation(.easeInOut) { controller.apply(intent) } }
        }
        .onPlayPauseCommand { controller.apply(.togglePlaylist) }
        // Only intercept Menu while the overlay is up; otherwise nil lets the system exit to Home (App Review 2.4.3).
        .onExitCommand(perform: controller.overlayVisible ? { withAnimation { controller.apply(.dismissOverlay) } } : nil)
        .onReceive(ticker) { now in
            let dt = now.timeIntervalSince(lastTick)
            lastTick = now
            controller.tick(min(dt, 0.1))   // clamp scheduling hitches
        }
    }
}
#endif

Note: TVOverlayView is created in Task 6. To build Task 5 in isolation, temporarily replace the if controller.overlayVisible { TVOverlayView(...) } block with EmptyView(), or implement Task 6 before first building. Recommended: implement Task 6 next, then build both together at Task 6 Step 3.

  • [ ] Step 3: Build (with Task 6 in place) — see Task 6 Step 3.

  • [ ] Step 4: Commit (after Task 6 builds green) — combined commit in Task 6.


Files: - Create: apple/PhysarumApp/Sources/TV/TVOverlayView.swift

  • [ ] Step 1: Create the overlay

Create apple/PhysarumApp/Sources/TV/TVOverlayView.swift:

#if os(tvOS)
import SwiftUI
import PhysarumEngine

/// Summon-able lean-back overlay: current preset name, a pin/resume toggle, and a
/// focusable horizontal carousel of presets to jump to. Summoned by swipe-up, dismissed
/// by Menu, auto-dismissed by the controller after idle.
struct TVOverlayView: View {
    let appState: AppState
    let controller: AmbientController

    var body: some View {
        VStack {
            Spacer()
            VStack(alignment: .leading, spacing: 32) {
                HStack(alignment: .firstTextBaseline) {
                    Text(appState.selectedPreset.name)
                        .font(.largeTitle.bold())
                        .foregroundStyle(.white)
                    Spacer()
                    Button(controller.isCycling ? "Pause Cycle" : "Resume Cycle") {
                        controller.apply(.togglePlaylist)
                    }
                }
                ScrollView(.horizontal, showsIndicators: false) {
                    LazyHStack(spacing: 32) {
                        ForEach(appState.presets, id: \.id) { preset in
                            Button { controller.apply(.jumpTo(preset.id)) } label: {
                                TVPresetCard(preset: preset, selected: preset.id == appState.selectedID)
                            }
                            .buttonStyle(.card)
                        }
                    }
                    .padding(.horizontal, 80)
                    .padding(.vertical, 40)
                }
            }
            .padding(60)
            .background(.black.opacity(0.55))
        }
    }
}

private struct TVPresetCard: View {
    let preset: Preset
    let selected: Bool

    var body: some View {
        VStack(spacing: 12) {
            thumbnail
                .frame(width: 320, height: 180)
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .overlay(RoundedRectangle(cornerRadius: 12).stroke(selected ? .white : .clear, lineWidth: 4))
            Text(preset.name).font(.headline).foregroundStyle(.white).lineLimit(1)
        }
    }

    @ViewBuilder private var thumbnail: some View {
        if let path = Bundle.main.path(forResource: preset.id, ofType: "png", inDirectory: "Thumbnails"),
           let img = UIImage(contentsOfFile: path) {
            Image(uiImage: img).resizable().scaledToFill()
        } else {
            Rectangle().fill(.gray.opacity(0.3))
        }
    }
}
#endif
  • [ ] Step 2: Restore the real overlay call in TVPlayerView

If Task 5 Step 2 used an EmptyView() placeholder, restore the real TVOverlayView(appState: appState, controller: controller) call now.

  • [ ] Step 3: Build the tvOS shell

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -25 Expected: ** BUILD SUCCEEDED **. Fix any tvOS-API issues (e.g. .buttonStyle(.card) availability, focus modifiers) reported by the compiler.

  • [ ] Step 4: Commit
git add -A apple/PhysarumApp
git commit -m "feat(tvos): auto-cycle playback, Siri Remote control, and lean-back overlay"

Task 7: Enable the test target on tvOS

Files: - Modify: apple/PhysarumApp/project.yml

  • [ ] Step 1: Add tvOS to the test target

In project.yml, under targets.PhysarumAppTests, add tvOS to destinations and the tvOS host paths (mirroring the iOS flat-bundle pattern):

  PhysarumAppTests:
    type: bundle.unit-test
    supportedDestinations: [macOS, iOS, tvOS]
    sources: [Tests]
    dependencies:
      - target: Physarum
    settings:
      base:
        # … existing keys unchanged …
        "TEST_HOST[sdk=appletvos*]": "$(BUILT_PRODUCTS_DIR)/Physarum.app/Physarum"
        "TEST_HOST[sdk=appletvsimulator*]": "$(BUILT_PRODUCTS_DIR)/Physarum.app/Physarum"
  • [ ] Step 2: Run the Ambient/* tests on the tvOS Simulator

cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' test \
  -only-testing:PhysarumAppTests/PlaylistDirectorTests \
  -only-testing:PhysarumAppTests/ControlIntentTests \
  -only-testing:PhysarumAppTests/AmbientControllerTests \
  -only-testing:PhysarumAppTests/FieldTierTVTests 2>&1 | tail -20
Expected: PASS on the tvOS Simulator. (Use xcrun simctl list devices tvOS to confirm the device name; substitute an available one if needed.)

  • [ ] Step 3: Commit
git add apple/PhysarumApp/project.yml
git commit -m "test(tvos): run the unit suite on the tvOS Simulator too"

Task 8: Full verification + docs/cockpit

Files: - Modify: docs/superpowers/specs/2026-06-15-tvos-ambient-design.md (mark T1 status) - (Cockpit is in the Obsidian vault — update its §🔍/▶ via the normal session flow.)

  • [ ] Step 1: Run the full build/test matrix

cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -12          # all app tests
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -6
xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | tail -6
cd ../PhysarumEngine && swift run EngineCheck 2>&1 | tail -5
Expected: app tests PASS (existing + 4 new classes); iOS + tvOS builds SUCCEED; EngineCheck OK.

  • [ ] Step 2: Launch in the tvOS Simulator (smoke — no crash, UI appears)

xcrun simctl boot "Apple TV 4K (3rd generation)" 2>/dev/null || true
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' build 2>&1 | tail -5
APP=$(find ~/Library/Developer/Xcode/DerivedData -path '*tvOS*' -name 'Physarum.app' -type d 2>/dev/null | head -1)
xcrun simctl install booted "$APP"
xcrun simctl launch booted de.jkaindl.physarum
sleep 4
xcrun simctl io booted screenshot /tmp/physarum-tvos.png
Then Read /tmp/physarum-tvos.png. Expected: app launched, no crash. The Metal animation may render a static/black frame headlessly (the sim display link does not advance reliably without a real display) — that is acceptable; the live animation + thermal + remote feel are Johannes' device sign-off. Confirm the process is alive and (if the overlay is summoned via the sim's remote) the overlay UI composits.

  • [ ] Step 3: Mark the spec + record the device sign-off list

In docs/superpowers/specs/2026-06-15-tvos-ambient-design.md, change **Status:** Approved … to **Status:** T1 implemented (<commit>); device sign-off pending. Append a short "Device sign-off (Johannes, ATV 4K)" checklist: full-bleed render, preset auto-cycle + morph smoothness, Siri-Remote set (swipe prev/next, Play/Pause pin, swipe-up overlay, Menu dismiss), multi-hour thermal, idle-timer/screensaver suppression, background pause/resume.

  • [ ] Step 4: Commit
git add docs/superpowers/specs/2026-06-15-tvos-ambient-design.md
git commit -m "docs(tvos): mark T1 implemented, record device sign-off checklist"

Notes for the implementer

  • DRY/reuse: Do not duplicate engine, render, preset, or audio logic — the tvOS shell composes the existing RenderCoordinator/AppState/MetalView/PresetCatalog/morphParams. The only genuinely new behaviour is the Ambient/* state machine and the TV/* views.
  • No engine changes. If a task seems to need a shader/param-layout/engine edit, stop — that is out of scope (T1 keeps the engine byte-for-byte). The structure-pinned morph exists precisely to avoid an engine change.
  • #if !os(tvOS) vs #if os(tvOS): guard iOS/macOS-only UI with #if !os(tvOS) (keeps it on those platforms, drops it from tvOS); guard tvOS-only views with #if os(tvOS). The Ambient/* layer carries no platform guard — it must compile (and be tested) on macOS.
  • Push: never. Commit to main only; Johannes pushes on a direct command.