Zum Inhalt

iPad-Workspace-Re-Architecture + UX-Politur — Design

Datum: 2026-06-13 Scope: Native App (apple/PhysarumApp). Engine unberührt (alle Änderungen sind App-Layer/Presentation; Völker bleiben hart 3, Param-Layout/Shader unverändert). Web-App unberührt. Status: Approved (Brainstorm 2026-06-13)

Problem

Johannes' iPad-Geräte-Test (iPad Pro 11", Landscape) + ein Code-Map über 7 UI-Bereiche ergaben ein zusammenhängendes Cluster: die App ist heute ein einziger full-bleed Screen mit floatender Glass-Topbar und modalen Overlays (Library = Sheet, Editor = .inspector/.sheet). Auf dem iPad fühlt sich das eng an, und mehrere Editor-Controls sind unter der schmalen Inspector-Breite gestaucht oder umständlich. Konkret aus dem Map (alle file:symbol-Refs verifiziert):

  • Kein NavigationSplitView existiert — grep über apple/ ist leer. RootView.swift ist der Layout-Owner: eine äußere GeometryReader{screen} wählt via editorPresentation(...) zwischen .inspector (Mac / iPad-Landscape) und .sheet (compact / iPad-Portrait); eine innere GeometryReader{geo} (playerLayer) misst die reduzierte Primärfläche (Fix aus 9c37ef9). Library ist ein separates modales .sheet mit iOS-17-pendingLibrary-Workaround (sequential-sheet-swallow).
  • Inspector-Breite ist die einzige Zahl inspectorColumnWidth(min: 300, ideal: 340) (RootView.swift:74) — kein max, nicht ziehbar, nicht persistiert.
  • AudioReactiveSection staucht: die Datei hat nirgends lineLimit/fixedSize/minimumScaleFactor → unter Breitendruck wrappt jedes Text (Meter-Labels „Bass"/„High" zeichenweise vertikal, Menü-Picker „Sensor"/„Deposit" zweizeilig, segmented „Lvl/Trans" überlappt).
  • Interaktions-Matrix ist ein 3×3-Grid aus kompakten ParamSlidern (Sections.swift:136-151) → jeder Slider ist sehr schmal.
  • Numerische Eingabe + Reset existieren bereits, sind aber unsichtbar/lückenhaft: ParamSlider kann Wert-antippen→TextField (ParamSlider.swift:40-53) und Doppeltipp→state.reset(descriptor) = zurück auf baseline = Preset-Wert (ParamSlider.swift:33, AppState.swift:70). Es gibt keinen sichtbaren Reset-Knopf, und die Nicht-Scalars (Spawn/Blend/Hintergrund/Volk-Farbe in Sections.swift:48-112) haben weder Eingabe noch Reset.
  • Species-Selector (Sections.swift:89-116): Reihenfolge [1,2,3,All], Default = Volk „1". Labels „1/2/3" (nicht „0/1/2"). Völker sind hart 3 = die Pheromon-Kanäle (Deposit-Shader mappt 0→R, 1→G, 2→B, deposit.wgsl:81-89); die anzeigbare Volk-Farbe ist frei editierbar (Default Volk 0 = orange).
  • Audio-Config ist heute global, einzeln (_audio.json = AudioReactive, _tuning.json = AudioTuning); es gibt keine speicher-/wechselbaren Audio-Sets.
  • Kein Datei-Export/Import für Presets — nur Capture nutzt ExportedCapture: FileDocument + .fileExporter (macOS-gated). Kein .fileImporter existiert irgendwo.
  • Fullscreen: Enter sitzt oben-rechts (Topbar, RootView.swift:215), Exit erscheint nach Canvas-Tap unten-mittig (overlay(alignment:.bottom), RootView.swift:153-161) — gegenüberliegende Ecke.

Das ist Politur + eine strukturelle Schicht, kein Engine-Eingriff.

Entscheidungen (aus dem Brainstorm)

  1. Hybrid-Layout. Regular (iPad-Landscape / Mac) = echter NavigationSplitView (Sidebar=Library, Detail=Bild, Inspector=Editor), Spalten einklappbar → full-bleed. Compact (iPhone / Portrait / Split-View) behält das heutige full-bleed + Editor-.sheet + Library-Sheet (Ambiance-first, „Bild läuft immer"). Bei offenen Leisten ist das Bild bewusst zwischen den Spalten eingerückt.
  2. Matrix nach Volk gruppieren. 3 Gruppen „R sieht …/G…/B…", je 3 full-width ParamSlider (treu zur Engine-Semantik „Reihe sieht Spalte").
  3. R/G/B = Buchstabe + Live-Farbpunkt. Buchstabe = fester Pheromon-Kanal, Punkt = aktuelle editierbare Volk-Farbe. Matrix-Achsen folgen R/G/B.
  4. Inspector-Breite breiter + ziehbar + gemerkt (navigationSplitViewColumnWidth(min:ideal:max:), Default ~380, via @AppStorage).
  5. Audio-Preset = nur AudioReactive (Routen+Gain+AutoGain). AudioTuning bleibt global/DEBUG.
  6. Export/Import = web-kompatibles JSON. Built-ins exportierbar; Import vergibt immer neue ID.
  7. Fullscreen: Exit→oben-rechts (Glyph wie Enter); Capture-Pill bleibt unten (Daumen-erreichbar); Tap-Reveal + 2,5 s bleiben.
  8. Eine Spec, wellenweise Umsetzung (W1 Fundament → W2 → W3 → W4), Sim/Device-Check zwischen den Wellen.

Welle 1 — Workspace-Re-Architecture (Fundament)

Ersetzt das Container-Modell in RootView.body, nicht die Blatt-Views: EditorView, LibraryView-Cardgrid und MetalView überleben als Spalten-Inhalt unverändert.

W1.1 · Presentation-Logik: binär → hybrid

EditorPresentation.swift: das enum {.inspector, .sheet} wird zu einem Layout-Modus, der auf Regular die Drei-Spalten-Sicht und auf Compact das Sheet wählt. Reine Funktion bleibt testbar:

enum WorkspaceLayout { case splitColumns   // regular: iPad-Landscape / Mac
                       case overlaySheets } // compact: iPhone / Portrait / Split-View
func workspaceLayout(isMac: Bool, isCompactWidth: Bool, size: CGSize) -> WorkspaceLayout
// Mac → .splitColumns; compact → .overlaySheets;
// regular (iPad) → .splitColumns wenn w>h (Landscape), sonst .overlaySheets (Portrait, ambiance-first)

EditorPresentationTests.swift wird auf diesen Vertrag neu geschrieben (alte 5 inspector/sheet-Fälle ersetzt; Portrait/Square → .overlaySheets).

W1.2 · NavigationSplitView auf Regular

RootView.body: bei .splitColumns ein NavigationSplitView(columnVisibility:): - Sidebar = LibraryView-Inhalt (Cardgrid + Suche + Tag-Pills) — zieht aus dem Modal-Sheet raus. - Detail = playerLayer (MetalView + Glass-Topbar als Detail-Overlay). Die innere GeometryReader{geo} (RootView.swift:130-164) bleibt — Detail muss seine eigene Fläche messen, um das Engine-Feld zu dimensionieren und die Topbar on-screen zu halten. - Inspector = EditorView (container-agnostisch, dropt unverändert rein).

Bei .overlaySheets bleibt der heutige Pfad: full-bleed playerLayer + Editor-.sheet (Detents wie gehabt: compact [.medium,.large], iPad-Portrait [.fraction(0.75),.large]) + Library-.sheet über Topbar-Button.

W1.3 · Sidebar-Selektion bridged auf AppState

Kein zweites NavigationSplitView-Selection-Binding. Die Sidebar-List/Grid-Selektion liest/schreibt state.selectedID und ruft state.select(id:) (AppState.swift:54) — sonst desynct die Auswahl. Der tote iOS-17-pendingLibrary-Defer (RootView.swift:231-238) entfällt (Library ist kein Sheet mehr auf Regular) und wird entfernt, nicht mitgeschleppt.

W1.4 · Einklappbare Leisten + full-bleed

@State NavigationSplitViewVisibility (Sidebar) + separates Inspector-Presented-Binding. Die bestehenden Topbar-Toggles (RootView.swift:215 Fullscreen, :216 Editor) steuern Spalten-Sichtbarkeit. Beide Spalten eingeklappt → Bild full-bleed. Das bestehende fullscreen-@State (RootView.swift:12) bleibt als tieferer Ambiance-Modus erhalten: nur er versteckt zusätzlich iOS-Statusbar + Home-Indicator (.statusBarHidden/.persistentSystemOverlays, RootView.swift:111-117) — Spalten-Einklappen allein tut das nicht. Sichtbarkeit-Toggle (Produktivität) und fullscreen (tiefe Ambiance) komponieren.

W1.5 · Inspector-Breite

.navigationSplitViewColumnWidth(min: 320, ideal: 380, max: 460) auf der Inspector-Spalte; ideal durch einen @AppStorage Double hinterlegt, damit ein Nutzer-Drag persistiert. Neues, additives, plattform- konsistentes @AppStorage-Schema (niedriges Risiko). Gilt nur für .splitColumns.

W1.6 · macOS-Fensterbreite

PhysarumApp.swift:13 minWidth: 900 ggf. anheben, damit drei Spalten + brauchbares Detail passen (verifiziert beim macOS-Build).

Gotchas (aus Map): (a) Zwei-GeometryReader-Falle — Modus weiter aus der vollen Größe entscheiden, Inhalt aus der inneren reduzierten Größe sizen; MetalView nie auf screen.size sizen (sonst Topbar-Overflow zurück). (b) Feld-Rebuild bei Spaltenanimation — Detail-Breitenänderung (Ein-/Ausklappen) resized das Engine-Feld (targetFieldSize(...) aus gemessener fullW/fullH, RootView.swift:136) = voller Engine-Rebuild. Schnelle Animation darf nicht jedes Frame rebuilden → Rebuild nur bei geänderten Ziel-Felddimensionen (gleicher Guard-Gedanke wie der Auto-Cycle-syncCycle). (c) „Bild läuft immer" bleibt auf beiden Pfaden invariant.


Welle 2 — Editor-Politur (lebt von der breiteren Spalte)

W2.1 · Audio-Reflow

Reine Layout-Änderung in AudioReactiveSection.swift, kein Model/Engine/Persistenz-Touch: - meterBar(_:_:) (~175-188): .lineLimit(1).fixedSize(horizontal:true, vertical:false) auf das Label-Text → killt „Bass/High"-Zeichen-Wrap (billigster, höchster Wert). - routeRow(_:) (~77-152): Menü-Picker (Quelle/Ziel) .lineLimit(1) → killt „Sensor/Deposit"-Wrap. Vertikaler Reflow der Zeile via ViewThatFits (kein verschachtelter GeometryReader — der Meter hat schon einen): eng → Zeile 1 = Quelle→Ziel (liest als Satz), darunter Mode + Depth-Slider full-width. Segmented „Lvl/Trans" behält Funktion; sein hartes .frame(width:116) wird vom Reflow gelöst (oder bei sehr eng .menu). - Reentrancy-Guards (idx < routes.count) in den Bindings erhalten; ModTarget/AudioBand/RouteMode- Case-Order nicht anfassen (u32-Uniform-Slots).

W2.2 · Interaktions-Matrix full-width gruppiert

MatrixSection (Sections.swift:136-151): Grid/GridRow raus → VStack mit 3 Volk-Gruppen. Jede Gruppe: Sub-Header („R sieht …") + die 3 Zellen M[r,0..2] als full-width ParamSlider (ParamSlider füllt Breite von selbst). Zell-Lookup unverändert (ParamRegistry.all.first { $0.id == .matrix(r,c) }). Wichtig: die ParamID.matrix(r,c) ↔ interactionMatrix[r][c]-Korrespondenz exakt halten (WGSL packParams liest Matrix bei Float-Offset 64+r*4; vertauschter Index mappt Völker still falsch).

Relabel M[r,c] → R→G-Stil: Label-Quelle ist ParamRegistry.swift:80. Der ParamHelp-Lookup hängt an label.hasPrefix("M[") (ParamHelp.swift:46) → mit-nachziehen, sonst bricht die Matrix-Hilfe.

W2.3 · Sichtbarer Reset pro Zeile + Nicht-Scalar-Reset

  • Scalars: ↩-Glyph in den ParamSlider-Header (ParamSlider.swift:15-20, neben LockButton), Aktion state.pushUndo(); state.reset(descriptor). Nur sichtbar wenn modifiziert — neue AppState.isModified(_ d:) -> Bool { abs(d.get(edited) - d.get(baseline)) > 1e-6 } (gleiche 1e-6- Toleranz wie paramsEqual). Doppeltipp-Reset bleibt als Power-User-Shortcut.
  • Nicht-Scalars (die eigentliche Lücke): Spawn/Blend/Hintergrund (Sections.swift:48-87) + Volk-Farbe (:100-112) sind keine Registry-Descriptors → neue gezielte AppState.resetSpawnPattern()/resetBlendMode()/resetBackgroundColor()/resetSpeciesColor(_ i:) (edited.x = baseline.x + pushUndo(), je eine Zuweisung → ein Persist-Write).
  • Gemeinsamer Control-Row-Wrapper: eine kleine View (Label+Help+isModified+resetAction), die HelpLabel-Zeilen und ParamSlider-Zeilen dieselbe Reset-Chrome gibt. HelpLabel bleibt label-only (Reset-Knopf in der komponierenden Zeile, nicht in HelpLabel).
  • Reset-Knopf plain SwiftUI (.buttonStyle(.plain), kein Hover-only — iPadOS hat keinen Hover), 44pt-Hit-Target. Globaler resetAll() im Editor-Header (EditorView.swift:90-98) bleibt (global = alle, per-Zeile = eine).

W2.4 · Species-Selector

SpeciesSection (Sections.swift:89-116): das min(tab,2)-Arithmetik-Pattern bricht, sobald „All" nach vorn wandert → explizites Mapping statt Arithmetik:

@State private var selected: Int? = nil   // nil == All, default = All (vorgewählt)
// Picker-Reihenfolge: All (nil) | R (0) | G (1) | B (2)
var focus: Int { selected ?? 0 }
// Schreiben: selected == nil ? broadcast(d) : write(species: focus)

Alle drei Magic-Number-Sites (focus, tab==3-Color-Broadcast :106, tab==3 ? broadcast(d) : d :114) auf das Mapping umstellen. Labels R●/G●/B●: Buchstabe = Kanal-Identität, ● = Swatch in state.edited.species[i].color. Matrix-Achsen (W2.2) folgen R/G/B; Matrix-Hilfetext (ParamHelp.swift:46) konsistent nachziehen. Reine App-Presentation; Engine/JSON/Shader unverändert (Völker bleiben 0/1/2 intern).


Welle 3 — Persistenz / Sharing

W3.1 · Audio-Presets

Neuer Codable-Payload + Store, beide spiegeln vorhandene Muster:

struct AudioConfig: Codable, Identifiable { let id: String; var name: String; var reactive: AudioReactive }
// reactive = {enabled,gain,autoGain,routes}. AudioTuning NICHT enthalten (global/DEBUG).

AudioPresetStore spiegelt PresetStore (Physarum/AudioPresets/<id>.json + _order.json, save/loadAll/ delete/reorder; baseDir-Injektion fürs Testen). AppState: audioPresets:[AudioConfig] + saveAudioPreset(name:) (Snapshot aus aktuellem audioReactive), applyAudioPreset(id:) (Zuweisung zurück in audioReactive — eine Zuweisung, ein didSet-Persist-Write, Muster aus setAudioEnabled, AppState.swift:180), deleteAudioPreset/reorder. UI: Picker + „Save current as…" + Manage-Liste in AudioReactiveSection (bindet schon ganz an state.audioReactive); Name-Dialog analog SaveDialog.swift.

W3.2 · Preset Export/Import (JSON)

struct PresetDocument: FileDocument {              // read+write (anders als ExportedCapture: export-only)
    static var readableContentTypes: [UTType] { [.json] }
    var preset: Preset
    init(preset: Preset) { self.preset = preset }
    init(configuration: ReadConfiguration) throws { /* decode JSON bytes → Preset */ }
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { /* encode Preset → Data */ }
}
  • Export: .fileExporter (auf beiden Plattformen — macOS-Panel, iOS-Files; nicht das macOS-only-Gate von Capture blind kopieren). Einstieg: LibraryView-Card-contextMenu „Export…". Built-ins exportierbar (Export liest die In-Memory-Preset). Default-Name = Preset-Name.
  • Import: erster .fileImporter(allowedContentTypes:[.json]) der App. Decode-Vertrag = PresetCatalog.loadAll (JSONDecoder().decode(Preset.self)). Neue AppState.importPreset(_:): re-id (custom-<slug>-<epoch>-Schema aus saveAsNew), builtIn=false, store.save + reload. iOS security-scoped URL (startAccessingSecurityScopedResource()/stop… — kein Präzedenzfall im Repo, leicht vergessen → still permission-denied). Validierung: nur AudioRoute.mode ist decodeIfPresent; fehlende Pflichtfelder werfen → Import braucht Fehlerbehandlung/User-Feedback.
  • Web-Kompatibilität: JSON ist das Web-Schema (src/presets/schema.ts); Export bleibt web-kompatibel. Eingebettetes PresetParams.audioReactive bleibt im Export (kosmetisches Schema-Rauschen), wird beim Import nicht in die globale Audio-Config übernommen (Runtime ignoriert es ohnehin — „Audio = wie ich es treibe, nicht der Look"). Thumbnail reist nicht mit → Re-Import warm-startet ein neues.
  • Audio-Presets nutzen dieselbe Export/Import-Maschinerie (encode AudioConfig statt Preset).

Welle 4 — Fullscreen-Politur

RootView.swift: das Exit+Capture-Overlay (overlay(alignment:.bottom){…padding(.bottom,44)}, :153-161) in der Anker-Position ändern: - Exit → .topTrailing mit padding(.top, geo.safeAreaInsets.top + Theme.Space.s3) + padding(.trailing, Theme.Space.s3) (geo ist im playerLayer-Reader in Scope). Form: kompakter Glyph via iconButton(...) + .glassPanel() (wie der Enter-Button), nicht die breite Text-Pille. Aktion unverändert (fsHintWork canceln, fsHintShown=false, fullscreen=false). - Capture-Pille bleibt unten-mittig (Daumen-erreichbar, bewusste Trennung von Exit). - Tap-Reveal + 2,5 s (handleCanvasTap→revealFsHint, :272/280) bleiben. - Nicht hinter #if !os(macOS) gaten — fullscreen + Topbar-Hide laufen auch auf Mac (dort safeAreaInsets.top ≈ 0). Top-Inset über safeAreaInsets statt Hardcode (Notch/Dynamic Island).


Komponenten-Übersicht

Welle Unit Datei Rolle
W1 workspaceLayout(...) + Tests neu App/EditorPresentation.swift, Tests/EditorPresentationTests.swift Hybrid-Modus-Entscheidung
W1 NavigationSplitView-Body + Visibility-State App/RootView.swift 3-Spalten Regular / Sheets Compact
W1 Sidebar-Inhalt aus Modal lösen Library/LibraryView.swift Library → Sidebar-Spalte
W1 Inspector-Breite ziehbar+gemerkt App/RootView.swift (+ @AppStorage) Breiter Default ~380
W1 min-Width prüfen App/PhysarumApp.swift 3-Spalten-Fenster
W2 lineLimit + ViewThatFits-Reflow Editor/AudioReactiveSection.swift Audio-Cramping
W2 Grid→3 Volk-Gruppen Editor/Sections.swift (MatrixSection) Matrix full-width
W2 M[r,c]→R→G + Help-Lookup Model/ParamRegistry.swift, Model/ParamHelp.swift Matrix-Labels
W2 Sichtbarer Reset + isModified Editor/ParamSlider.swift, Model/AppState.swift Per-Zeile Reset (Scalars)
W2 Nicht-Scalar-Reset + Control-Row-Wrapper Model/AppState.swift, Editor/Sections.swift, UI/ Reset für Spawn/Blend/BG/Farbe
W2 Species All-first + R/G/B-Mapping Editor/Sections.swift (SpeciesSection) Selector-Order/Labels
W3 AudioConfig + AudioPresetStore + AppState Model/ (neu) + Model/AppState.swift Audio-Presets
W3 Audio-Preset-UI Editor/AudioReactiveSection.swift, Name-Dialog Picker + Save-as
W3 PresetDocument + Export/Import Model/ (neu), Library/LibraryView.swift, Model/AppState.swift Preset JSON I/O
W4 Exit-Overlay → top-trailing Glyph App/RootView.swift Fullscreen-Exit-Position

Testing (Muster: bestehende Tests/*, Host-GPU-XCTest)

  • W1: workspaceLayout(...): Mac→splitColumns; compact→overlaySheets; iPad-Landscape→splitColumns; iPad-Portrait→overlaySheets. (EditorPresentationTests neu.) Layout selbst = Sim/Device-Screenshot.
  • W2: AppState.isModified(_:) (1e-6, modifiziert/unmodifiziert/Round-Trip-exakt); Nicht-Scalar-Reset setzt Feld auf baseline + pusht Undo; Species-Mapping (All→broadcast, R/G/B→Volk-Index; All-default). Audio-Reflow + Matrix-Layout = Screenshot.
  • W3: AudioConfig Encode/Decode-Round-Trip; AudioPresetStore CRUD + Reorder (tempStore via baseDir, Muster PresetStoreTests); PresetDocument Round-Trip; importPreset re-id't + lädt; Import-Fehlerpfad (fehlendes Pflichtfeld wirft, kein Crash).
  • W4: Exit-Action-Logik (State-Reset) testbar; Position = Screenshot.
  • Gates pro Welle: xcodebuild … test (App, macOS+iOS) + swift test (Engine, falls berührt — hier i.d.R. nicht) + EngineCheck. Wellen-Abschluss: macOS-Build + iPad-Sim gebaut/gestartet/ gescreenshottet; Landscape/Portrait-Rotation = Johannes' Geräte-Sign-off (Sim rotiert nicht headless).

Bewusste Abweichungen (→ AGENTS.md nachziehen)

  • Layout-Modell: App ist nicht mehr „ein full-bleed Screen" — Regular nutzt NavigationSplitView (Bild eingerückt bei offenen Leisten, full-bleed eingeklappt). Compact bleibt full-bleed + Sheets.
  • Inspector-Breite ziehbar + @AppStorage-persistiert (neues additives Schema).
  • Erster .fileImporter der App (Preset/Audio-Import); iOS security-scoped-URL-Pattern neu im Repo.
  • ParamSlider zeigt jetzt einen Reset-Knopf (modifiziert-gegated); Nicht-Scalars haben erstmals Reset.
  • Species-Labels R/G/B (Kanal-Identität + Live-Farb-Swatch) statt „1/2/3"; Matrix-Achsen folgen.

Bewusst nicht in Scope (YAGNI)

  • Variable Völker-Zahl — hart 3 = RGB-Kanäle (GPU-Uniform-3-Tupel, packParams-Offsets, Deposit-Shader, 3×3-Matrix); mehr Völker = Engine/Shader/Uniform-Umbau, separates großes Vorhaben.
  • AudioTuning in Audio-Presets — bleibt global/DEBUG-Kalibrierung.
  • macOS-Menübefehle / Undo-Menü / native Fullscreen-Toolbar (Tier C).
  • Eigener Audio-Preset-Browser wie LibraryView — inline Picker+Save reicht (Thumbnails/Preview nicht nötig).
  • Drag-and-Drop-Reorder, tvOS, CloudKit-Sync von Presets.