Zum Inhalt

iPad-Workspace-Re-Architecture + UX-Politur — 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 single full-bleed screen into a hybrid three-column iPad/Mac workspace and ship the editor/persistence/fullscreen UX polish Johannes flagged on device.

Architecture: Regular width (iPad-landscape / Mac) uses a native NavigationSplitView (Sidebar=Library, Detail=living image, Inspector=Editor), collapsible to full-bleed; compact (iPhone / portrait / split-view) keeps today's full-bleed + sheet model. Editor polish, audio-presets, preset JSON import/export and the fullscreen-exit relocation layer on top. Engine is untouched — every change is App-layer presentation/persistence; species stay hard-3 (= R/G/B pheromone channels), the 304-byte param layout and WGSL shaders are unchanged.

Tech Stack: Swift / SwiftUI multiplatform (apple/PhysarumApp), XcodeGen (project.yml SSOT, .xcodeproj gitignored), XCTest on the macOS host. Spec: docs/superpowers/specs/2026-06-13-ipad-workspace-ux-design.md.

Conventions for every task

  • App test gate: from apple/PhysarumApp: xcodebuild test -scheme Physarum -destination 'platform=macOS' (whole suite). For a single class while iterating: append -only-testing:PhysarumTests/<ClassName>. Confirm the exact test-target name in project.yml if -only-testing is rejected.
  • New source/test files: they live under apple/PhysarumApp/Sources/... / Tests/.... After adding a file, run cd apple/PhysarumApp && xcodegen generate before building (sources are XcodeGen-globbed; .xcodeproj is regenerated, gitignored).
  • Never --no-verify. Commits use Conventional Commits, no emoji. Trailer Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>.
  • UI-layout acceptance (where no unit test applies): build the macOS app + launch + screenshot, and build the iPad Sim + screenshot landscape. Portrait/rotation = Johannes' device sign-off (Sim doesn't rotate headless). State this explicitly when closing a wave.
  • Do NOT touch PhysarumEngine, WGSL, ParamRegistry ranges, or any ModTarget/AudioBand/RouteMode case order.

WAVE 1 — Workspace Re-Architecture (foundation)

Replaces the container model in RootView.body, not the leaf views. EditorView, LibraryView's grid and MetalView survive as column contents.

Task 1: workspaceLayout pure function (replaces editorPresentation)

Files: - Modify: apple/PhysarumApp/Sources/App/EditorPresentation.swift - Create: apple/PhysarumApp/Tests/WorkspaceLayoutTests.swift - Delete (end of task): apple/PhysarumApp/Tests/EditorPresentationTests.swift

  • [ ] Step 1: Write the failing test — WorkspaceLayoutTests.swift:
import XCTest
import CoreGraphics
@testable import Physarum

final class WorkspaceLayoutTests: XCTestCase {
    func testMacAlwaysSplitColumns() {
        XCTAssertEqual(workspaceLayout(isMac: true, isCompactWidth: false, size: CGSize(width: 800, height: 1200)), .splitColumns)
    }
    func testCompactAlwaysOverlaySheets() {
        XCTAssertEqual(workspaceLayout(isMac: false, isCompactWidth: true, size: CGSize(width: 390, height: 844)), .overlaySheets)
    }
    func testRegularLandscapeIsSplitColumns() {
        XCTAssertEqual(workspaceLayout(isMac: false, isCompactWidth: false, size: CGSize(width: 1366, height: 1024)), .splitColumns)
    }
    func testRegularPortraitIsOverlaySheets() {
        XCTAssertEqual(workspaceLayout(isMac: false, isCompactWidth: false, size: CGSize(width: 1024, height: 1366)), .overlaySheets)
    }
    func testRegularSquareIsOverlaySheets() {
        XCTAssertEqual(workspaceLayout(isMac: false, isCompactWidth: false, size: CGSize(width: 1024, height: 1024)), .overlaySheets)
    }
}
  • [ ] Step 2: Run test to verify it fails — App test gate -only-testing:PhysarumTests/WorkspaceLayoutTests. Expected: FAIL (workspaceLayout/WorkspaceLayout undefined).

  • [ ] Step 3: Rewrite EditorPresentation.swift (keep file name; new contents):

import CoreGraphics

/// How the workspace is laid out relative to the always-on player.
/// - `.splitColumns`: three-column NavigationSplitView (regular width — iPad-landscape / Mac).
/// - `.overlaySheets`: full-bleed player with editor/library as sheets (compact / iPad-portrait).
enum WorkspaceLayout { case splitColumns, overlaySheets }

/// Maps platform + size class + geometry to a layout mode.
/// - macOS → always `.splitColumns`.
/// - compact width (iPhone, iPad split-view/slide-over) → always `.overlaySheets`.
/// - regular width (iPad full-screen) → `.splitColumns` in landscape (w > h),
///   `.overlaySheets` in portrait/square (ambiance-first, the "living picture").
func workspaceLayout(isMac: Bool, isCompactWidth: Bool, size: CGSize) -> WorkspaceLayout {
    if isMac { return .splitColumns }
    if isCompactWidth { return .overlaySheets }
    return size.width > size.height ? .splitColumns : .overlaySheets
}
  • [ ] Step 4: Delete the obsolete test — remove apple/PhysarumApp/Tests/EditorPresentationTests.swift (it locks the old .inspector/.sheet contract). RootView still references the old names — that's fixed in Task 2; the target will not build until then, so run ONLY WorkspaceLayoutTests is not possible yet. Defer the green run to Task 2's gate. Commit this task together with Task 2 if the build is red in isolation.

  • [ ] Step 5: Commit (may be folded into Task 2's commit if the target is red standalone):

cd apple/PhysarumApp && xcodegen generate
git add apple/PhysarumApp/Sources/App/EditorPresentation.swift apple/PhysarumApp/Tests/WorkspaceLayoutTests.swift
git rm apple/PhysarumApp/Tests/EditorPresentationTests.swift
git commit -m "refactor(app): editorPresentation → workspaceLayout (splitColumns/overlaySheets)"

Task 2: NavigationSplitView scaffold for .splitColumns

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

Introduce split-view state and branch body on workspaceLayout. The inner playerLayer GeometryReader STAYS (Detail must size the engine field + keep the topbar on-screen). Keep .overlaySheets identical to today.

  • [ ] Step 1: Add state near the other @State (RootView.swift:8-24):
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var showInspector = true   // inspector column shown (regular only)
@AppStorage("inspectorWidth") private var inspectorWidth: Double = 380
  • [ ] Step 2: Replace presentation(for:) (RootView.swift:35-41) to return WorkspaceLayout:
private func layout(for size: CGSize) -> WorkspaceLayout {
    #if os(macOS)
    return workspaceLayout(isMac: true, isCompactWidth: false, size: size)
    #else
    return workspaceLayout(isMac: false, isCompactWidth: hSizeClass == .compact, size: size)
    #endif
}
  • [ ] Step 3: Branch body's GeometryReader (RootView.swift:65-103). Keep the outer GeometryReader { screen in }; switch on layout(for: screen.size):
GeometryReader { screen in
    switch layout(for: screen.size) {
    case .splitColumns:
        NavigationSplitView(columnVisibility: $columnVisibility) {
            LibraryView(state: state, isPresented: .constant(true))   // sidebar content; selecting does NOT dismiss
                .navigationSplitViewColumnWidth(min: 260, ideal: 300)
        } detail: {
            playerLayer(layout: .splitColumns)
                .inspector(isPresented: $showInspector) {
                    EditorView(state: state)
                        .inspectorColumnWidth(min: 320, ideal: inspectorWidth, max: 460)
                }
        }
        .navigationSplitViewStyle(.balanced)
    case .overlaySheets:
        playerLayer(layout: .overlaySheets)
            .sheet(isPresented: Binding(get: { showEditor && !fullscreen }, set: { showEditor = $0 })) {
                EditorView(state: state)
                    .presentationDetents(editorSheetDetents)
                    .presentationBackgroundInteraction(.enabled)
                    .presentationContentInteraction(.scrolls)
            }
            .sheet(isPresented: $showLibrary) { LibraryView(state: state, isPresented: $showLibrary) }
    }
    // shared modifiers (captureFS sheet, onAppear init) attach to both branches —
    // factor into a `.modifier`/extracted ViewBuilder to avoid duplication.
}

Notes: playerLayer gains a layout: param (was mode: EditorPresentation); update its signature + the topbar(mode:) reference (Task uses layout). LibraryView's isPresented binding in the sidebar is .constant(true) (it never dismisses itself as a column) — verify LibraryView tolerates that (it calls isPresented.wrappedValue = false on select today; in sidebar mode selection must NOT dismiss — see Task 3).

  • [ ] Step 4: Update playerLayer + topbar signatures — EditorPresentation type no longer exists. Change playerLayer(mode: EditorPresentation) → playerLayer(layout: WorkspaceLayout) and topbar(mode:) → topbar(layout:); the topbar's editor-toggle glyph picks on layout == .splitColumns ? "sidebar.right" : "slider.horizontal.3", and its action becomes showInspector.toggle() in split mode / showEditor.toggle() in sheet mode (handled in Task 4 — for now keep showEditor.toggle() so it builds).

  • [ ] Step 5: Build + verify — cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build, then run the App test gate (whole suite incl. WorkspaceLayoutTests). Expected: BUILD + tests PASS. Launch the macOS app, screenshot: three columns (Library | image | Editor), image renders.

  • [ ] Step 6: Commit

git add apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(app): NavigationSplitView three-column layout on regular width"

Task 3: Sidebar selection bridge + remove dead sheet workaround

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

  • [ ] Step 1: Read LibraryView.swift to find where card selection calls state.select + isPresented.wrappedValue = false. Introduce a dismissOnSelect: Bool parameter (default true). In sidebar mode pass dismissOnSelect: false so selecting a preset updates state.selectedID but keeps the sidebar. Selection already bridges through state.select(id:) (AppState.swift:54) — keep that single source; do NOT add a NavigationSplitView selection binding.

  • [ ] Step 2: Remove the iOS-17 pendingLibrary defer — in .splitColumns the library is a column, not a sheet, so openLibrary(mode:) (RootView.swift:231-238) + pendingLibrary state + the .onChange(of: showEditor) reopen (RootView.swift:90-95) are dead on regular. Keep openLibrary ONLY for .overlaySheets (compact still opens the library sheet from the topbar). Delete pendingLibrary @State and the reopen onChange if no longer referenced after the split-mode topbar no longer calls the sheet path.

  • [ ] Step 3: Build + App test gate. Expected: PASS. Launch macOS app, screenshot: clicking a sidebar preset swaps the living image without closing the sidebar.

  • [ ] Step 4: Commit

git add apple/PhysarumApp/Sources/Library/LibraryView.swift apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(app): sidebar preset selection bridges to AppState; drop iOS-17 sheet workaround on regular"

Task 4: Collapsible bars → full-bleed + inspector width persistence

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

  • [ ] Step 1: Wire the topbar toggles to column visibility (split mode). The editor-toggle iconButton toggles showInspector; add/repurpose the library affordance to toggle columnVisibility between .all and .detailOnly. When BOTH columnVisibility == .detailOnly and showInspector == false, the Detail (living image) is effectively full-bleed.

  • [ ] Step 2: Keep fullscreen as the deeper mode — the existing fullscreen button (RootView.swift:215) still sets fullscreen = true (hides topbar + iOS status bar/home indicator, RootView.swift:111-117). Column-collapse (Step 1) and fullscreen compose; do not merge them.

  • [ ] Step 3: Persist inspector width — .inspectorColumnWidth(min: 320, ideal: inspectorWidth, max: 460) already reads @AppStorage inspectorWidth (Task 2). Risk (spec-flagged): SwiftUI's inspectorColumnWidth/navigationSplitViewColumnWidth may not expose the user-dragged width to write back to @AppStorage. Spend ≤30 min: if a drag callback / width binding is reachable, persist it; if not, accept the wider fixed ideal: 380 (no persisted drag) and note it in the commit body. Do NOT block the wave on drag-capture.

  • [ ] Step 4: Build + verify — App test gate PASS. macOS screenshot: collapse both bars → image goes edge-to-edge; expand → three columns return. iPad-Sim landscape screenshot.

  • [ ] Step 5: Commit

git add apple/PhysarumApp/Sources/App/RootView.swift apple/PhysarumApp/Sources/App/PhysarumApp.swift
git commit -m "feat(app): collapsible columns to full-bleed; wider persisted inspector width"

(Also raise PhysarumApp.swift:13 minWidth if three columns + Detail don't fit — verify at build.)

Wave 1 close: macOS app three-column + collapse verified by screenshot; iPad-Sim landscape screenshot attached. Portrait/rotation + Slide-Over = Johannes' device sign-off.


WAVE 2 — Editor Polish

Task 5: AppState.isModified + non-scalar reset methods (TDD)

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

  • [ ] Step 1: Write failing tests — AppStateResetTests.swift:
import XCTest
import PhysarumEngine
@testable import Physarum

final class AppStateResetTests: XCTestCase {
    private func freshState() -> AppState {
        let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("phys-reset-\(UUID().uuidString)", isDirectory: true)
        return AppState(store: PresetStore(baseDir: tmp))
    }

    func testIsModifiedFalseAtBaseline() {
        let s = freshState()
        let d = ParamRegistry.all.first { $0.id == .exposure }!
        XCTAssertFalse(s.isModified(d))
    }
    func testIsModifiedTrueAfterEdit() {
        let s = freshState()
        let d = ParamRegistry.all.first { $0.id == .exposure }!
        d.set(&s.edited, d.get(s.baseline) + 1.0)
        XCTAssertTrue(s.isModified(d))
    }
    func testResetSpeciesColorRestoresBaseline() {
        let s = freshState()
        let original = s.baseline.species[0].color
        s.edited.species[0].color = RGB(0.123, 0.456, 0.789)
        s.resetSpeciesColor(0)
        XCTAssertEqual(s.edited.species[0].color, original)
    }
    func testResetSpawnPatternRestoresBaseline() {
        let s = freshState()
        let original = s.baseline.spawnPattern
        // pick any different case
        s.edited.spawnPattern = SpawnPattern.allCases.first { $0 != original } ?? original
        s.resetSpawnPattern()
        XCTAssertEqual(s.edited.spawnPattern, original)
    }
}
  • [ ] Step 2: Run → FAIL (isModified, resetSpeciesColor, resetSpawnPattern undefined).

  • [ ] Step 3: Implement in AppState.swift (near reset/resetAll, lines 70-71):

/// True if `descriptor` differs from the selected preset's baseline (per-param dirty).
func isModified(_ d: ParamDescriptor) -> Bool { abs(d.get(edited) - d.get(baseline)) > 1e-6 }

func resetSpawnPattern()   { pushUndo(); edited.spawnPattern = baseline.spawnPattern }
func resetBlendMode()      { pushUndo(); edited.blendMode = baseline.blendMode }
func resetBackgroundColor(){ pushUndo(); edited.backgroundColor = baseline.backgroundColor }
func resetSpeciesColor(_ i: Int) { pushUndo(); edited.species[i].color = baseline.species[i].color }

/// Non-scalar dirty checks (mirror `isModified` for enum/colour controls).
var spawnModified: Bool { edited.spawnPattern != baseline.spawnPattern }
var blendModified: Bool { edited.blendMode != baseline.blendMode }
var backgroundModified: Bool { edited.backgroundColor != baseline.backgroundColor }
func speciesColorModified(_ i: Int) -> Bool { edited.species[i].color != baseline.species[i].color }
  • [ ] Step 4: Run → PASS. App test gate -only-testing:PhysarumTests/AppStateResetTests.

  • [ ] Step 5: Commit

git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Tests/AppStateResetTests.swift
git commit -m "feat(app): per-param isModified + non-scalar reset-to-baseline"

Task 6: Visible per-row reset on ParamSlider + displayLabel

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

  • [ ] Step 1: Add displayLabel + reset button. ParamSlider gets var displayLabel: String? = nil (used by the matrix for friendly labels; tooltip stays from descriptor). In the header HStack (ParamSlider.swift:15-20), insert a reset button between LockButton and valueLabel, visible only when modified:
HStack {
    labelView
    Spacer()
    if state.isModified(descriptor) {
        Button { state.pushUndo(); state.reset(descriptor) } label: {
            Image(systemName: "arrow.uturn.backward").font(.system(size: 11))
                .foregroundStyle(Theme.Colors.fgTertiary)
        }
        .buttonStyle(.plain)
        .accessibilityLabel("Reset \(descriptor.label) to preset")
    }
    LockButton(state: state, id: descriptor.id)
    valueLabel
}

And labelView uses the override:

private var labelView: some View { HelpLabel(displayLabel ?? descriptor.label, help: descriptor.tooltip) }

Keep the double-tap reset (ParamSlider.swift:33) as a power-user shortcut.

  • [ ] Step 2: Build + App test gate PASS (no logic test; existing tests must stay green). Launch macOS app, edit a slider → ↩ appears; tap it → value returns to preset, ↩ disappears.

  • [ ] Step 3: Commit

git add apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(app): visible per-row reset button on ParamSlider (shown when modified)"

Task 7: Non-scalar reset chrome (Spawn/Blend/Background/Species color)

Files: - Modify: apple/PhysarumApp/Sources/Editor/Sections.swift

  • [ ] Step 1: Add a reset affordance to each non-scalar HelpLabel row in GlobalSection (Spawn :54-64, Blend :65-75, Background :76-85) and SpeciesSection Color (:100-112). Pattern per row — a trailing ↩ shown only when the corresponding *Modified is true, calling the matching reset*():
HelpLabel("Spawn", help: ParamHelp.text(for: "Spawn pattern"))
if state.spawnModified {
    Button { state.resetSpawnPattern() } label: {
        Image(systemName: "arrow.uturn.backward").font(.system(size: 11)).foregroundStyle(Theme.Colors.fgTertiary)
    }.buttonStyle(.plain).accessibilityLabel("Reset spawn pattern to preset")
}

Place the button in the label HStack/VStack consistently with ParamSlider (trailing, before the control). For species Color, gate on state.speciesColorModified(focus) and call state.resetSpeciesColor(focus) (in broadcast/All mode reset all three — see Task 8 for the All-mode wiring; here just resolve focus).

  • [ ] Step 2: Build + App test gate PASS. Screenshot: change spawn pattern → ↩ appears; reset works.

  • [ ] Step 3: Commit

git add apple/PhysarumApp/Sources/Editor/Sections.swift
git commit -m "feat(app): reset-to-preset affordance for non-scalar editor controls"

Task 8: Species selector — explicit mapping, All-first + default, R/G/B labels (TDD on the pure helper)

Files: - Modify: apple/PhysarumApp/Sources/Editor/Sections.swift - Create: apple/PhysarumApp/Tests/SpeciesSelectorTests.swift

  • [ ] Step 1: Write failing tests for a pure mapping helper — SpeciesSelectorTests.swift:
import XCTest
@testable import Physarum

final class SpeciesSelectorTests: XCTestCase {
    func testNilIsAllAndBroadcasts() {
        XCTAssertTrue(speciesIsBroadcast(nil))
        XCTAssertEqual(speciesFocus(nil), 0)   // All displays species 0
    }
    func testConcreteSelectionFocusesAndDoesNotBroadcast() {
        XCTAssertFalse(speciesIsBroadcast(1))
        XCTAssertEqual(speciesFocus(2), 2)
    }
    func testChannelLabel() {
        XCTAssertEqual(speciesChannelLabel(0), "R")
        XCTAssertEqual(speciesChannelLabel(1), "G")
        XCTAssertEqual(speciesChannelLabel(2), "B")
    }
}
  • [ ] Step 2: Run → FAIL.

  • [ ] Step 3: Add pure helpers (top of Sections.swift, near speciesIndex):

/// Species selector mapping. `nil` == "All" (broadcast). Concrete 0..2 focus one species.
func speciesFocus(_ selected: Int?) -> Int { selected ?? 0 }
func speciesIsBroadcast(_ selected: Int?) -> Bool { selected == nil }
/// Pheromone-channel identity label for a species index (deposit shader maps 0→R,1→G,2→B).
func speciesChannelLabel(_ i: Int) -> String { ["R", "G", "B"][i] }
  • [ ] Step 4: Rewrite SpeciesSection to use @State private var selected: Int? = nil (default All), replacing the tab/min(tab,2) arithmetic. Picker order All first:
Picker("Species", selection: $selected) {
    Text("All").tag(Int?.none)
    speciesTab(0); speciesTab(1); speciesTab(2)
}.pickerStyle(.segmented)
// where:
private func speciesTab(_ i: Int) -> some View {
    Label {
        Text(speciesChannelLabel(i))
    } icon: {
        Circle().fill(Color(rgb: state.edited.species[i].color)).frame(width: 9, height: 9)
    }
    .labelStyle(.titleAndIcon)
    .tag(Int?.some(i))
}

Replace every old tab/focus site: let focus = speciesFocus(selected); color set uses if speciesIsBroadcast(selected) { for i in 0..<3 { state.edited.species[i].color = newC } } else { state.edited.species[focus].color = newC }; slider list uses ParamSlider(state: state, descriptor: speciesIsBroadcast(selected) ? broadcast(d) : d). Add a small Color(rgb:) initializer helper if absent (from RGB/SIMD3<Float>): extension Color { init(rgb c: RGB) { self = Color(red: Double(c.x), green: Double(c.y), blue: Double(c.z)) } }.

  • [ ] Step 5: Run → PASS (SpeciesSelectorTests) + whole App gate. Screenshot: selector reads All R● G● B●, All preselected, swatches tinted to live species colours.

  • [ ] Step 6: Commit

git add apple/PhysarumApp/Sources/Editor/Sections.swift apple/PhysarumApp/Tests/SpeciesSelectorTests.swift
git commit -m "feat(app): species selector All-first + default, R/G/B channel labels with live swatch"

Task 9: Interaction matrix — full-width grouped by species

Files: - Modify: apple/PhysarumApp/Sources/Editor/Sections.swift

  • [ ] Step 1: Rewrite MatrixSection (Sections.swift:136-151): drop the Grid/GridRow; emit three groups, each a sub-header + three full-width ParamSliders with friendly displayLabel. Keep the exact .matrix(r,c) ↔ interactionMatrix[r][c] lookup:
struct MatrixSection: View {
    @Bindable var state: AppState
    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s3) {
            ForEach(0..<3, id: \.self) { r in
                VStack(alignment: .leading, spacing: Theme.Space.s1) {
                    HelpLabel("\(speciesChannelLabel(r)) senses…", help: ParamHelp.matrix)
                    ForEach(0..<3, id: \.self) { c in
                        if let d = ParamRegistry.all.first(where: { $0.id == .matrix(r, c) }) {
                            ParamSlider(state: state, descriptor: d,
                                        displayLabel: "→ \(speciesChannelLabel(c))")
                        }
                    }
                }
            }
        }
    }
}

displayLabel shows → G (channel) while the group header gives the row species; descriptor.tooltip still resolves via the canonical M[r,c] label, so ParamHelp is untouched. (Confirm HelpLabel accepts a plain help string — it does, ParamHelp.matrix is a String.)

  • [ ] Step 2: Build + App test gate PASS. Screenshot: matrix is three labelled groups of full-width sliders, each with the per-row ↩ reset (from Task 6) when modified.

  • [ ] Step 3: Commit

git add apple/PhysarumApp/Sources/Editor/Sections.swift
git commit -m "feat(app): interaction matrix as full-width groups per species (R/G/B)"

Task 10: Audio section reflow (no model change)

Files: - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift

  • [ ] Step 1: Wrap-guards. meterBar (AudioReactiveSection.swift:175-188): add .lineLimit(1).fixedSize(horizontal: true, vertical: false) to the label Text and widen its frame width: 32 → 40. Source/Target menu pickers (routeRow :82-111): add .lineLimit(1) to each Picker. This alone kills "Bass/High" char-wrap and "Sensor/Deposit" wrap.

  • [ ] Step 2: Vertical reflow of routeRow (:77-152) via ViewThatFits (NOT a nested GeometryReader — the meter already has one). Wide → today's two-line layout; narrow → stack source→target row, then mode row, then full-width depth slider. Preserve all idx < routes.count guards inside every Binding. Keep the segmented Lvl/Trans; drop its hard .frame(width: 116) in the narrow branch (let it size naturally) or switch to .pickerStyle(.menu) when narrow.

ViewThatFits(in: .horizontal) {
    wideRouteRow(idx)     // existing two-HStack layout, .frame(width:116) kept
    narrowRouteRow(idx)   // source→target row, mode row, depth slider full-width
}

Factor the existing body into wideRouteRow(_:); write narrowRouteRow(_:) reusing the same Bindings (extract the pickers/sliders into small private helpers to stay DRY and keep the guards in one place).

  • [ ] Step 3: Build + App test gate PASS. Screenshot at the inspector min width AND in the compact sheet: no character-wrap, no overlap; routes read cleanly.

  • [ ] Step 4: Commit

git add apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift
git commit -m "fix(app): audio-section reflow — line-limit guards + vertical route layout when narrow"

Wave 2 close: macOS screenshots of editor (matrix groups, species R/G/B, reset buttons, audio routes) attached; iPad-Sim landscape screenshot. Touch interaction (tap-to-type, reset taps) on the matrix/species = Johannes' device check.


WAVE 3 — Persistence / Sharing

Task 11: AudioConfig + AudioPresetStore (TDD)

Files: - Create: apple/PhysarumApp/Sources/Model/AudioConfig.swift - Create: apple/PhysarumApp/Sources/Model/AudioPresetStore.swift - Create: apple/PhysarumApp/Tests/AudioPresetStoreTests.swift

  • [ ] Step 1: Write failing tests — AudioPresetStoreTests.swift:
import XCTest
import PhysarumEngine
@testable import Physarum

final class AudioPresetStoreTests: XCTestCase {
    private func tempStore() -> AudioPresetStore {
        AudioPresetStore(baseDir: URL(fileURLWithPath: NSTemporaryDirectory())
            .appendingPathComponent("phys-audiopreset-\(UUID().uuidString)", isDirectory: true))
    }
    private func sampleReactive() -> AudioReactive {
        AudioReactive(enabled: true, gain: 1.5, autoGain: true,
                      routes: [AudioRoute(source: .bass, target: .moveSpeed, depth: 1, mode: .transient)])
    }

    func testSaveLoadRoundTrip() {
        let store = tempStore()
        let cfg = AudioConfig(id: "a1", name: "Club", reactive: sampleReactive())
        try? store.save(cfg)
        let loaded = store.loadAll()
        XCTAssertEqual(loaded.count, 1)
        XCTAssertEqual(loaded.first?.name, "Club")
        XCTAssertEqual(loaded.first?.reactive.gain, 1.5)
        XCTAssertEqual(loaded.first?.reactive.routes.count, 1)
    }
    func testDeleteAndOrder() {
        let store = tempStore()
        try? store.save(AudioConfig(id: "a1", name: "One", reactive: sampleReactive()))
        try? store.save(AudioConfig(id: "a2", name: "Two", reactive: sampleReactive()))
        store.delete("a1")
        XCTAssertEqual(store.loadAll().map(\.id), ["a2"])
    }
    func testReorder() {
        let store = tempStore()
        try? store.save(AudioConfig(id: "a1", name: "One", reactive: sampleReactive()))
        try? store.save(AudioConfig(id: "a2", name: "Two", reactive: sampleReactive()))
        store.reorder(["a2", "a1"])
        XCTAssertEqual(store.loadAll().map(\.id), ["a2", "a1"])
    }
}
  • [ ] Step 2: Run → FAIL.

  • [ ] Step 3: Implement AudioConfig.swift:

import PhysarumEngine

/// A saveable, switchable audio-reactivity preset: routes + gain + auto-gain.
/// AudioTuning (loudness-response constants) is intentionally NOT included — it stays
/// a global/DEBUG device-calibration knob, not a shareable config.
struct AudioConfig: Codable, Identifiable, Equatable {
    var id: String
    var name: String
    var reactive: AudioReactive
}
  • [ ] Step 4: Implement AudioPresetStore.swift — mirror PresetStore (baseDir-injectable, _order.json):
import Foundation

/// Persists audio presets as JSON in Application Support (`Physarum/AudioPresets/<id>.json`),
/// ordered by `_order.json`. Mirrors PresetStore.
final class AudioPresetStore {
    private let dir: URL
    private let orderURL: URL
    private let fm = FileManager.default

    init(baseDir: URL? = nil) {
        if let base = baseDir { dir = base }
        else if let appSupport = try? FileManager.default.url(
            for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
            dir = appSupport.appendingPathComponent("Physarum/AudioPresets", isDirectory: true)
        } else {
            dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("PhysarumAudioPresets", isDirectory: true)
        }
        orderURL = dir.appendingPathComponent("_order.json")
        try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
    }

    func save(_ cfg: AudioConfig) throws {
        try JSONEncoder().encode(cfg).write(to: fileURL(cfg.id))
        var order = loadOrder()
        if !order.contains(cfg.id) { order.append(cfg.id); writeOrder(order) }
    }
    func loadAll() -> [AudioConfig] {
        let order = loadOrder(); let dec = JSONDecoder()
        let files = ((try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [])
            .filter { $0.pathExtension == "json" && $0.lastPathComponent != "_order.json" }
        let byId = Dictionary(files.compactMap { try? dec.decode(AudioConfig.self, from: Data(contentsOf: $0)) }.map { ($0.id, $0) },
                              uniquingKeysWith: { a, _ in a })
        var result = order.compactMap { byId[$0] }
        for (id, c) in byId where !order.contains(id) { result.append(c) }
        return result
    }
    func delete(_ id: String) { try? fm.removeItem(at: fileURL(id)); writeOrder(loadOrder().filter { $0 != id }) }
    func reorder(_ ids: [String]) { writeOrder(ids) }

    private func fileURL(_ id: String) -> URL { dir.appendingPathComponent("\(id).json") }
    private func loadOrder() -> [String] {
        guard let d = try? Data(contentsOf: orderURL), let a = try? JSONDecoder().decode([String].self, from: d) else { return [] }
        return a
    }
    private func writeOrder(_ ids: [String]) { try? JSONEncoder().encode(ids).write(to: orderURL) }
}
  • [ ] Step 5: Run → PASS (AudioPresetStoreTests). xcodegen generate first (new files).

  • [ ] Step 6: Commit

git add apple/PhysarumApp/Sources/Model/AudioConfig.swift apple/PhysarumApp/Sources/Model/AudioPresetStore.swift apple/PhysarumApp/Tests/AudioPresetStoreTests.swift
git commit -m "feat(app): AudioConfig + AudioPresetStore (saveable audio presets)"

Task 12: AppState audio-preset CRUD + UI

Files: - Modify: apple/PhysarumApp/Sources/Model/AppState.swift - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift - Create: apple/PhysarumApp/Tests/AudioPresetAppStateTests.swift

  • [ ] Step 1: Write failing test:
import XCTest
import PhysarumEngine
@testable import Physarum

final class AudioPresetAppStateTests: XCTestCase {
    private func state() -> AppState {
        let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("phys-aps-\(UUID().uuidString)", isDirectory: true)
        return AppState(store: PresetStore(baseDir: tmp), audioStore: AudioPresetStore(baseDir: tmp.appendingPathComponent("audio")))
    }
    func testSaveThenApply() {
        let s = state()
        s.audioReactive = AudioReactive(enabled: true, gain: 2, autoGain: false, routes: [])
        s.saveAudioPreset(name: "Loud")
        s.audioReactive = AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
        let id = s.audioPresets.first!.id
        s.applyAudioPreset(id: id)
        XCTAssertEqual(s.audioReactive.gain, 2)
        XCTAssertTrue(s.audioReactive.enabled)
    }
}
  • [ ] Step 2: Run → FAIL.

  • [ ] Step 3: Implement in AppState.swift — inject an AudioPresetStore and add CRUD. Update init to accept audioStore: AudioPresetStore = AudioPresetStore() and store it; add:

private let audioStore: AudioPresetStore
var audioPresets: [AudioConfig]
// in init: self.audioStore = audioStore; audioPresets = audioStore.loadAll()

func saveAudioPreset(name: String) {
    let id = "audio-\(slug(name))-\(Int(Date().timeIntervalSince1970))"
    try? audioStore.save(AudioConfig(id: id, name: name.isEmpty ? "Audio" : name, reactive: audioReactive))
    audioPresets = audioStore.loadAll()
}
func applyAudioPreset(id: String) {
    guard let cfg = audioPresets.first(where: { $0.id == id }) else { return }
    audioReactive = cfg.reactive   // single assignment → one persistence write (didSet)
}
func deleteAudioPreset(id: String) { audioStore.delete(id); audioPresets = audioStore.loadAll() }
func reorderAudioPreset(id: String, up: Bool) {
    audioStore.reorder(reorderedIDs(audioPresets.map { $0.id }, moving: id, up: up))
    audioPresets = audioStore.loadAll()
}

(Add audioStore param to the existing init(store:) signature; default both stores so call sites compile. slug is already a private method.)

  • [ ] Step 4: Run → PASS.

  • [ ] Step 5: Add UI to AudioReactiveSection — above routesEditor, a saved-config row: a Menu/Picker listing state.audioPresets (apply on select) + a "Save current as…" button opening a name dialog (mirror Library/SaveDialog.swift; a minimal TextField alert is acceptable) + per-row delete in the menu. Keep it compact; no thumbnails.

  • [ ] Step 6: Build + App gate PASS. Screenshot: save an audio config, switch it.

  • [ ] Step 7: Commit

git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift apple/PhysarumApp/Tests/AudioPresetAppStateTests.swift
git commit -m "feat(app): audio-preset save/apply/delete with inline picker in AudioReactiveSection"

Task 13: Preset JSON export/import

Files: - Create: apple/PhysarumApp/Sources/Model/PresetDocument.swift - Modify: apple/PhysarumApp/Sources/Model/AppState.swift - Modify: apple/PhysarumApp/Sources/Library/LibraryView.swift - Create: apple/PhysarumApp/Tests/PresetImportTests.swift

  • [ ] Step 1: Write failing tests (codec round-trip + import re-id):
import XCTest
import PhysarumEngine
@testable import Physarum

final class PresetImportTests: XCTestCase {
    func testPresetDocumentRoundTrip() throws {
        let preset = PresetCatalog.fallbackPreset
        let data = try JSONEncoder().encode(preset)
        let decoded = try JSONDecoder().decode(Preset.self, from: data)
        XCTAssertEqual(decoded.id, preset.id)
    }
    func testImportReIdsAndAddsCustom() {
        let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("phys-imp-\(UUID().uuidString)", isDirectory: true)
        let s = AppState(store: PresetStore(baseDir: tmp))
        var incoming = PresetCatalog.fallbackPreset
        incoming.id = "some-other-id"; incoming.builtIn = true
        let before = s.customs.count
        s.importPreset(incoming)
        XCTAssertEqual(s.customs.count, before + 1)
        let added = s.customs.last!
        XCTAssertNotEqual(added.id, "some-other-id")   // re-id'd
        XCTAssertFalse(added.builtIn)                  // forced custom
    }
}
  • [ ] Step 2: Run → FAIL.

  • [ ] Step 3: Implement PresetDocument.swift (read+write, unlike export-only ExportedCapture):

import SwiftUI
import UniformTypeIdentifiers
import PhysarumEngine

struct PresetDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.json] }
    var preset: Preset
    init(preset: Preset) { self.preset = preset }
    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) }
        preset = try JSONDecoder().decode(Preset.self, from: data)
    }
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: try JSONEncoder().encode(preset))
    }
}
  • [ ] Step 4: Implement AppState.importPreset:
/// Add an imported preset as a fresh custom (always re-id'd, forced non-builtin).
func importPreset(_ incoming: Preset) {
    var p = incoming
    p.id = "custom-\(slug(incoming.name))-\(Int(Date().timeIntervalSince1970))"
    p.builtIn = false
    p.author = nil
    p.thumbnail = nil
    try? store.save(p)
    customs = store.loadCustom()
}
  • [ ] Step 5: Run → PASS.

  • [ ] Step 6: Wire UI in LibraryView — card contextMenu gains "Export…" (presents .fileExporter(isPresented:document:contentType:.json:defaultFilename:) with PresetDocument(preset:) for the card's preset, built-ins included); a toolbar/empty-area "Import…" button presents .fileImporter(isPresented:allowedContentTypes:[.json]). On import success, wrap the URL read in url.startAccessingSecurityScopedResource() / defer { url.stopAccessingSecurityScopedResource() }, decode Preset, call state.importPreset(_:). On decode failure show a brief inline error (no crash). Both .fileExporter and .fileImporter are shown on all platforms (do NOT copy Capture's macOS-only gate).

  • [ ] Step 7: Build + App gate PASS. macOS: export a preset to disk, re-import it → appears as a new custom. Commit:

git add apple/PhysarumApp/Sources/Model/PresetDocument.swift apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/Library/LibraryView.swift apple/PhysarumApp/Tests/PresetImportTests.swift
git commit -m "feat(app): preset JSON export/import (FileDocument + fileExporter/fileImporter)"

Task 14: Audio-preset export/import (reuse the machinery)

Files: - Modify: apple/PhysarumApp/Sources/Model/PresetDocument.swift (add AudioConfigDocument) - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift

  • [ ] Step 1: Add AudioConfigDocument (same shape as PresetDocument, encoding AudioConfig). Add an export/import affordance to the audio-preset menu (Task 12 UI): export the selected config, import via .fileImporter → state.audioPresets gains it (add AppState.importAudioConfig(_:) that re-ids and saves via audioStore). Security-scoped URL handling as in Task 13.

  • [ ] Step 2: Build + App gate PASS. Commit:

git add apple/PhysarumApp/Sources/Model/PresetDocument.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift apple/PhysarumApp/Sources/Model/AppState.swift
git commit -m "feat(app): audio-preset JSON export/import"

Wave 3 close: macOS export/import round-trips verified; iOS Files import + security-scoped read = Johannes' device check (no iOS Files headless).


WAVE 4 — Fullscreen Polish

Task 15: Relocate fullscreen exit to top-trailing

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

  • [ ] Step 1: Move the reveal overlay (RootView.swift:153-161) from .overlay(alignment: .bottom) to two overlays: keep captureFullscreenButton at .bottom (thumb-reachable), move the exit control to .topTrailing:
.overlay(alignment: .bottom) {
    if fullscreen && fsHintShown { captureFullscreenButton.padding(.bottom, 44) }
}
.overlay(alignment: .topTrailing) {
    if fullscreen && fsHintShown {
        exitFullscreenControl
            .padding(.top, geo.safeAreaInsets.top + Theme.Space.s3)
            .padding(.trailing, Theme.Space.s3)
    }
}
  • [ ] Step 2: Make exitFullscreenControl a compact glyph matching the enter button (iconButton-style glass), not the wide "Exit fullscreen" pill — reuse the arrow.down.right.and.arrow.up.left glyph in a .glassPanel(); keep the same action closure (cancel fsHintWork, fsHintShown=false, fullscreen=false). Do NOT gate behind #if !os(macOS) (safeAreaInsets.top ≈ 0 on Mac). Keep tap-reveal + 2.5 s auto-hide.

  • [ ] Step 3: Build + App gate PASS. macOS + iPad-Sim: enter fullscreen, tap canvas → exit glyph appears top-right (where enter sat); capture stays bottom.

  • [ ] Step 4: Commit

git add apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(app): fullscreen exit control relocates to top-trailing (where enter sits)"

Wave 4 close: screenshot of fullscreen exit position; tap-reveal behaviour = device check.


Final: docs sync

Task 16: Update AGENTS.md + spec status

Files: - Modify: apple/PhysarumApp/AGENTS.md (gitignored local) — add an "Inc" status line summarizing the hybrid layout, R/G/B labels, audio presets, preset import/export, fullscreen-exit move, per the "Bewusste Abweichungen" in the spec.

  • [ ] Step 1: Append the status block. Step 2: Commit docs(agents): record iPad-workspace UX increment.

Self-Review

Spec coverage: W1 layout → Tasks 1-4; inspector width → Task 4; audio reflow → Task 10; matrix grouping/labels → Task 9; visible reset + non-scalar reset → Tasks 5-7; numeric entry already universal (no task needed — verified in ParamSlider); species All-first + R/G/B → Task 8; audio presets → Tasks 11-12; preset export/import → Task 13; audio-preset export/import → Task 14; fullscreen exit → Task 15. All spec sections mapped.

Placeholder scan: No "TBD/handle edge cases" — every code step shows real code; UI-layout steps name exact files + acceptance (build/screenshot) since no unit test applies. The inspector-drag-persistence risk (Task 4 Step 3) has a concrete fallback, not a placeholder.

Type consistency: WorkspaceLayout (.splitColumns/.overlaySheets) used identically in Tasks 1-2; isModified/reset*/*Modified names match across Tasks 5-7-9; AudioConfig{id,name,reactive} consistent across Tasks 11-12-14; AudioPresetStore API (save/loadAll/delete/reorder) matches its tests; speciesFocus/speciesIsBroadcast/speciesChannelLabel consistent across Tasks 8-9; displayLabel on ParamSlider introduced Task 6, used Task 9. PresetDocument/AudioConfigDocument parallel. No drift found.