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 inproject.ymlif-only-testingis rejected. - New source/test files: they live under
apple/PhysarumApp/Sources/.../Tests/.... After adding a file, runcd apple/PhysarumApp && xcodegen generatebefore building (sources are XcodeGen-globbed;.xcodeprojis regenerated, gitignored). - Never
--no-verify. Commits use Conventional Commits, no emoji. TrailerCo-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,ParamRegistryranges, or anyModTarget/AudioBand/RouteModecase 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/WorkspaceLayoutundefined). -
[ ] 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/.sheetcontract).RootViewstill references the old names — that's fixed in Task 2; the target will not build until then, so run ONLYWorkspaceLayoutTestsis 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 returnWorkspaceLayout:
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 outerGeometryReader { screen in }; switch onlayout(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+topbarsignatures —EditorPresentationtype no longer exists. ChangeplayerLayer(mode: EditorPresentation)→playerLayer(layout: WorkspaceLayout)andtopbar(mode:)→topbar(layout:); the topbar's editor-toggle glyph picks onlayout == .splitColumns ? "sidebar.right" : "slider.horizontal.3", and its action becomesshowInspector.toggle()in split mode /showEditor.toggle()in sheet mode (handled in Task 4 — for now keepshowEditor.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.swiftto find where card selection callsstate.select+isPresented.wrappedValue = false. Introduce adismissOnSelect: Boolparameter (defaulttrue). In sidebar mode passdismissOnSelect: falseso selecting a preset updatesstate.selectedIDbut keeps the sidebar. Selection already bridges throughstate.select(id:)(AppState.swift:54) — keep that single source; do NOT add aNavigationSplitViewselection binding. -
[ ] Step 2: Remove the iOS-17
pendingLibrarydefer — in.splitColumnsthe library is a column, not a sheet, soopenLibrary(mode:)(RootView.swift:231-238) +pendingLibrarystate + the.onChange(of: showEditor)reopen (RootView.swift:90-95) are dead on regular. KeepopenLibraryONLY for.overlaySheets(compact still opens the library sheet from the topbar). DeletependingLibrary@Stateand the reopenonChangeif 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 togglecolumnVisibilitybetween.alland.detailOnly. When BOTHcolumnVisibility == .detailOnlyandshowInspector == false, the Detail (living image) is effectively full-bleed. -
[ ] Step 2: Keep
fullscreenas the deeper mode — the existing fullscreen button (RootView.swift:215) still setsfullscreen = true(hides topbar + iOS status bar/home indicator, RootView.swift:111-117). Column-collapse (Step 1) andfullscreencompose; 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'sinspectorColumnWidth/navigationSplitViewColumnWidthmay 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 fixedideal: 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,resetSpawnPatternundefined). -
[ ] Step 3: Implement in
AppState.swift(nearreset/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.ParamSlidergetsvar displayLabel: String? = nil(used by the matrix for friendly labels; tooltip stays fromdescriptor). In the headerHStack(ParamSlider.swift:15-20), insert a reset button betweenLockButtonandvalueLabel, 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
HelpLabelrow inGlobalSection(Spawn :54-64, Blend :65-75, Background :76-85) andSpeciesSectionColor (:100-112). Pattern per row — a trailing ↩ shown only when the corresponding*Modifiedis true, calling the matchingreset*():
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, nearspeciesIndex):
/// 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
SpeciesSectionto use@State private var selected: Int? = nil(default All), replacing thetab/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 readsAll 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 theGrid/GridRow; emit three groups, each a sub-header + three full-widthParamSliders with friendlydisplayLabel. 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 labelTextand widen its framewidth: 32 → 40. Source/Target menu pickers (routeRow:82-111): add.lineLimit(1)to eachPicker. This alone kills "Bass/High" char-wrap and "Sensor/Deposit" wrap. -
[ ] Step 2: Vertical reflow of
routeRow(:77-152) viaViewThatFits(NOT a nestedGeometryReader— 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 allidx < routes.countguards inside everyBinding. Keep the segmentedLvl/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— mirrorPresetStore(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 generatefirst (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 anAudioPresetStoreand add CRUD. Updateinitto acceptaudioStore: 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— aboveroutesEditor, a saved-config row: aMenu/Pickerlistingstate.audioPresets(apply on select) + a "Save current as…" button opening a name dialog (mirrorLibrary/SaveDialog.swift; a minimalTextFieldalert 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-onlyExportedCapture):
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— cardcontextMenugains "Export…" (presents.fileExporter(isPresented:document:contentType:.json:defaultFilename:)withPresetDocument(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 inurl.startAccessingSecurityScopedResource()/defer { url.stopAccessingSecurityScopedResource() }, decodePreset, callstate.importPreset(_:). On decode failure show a brief inline error (no crash). Both.fileExporterand.fileImporterare 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 asPresetDocument, encodingAudioConfig). Add an export/import affordance to the audio-preset menu (Task 12 UI): export the selected config, import via.fileImporter→state.audioPresetsgains it (addAppState.importAudioConfig(_:)that re-ids and saves viaaudioStore). 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: keepcaptureFullscreenButtonat.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
exitFullscreenControla compact glyph matching the enter button (iconButton-style glass), not the wide "Exit fullscreen" pill — reuse thearrow.down.right.and.arrow.up.leftglyph in a.glassPanel(); keep the same action closure (cancelfsHintWork,fsHintShown=false,fullscreen=false). Do NOT gate behind#if !os(macOS)(safeAreaInsets.top ≈ 0on 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.