Zum Inhalt

Authoring Editor Core (Increment 3b-1) 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: Turn the native app into a full parameter editor — all ~52 params editable live across macOS/iPad/iPhone, via a Parameter-Registry metamodel, with an adaptive inspector/sheet UI, ParamSlider (tap-to-type, double-tap-reset, macOS modifiers), and the collapsible-panel + fullscreen fixes.

Architecture: A ParamRegistry of ParamDescriptors (id, label, section, range, isRespawn, get/set on PresetParams) is the single source for every scalar param. AppState.edited/baseline replace the old LiveParams; the player gets edited every frame (live) and a debounced re-seed for respawn params. The editor is generated from the registry; non-scalar params (enums, colors) get bespoke controls but share ParamID identity. Preset-management + exploration features are later plans (3b-2, 3b-3).

Tech Stack: Swift 6.3 (lang mode 5), SwiftUI, MetalKit, XcodeGen; engine PhysarumEngine package.

Reference (read first): spec docs/superpowers/specs/2026-06-03-authoring-editor-design.md; apple/PhysarumApp/Sources/Model/{AppState,LiveParams,PresetCatalog}.swift; apple/PhysarumApp/Sources/Player/{RenderCoordinator,MetalView}.swift; apple/PhysarumApp/Sources/App/RootView.swift; engine Model/{Preset,Types}.swift. Param ranges (from src/presets/schema.ts): see Task 1.


File Structure

  • apple/PhysarumApp/Sources/Model/ParamRegistry.swift — CREATE: ParamID, ParamDescriptor, EditorSection, ParamRegistry (all scalar params), randomValue/range helpers.
  • apple/PhysarumApp/Sources/Model/AppState.swift — REWRITE: edited/baseline/isDirty, selection seeds both; remove the old LiveParams dependency.
  • apple/PhysarumApp/Sources/Model/LiveParams.swift — DELETE (superseded by edited: PresetParams).
  • apple/PhysarumApp/Sources/Player/RenderCoordinator.swift — MODIFY: debounced respawn on respawn-param change; currentParams setter.
  • apple/PhysarumApp/Sources/Player/MetalView.swift — MODIFY: pass edited (full PresetParams) instead of livePreset.
  • apple/PhysarumApp/Sources/Editor/ParamSlider.swift — CREATE: generic slider from a ParamDescriptor.
  • apple/PhysarumApp/Sources/Editor/EditorView.swift — CREATE: sections container + toolbar.
  • apple/PhysarumApp/Sources/Editor/Sections.swift — CREATE: Global/Pheromone/Dynamics/Effects/Species/Matrix section views.
  • apple/PhysarumApp/Sources/App/RootView.swift — MODIFY: collapsible inspector + fullscreen; host EditorView.
  • apple/PhysarumApp/Sources/Controls/ControlsView.swift — DELETE (replaced by EditorView).
  • apple/PhysarumEngine/Tests/PhysarumEngineTests/ — engine tests stay; registry tests live in the app (Task 1 note).

Test target note: Registry/AppState are app code. Add a PhysarumAppTests XCTest target to project.yml (Task 1) so xcodebuild test covers the registry roundtrip + randomize/mutate later.


Task 1: ParamRegistry + test target

Files: - Create: apple/PhysarumApp/Sources/Model/ParamRegistry.swift - Modify: apple/PhysarumApp/project.yml (add test target) - Create: apple/PhysarumApp/Tests/ParamRegistryTests.swift

  • [ ] Step 1: Write ParamRegistry.swift — ParamID, ParamDescriptor, EditorSection, and the registry of all scalar params. Ranges are from src/presets/schema.ts.
import PhysarumEngine
import simd

enum EditorSection: String, CaseIterable { case global, species, matrix, pheromone, dynamics, effects }

/// Stable identity for every editable parameter (scalars + non-scalars), so locks/
/// undo/morph (later plans) can address each one.
enum ParamID: Hashable {
    // global scalars
    case agentCount, exposure, simSpeed
    // global non-scalars (own controls)
    case spawnPattern, blendMode, backgroundColor
    // per-species (index 0..2)
    case speciesColor(Int), populationShare(Int), sensorAngle(Int), sensorDistance(Int)
    case turnSpeed(Int), moveSpeed(Int), depositAmount(Int)
    // matrix cell (row, col)
    case matrix(Int, Int)
    // pheromone
    case decayRate, diffusionFreq, fadeStrength, trailSaturation
    // dynamics
    case chaosInterval, chaosStrength, rhythmAmplitude
    // effects
    case headIntensity, kaleidoscope, feedbackAmount, bloomThreshold, bloomIntensity, vignette, saturation, contrast
}

/// A scalar, slider-editable parameter. Non-scalar params (enums/colors) are not
/// in the registry — they get bespoke controls but share ParamID identity.
struct ParamDescriptor: Identifiable {
    let id: ParamID
    let label: String
    let section: EditorSection
    let range: ClosedRange<Double>
    let isInt: Bool
    let isRespawn: Bool
    let get: (PresetParams) -> Double
    let set: (inout PresetParams, Double) -> Void
    var idKey: String { "\(id)" }   // for Identifiable/ForEach
}

enum ParamRegistry {
    /// All scalar editable params with web-schema ranges. (color/spawnPattern/blendMode
    /// are non-scalar → bespoke controls, not here.)
    static let all: [ParamDescriptor] = {
        var d: [ParamDescriptor] = []
        func add(_ id: ParamID, _ label: String, _ s: EditorSection, _ r: ClosedRange<Double>,
                 int: Bool = false, respawn: Bool = false,
                 _ get: @escaping (PresetParams) -> Double,
                 _ set: @escaping (inout PresetParams, Double) -> Void) {
            d.append(ParamDescriptor(id: id, label: label, section: s, range: r, isInt: int, isRespawn: respawn, get: get, set: set))
        }
        // Global
        add(.agentCount, "Agents", .global, 100...500_000, int: true, respawn: true,
            { Double($0.agentCount) }, { $0.agentCount = Int($1.rounded()) })
        add(.exposure, "Exposure", .global, 0.1...10, { Double($0.exposure) }, { $0.exposure = Float($1) })
        add(.simSpeed, "Sim speed", .global, 1...8, int: true, { Double($0.simSpeed) }, { $0.simSpeed = Int($1.rounded()) })
        // Per-species (i = 0..2)
        for i in 0..<3 {
            add(.populationShare(i), "Population", .species, 0...3, respawn: true,
                { Double($0.species[i].populationShare) }, { $0.species[i].populationShare = Float($1) })
            add(.sensorAngle(i), "Sensor angle", .species, 0...Double.pi,
                { Double($0.species[i].sensorAngle) }, { $0.species[i].sensorAngle = Float($1) })
            add(.sensorDistance(i), "Sensor distance", .species, 1...80,
                { Double($0.species[i].sensorDistance) }, { $0.species[i].sensorDistance = Float($1) })
            add(.turnSpeed(i), "Turn speed", .species, 0...2,
                { Double($0.species[i].turnSpeed) }, { $0.species[i].turnSpeed = Float($1) })
            add(.moveSpeed(i), "Move speed", .species, 0...10,
                { Double($0.species[i].moveSpeed) }, { $0.species[i].moveSpeed = Float($1) })
            add(.depositAmount(i), "Deposit", .species, 0...100,
                { Double($0.species[i].depositAmount) }, { $0.species[i].depositAmount = Float($1) })
        }
        // Matrix (r,c)
        for r in 0..<3 { for c in 0..<3 {
            add(.matrix(r, c), "M[\(r)][\(c)]", .matrix, -2...2,
                { Double($0.interactionMatrix[r][c]) }, { $0.interactionMatrix[r][c] = Float($1) })
        } }
        // Pheromone
        add(.decayRate, "Decay", .pheromone, 0.5...1, { Double($0.decayRate) }, { $0.decayRate = Float($1) })
        add(.diffusionFreq, "Diffusion", .pheromone, 1...20, int: true, { Double($0.diffusionFreq) }, { $0.diffusionFreq = Int($1.rounded()) })
        add(.fadeStrength, "Fade", .pheromone, 0...1, { Double($0.fadeStrength) }, { $0.fadeStrength = Float($1) })
        add(.trailSaturation, "Trail cap", .pheromone, 1...500, { Double($0.trailSaturation) }, { $0.trailSaturation = Float($1) })
        // Dynamics
        add(.chaosInterval, "Chaos interval", .dynamics, 0...2000, int: true, { Double($0.chaosInterval) }, { $0.chaosInterval = Int($1.rounded()) })
        add(.chaosStrength, "Chaos strength", .dynamics, 0...2, { Double($0.chaosStrength) }, { $0.chaosStrength = Float($1) })
        add(.rhythmAmplitude, "Rhythm", .dynamics, 0...0.5, { Double($0.rhythmAmplitude) }, { $0.rhythmAmplitude = Float($1) })
        // Effects
        add(.headIntensity, "Head glow", .effects, 0...3, { Double($0.headIntensity) }, { $0.headIntensity = Float($1) })
        add(.kaleidoscope, "Kaleidoscope", .effects, 0...6, int: true, { Double($0.kaleidoscope) }, { $0.kaleidoscope = Int($1.rounded()) })
        add(.feedbackAmount, "Feedback", .effects, 0...0.97, { Double($0.feedbackAmount) }, { $0.feedbackAmount = Float($1) })
        add(.bloomThreshold, "Bloom threshold", .effects, 0...2, { Double($0.bloomThreshold) }, { $0.bloomThreshold = Float($1) })
        add(.bloomIntensity, "Bloom", .effects, 0...3, { Double($0.bloomIntensity) }, { $0.bloomIntensity = Float($1) })
        add(.vignette, "Vignette", .effects, 0...1, { Double($0.vignette) }, { $0.vignette = Float($1) })
        add(.saturation, "Saturation", .effects, 0...2, { Double($0.saturation) }, { $0.saturation = Float($1) })
        add(.contrast, "Contrast", .effects, 0...2, { Double($0.contrast) }, { $0.contrast = Float($1) })
        return d
    }()

    static func descriptors(in section: EditorSection) -> [ParamDescriptor] { all.filter { $0.section == section } }

    /// True if any registry param in this section is a respawn param.
    static let respawnIDs: Set<String> = Set(all.filter { $0.isRespawn }.map { "\($0.id)" })
}
  • [ ] Step 2: Add a PhysarumAppTests target to project.yml

In apple/PhysarumApp/project.yml, after the Physarum target, add:

  PhysarumAppTests:
    type: bundle.unit-test
    supportedDestinations: [macOS]
    sources: [Tests]
    dependencies:
      - target: Physarum
    settings:
      base:
        SWIFT_VERSION: "5.0"
        GENERATE_INFOPLIST_FILE: YES
schemes:
  Physarum:
    build:
      targets:
        Physarum: all
        PhysarumAppTests: [test]
    test:
      targets: [PhysarumAppTests]

  • [ ] Step 3: Write ParamRegistryTests.swift — get/set roundtrip over every descriptor.
import XCTest
@testable import Physarum
import PhysarumEngine

final class ParamRegistryTests: XCTestCase {
    private func sample() -> PresetParams { PresetCatalog.fallbackPreset.params }

    func testEveryDescriptorRoundTrips() {
        var p = sample()
        for d in ParamRegistry.all {
            let mid = (d.range.lowerBound + d.range.upperBound) / 2
            d.set(&p, mid)
            let got = d.get(p)
            if d.isInt {
                XCTAssertEqual(got, mid.rounded(), accuracy: 0.5, "\(d.idKey) int roundtrip")
            } else {
                XCTAssertEqual(got, mid, accuracy: 1e-4, "\(d.idKey) roundtrip")
            }
        }
    }

    func testRegistryCoversExpectedCount() {
        // 3 global scalars + 6×3 species + 9 matrix + 4 pheromone + 3 dynamics + 8 effects = 45 scalars
        XCTAssertEqual(ParamRegistry.all.count, 45)
        XCTAssertFalse(ParamRegistry.respawnIDs.isEmpty)
    }

    func testRangesNonEmpty() {
        for d in ParamRegistry.all { XCTAssertLessThan(d.range.lowerBound, d.range.upperBound, "\(d.idKey)") }
    }
}
  • [ ] Step 4: Generate + test

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8 Expected: ** TEST SUCCEEDED ** (3 tests pass). If bundle.unit-test/scheme syntax errors, check XcodeGen docs — the test target type is bundle.unit-test and the host is target: Physarum.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Model/ParamRegistry.swift apple/PhysarumApp/project.yml apple/PhysarumApp/Tests
git commit -m "feat(app): ParamRegistry metamodel for all 45 scalar params + test target"

Task 2: AppState rewrite (edited/baseline) + MetalView/Coordinator full-params

Files: - Modify: apple/PhysarumApp/Sources/Model/AppState.swift - Delete: apple/PhysarumApp/Sources/Model/LiveParams.swift - Delete: apple/PhysarumApp/Sources/Controls/ControlsView.swift - Modify: apple/PhysarumApp/Sources/Player/MetalView.swift

  • [ ] Step 1: Rewrite AppState.swift — edited/baseline, selection seeds both, per-param reset helper.
import SwiftUI
import PhysarumEngine

@Observable
final class AppState {
    let presets: [Preset]
    var selectedIndex: Int = 0
    var edited: PresetParams          // live-edited working copy
    var baseline: PresetParams        // the selected preset's original (for dirty + per-param reset)
    var autoCycle: Bool = false

    var selectedPreset: Preset { presets[selectedIndex] }
    var isDirty: Bool { !paramsEqual(edited, baseline) }

    init() {
        let loaded = PresetCatalog.loadAll()
        presets = loaded.isEmpty ? [PresetCatalog.fallbackPreset] : loaded
        let p = presets[0].params
        edited = p
        baseline = p
    }

    func select(_ index: Int) {
        guard presets.indices.contains(index) else { return }
        selectedIndex = index
        edited = presets[index].params
        baseline = presets[index].params
    }

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

    /// Reset one param to the selected preset's default (double-tap on a slider).
    func reset(_ descriptor: ParamDescriptor) { descriptor.set(&edited, descriptor.get(baseline)) }

    func resetAll() { edited = baseline }
}

/// Structural equality for dirty tracking (PresetParams isn't Equatable; compare packed form).
func paramsEqual(_ a: PresetParams, _ b: PresetParams) -> Bool {
    packParams(simEngineUniform(a)) == packParams(simEngineUniform(b)) &&
    packPostParams(rendererUniform(a)) == packPostParams(rendererUniform(b))
}

Note: paramsEqual reuses the engine's packing for a cheap structural compare. If simEngineUniform/rendererUniform helpers don't exist, compare field-by-field instead — implement paramsEqual as a direct comparison of every registry param plus the non-scalars (spawnPattern, blendMode, colors). Simplest robust form:

func paramsEqual(_ a: PresetParams, _ b: PresetParams) -> Bool {
    for d in ParamRegistry.all where abs(d.get(a) - d.get(b)) > 1e-6 { return false }
    return a.spawnPattern == b.spawnPattern && a.blendMode == b.blendMode
        && a.backgroundColor == b.backgroundColor
        && zip(a.species, b.species).allSatisfy { $0.color == $1.color }
}
Use this direct form (no dependency on engine helpers).

  • [ ] Step 2: Delete superseded files
git rm apple/PhysarumApp/Sources/Model/LiveParams.swift apple/PhysarumApp/Sources/Controls/ControlsView.swift
  • [ ] Step 3: Update MetalView.swift — pass edited (full params).

Change the struct's stored input from livePreset: PresetParams usage to accept the full edited params and the selected preset for switch detection. Replace the property and the two call sites:

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

    func makeCoordinator() -> RenderCoordinator? {
        RenderCoordinator(field: field, initialPreset: selectedPreset, live: editedParams)
    }
    private func sync(_ coordinator: RenderCoordinator?) {
        guard let c = coordinator else { return }
        c.switchPreset(selectedPreset, live: editedParams)
        c.setParams(editedParams)
    }
    // makeNSView/updateNSView (+ UIView variants) unchanged except they call sync(context.coordinator)
}

  • [ ] Step 4: Build (RootView still references old API — expect it to break; Task 5 fixes RootView. Build just the model/player by checking compile errors are only in RootView.)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:|SUCCEEDED" | head Expected: errors only in RootView.swift/MetalView call sites (fixed in Task 4-5). If errors elsewhere, fix them.

  • [ ] Step 5: Commit (after Task 5 builds clean — defer commit; this task's files commit together with the coordinator in Task 3)

Task 3: Debounced respawn in RenderCoordinator

Files: - Modify: apple/PhysarumApp/Sources/Player/RenderCoordinator.swift

  • [ ] Step 1: Add setParams with respawn detection — replace the livePreset didSet model.

// Replace `var livePreset: PresetParams { didSet { sim.updateLiveParams(livePreset) } }` with:
private(set) var params: PresetParams
private var respawnWork: DispatchWorkItem?

/// Apply edited params: live ones immediately; respawn ones (agentCount/spawn/share)
/// via a 250ms debounce so dragging the Agents slider doesn't realloc every frame.
func setParams(_ p: PresetParams) {
    let needsRespawn = p.agentCount != params.agentCount
        || p.spawnPattern != params.spawnPattern
        || zip(p.species, params.species).contains { $0.populationShare != $1.populationShare }
    params = p
    sim.updateLiveParams(p)            // live fields now
    if needsRespawn {
        respawnWork?.cancel()
        let work = DispatchWorkItem { [weak self] in self?.respawn() }
        respawnWork = work
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
    }
}

private func respawn() {
    if let s = try? SimEngine(device: device, library: lib, width: field, height: field, preset: params) {
        sim = s
        renderer.resetFeedback()
    }
}
Update init? to set self.params = live (instead of livePreset), and draw(in:) to use params (renderer.render(sim: sim, preset: params, ...)). switchPreset sets params = live at the end and cancels any pending respawnWork.

  • [ ] Step 2: Build (RootView still broken — that's Task 4-5)

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:" | grep -v RootView | head Expected: no errors outside RootView.


Task 4: ParamSlider (generic)

Files: - Create: apple/PhysarumApp/Sources/Editor/ParamSlider.swift

  • [ ] Step 1: Write ParamSlider.swift — generic scalar slider bound to AppState via a descriptor.

import SwiftUI

/// One slider for any scalar ParamDescriptor. Tap value → numeric entry;
/// double-tap → reset to preset default; macOS Shift/Alt = fine/coarse.
struct ParamSlider: View {
    @Bindable var state: AppState
    let descriptor: ParamDescriptor
    @State private var editingText: String?

    private var value: Double { descriptor.get(state.edited) }

    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            HStack {
                Text(descriptor.label).font(.caption)
                Spacer()
                valueLabel
            }
            Slider(
                value: Binding(get: { value },
                               set: { commitDrag($0) }),
                in: descriptor.range
            )
            .help(descriptor.label)   // tooltip; replaced by paramHelp text in a later step
        }
        .contentShape(Rectangle())
        .onTapGesture(count: 2) { state.reset(descriptor) }   // double-tap → reset to default
    }

    @ViewBuilder private var valueLabel: some View {
        if let t = editingText {
            TextField("", text: Binding(get: { t }, set: { editingText = $0 }))
                .frame(width: 64).textFieldStyle(.roundedBorder)
                .onSubmit { commitText() }
        } else {
            Text(formatted).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
                .onTapGesture { editingText = formatted }
        }
    }

    private var formatted: String {
        descriptor.isInt ? String(Int(value.rounded())) : String(format: "%.3g", value)
    }
    private func commitDrag(_ v: Double) {
        var p = state.edited; descriptor.set(&p, clamp(v)); state.edited = p
    }
    private func commitText() {
        if let t = editingText, let v = Double(t) { commitDrag(v) }
        editingText = nil
    }
    private func clamp(_ v: Double) -> Double { min(max(v, descriptor.range.lowerBound), descriptor.range.upperBound) }
}
Note: Shift/Alt fine/coarse drag is a macOS refinement — wire it after the editor works end-to-end (it needs an NSEvent modifier read in the drag; defer to a follow-up step within this task only if time permits, otherwise the base slider + tap-to-type + double-tap-reset satisfies the core). Lock icon is added in plan 3b-3.

  • [ ] Step 2: Build (still needs EditorView/RootView — check ParamSlider compiles)

Run: cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "ParamSlider.*error" | head Expected: no ParamSlider-specific errors (RootView errors still expected).


Task 5: Section views + EditorView + RootView wiring

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

  • [ ] Step 1: Sections.swift — one view per section. Scalar sections are pure registry-driven; Global/Species/Matrix add bespoke controls.

import SwiftUI
import PhysarumEngine

/// Pure registry-driven slider list for a section.
struct ScalarSection: View {
    @Bindable var state: AppState
    let section: EditorSection
    var body: some View {
        ForEach(ParamRegistry.descriptors(in: section)) { d in
            ParamSlider(state: state, descriptor: d)
        }
    }
}

struct GlobalSection: View {
    @Bindable var state: AppState
    var body: some View {
        // agentCount / exposure / simSpeed sliders come from the registry:
        ForEach(ParamRegistry.descriptors(in: .global)) { d in ParamSlider(state: state, descriptor: d) }
        // non-scalars:
        Picker("Spawn", selection: Binding(get: { state.edited.spawnPattern },
                                           set: { state.edited.spawnPattern = $0 })) {
            ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
        }
        Picker("Blend", selection: Binding(get: { state.edited.blendMode },
                                           set: { state.edited.blendMode = $0 })) {
            ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
        }
        ColorPicker("Background", selection: rgbBinding(\.backgroundColor))
    }
    private func rgbBinding(_ kp: WritableKeyPath<PresetParams, RGB>) -> Binding<Color> {
        Binding(
            get: { let c = state.edited[keyPath: kp]; return Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) },
            set: { newC in
                let r = resolveRGB(newC); state.edited[keyPath: kp] = RGB(Float(r.0), Float(r.1), Float(r.2))
            })
    }
}

struct SpeciesSection: View {
    @Bindable var state: AppState
    @State private var tab = 0    // 0..2 species, 3 = "All"
    var body: some View {
        Picker("Species", selection: $tab) {
            Text("1").tag(0); Text("2").tag(1); Text("3").tag(2); Text("All").tag(3)
        }.pickerStyle(.segmented)
        let i = min(tab, 2)
        // color for the focused species (or species 0 when "All")
        ColorPicker("Color", selection: speciesColor(i))
        ForEach(ParamRegistry.all.filter { isSpeciesParam($0.id, index: i) }) { d in
            ParamSlider(state: state, descriptor: tab == 3 ? broadcast(d) : d)
        }
    }
    // helpers: speciesColor(i) Binding<Color>; isSpeciesParam(id, index); broadcast(d) returns a
    // descriptor whose set writes the value to ALL three species (for the "All" tab).
}

struct MatrixSection: View {
    @Bindable var state: AppState
    var body: some View {
        Grid {
            ForEach(0..<3, id: \.self) { r in
                GridRow {
                    ForEach(0..<3, id: \.self) { c in
                        if let d = ParamRegistry.all.first(where: { "\($0.id)" == "\(ParamID.matrix(r, c))" }) {
                            ParamSlider(state: state, descriptor: d)
                        }
                    }
                }
            }
        }
    }
}
(Implement the small helpers resolveRGB, speciesColor, isSpeciesParam, broadcast inline — keep them in Sections.swift. broadcast builds a ParamDescriptor copy whose set loops the same field over species 0..2.)

  • [ ] Step 2: EditorView.swift — disclosure sections + toolbar.
import SwiftUI

struct EditorView: View {
    @Bindable var state: AppState
    var body: some View {
        Form {
            Section { HStack {
                Button("Reset") { state.resetAll() }.disabled(!state.isDirty)
                Spacer()
                Toggle("Auto-cycle", isOn: $state.autoCycle)
            } }
            DisclosureGroup("Global") { GlobalSection(state: state) }
            DisclosureGroup("Species") { SpeciesSection(state: state) }
            DisclosureGroup("Interaction Matrix") { MatrixSection(state: state) }
            DisclosureGroup("Pheromone") { ScalarSection(state: state, section: .pheromone) }
            DisclosureGroup("Dynamics") { ScalarSection(state: state, section: .dynamics) }
            DisclosureGroup("Effects") { ScalarSection(state: state, section: .effects) }
        }
        .formStyle(.grouped)
    }
}
  • [ ] Step 3: RootView.swift — collapsible inspector + fullscreen + full-params MetalView.

import SwiftUI

struct RootView: View {
    @State private var state = AppState()
    @State private var showInspector = true
    @State private var fullscreen = false
    private let field = 720
    @State private var cycleTimer: Timer?

    var body: some View {
        playerArea
            .inspector(isPresented: $showInspector) {
                EditorView(state: state).inspectorColumnWidth(min: 260, ideal: 320)
            }
            .toolbar {
                Button { showInspector.toggle() } label: { Image(systemName: "sidebar.right") }
                Button { fullscreen.toggle() } label: { Image(systemName: "arrow.up.left.and.arrow.down.right") }
            }
            .onChange(of: state.autoCycle) { _, on in on ? startCycle() : stopCycle() }
    }

    private var playerArea: some View {
        MetalView(field: field, selectedPreset: state.selectedPreset, editedParams: state.edited)
            .ignoresSafeArea()
            .overlay(alignment: .bottomLeading) { presetPicker }
    }
    private var presetPicker: some View {   // simple inline browser until plan 3b-2 enriches it
        Menu(state.selectedPreset.name) {
            ForEach(Array(state.presets.enumerated()), id: \.element.id) { i, p in
                Button(p.name) { state.select(i) }
            }
        }.padding()
    }
    private func startCycle() { cycleTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { _ in state.advance(by: 1) } }
    private func stopCycle() { cycleTimer?.invalidate(); cycleTimer = nil }
}
Note: this temporarily replaces the NavigationSplitView library sidebar with a compact preset menu; the full adaptive library returns in plan 3b-2. Fullscreen wiring: on macOS bind fullscreen to NSApplication's toggleFullScreen via a hosting helper, or use .fullScreenCover on iOS — minimal version: a .persistentSystemOverlays(.hidden) + hide chrome when fullscreen. Keep it simple: when fullscreen, set showInspector = false and hide the toolbar/preset menu.

  • [ ] Step 4: Generate, build, test

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "error:|SUCCEEDED" | head then swift -version >/dev/null; cd apple/PhysarumEngine && swift test 2>&1 | grep Executed | tail -1 Expected: ** BUILD SUCCEEDED **; engine tests still 20 pass.

  • [ ] Step 5: Commit Tasks 2–5 together
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp
git commit -m "feat(app): full parameter editor — registry-driven sections, edited/baseline state, debounced respawn, collapsible inspector + fullscreen"

Task 6: Launch + visual verification

Files: none (verification)

  • [ ] Step 1: Clean build + launch

Run:

cd apple/PhysarumApp && xcodebuild -project Physarum.xcodeproj -scheme Physarum -destination 'platform=macOS' clean build 2>&1 | grep -E "SUCCEEDED|FAILED"
open "$(cat /tmp/physarum-app-path.txt)"
(Path file from the previous increment; if missing, recompute via -showBuildSettings.)

  • [ ] Step 2: Observe — window with live player; inspector shows all 6 sections; expanding a section shows sliders; dragging a slider changes the render live; double-tap resets a param; tap-on-value lets you type; the inspector toggles closed/open; fullscreen hides chrome; changing Agents triggers a respawn after a beat. Capture a screenshot for the record.

  • [ ] Step 3: Commit any fixes found during observation.


Definition of Done

  • [ ] xcodebuild test (PhysarumAppTests) green: registry roundtrips all 45 scalars; engine swift test still 20.
  • [ ] App builds + launches; all ~52 params editable (45 scalar sliders + spawn/blend pickers + 4 colors); live render updates; respawn params debounce-respawn.
  • [ ] Inspector is collapsible; fullscreen mode hides chrome (the two reported bugs fixed).
  • [ ] Double-tap-reset + tap-to-type work; sections generated from the registry.
  • [ ] Committed (push deferred to Johannes).

Self-Review notes

  • Spec coverage: §3 registry (Task 1), §4 state + respawn (Tasks 2–3), §5 editor UI + slider + fixes (Tasks 4–5), §9 tests (Task 1 + DoD). Preset-mgmt (§6) + exploration (§7) are plans 3b-2/3b-3 — intentionally out. Lock icon stub noted in Task 4 (activated in 3b-3).
  • Non-scalars: spawnPattern/blendMode (Pickers) + backgroundColor + 3 species colors (ColorPickers) are handled in Global/Species sections, not the scalar registry — covered, with ParamID identity reserved for later locks/morph.
  • Carried risk: macOS Shift/Alt fine/coarse drag is deferred within Task 4 (base slider + tap-to-type + double-tap-reset is the must-have). paramHelp tooltips wired via .help placeholder — port the real text in 3b-2 polish.
  • Type consistency: ParamDescriptor.get/set, AppState.edited/baseline/reset/resetAll, RenderCoordinator.setParams/params, MetalView.editedParams used consistently.
  • Respawn correctness: detection compares agentCount/spawnPattern/populationShare only; matches isRespawn descriptors. Debounce on main thread (same as draw loop).