Zum Inhalt

Universal App — macOS Playback (Increment 3a) 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: A SwiftUI Multiplatform app whose macOS build plays the native PhysarumEngine fullscreen/windowed, with a preset browser (25 presets + thumbnails) and a curated set of respawn-free live-sliders.

Architecture: One XcodeGen-generated Multiplatform app target (Physarum) depending on the local PhysarumEngine SwiftPM package. SwiftUI views are platform-shared; only a thin MetalView wrapper and navigation use #if os(). macOS UI is built now; iOS/tvOS destinations are declared but their UI is a later increment. A RenderCoordinator (MTKViewDelegate) drives the 60fps loop; post-FX live-params flow through the per-frame renderer.render(preset:) call, sim-side live-params through a new SimEngine.updateLiveParams.

Tech Stack: Swift 6.3 / SwiftPM, Metal/MetalKit, SwiftUI, XcodeGen 2.45, macOS 26 / Xcode 26.5.

Reference (read first): spec docs/superpowers/specs/2026-06-02-universal-app-macos-playback-design.md; engine sources apple/PhysarumEngine/Sources/PhysarumEngine/ (especially Renderer/SimEngine.swift, Renderer/Renderer.swift, Model/Preset.swift, Model/Params.swift); the existing AppKit prototype apple/PhysarumEngine/Sources/PhysarumViewer/main.swift (the macOS playback loop to mirror).


File Structure

  • apple/PhysarumEngine/Package.swift — MODIFY: add .iOS(.v17), .tvOS(.v17) platforms.
  • apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift — MODIFY: preset → var, add updateLiveParams(_:).
  • apple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift — MODIFY: add live-params test.
  • scripts/export-native-presets.ts — MODIFY: add the app Resources/Presets dir as a third target; also copy thumbnails.
  • apple/PhysarumApp/project.yml — CREATE: XcodeGen manifest (one multiplatform Physarum target).
  • apple/PhysarumApp/.gitignore — CREATE: ignore generated *.xcodeproj.
  • apple/PhysarumApp/Sources/App/PhysarumApp.swift — CREATE: @main SwiftUI app + root scene.
  • apple/PhysarumApp/Sources/Model/AppState.swift — CREATE: @Observable app state.
  • apple/PhysarumApp/Sources/Model/PresetCatalog.swift — CREATE: loads the 25 bundled preset JSONs.
  • apple/PhysarumApp/Sources/Model/LiveParams.swift — CREATE: editable overrides + apply(to:).
  • apple/PhysarumApp/Sources/Player/MetalView.swift — CREATE: NSViewRepresentable/UIViewRepresentable around MTKView.
  • apple/PhysarumApp/Sources/Player/RenderCoordinator.swift — CREATE: MTKViewDelegate driving SimEngine + Renderer.
  • apple/PhysarumApp/Sources/Library/LibraryView.swift — CREATE: preset grid.
  • apple/PhysarumApp/Sources/Controls/ControlsView.swift — CREATE: live-slider panel.
  • apple/PhysarumApp/Resources/Presets/*.json — generated (gitignored or committed alongside apple/presets; see Task 3).
  • apple/PhysarumApp/Resources/Thumbnails/*.png — generated.
  • apple/PhysarumApp/Resources/Info.plist, Assets.xcassets — CREATE: app metadata + icon.

Task 1: Extend engine package to iOS/tvOS platforms

Files: - Modify: apple/PhysarumEngine/Package.swift

  • [ ] Step 1: Edit the platforms line

Change:

    platforms: [.macOS(.v14)],
to:
    platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)],

  • [ ] Step 2: Verify the library still builds and tests pass on macOS

Run: cd apple/PhysarumEngine && swift build && swift test Expected: Build complete! and Executed 19 tests ... 0 failures. (The macOS-only executables — Viewer/EngineCheck/PresetGallery — still build on macOS; consumers only link the PhysarumEngine library product, so the added platforms don't force AppKit onto iOS/tvOS.)

  • [ ] Step 3: Commit
git add apple/PhysarumEngine/Package.swift
git commit -m "feat(native): declare iOS/tvOS platforms on PhysarumEngine package"

Task 2: SimEngine.updateLiveParams (TDD)

Files: - Modify: apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift - Modify: apple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift

  • [ ] Step 1: Write the failing test — drives sim-side live updates via the frame counter (simSpeed) which is observable.

Add to SimEngineTests:

func testUpdateLiveParamsAppliesRespawnFreeFields() throws {
    let device = try makeDevice()
    let lib = ShaderLibrary(device: device)
    var p = try TestFixtures.minimalPreset().params
    p.simSpeed = 1
    let engine = try SimEngine(device: device, library: lib, width: 64, height: 64, preset: p)

    var edited = p
    edited.simSpeed = 4              // respawn-free → must take effect
    edited.agentCount = 999_999      // respawn-only → must be ignored (no realloc/crash)
    engine.updateLiveParams(edited)

    engine.step()
    XCTAssertEqual(engine.frame, 4, "simSpeed 4 applied → one step advances 4 sub-step frames")
}

  • [ ] Step 2: Run, verify it fails

Run: cd apple/PhysarumEngine && swift test --filter testUpdateLiveParamsAppliesRespawnFreeFields Expected: FAIL — value of type 'SimEngine' has no member 'updateLiveParams'.

  • [ ] Step 3: Implement — make preset mutable and add the method.

In SimEngine.swift, change the stored property:

    private let preset: PresetParams
to:
    private var preset: PresetParams

Add this method (place it just after writeParams()):

    /// Apply respawn-free parameter changes live (next frame picks them up via
    /// writeParams). Fields that require re-seeding the agent buffers
    /// — agentCount, spawnPattern, per-species populationShare — are deliberately
    /// NOT applied here; changing them needs a fresh SimEngine.
    public func updateLiveParams(_ p: PresetParams) {
        preset.decayRate = p.decayRate
        preset.fadeStrength = p.fadeStrength
        preset.trailSaturation = p.trailSaturation
        preset.diffusionFreq = p.diffusionFreq
        preset.chaosInterval = p.chaosInterval
        preset.chaosStrength = p.chaosStrength
        preset.rhythmAmplitude = p.rhythmAmplitude
        preset.exposure = p.exposure
        preset.headIntensity = p.headIntensity
        preset.simSpeed = p.simSpeed
        preset.backgroundColor = p.backgroundColor
        preset.blendMode = p.blendMode
        // per-species motion fields (turn/move/sensor/deposit/color/angle) are
        // live too; populationShare is respawn-only but harmless to copy since
        // seeding already happened.
        preset.species = p.species
        preset.interactionMatrix = p.interactionMatrix
    }

  • [ ] Step 4: Run, verify pass

Run: cd apple/PhysarumEngine && swift test --filter testUpdateLiveParamsAppliesRespawnFreeFields Expected: PASS.

  • [ ] Step 5: Full suite + commit

Run: cd apple/PhysarumEngine && swift test Expected: all tests pass (20 now).

git add apple/PhysarumEngine/Sources/PhysarumEngine/Renderer/SimEngine.swift apple/PhysarumEngine/Tests/
git commit -m "feat(native): SimEngine.updateLiveParams for respawn-free live editing"


Task 3: Export presets into the app bundle

Files: - Modify: scripts/export-native-presets.ts

  • [ ] Step 1: Add the app Resources dir as a third JSON target + copy thumbnails

In scripts/export-native-presets.ts, extend TARGETS:

const TARGETS = [
  resolve(ROOT, 'apple/presets'),
  resolve(ROOT, 'apple/PhysarumEngine/Sources/PhysarumViewer/Presets'),
  resolve(ROOT, 'apple/PhysarumApp/Resources/Presets'),
];

After the export loop and before the biome step, add a thumbnail copy into the app bundle:

import { copyFileSync, existsSync } from 'node:fs';
// ... (merge with existing node:fs import)

const thumbsSrc = resolve(ROOT, 'apple/presets/thumbs');
const thumbsDst = resolve(ROOT, 'apple/PhysarumApp/Resources/Thumbnails');
mkdirSync(thumbsDst, { recursive: true });
for (const preset of BUILT_IN_PRESETS) {
  const png = `${preset.id}.png`;
  const from = resolve(thumbsSrc, png);
  if (existsSync(from)) copyFileSync(from, resolve(thumbsDst, png));
}

  • [ ] Step 2: Run it + verify

Run: npx tsx scripts/export-native-presets.ts && ls apple/PhysarumApp/Resources/Presets/*.json | wc -l && ls apple/PhysarumApp/Resources/Thumbnails/*.png | wc -l Expected: 25 and 25. Then npx biome check apple/PhysarumApp/Resources/Presets/*.json scripts/export-native-presets.ts → no errors (run --write if biome reformats the script).

  • [ ] Step 3: Commit
git add scripts/export-native-presets.ts apple/PhysarumApp/Resources/Presets apple/PhysarumApp/Resources/Thumbnails
git commit -m "feat(native): export presets + thumbnails into the app bundle resources"

Task 4: XcodeGen scaffold + minimal app (scaffold gate)

Files: - Create: apple/PhysarumApp/project.yml - Create: apple/PhysarumApp/.gitignore - Create: apple/PhysarumApp/Sources/App/PhysarumApp.swift - Create: apple/PhysarumApp/Resources/Info.plist

  • [ ] Step 1: project.yml
name: Physarum
options:
  bundleIdPrefix: de.jkaindl.physarum
  deploymentTarget:
    macOS: "14.0"
    iOS: "17.0"
    tvOS: "17.0"
packages:
  PhysarumEngine:
    path: ../PhysarumEngine
targets:
  Physarum:
    type: application
    supportedDestinations: [macOS, iOS, tvOS]
    sources:
      - path: Sources
    resources:
      - path: Resources/Presets
      - path: Resources/Thumbnails
    dependencies:
      - package: PhysarumEngine
        product: PhysarumEngine
    info:
      path: Resources/Info.plist
      properties:
        CFBundleDisplayName: Physarum
        LSApplicationCategoryType: public.app-category.entertainment
    settings:
      base:
        PRODUCT_BUNDLE_IDENTIFIER: de.jkaindl.physarum
        MARKETING_VERSION: "0.1.0"
        CURRENT_PROJECT_VERSION: "1"
        GENERATE_INFOPLIST_FILE: NO
        SWIFT_VERSION: "6.0"
  • [ ] Step 2: .gitignore
*.xcodeproj
*.xcworkspace
.build/
DerivedData/
  • [ ] Step 3: Minimal Info.plist (XcodeGen merges the info.properties; a base plist keeps it explicit)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
</dict>
</plist>
  • [ ] Step 4: Minimal app entry (just enough to build + launch a window)

Sources/App/PhysarumApp.swift:

import SwiftUI

@main
struct PhysarumApp: App {
    var body: some Scene {
        WindowGroup {
            Text("Physarum — scaffold")
                .frame(minWidth: 640, minHeight: 400)
        }
    }
}

  • [ ] Step 5: Generate + build (the scaffold gate)

Run:

cd apple/PhysarumApp && xcodegen generate && \
xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5
Expected: xcodegen writes Physarum.xcodeproj; xcodebuild ends with ** BUILD SUCCEEDED **. (If XcodeGen syntax errors: supportedDestinations requires XcodeGen ≥ 2.42 — xcodegen --version is 2.45, fine. If swift test of the package is triggered, it's harmless.)

  • [ ] Step 6: Commit
git add apple/PhysarumApp/project.yml apple/PhysarumApp/.gitignore apple/PhysarumApp/Sources/App apple/PhysarumApp/Resources/Info.plist
git commit -m "feat(app): XcodeGen multiplatform scaffold + minimal macOS app shell"

Task 5: PresetCatalog (loads bundled presets)

Files: - Create: apple/PhysarumApp/Sources/Model/PresetCatalog.swift

  • [ ] Step 1: Implement — load every bundled *.json into [Preset], sorted by name.
import Foundation
import PhysarumEngine

enum PresetCatalog {
    /// All built-in presets bundled under Resources/Presets, decoded with the
    /// engine's Codable model. Sorted by display name for a stable browser order.
    static func loadAll(bundle: Bundle = .main) -> [Preset] {
        guard let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: "Presets")
            ?? bundle.urls(forResourcesWithExtension: "json", subdirectory: nil)
        else { return [] }
        let decoder = JSONDecoder()
        return urls
            .compactMap { try? decoder.decode(Preset.self, from: Data(contentsOf: $0)) }
            .sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
    }

    /// Thumbnail file URL for a preset id (Resources/Thumbnails/<id>.png), if present.
    static func thumbnailURL(for id: String, bundle: Bundle = .main) -> URL? {
        bundle.url(forResource: id, withExtension: "png", subdirectory: "Thumbnails")
            ?? bundle.url(forResource: id, withExtension: "png", subdirectory: nil)
    }
}
  • [ ] Step 2: Verify (deferred to Task 12) — there is no app unit-test target; PresetCatalog is verified by the running app showing 25 presets. Build only:

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Model/PresetCatalog.swift
git commit -m "feat(app): PresetCatalog loads bundled preset JSONs + thumbnails"

Task 6: LiveParams (editable overrides)

Files: - Create: apple/PhysarumApp/Sources/Model/LiveParams.swift

  • [ ] Step 1: Implement — a small value type holding the curated slider values, seeded from a preset and applied back onto a PresetParams.
import PhysarumEngine

/// The curated, respawn-free live-editable parameters (see spec §6). Seeded from
/// a preset's defaults; `apply(to:)` writes them back so the engine/renderer use
/// the edited values.
struct LiveParams: Equatable {
    // Motion
    var simSpeed: Int
    var decayRate: Float
    // Glow
    var bloomIntensity: Float
    var feedbackAmount: Float
    // Color / tone
    var exposure: Float
    var saturation: Float
    var contrast: Float
    var vignette: Float

    init(_ p: PresetParams) {
        simSpeed = p.simSpeed
        decayRate = p.decayRate
        bloomIntensity = p.bloomIntensity
        feedbackAmount = p.feedbackAmount
        exposure = p.exposure
        saturation = p.saturation
        contrast = p.contrast
        vignette = p.vignette
    }

    /// Returns a copy of `base` with the live values applied.
    func apply(to base: PresetParams) -> PresetParams {
        var p = base
        p.simSpeed = simSpeed
        p.decayRate = decayRate
        p.bloomIntensity = bloomIntensity
        p.feedbackAmount = feedbackAmount
        p.exposure = exposure
        p.saturation = saturation
        p.contrast = contrast
        p.vignette = vignette
        return p
    }
}
  • [ ] Step 2: Build

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Model/LiveParams.swift
git commit -m "feat(app): LiveParams curated overrides with apply(to:)"

Task 7: AppState

Files: - Create: apple/PhysarumApp/Sources/Model/AppState.swift

  • [ ] Step 1: Implement — @Observable holding catalog, selection, live params, playback flags.
import SwiftUI
import PhysarumEngine

@Observable
final class AppState {
    let presets: [Preset]
    var selectedIndex: Int = 0
    var live: LiveParams
    var autoCycle: Bool = false

    var selectedPreset: Preset { presets[selectedIndex] }
    /// The preset with live overrides applied — handed to the engine each frame.
    var livePreset: PresetParams { live.apply(to: selectedPreset.params) }

    init() {
        let loaded = PresetCatalog.loadAll()
        presets = loaded.isEmpty ? [] : loaded
        live = LiveParams(loaded.first?.params ?? PresetCatalog.fallbackParams)
    }

    func select(_ index: Int) {
        guard presets.indices.contains(index) else { return }
        selectedIndex = index
        live = LiveParams(presets[index].params)   // reset sliders to the new preset's defaults
    }

    func advance(by step: Int) {
        guard !presets.isEmpty else { return }
        select((selectedIndex + step + presets.count) % presets.count)
    }

    func resetLive() { live = LiveParams(selectedPreset.params) }
}
  • [ ] Step 2: Add fallbackParams to PresetCatalog (guards an empty bundle so AppState.init never crashes)

Append to PresetCatalog in PresetCatalog.swift:

    /// A safe default so the app can launch even if no presets are bundled.
    static var fallbackParams: PresetParams {
        // Decoded from a minimal valid preset JSON.
        let json = #"""
        {"id":"_","name":"_","builtIn":true,"tags":[],"tagSignal":"pearl","params":{
          "agentCount":1000,"spawnPattern":"random",
          "species":[{"color":[0.9,0.6,0.2],"populationShare":1,"sensorAngle":0.5,"sensorDistance":9,"turnSpeed":0.3,"moveSpeed":1.5,"depositAmount":1},
                     {"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0},
                     {"color":[0,0,0],"populationShare":0,"sensorAngle":0,"sensorDistance":1,"turnSpeed":0,"moveSpeed":0,"depositAmount":0}],
          "interactionMatrix":[[1,0,0],[0,1,0],[0,0,1]],
          "decayRate":0.95,"diffusionFreq":1,"fadeStrength":0,"trailSaturation":100,
          "chaosInterval":0,"chaosStrength":0,"rhythmAmplitude":0,"simSpeed":1,
          "backgroundColor":[0,0,0],"blendMode":"additive","exposure":1,"headIntensity":0,"kaleidoscope":0,
          "feedbackAmount":0,"bloomThreshold":1,"bloomIntensity":1,"vignette":0,"saturation":1,"contrast":1,
          "audioReactive":{"enabled":false,"gain":1,"autoGain":false,"routes":[]}}}
        """#
        return (try? JSONDecoder().decode(Preset.self, from: Data(json.utf8)).params)!
    }

  • [ ] Step 3: Build + commit

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/Model/PresetCatalog.swift
git commit -m "feat(app): AppState (@Observable) with selection + live params"


Task 8: MetalView + RenderCoordinator

Files: - Create: apple/PhysarumApp/Sources/Player/RenderCoordinator.swift - Create: apple/PhysarumApp/Sources/Player/MetalView.swift

  • [ ] Step 1: RenderCoordinator — owns SimEngine + Renderer, drives the loop, reacts to preset/live changes.
import MetalKit
import PhysarumEngine

final class RenderCoordinator: NSObject, MTKViewDelegate {
    let device: MTLDevice
    private let lib: ShaderLibrary
    private let renderer: Renderer
    private var sim: SimEngine
    private let field: Int

    /// Current preset params with live overrides — set by the SwiftUI layer.
    var livePreset: PresetParams { didSet { sim.updateLiveParams(livePreset) } }
    private var currentPresetID: String

    init?(field: Int, initialPreset: Preset, live: PresetParams) {
        guard let device = MTLCreateSystemDefaultDevice() else { return nil }
        self.device = device
        self.field = field
        self.lib = ShaderLibrary(device: device)
        guard let r = try? Renderer(device: device, library: lib, width: field, height: field),
              let s = try? SimEngine(device: device, library: lib, width: field, height: field, preset: live)
        else { return nil }
        self.renderer = r
        self.sim = s
        self.livePreset = live
        self.currentPresetID = initialPreset.id
        super.init()
    }

    /// Switch to a different preset (re-seeds the sim + clears the echo).
    func switchPreset(_ preset: Preset, live: PresetParams) {
        guard preset.id != currentPresetID else { return }
        currentPresetID = preset.id
        if let s = try? SimEngine(device: device, library: lib, width: field, height: field, preset: live) {
            sim = s
            renderer.resetFeedback()
        }
        livePreset = live
    }

    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}

    func draw(in view: MTKView) {
        sim.step()
        guard let desc = view.currentRenderPassDescriptor, let drawable = view.currentDrawable else { return }
        renderer.render(sim: sim, preset: livePreset, into: desc, drawable: drawable)
    }
}
  • [ ] Step 2: MetalView — SwiftUI wrapper around MTKView, platform-conditional.
import SwiftUI
import MetalKit
import PhysarumEngine

#if os(macOS)
typealias ViewRepresentable = NSViewRepresentable
#else
typealias ViewRepresentable = UIViewRepresentable
#endif

struct MetalView: ViewRepresentable {
    let field: Int
    let selectedPreset: Preset
    let livePreset: PresetParams

    func makeCoordinator() -> RenderCoordinator? {
        RenderCoordinator(field: field, initialPreset: selectedPreset, live: livePreset)
    }

    private func makeMTKView(_ coordinator: RenderCoordinator?) -> MTKView {
        let view = MTKView()
        if let c = coordinator {
            view.device = c.device
            view.delegate = c
        }
        view.colorPixelFormat = .bgra8Unorm
        view.preferredFramesPerSecond = 60
        return view
    }

    private func sync(_ coordinator: RenderCoordinator?) {
        guard let c = coordinator else { return }
        c.switchPreset(selectedPreset, live: livePreset)
        c.livePreset = livePreset   // live-slider updates between preset switches
    }

#if os(macOS)
    func makeNSView(context: Context) -> MTKView { makeMTKView(context.coordinator) }
    func updateNSView(_ nsView: MTKView, context: Context) { sync(context.coordinator) }
#else
    func makeUIView(context: Context) -> MTKView { makeMTKView(context.coordinator) }
    func updateUIView(_ uiView: MTKView, context: Context) { sync(context.coordinator) }
#endif
}
  • [ ] Step 3: Build + commit

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

git add apple/PhysarumApp/Sources/Player
git commit -m "feat(app): MetalView + RenderCoordinator driving the live engine"


Task 9: LibraryView

Files: - Create: apple/PhysarumApp/Sources/Library/LibraryView.swift

  • [ ] Step 1: Implement — a grid of preset thumbnails; tapping selects.
import SwiftUI
import PhysarumEngine

struct LibraryView: View {
    @Bindable var state: AppState
    private let columns = [GridItem(.adaptive(minimum: 120), spacing: 12)]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(Array(state.presets.enumerated()), id: \.element.id) { index, preset in
                    Button { state.select(index) } label: {
                        VStack(spacing: 4) {
                            thumbnail(for: preset)
                                .overlay(RoundedRectangle(cornerRadius: 8)
                                    .strokeBorder(.tint, lineWidth: index == state.selectedIndex ? 3 : 0))
                            Text(preset.name).font(.caption).lineLimit(1)
                        }
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding(12)
        }
    }

    @ViewBuilder
    private func thumbnail(for preset: Preset) -> some View {
        if let url = PresetCatalog.thumbnailURL(for: preset.id),
           let image = loadImage(url) {
            image.resizable().aspectRatio(1, contentMode: .fit)
                .clipShape(RoundedRectangle(cornerRadius: 8))
        } else {
            RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.3)).aspectRatio(1, contentMode: .fit)
        }
    }

    private func loadImage(_ url: URL) -> Image? {
#if os(macOS)
        NSImage(contentsOf: url).map { Image(nsImage: $0) }
#else
        (try? Data(contentsOf: url)).flatMap { UIImage(data: $0) }.map { Image(uiImage: $0) }
#endif
    }
}
  • [ ] Step 2: Build + commit

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

git add apple/PhysarumApp/Sources/Library
git commit -m "feat(app): LibraryView preset grid with thumbnails"


Task 10: ControlsView

Files: - Create: apple/PhysarumApp/Sources/Controls/ControlsView.swift

  • [ ] Step 1: Implement — curated sliders bound to AppState.live, grouped, with a reset. Ranges from the Zod schema (spec §6).
import SwiftUI

struct ControlsView: View {
    @Bindable var state: AppState

    var body: some View {
        Form {
            Section("Motion") {
                Stepper("Speed: \(state.live.simSpeed)×", value: $state.live.simSpeed, in: 1...8)
                slider("Trail persistence", value: $state.live.decayRate, in: 0.5...1)
            }
            Section("Glow") {
                slider("Bloom", value: $state.live.bloomIntensity, in: 0...3)
                slider("Echo", value: $state.live.feedbackAmount, in: 0...0.97)
            }
            Section("Color") {
                slider("Exposure", value: $state.live.exposure, in: 0.1...10)
                slider("Saturation", value: $state.live.saturation, in: 0...2)
                slider("Contrast", value: $state.live.contrast, in: 0...2)
                slider("Vignette", value: $state.live.vignette, in: 0...1)
            }
            Section {
                Button("Reset to preset defaults") { state.resetLive() }
                Toggle("Auto-cycle presets", isOn: $state.autoCycle)
            }
        }
        .formStyle(.grouped)
    }

    private func slider(_ label: String, value: Binding<Float>, in range: ClosedRange<Float>) -> some View {
        VStack(alignment: .leading, spacing: 2) {
            Text(label).font(.caption)
            Slider(value: value, in: range)
        }
    }
}
  • [ ] Step 2: Build + commit

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

git add apple/PhysarumApp/Sources/Controls
git commit -m "feat(app): ControlsView curated live-sliders + reset"


Task 11: Wire the app together

Files: - Modify: apple/PhysarumApp/Sources/App/PhysarumApp.swift - Create: apple/PhysarumApp/Sources/App/RootView.swift

  • [ ] Step 1: RootView — NavigationSplitView on macOS; drives auto-cycle.
import SwiftUI

struct RootView: View {
    @State private var state = AppState()
    private let field = 720
    private let cycleSeconds = 25.0
    @State private var cycleTimer: Timer?

    var body: some View {
        NavigationSplitView {
            LibraryView(state: state)
                .navigationTitle("Presets")
                .frame(minWidth: 220)
        } detail: {
            MetalView(field: field,
                      selectedPreset: state.selectedPreset,
                      livePreset: state.livePreset)
                .ignoresSafeArea()
                .navigationTitle(state.selectedPreset.name)
                .inspector(isPresented: .constant(true)) {
                    ControlsView(state: state).inspectorColumnWidth(min: 240, ideal: 280)
                }
        }
        .onChange(of: state.autoCycle) { _, on in on ? startCycle() : stopCycle() }
    }

    private func startCycle() {
        cycleTimer = Timer.scheduledTimer(withTimeInterval: cycleSeconds, repeats: true) { _ in
            state.advance(by: 1)
        }
    }
    private func stopCycle() { cycleTimer?.invalidate(); cycleTimer = nil }
}
  • [ ] Step 2: Replace the app entry

Sources/App/PhysarumApp.swift:

import SwiftUI

@main
struct PhysarumApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
                .frame(minWidth: 900, minHeight: 600)
        }
        #if os(macOS)
        .windowStyle(.hiddenTitleBar)
        #endif
    }
}

  • [ ] Step 3: Build + commit

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -3 Expected: ** BUILD SUCCEEDED **.

git add apple/PhysarumApp/Sources/App
git commit -m "feat(app): wire RootView (split + player + inspector + auto-cycle)"


Task 12: Verification (build, launch, observe)

Files: none (verification only)

  • [ ] Step 1: Clean generate + build

Run:

cd apple/PhysarumApp && xcodegen generate && \
xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' clean build 2>&1 | tail -5
Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 2: Launch the built app + observe

Run (find + open the product):

APP=$(xcodebuild -project Physarum.xcodeproj -scheme Physarum -showBuildSettings -destination 'platform=macOS' 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR /{d=$2} / FULL_PRODUCT_NAME /{n=$2} END{print d"/"n}')
open "$APP"
Expected: a window opens; the player renders an animated preset; the Library sidebar shows 25 thumbnails; selecting a preset switches the render; sliders change the look live; "Reset" restores defaults; "Auto-cycle" advances every 25s. (Capture a screenshot for the visual record; the agent reports observed behaviour.)

  • [ ] Step 3: Engine suite still green

Run: cd apple/PhysarumEngine && swift test 2>&1 | tail -3 Expected: 20 tests, 0 failures.

  • [ ] Step 4: Final commit (if any docs/cleanup)
git add -A apple/PhysarumApp
git commit -m "chore(app): macOS playback verified — build + launch green" || true

Definition of Done

  • [ ] Engine package builds for macOS/iOS/tvOS platforms; swift test green (20 tests inc. updateLiveParams).
  • [ ] xcodegen generate + xcodebuild ... -destination 'platform=macOS' build → BUILD SUCCEEDED.
  • [ ] App launches: animated playback, 25-preset browser with thumbnails, working preset switching, live-sliders affecting the render, reset, auto-cycle.
  • [ ] Presets exported to the app bundle from the single source (no drift).
  • [ ] Everything committed (push deferred to Johannes).

Self-Review notes

  • Spec coverage: §3 scaffold (Task 4), §4 components (Tasks 5–11), §5 engine extensions (Tasks 1–2), §6 sliders (Task 10), §7 platform strategy (#if os in Tasks 8–9; iOS/tvOS UI out-of-scope), §8 testing (engine TDD Task 2 + build/visual Task 12), §10 scope respected (no editor/audio/export/Hue/CloudKit). §9 distribution is roadmap-only — no DMG task here (correct).
  • Carried risk: thread-safety — RenderCoordinator.livePreset.didSet calls sim.updateLiveParams on the main thread while draw(in:) runs on the MTKView's thread. MTKView's delegate callbacks are on the main thread by default (no explicit MTLCommandQueue threading set), so both touch sim on the main thread — safe. If a frame ever runs off-main, guard with a serial queue. Documented for the executor.
  • Type consistency: livePreset: PresetParams, LiveParams.apply(to:), SimEngine.updateLiveParams, Renderer.render(sim:preset:into:drawable:), PresetCatalog.loadAll/thumbnailURL/fallbackParams used consistently across tasks.
  • Bundle-path risk: bundle.urls(forResourcesWithExtension:subdirectory:) with the "Presets"/"Thumbnails" subdir — XcodeGen resources: with a folder path creates a folder reference, so the subdirectory is preserved; the ?? subdirectory: nil fallback covers a flattened layout.