Adaptive Compact UI (iPad/iPhone) 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: Make the native app's editor presentation adaptive — an .inspector column on regular width (macOS, iPad-landscape) and a bottom-sheet over a full-bleed player on compact width / iPad-portrait — so the full authoring editor works idiomatically on iPhone + iPad.
Architecture: A single pure, unit-tested function editorPresentation(isMac:isCompactWidth:size:) -> EditorPresentation maps platform + size class + geometry to a presentation mode. RootView wraps its content in a GeometryReader, computes the mode, and attaches BOTH an .inspector and a .sheet modifier whose isPresented bindings are gated so exactly one is ever active for the current mode. The live MetalView player stays full-bleed underneath in every mode (presentationBackgroundInteraction(.enabled) keeps it visible behind the sheet). Engine, renderer, and the editor content (all Plan-3 features) are unchanged — only RootView presentation + a macOS-only window-frame guard.
Tech Stack: Swift 5 / SwiftUI (GeometryReader, @Environment(\.horizontalSizeClass), .inspector, .sheet + presentationDetents), PhysarumEngine, XCTest in the hosted PhysarumAppTests target (macOS), XcodeGen + xcodebuild (macOS + iOS Simulator).
Background / IST-state (verified 2026-06-03)¶
RootView(Sources/App/RootView.swift) currently attaches.inspector(isPresented:)+.sheet(isPresented:)(library) unconditionally — no size-class / geometry adaptivity. It owns@State showInspector/showLibrary/fullscreen, a floating glasstopbar, anexitFullscreenButton, and an autoCycleTimer.MetalView(Sources/Player/MetalView.swift) already builds on iOS — ittypealiasesViewRepresentabletoUIViewRepresentablevia#if os(macOS)/#elseand has bothmakeNSView/makeUIViewpaths. No change needed.PhysarumApp(Sources/App/PhysarumApp.swift) sets.frame(minWidth: 900, minHeight: 600)on the root for all platforms — this is wrong on iPhone and must be macOS-only.EditorView(the editor content with the Plan-3 toolbar/sections/Morph/Save) is presented inside whatever container RootView gives it; it is unchanged by this plan.MorphSheet+SaveDialogare presented from withinEditorViewand stack naturally on top of the editor container on iOS.- Deployment targets: macOS 14, iOS 17 — so
presentationDetents,presentationBackgroundInteraction,presentationContentInteraction, and.inspectorare all available. - The app XCTest target
PhysarumAppTestsis macOS-hosted; new.swiftfiles underTests/auto-include afterxcodegen generate. Baseline suite: 31 tests green.
Build & test commands (used in tasks)¶
- Regenerate after adding files:
cd apple/PhysarumApp && xcodegen generate - Run a single test class:
xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<Class> 2>&1 | tail -25 - Full app suite:
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -25 - macOS build:
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -12 - iOS Simulator build (the new cross-platform gate):
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -15
File Structure¶
Create:
- Sources/App/EditorPresentation.swift — EditorPresentation enum + the pure decision function. One responsibility: the mode rule.
- Tests/EditorPresentationTests.swift — table-driven unit tests of the rule.
Modify:
- Sources/App/RootView.swift — adaptive presentation (GeometryReader + gated inspector/sheet bindings), initial state, topbar adaptivity (eyebrow hide on compact, mode-aware toggle icon, library-closes-sheet on compact).
- Sources/App/PhysarumApp.swift — make the minWidth/minHeight frame macOS-only.
Do NOT modify: MetalView.swift, RenderCoordinator.swift, EditorView.swift, MorphSheet.swift, Sections.swift, ParamRegistry.swift, project.yml (folder-sourced; just regenerate).
Task 1: EditorPresentation decision function (pure, unit-tested)¶
Files:
- Create: apple/PhysarumApp/Sources/App/EditorPresentation.swift
- Create: apple/PhysarumApp/Tests/EditorPresentationTests.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/EditorPresentationTests.swift:
import XCTest
@testable import Physarum
final class EditorPresentationTests: XCTestCase {
func testMacIsAlwaysInspector() {
XCTAssertEqual(editorPresentation(isMac: true, isCompactWidth: false, size: CGSize(width: 1440, height: 900)), .inspector)
// even a portrait-ish mac window stays inspector
XCTAssertEqual(editorPresentation(isMac: true, isCompactWidth: false, size: CGSize(width: 600, height: 900)), .inspector)
}
func testCompactWidthIsAlwaysSheet() {
// iPhone, or iPad in split-view/slide-over
XCTAssertEqual(editorPresentation(isMac: false, isCompactWidth: true, size: CGSize(width: 390, height: 844)), .sheet)
XCTAssertEqual(editorPresentation(isMac: false, isCompactWidth: true, size: CGSize(width: 844, height: 390)), .sheet)
}
func testRegularLandscapeIsInspector() {
// iPad full-screen, landscape
XCTAssertEqual(editorPresentation(isMac: false, isCompactWidth: false, size: CGSize(width: 1180, height: 820)), .inspector)
}
func testRegularPortraitIsSheet() {
// iPad full-screen, portrait
XCTAssertEqual(editorPresentation(isMac: false, isCompactWidth: false, size: CGSize(width: 820, height: 1180)), .sheet)
}
func testSquareIsSheet() {
// width not strictly greater than height → sheet (ambiance default)
XCTAssertEqual(editorPresentation(isMac: false, isCompactWidth: false, size: CGSize(width: 800, height: 800)), .sheet)
}
}
- [ ] Step 2: Run to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/EditorPresentationTests 2>&1 | tail -20
Expected: compile failure — EditorPresentation / editorPresentation not found.
- [ ] Step 3: Implement the function
Create apple/PhysarumApp/Sources/App/EditorPresentation.swift:
import CoreGraphics
/// How the editor is presented relative to the always-on player.
enum EditorPresentation { case inspector, sheet }
/// Maps platform + size class + available geometry to a presentation mode.
/// - macOS → always `.inspector`.
/// - compact width (iPhone, iPad split-view/slide-over) → always `.sheet`.
/// - regular width (iPad full-screen) → `.inspector` in landscape (w > h),
/// `.sheet` in portrait/square (ambiance-first, the "living picture").
func editorPresentation(isMac: Bool, isCompactWidth: Bool, size: CGSize) -> EditorPresentation {
if isMac { return .inspector }
if isCompactWidth { return .sheet }
return size.width > size.height ? .inspector : .sheet
}
- [ ] Step 4: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/EditorPresentationTests 2>&1 | tail -20
Expected: PASS (5 tests).
- [ ] Step 5: Run the full suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
Expected: 36 tests, 0 failures.
- [ ] Step 6: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/App/EditorPresentation.swift apple/PhysarumApp/Tests/EditorPresentationTests.swift
git commit -m "feat(app): EditorPresentation decision rule (adaptive editor mode)"
Task 2: macOS-only window min-frame¶
Files:
- Modify: apple/PhysarumApp/Sources/App/PhysarumApp.swift
The .frame(minWidth: 900, minHeight: 600) is fine on macOS (window sizing) but invalid on iPhone (no window can be 900pt wide). Make it macOS-only.
- [ ] Step 1: Edit PhysarumApp.swift
Replace the body of PhysarumApp so the frame is guarded:
import SwiftUI
@main
struct PhysarumApp: App {
var body: some Scene {
WindowGroup {
#if os(macOS)
RootView()
.frame(minWidth: 900, minHeight: 600)
#else
RootView()
#endif
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
#endif
}
}
- [ ] Step 2: Build (macOS) to verify it compiles
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -8
Expected: BUILD SUCCEEDED.
- [ ] Step 3: Build (iOS Simulator) to confirm the frame no longer constrains iOS
Run: xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -15
Expected: BUILD SUCCEEDED.
- [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/App/PhysarumApp.swift
git commit -m "fix(app): constrain the 900x600 min window frame to macOS"
Task 3: Adaptive RootView¶
Files:
- Modify (full rewrite): apple/PhysarumApp/Sources/App/RootView.swift
UI task — verified by macOS build + iOS Simulator build + the full suite still green (no unit test for the SwiftUI view itself; the decision logic is covered by Task 1).
- [ ] Step 1: Rewrite RootView.swift
Replace the entire contents of apple/PhysarumApp/Sources/App/RootView.swift with:
import SwiftUI
/// Root: a full-bleed player with a floating glass topbar and an ADAPTIVE editor —
/// an `.inspector` column on regular width (macOS, iPad-landscape) or a bottom-sheet
/// over the still-running player on compact width / iPad-portrait — plus a library
/// overlay and a fullscreen mode. The player never stops; the sheet floats over it.
struct RootView: View {
@State private var state = AppState()
@State private var showEditor = true
@State private var showLibrary = false
@State private var fullscreen = false
@State private var didInitEditor = false
private let field = 720
@State private var cycleTimer: Timer?
#if os(macOS)
private let isMac = true
#else
@Environment(\.horizontalSizeClass) private var hSizeClass
private let isMac = false
#endif
private func presentation(for size: CGSize) -> EditorPresentation {
#if os(macOS)
return editorPresentation(isMac: true, isCompactWidth: false, size: size)
#else
return editorPresentation(isMac: false, isCompactWidth: hSizeClass == .compact, size: size)
#endif
}
private var showEyebrow: Bool {
#if os(macOS)
return true
#else
return hSizeClass != .compact
#endif
}
var body: some View {
GeometryReader { geo in
let mode = presentation(for: geo.size)
player(mode: mode)
.onAppear {
if !didInitEditor {
showEditor = (mode == .inspector) // inspector starts open; sheet starts player-first
didInitEditor = true
}
}
}
.preferredColorScheme(.dark)
.onChange(of: state.autoCycle) { _, on in on ? startCycle() : stopCycle() }
}
private func player(mode: EditorPresentation) -> some View {
MetalView(field: field, selectedPreset: state.selectedPreset, editedParams: state.edited)
.ignoresSafeArea()
.overlay(alignment: .top) { if !fullscreen { topbar(mode: mode) } }
.overlay(alignment: .topTrailing) { if fullscreen { exitFullscreenButton } }
.inspector(isPresented: Binding(
get: { mode == .inspector && showEditor && !fullscreen },
set: { showEditor = $0 })) {
EditorView(state: state).inspectorColumnWidth(min: 300, ideal: 340)
}
.sheet(isPresented: Binding(
get: { mode == .sheet && showEditor && !fullscreen },
set: { showEditor = $0 })) {
EditorView(state: state)
.presentationDetents([.medium, .large])
.presentationBackgroundInteraction(.enabled)
.presentationContentInteraction(.scrolls)
}
.sheet(isPresented: $showLibrary) {
LibraryView(state: state, isPresented: $showLibrary)
}
}
private func topbar(mode: EditorPresentation) -> some View {
HStack(spacing: Theme.Space.s3) {
if showEyebrow {
Text("PHYSARUM")
.font(Theme.Fonts.eyebrow).tracking(2)
.foregroundStyle(Theme.Colors.fgSecondary)
}
Button { openLibrary(mode: mode) } label: {
HStack(spacing: 6) {
Circle().fill(Theme.Colors.signal(state.selectedPreset.tagSignal.rawValue)).frame(width: 7, height: 7)
Text(state.selectedPreset.name).font(Theme.Fonts.label).foregroundStyle(Theme.Colors.fgPrimary)
.lineLimit(1).truncationMode(.tail)
Image(systemName: "chevron.down").font(.system(size: 8)).foregroundStyle(Theme.Colors.fgTertiary)
}
}
.buttonStyle(.plain)
Spacer()
iconButton("rectangle.grid.2x2") { openLibrary(mode: mode) }
iconButton("arrow.up.left.and.arrow.down.right") { fullscreen = true }
iconButton(mode == .inspector ? "sidebar.right" : "slider.horizontal.3") { showEditor.toggle() }
}
.padding(.horizontal, Theme.Space.s4)
.padding(.vertical, Theme.Space.s2)
.glassPanel()
.padding(Theme.Space.s3)
}
/// On compact the editor sheet and the library sheet share RootView as their
/// presenter, so opening the library must first dismiss the editor sheet.
private func openLibrary(mode: EditorPresentation) {
if mode == .sheet { showEditor = false }
showLibrary = true
}
private func iconButton(_ name: String, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: name).font(.system(size: 13)).foregroundStyle(Theme.Colors.fgSecondary)
}
.buttonStyle(.plain)
}
private var exitFullscreenButton: some View {
Button { fullscreen = false } label: {
Image(systemName: "xmark.circle.fill").font(.title).foregroundStyle(Theme.Colors.fgSecondary)
}
.buttonStyle(.plain)
.padding()
}
private func startCycle() {
cycleTimer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { _ in state.advance(by: 1) }
}
private func stopCycle() { cycleTimer?.invalidate(); cycleTimer = nil }
}
- [ ] Step 2: Build (macOS) — confirm no regression to the desktop layout
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -10
Expected: BUILD SUCCEEDED.
- [ ] Step 3: Build (iOS Simulator) — the cross-platform gate
Run: xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -15
Expected: BUILD SUCCEEDED. (If \.horizontalSizeClass or a presentation modifier errors on a platform, that surfaces here.)
- [ ] Step 4: Full app suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
Expected: 36 tests, 0 failures.
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(app): adaptive editor presentation — inspector (regular) / sheet (compact)"
Task 4: Verification gate + push¶
- [ ] Step 1: Confirm macOS build + suite + iOS-sim build all green
Run:
cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | grep -E "Executed [0-9]+ tests|TEST (SUCCEEDED|FAILED)" | tail -3
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
- [ ] Step 2: Run on iOS Simulator + screenshot (or hand to Johannes)
Boot a simulator and install, e.g.:
xcrun simctl boot "iPhone 15" 2>/dev/null; open -a Simulator
xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 15' build 2>&1 | tail -5
# locate the built .app under DerivedData/.../Build/Products/Debug-iphonesimulator/Physarum.app
xcrun simctl install booted "<path>/Physarum.app" && xcrun simctl launch booted de.jkaindl.physarum
sleep 4; xcrun simctl io booted screenshot /tmp/physarum-iphone.png
slider.horizontal.3) raises a bottom-sheet at .medium with the player still visible behind; the toolbar/sections scroll; Randomize/Morph reachable. Repeat on an iPad simulator and rotate (portrait → sheet, landscape → inspector; the editor stays open across rotation).
- [ ] Step 3: Verify on Johannes's real iPhone + iPad (free-provisioning)
Open apple/PhysarumApp/Physarum.xcodeproj in Xcode, select the device + a personal team, run. Confirm touch ergonomics (slider tap-to-type, 🔒 toggles, Randomize/Morph), sheet detents feel, and live-render performance behind the .medium sheet.
- [ ] Step 4: Push
Self-Review (completed by plan author)¶
Spec coverage (2026-06-03-adaptive-compact-ui-design.md):
- §3 decision function → Task 1 (pure editorPresentation, table-driven tests). ✓
- §4 RootView architecture (GeometryReader, gated inspector/sheet bindings, EditorPresenter behaviour, initial state once via didInitEditor) → Task 3. ✓
- §5 topbar adaptivity (eyebrow hidden on compact, mode-aware toggle icon, name truncation) → Task 3. ✓
- §6 sheet stacking (library closes editor sheet on compact via openLibrary) → Task 3. ✓
- §7 fullscreen-as-chrome-hide (bindings gate on !fullscreen; existing fullscreen overlay kept) → Task 3. ✓
- §8 engine unchanged → no task (verified MetalView already has the iOS path). ✓
- §9 testing → Task 1 (unit) + Task 4 (macOS+iOS build gate, sim + device verify). ✓
- §11 risk "horizontalSizeClass unavailable on macOS" → handled: the env property and its use are inside #if !os(macOS); the macOS path is a literal isMac = true. ✓
- Extra (not in spec but required): PhysarumApp's 900×600 frame breaks iOS → Task 2. ✓
Placeholder scan: none — every step has full code or an exact command.
Type consistency: EditorPresentation (enum .inspector/.sheet), editorPresentation(isMac:isCompactWidth:size:), presentation(for:), showEditor/showLibrary/fullscreen/didInitEditor, openLibrary(mode:), showEyebrow, player(mode:), topbar(mode:) — names consistent across Task 1 (function) and Task 3 (callers). The function signature in the test (Task 1) matches the definition and the RootView call sites (Task 3).
Out-of-scope (correctly deferred): tvOS UI, render-cadence throttling behind the sheet (only if device perf demands it), Pencil/trackpad niceties, multitasking edge-cases beyond the size-class rule.