Zum Inhalt

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 glass topbar, an exitFullscreenButton, and an autoCycle Timer.
  • MetalView (Sources/Player/MetalView.swift) already builds on iOS — it typealiases ViewRepresentable to UIViewRepresentable via #if os(macOS)/#else and has both makeNSView/makeUIView paths. 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 + SaveDialog are presented from within EditorView and stack naturally on top of the editor container on iOS.
  • Deployment targets: macOS 14, iOS 17 — so presentationDetents, presentationBackgroundInteraction, presentationContentInteraction, and .inspector are all available.
  • The app XCTest target PhysarumAppTests is macOS-hosted; new .swift files under Tests/ auto-include after xcodegen 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)"
Expected: macOS BUILD SUCCEEDED · TEST SUCCEEDED (36) · iOS-sim BUILD SUCCEEDED.

  • [ ] 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
Confirm: launches player-first (no sheet auto-open on iPhone); tapping the editor toggle (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
cd "$(git rev-parse --show-toplevel)"
git push   # main → Codeberg (+ GitHub mirror)

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.