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()usingfadeDuration: 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
morphDurationwithfadeDuration+ addblackHold; replace theMorphstruct/morph/pinnedMorph/tick-morph-branch with the fadeTransition; replacebeginMorphwithbeginTransition;currentIndexreadstransition?.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 usesselect). -
[ ] Step 5: Run → passes.
xcodebuild test … -only-testing:PhysarumAppTests/AmbientControllerTests -destination 'platform=macOS'. IfmorphParamsis 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.overlayVisibleblock), add:
(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/launchto ATV70720F26-…. - [ ] 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).