Zum Inhalt

Crossfade-to-Black Transition — Implementation Plan

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

Goal: Replace the Phase-1 structure-holding param morph with a fade-to-black transition that re-seeds the full target preset at black — adopting the complete target structure with no engine changes.

Architecture: AmbientController runs a fade state machine (transitionDim 0→1→0); the full AppState.select(target) re-seed fires once at the black peak; TVPlayerView shows a black overlay dimmed by transitionDim. Engine/Renderer/WGSL untouched.

Spec: docs/superpowers/specs/2026-06-16-tvos-t1-crossfade-to-black-design.md


Task 1: AmbientController fade state machine + tests

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

  • [ ] Step 1: Rewrite the transition tests (replace the morph/pinnedMorph/adoptTransition tests). Keep HUD + hint tests. New deterministic timings via make() using fadeDuration: 0.5, blackHold: 0.2:
private func make() -> (AppState, [Preset], AmbientController) {
    let presets = PresetCatalog.loadAll()
    let state = AppState(); state.select(id: presets[0].id)
    let c = AmbientController(appState: state, playlist: presets,
                             fadeDuration: 0.5, blackHold: 0.2, dwell: 2.0)
    return (state, presets, c)
}

func testTransitionDimCurve() {
    let (_, _, c) = make()                 // fade 0.5, hold 0.2
    XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
    c.apply(.next)
    c.tick(0.25); XCTAssertEqual(c.transitionDim, 0.5, accuracy: 0.05)   // mid fade-out
    c.tick(0.25); XCTAssertEqual(c.transitionDim, 1, accuracy: 1e-9)     // black
    c.tick(0.2);  XCTAssertEqual(c.transitionDim, 1, accuracy: 1e-9)     // still black (hold)
    c.tick(0.5);  XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)     // faded back in
}

func testReseedFiresOnceAtBlackPeak() {
    let (state, presets, c) = make()
    c.apply(.next)
    c.tick(0.49); XCTAssertEqual(state.selectedID, presets[0].id)        // pre-black: still old
    c.tick(0.02); XCTAssertEqual(state.selectedID, presets[1].id)        // crossed black → re-seeded
    XCTAssertTrue(paramsEqual(state.edited, presets[1].params))          // full structure adopted
    c.tick(0.5)                                                           // fade-in completes; no second select
    XCTAssertEqual(state.selectedID, presets[1].id)
}

func testAutoCycleAdvancesViaFade() {
    let (state, presets, c) = make()       // dwell 2.0
    c.tick(2.0)                            // dwell reached → begins transition
    c.tick(0.5 + 0.2 + 0.5)               // fade-out + hold + fade-in
    XCTAssertEqual(state.selectedID, presets[1].id)
    XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
}

func testJumpToCurrentStartsNoTransition() {
    let (state, _, c) = make()
    c.apply(.jumpTo(state.selectedID))
    XCTAssertNil(c.hud)
    XCTAssertEqual(c.transitionDim, 0, accuracy: 1e-9)
}

Delete testPinnedMorphHoldsStructure, testPinnedMorphIsRespawnStableAcrossT, testManualNextMorphsThenSelectsTarget, testTransitionHoldsStructureNoReseed. Keep all HUD/hint tests and testTogglePlaylistFlipsCycling, testOverlayAutoDismisses, testManualSwitchOnSinglePresetRaisesNoHUD, etc. (adjust any that referenced morphDuration).

  • [ ] Step 2: Run → fails (no fadeDuration/transitionDim).

  • [ ] Step 3: Rewrite AmbientController. Replace morphDuration with fadeDuration + add blackHold; replace the Morph struct/morph/pinnedMorph/tick-morph-branch with the fade Transition; replace beginMorph with beginTransition; currentIndex reads transition?.toID. Key members:

let fadeDuration: Double
let blackHold: Double
private struct Transition { let toID: String; var elapsed: Double; var reseeded: Bool }
private var transition: Transition?

init(appState: AppState, playlist: [Preset], fadeDuration: Double = 0.7, blackHold: Double = 0.3,
     dwell: Double = 60.0, hudDuration: Double = 2.0, hintDuration: Double = 5.0) { … assign … }

var transitionDim: Double {
    guard let t = transition else { return 0 }
    if t.elapsed < fadeDuration { return t.elapsed / fadeDuration }
    if t.elapsed < fadeDuration + blackHold { return 1 }
    return max(0, 1 - (t.elapsed - fadeDuration - blackHold) / fadeDuration)
}

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

@discardableResult
private func beginTransition(toIndex i: Int) -> Bool {
    guard !playlist.isEmpty else { return false }
    let target = playlist[i]
    guard target.id != appState.selectedID || transition != nil else { return false }
    transition = Transition(toID: target.id, elapsed: 0, reseeded: false)
    return true
}

apply: swap beginMorph → beginTransition (next/previous/jumpTo), unchanged otherwise.

tick:

if var t = transition {
    t.elapsed += dt
    if !t.reseeded && t.elapsed >= fadeDuration {   // full black → adopt the whole target
        appState.select(id: t.toID)
        t.reseeded = true
    }
    if t.elapsed >= 2 * fadeDuration + blackHold {
        transition = nil
        dwellElapsed = 0
    } else {
        transition = t
    }
} else if isCycling {
    dwellElapsed += dt
    if PlaylistDirector.shouldAdvance(dwellElapsed: dwellElapsed, dwell: dwell) {
        beginTransition(toIndex: PlaylistDirector.nextIndex(current: currentIndex, count: playlist.count, step: 1))
    }
}
// overlay / hud / hint blocks unchanged

Update the class doc comment to describe the fade-to-black transition (no more structure-pinned morph).

  • [ ] Step 4: Remove AppState.adoptTransition (now unused; the morph completion uses select).

  • [ ] Step 5: Run → passes. xcodebuild test … -only-testing:PhysarumAppTests/AmbientControllerTests -destination 'platform=macOS'. If morphParams is now unused in the app, leave the engine function alone (it lives in PhysarumEngine).

  • [ ] Step 6: Commit feat(tvos): crossfade-to-black transition (full re-seed at black) replaces in-place morph.


Task 2: TVPlayerView black overlay

Files: Modify Sources/TV/TVPlayerView.swift.

  • [ ] Step 1: In the ZStack, immediately after the MetalView modifier chain (before the if controller.overlayVisible block), add:
            Color.black
                .opacity(controller.transitionDim)
                .ignoresSafeArea()
                .allowsHitTesting(false)

(So black dims the visual but sits below the overlay/HUD/hint. The 30 Hz tick drives transitionDim; no .animation.)

  • [ ] Step 2: tvOS-sim build green.
  • [ ] Step 3: Commit feat(tvos): black transition overlay driven by AmbientController.transitionDim.

Task 3: Gates + review + device deploy

  • [ ] Full macOS host suite green.
  • [ ] tvOS-sim build green; screenshot.
  • [ ] Adversarial multi-lens review (workflow); fix confirmed findings.
  • [ ] Release device build + devicectl install/launch to ATV 70720F26-….
  • [ ] Hand to Johannes: switch a preset → gentle fade to black → target preset (own density/ratio) fades in; auto-cycle same; tune duration/blackness.

Self-Review

Spec coverage: fade state machine + transitionDim + reseed-at-peak → Task 1; black overlay → Task 2; full structure adoption via select → Task 1 (reseed). Placeholders: none. Type consistency: fadeDuration/blackHold/transitionDim/Transition.reseeded/beginTransition defined in Task 1 and used in Task 1 tests + Task 2 view; select is the existing AppState method. Risk: paramsEqual (free fn) for the full-adoption assert; remove of adoptTransition must have no other caller (it was added in Phase 1 solely for the morph completion — verify via grep in Step 4).