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, addupdateLiveParams(_:).apple/PhysarumEngine/Tests/PhysarumEngineTests/SimEngineTests.swift— MODIFY: add live-params test.scripts/export-native-presets.ts— MODIFY: add the appResources/Presetsdir as a third target; also copy thumbnails.apple/PhysarumApp/project.yml— CREATE: XcodeGen manifest (one multiplatformPhysarumtarget).apple/PhysarumApp/.gitignore— CREATE: ignore generated*.xcodeproj.apple/PhysarumApp/Sources/App/PhysarumApp.swift— CREATE:@mainSwiftUI app + root scene.apple/PhysarumApp/Sources/Model/AppState.swift— CREATE:@Observableapp 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/UIViewRepresentablearoundMTKView.apple/PhysarumApp/Sources/Player/RenderCoordinator.swift— CREATE:MTKViewDelegatedriving 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 alongsideapple/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:
to:- [ ] 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
presetmutable and add the method.
In SimEngine.swift, change the stored property:
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
- [ ] 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
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
*.jsoninto[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;
PresetCatalogis 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 —
@Observableholding 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
fallbackParamsto PresetCatalog (guards an empty bundle soAppState.initnever 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
** 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"
- [ ] 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 testgreen (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 osin 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.didSetcallssim.updateLiveParamson the main thread whiledraw(in:)runs on the MTKView's thread. MTKView's delegate callbacks are on the main thread by default (no explicitMTLCommandQueuethreading set), so both touchsimon 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/fallbackParamsused consistently across tasks. - Bundle-path risk:
bundle.urls(forResourcesWithExtension:subdirectory:)with the "Presets"/"Thumbnails" subdir — XcodeGenresources:with a folder path creates a folder reference, so the subdirectory is preserved; the?? subdirectory: nilfallback covers a flattened layout.