Zum Inhalt

tvOS T1 Device-Iteration 2 (Phase 1) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Checkbox (- [ ]) steps.

Goal: Make tvOS preset transitions seamless (no re-seed/restart), add select-click cycle toggle, fix the overlay cycle toggle (rename/center/reachable), and add a tvOS app icon + static Top Shelf.

Architecture: App-layer only. RenderCoordinator.switchPreset re-seeds only on a structural change; the cycler completes its morph via a structure-holding AppState.adoptTransition so no structural change occurs → no re-seed. Engine/WGSL untouched.

Spec: docs/superpowers/specs/2026-06-16-tvos-t1-device-iteration-2-design.md


Task 1: Structural-change predicate + seamless switchPreset

Files: Modify Sources/Player/RenderCoordinator.swift; Test Tests/RenderCoordinatorTests.swift.

  • [ ] Step 1: Failing test (append to RenderCoordinatorTests):
    func testIsStructuralChange() {
        let p = anyPreset().params
        var look = p; look.bloomIntensity += 0.5          // a non-structural (look) param
        XCTAssertFalse(RenderCoordinator.isStructuralChange(look, p))
        var count = p; count.agentCount = p.agentCount / 2 + 1
        XCTAssertTrue(RenderCoordinator.isStructuralChange(count, p))
        var pop = p; pop.species[0].populationShare += 0.1
        XCTAssertTrue(RenderCoordinator.isStructuralChange(pop, p))
    }
  • [ ] Step 2: Run → fails (isStructuralChange undefined). xcodebuild test … -only-testing:PhysarumAppTests/RenderCoordinatorTests -destination 'platform=macOS'.

  • [ ] Step 3: Add the helper + reuse it. In RenderCoordinator:

/// A change that requires re-seeding the sim (vs a live look-only edit).
static func isStructuralChange(_ a: PresetParams, _ b: PresetParams) -> Bool {
    a.agentCount != b.agentCount
        || a.spawnPattern != b.spawnPattern
        || zip(a.species, b.species).contains { $0.populationShare != $1.populationShare }
}

Rewrite setParams's needsRespawn to let needsRespawn = Self.isStructuralChange(p, params).

Rewrite switchPreset:

func switchPreset(_ preset: Preset, live: PresetParams) {
    guard preset.id != currentPresetID else { return }
    currentPresetID = preset.id
    let structural = Self.isStructuralChange(live, params)
    params = live
    guard structural else { sim.updateLiveParams(live); return }
    respawnWork?.cancel()
    if let s = try? Self.makeSim(device: device, lib: lib, field: currentField, preset: live, tier: tier, queue: queue) {
        sim = s
        renderer.resetFeedback()
    }
}
  • [ ] Step 4: Run → passes. Same command.
  • [ ] Step 5: Commit feat(tvos): switchPreset re-seeds only on structural change (seamless look adoption).

Task 2: Structure-holding transition completion

Files: Modify Sources/Model/AppState.swift, Sources/Ambient/AmbientController.swift; Test Tests/AmbientControllerTests.swift.

  • [ ] Step 1: Failing test (append to AmbientControllerTests):
    func testTransitionHoldsStructureNoReseed() {
        let (state, presets, c) = make()       // selected presets[0], morphDuration 1.0
        let from = presets[0].params
        let target = presets[1]
        c.apply(.next)
        c.tick(1.1)                            // cross t=1
        XCTAssertEqual(state.selectedID, target.id)
        XCTAssertEqual(state.baseline, target.params)
        // edited keeps the running sim's (from) structure → no structural change → no re-seed
        XCTAssertEqual(state.edited.agentCount, from.agentCount)
        XCTAssertEqual(state.edited.spawnPattern, from.spawnPattern)
        for i in 0..<3 { XCTAssertEqual(state.edited.species[i].populationShare, from.species[i].populationShare) }
        XCTAssertFalse(RenderCoordinator.isStructuralChange(state.edited, from))
    }

(Requires PresetParams: Equatable for baseline ==; if it isn't, assert field-wise instead — check and adjust.)

  • [ ] Step 2: Run → fails (adoptTransition undefined / still calls select).

  • [ ] Step 3: Add AppState.adoptTransition:

/// Adopt a preset as the new selection during a seamless cycler transition: update
/// selection + baseline but DON'T touch `edited` — the morph already left it at the
/// target's look while holding the running structure, so no re-seed is triggered.
func adoptTransition(id: String) {
    guard let p = presets.first(where: { $0.id == id }) else { return }
    selectedID = id
    baseline = p.params
    undoStack.removeAll(); redoStack.removeAll()
}
  • [ ] Step 4: In AmbientController.tick, replace appState.select(id: m.toID) with appState.adoptTransition(id: m.toID) (keep the surrounding morph = nil; dwellElapsed = 0).
  • [ ] Step 5: Run → passes. If baseline == fails to compile, compare a representative field (e.g. state.baseline.bloomIntensity) or add field-wise asserts.
  • [ ] Step 6: Commit feat(tvos): cycler transition holds structure (no re-seed) via adoptTransition.

Task 3: Select-click toggles the cycle

Files: Modify Sources/TV/TVPlayerView.swift. (View — no unit test; G2 logic already covered by AmbientController toggle tests.)

  • [ ] Step 1: Add to the MetalView modifier chain (next to .onPlayPauseCommand):
            .onTapGesture { if !controller.overlayVisible { withAnimation { controller.apply(.togglePlaylist) } } }
  • [ ] Step 2: tvOS-sim build green (Task 6).
  • [ ] Step 3: Commit feat(tvos): touchpad select-click toggles the cycle.

Task 4: Overlay cycle toggle — rename, center, reachable

Files: Modify Sources/TV/TVOverlayView.swift.

  • [ ] Step 1: Replace the header HStack { name; Spacer; Button("Pause/Resume Cycle") } with a centered name + a centered, focusable cycle toggle above the carousel:
            VStack(spacing: 24) {
                Text(appState.selectedPreset.name)
                    .font(.largeTitle.bold())
                    .foregroundStyle(.white)
                    .frame(maxWidth: .infinity)
                Button {
                    controller.apply(.togglePlaylist)
                } label: {
                    Label(controller.isCycling ? "Cycle On" : "Cycle Off",
                          systemImage: controller.isCycling ? "arrow.triangle.2.circlepath" : "pause.fill")
                }
                .frame(maxWidth: .infinity)
                ScrollView(.horizontal, showsIndicators: false) {
                    LazyHStack(spacing: 32) { /* unchanged cards */ }
                    .padding(.horizontal, 80).padding(.vertical, 40)
                }
                .focusSection()
            }

(Keep alignment neutral now that content is centered; keep the existing .padding(60).background(.black.opacity(0.55)) on the inner VStack and the root .onMoveCommand { .down → dismiss } + .onAppear { focusedCard = appState.selectedID }.)

  • [ ] Step 2: tvOS-sim build green.
  • [ ] Step 3: Commit feat(tvos): overlay cycle toggle renamed Cycle On/Off, centered + reachable.

Task 5: tvOS app icon + static Top Shelf (Brand Assets)

Files: Create scripts/gen-tvos-icons.sh, Resources/Assets.xcassets/TVBrandAssets.brandassets/**; Modify project.yml.

  • [ ] Step 1: Write scripts/gen-tvos-icons.sh — render from assets/icon.svg (+ KSP background) the canvases: 400×240, 1280×768 (app-icon layers, square logo centered on bg, fit to 5:3); 1920×720 & 3840×1440 (Top Shelf); 2320×720 & 4640×1440 (Top Shelf Wide). Use sips/qlmanage + a compositing step (background fill + centered logo). Reproducible, like gen-icons.sh.
  • [ ] Step 2: Build TVBrandAssets.brandassets with correct Contents.json for: App Icon.imagestack (single .imagestacklayer → Content.imageset 400×240), App Icon - App Store.imagestack (1280×768), Top Shelf Image.imageset (1920×720 @1x, 3840×1440 @2x), Top Shelf Image Wide.imageset (2320×720 @1x, 4640×1440 @2x). Brand-assets Contents.json declares primary-app-icon + the two top-shelf roles.
  • [ ] Step 3: project.yml — add under target settings.base: "ASSETCATALOG_COMPILER_APPICON_NAME[sdk=appletvos*]": "TVBrandAssets" (macOS/iOS keep AppIcon). Regenerate.
  • [ ] Step 4: tvOS-sim build green (asset catalog compiles, no missing-icon error). Boot sim, install, confirm the icon appears in the sim Home screen.
  • [ ] Step 5: Commit feat(tvos): add tvOS app icon + static Top Shelf (brand assets).

Task 6: Gates + device deploy

  • [ ] Full macOS host suite green (xcodebuild test … -destination 'platform=macOS').
  • [ ] tvOS-sim build green; screenshot.
  • [ ] Adversarial multi-lens review (workflow) of the diff; fix confirmed findings.
  • [ ] Release device build (platform=tvOS,id=c2f1eba2a1841970a35e5e333fb1ba5dd1985bd9, -allowProvisioningUpdates) + devicectl install/launch to ATV 70720F26-….
  • [ ] Hand to Johannes: seamless transition (no restart), select-click toggle + HUD, overlay "Cycle On/Off" reachable + centered, app icon on Home screen, static top shelf.

Self-Review

Spec coverage: G1 → Tasks 1+2 (+tests); G2 → Task 3; G3 → Task 4; G4 → Task 5. Placeholders: none (Task 5 asset PNGs are generated by the scripted step). Type consistency: isStructuralChange(_:_:) defined in Task 1 and reused in Tasks 1 (setParams) + 2 (test); adoptTransition(id:) defined in Task 2 and called by AmbientController; togglePlaylist matches existing ControlIntent. Risk: PresetParams: Equatable for the baseline == assert — verify in Task 2 Step 5, fall back to field-wise; overlay focus + brand-asset structure are empirical/fiddly → gated by tvOS-sim build + device sign-off.