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
NavigationSplitViewexistiert —grepüberapple/ist leer.RootView.swiftist der Layout-Owner: eine äußereGeometryReader{screen}wählt viaeditorPresentation(...)zwischen.inspector(Mac / iPad-Landscape) und.sheet(compact / iPad-Portrait); eine innereGeometryReader{geo}(playerLayer) misst die reduzierte Primärfläche (Fix aus9c37ef9). Library ist ein separates modales.sheetmit 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. AudioReactiveSectionstaucht: die Datei hat nirgendslineLimit/fixedSize/minimumScaleFactor→ unter Breitendruck wrappt jedesText(Meter-Labels „Bass"/„High" zeichenweise vertikal, Menü-Picker „Sensor"/„Deposit" zweizeilig, segmented „Lvl/Trans" überlappt).- Interaktions-Matrix ist ein 3×3-
Gridaus kompaktenParamSlidern (Sections.swift:136-151) → jeder Slider ist sehr schmal. - Numerische Eingabe + Reset existieren bereits, sind aber unsichtbar/lückenhaft:
ParamSliderkann Wert-antippen→TextField(ParamSlider.swift:40-53) und Doppeltipp→state.reset(descriptor)= zurück aufbaseline= Preset-Wert (ParamSlider.swift:33,AppState.swift:70). Es gibt keinen sichtbaren Reset-Knopf, und die Nicht-Scalars (Spawn/Blend/Hintergrund/Volk-Farbe inSections.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.fileImporterexistiert 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)¶
- 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. - Matrix nach Volk gruppieren. 3 Gruppen „R sieht …/G…/B…", je 3 full-width
ParamSlider(treu zur Engine-Semantik „Reihe sieht Spalte"). - R/G/B = Buchstabe + Live-Farbpunkt. Buchstabe = fester Pheromon-Kanal, Punkt = aktuelle editierbare Volk-Farbe. Matrix-Achsen folgen R/G/B.
- Inspector-Breite breiter + ziehbar + gemerkt (
navigationSplitViewColumnWidth(min:ideal:max:), Default ~380, via@AppStorage). - Audio-Preset = nur
AudioReactive(Routen+Gain+AutoGain).AudioTuningbleibt global/DEBUG. - Export/Import = web-kompatibles JSON. Built-ins exportierbar; Import vergibt immer neue ID.
- Fullscreen: Exit→oben-rechts (Glyph wie Enter); Capture-Pill bleibt unten (Daumen-erreichbar); Tap-Reveal + 2,5 s bleiben.
- 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, nebenLockButton), Aktionstate.pushUndo(); state.reset(descriptor). Nur sichtbar wenn modifiziert — neueAppState.isModified(_ d:) -> Bool { abs(d.get(edited) - d.get(baseline)) > 1e-6 }(gleiche 1e-6- Toleranz wieparamsEqual). 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 gezielteAppState.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 undParamSlider-Zeilen dieselbe Reset-Chrome gibt.HelpLabelbleibt label-only (Reset-Knopf in der komponierenden Zeile, nicht inHelpLabel). - Reset-Knopf plain SwiftUI (
.buttonStyle(.plain), kein Hover-only — iPadOS hat keinen Hover), 44pt-Hit-Target. GlobalerresetAll()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)). NeueAppState.importPreset(_:): re-id (custom-<slug>-<epoch>-Schema aussaveAsNew),builtIn=false,store.save+ reload. iOS security-scoped URL (startAccessingSecurityScopedResource()/stop…— kein Präzedenzfall im Repo, leicht vergessen → still permission-denied). Validierung: nurAudioRoute.modeistdecodeIfPresent; fehlende Pflichtfelder werfen → Import braucht Fehlerbehandlung/User-Feedback. - Web-Kompatibilität: JSON ist das Web-Schema (
src/presets/schema.ts); Export bleibt web-kompatibel. EingebettetesPresetParams.audioReactivebleibt 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
AudioConfigstattPreset).
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. (EditorPresentationTestsneu.) 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:
AudioConfigEncode/Decode-Round-Trip;AudioPresetStoreCRUD + Reorder (tempStore via baseDir, MusterPresetStoreTests);PresetDocumentRound-Trip;importPresetre-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
.fileImporterder App (Preset/Audio-Import); iOS security-scoped-URL-Pattern neu im Repo. ParamSliderzeigt 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. AudioTuningin 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.