Zum Inhalt

Pre-Submission Quality Hardening (SP1) 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: Harden the native iOS/iPad/tvOS app (macOS comes along for free) before App Store submission — surface silent user-facing errors, make all text scale with Dynamic Type, and label every control for VoiceOver, with pure-logic XCTests where meaningful.

Architecture: A single ErrorPresenter (@Observable, injected via environment) backs one .errorAlert at each app root; the five silent failure sites (mic-permission-denied, Photos-save failure, two import-decode failures, capture-render failure already shows inline) route through it. Theme.Fonts is rewritten from fixed point sizes to a testable FontRole mapping onto Dynamic-Type text styles (monospaced look preserved). VoiceOver labels are applied inline across all view files, with the few logic-bearing transforms (toggle on/off, lock/pin/selected state, canvas-preset-name) factored into a pure A11y helper that is unit-tested.

Tech Stack: Swift 5 / SwiftUI (#if !os(tvOS) for the iOS/iPad/macOS UI, #if os(tvOS) for the TV UI), XcodeGen project (apple/PhysarumApp/project.yml), XCTest host suite (@testable import Physarum), built/tested via xcodegen generate + xcodebuild.

Spec: docs/superpowers/specs/2026-06-21-pre-submission-quality-hardening-design.md


Conventions used in every task

  • Repo root: the physarum checkout (all paths below are repo-relative)
  • App dir: apple/PhysarumApp (all Sources/… and Tests/… paths below are relative to it)
  • Build (macOS): cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build — expected: ** BUILD SUCCEEDED **
  • Test (macOS host, all): cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' — expected: ** TEST SUCCEEDED **
  • Test (one class, fast TDD loop): xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<ClassName> — the test target is named PhysarumAppTests (confirm in project.yml targets: during Task 1; adjust if different).
  • Commits: Conventional Commits, no emoji, never --no-verify. Co-Authored-By trailer optional. Commit after each task. Do not push (push is gated; Johannes pushes explicitly).
  • Build only on main (trunk-based, authorized) — no new branch unless Johannes asks.

Phase 0 — Error-handling foundation

Task 1: Confirm test target name + create UserFacingError

Files: - Inspect: apple/PhysarumApp/project.yml - Create: apple/PhysarumApp/Sources/UI/UserFacingError.swift - Test: apple/PhysarumApp/Tests/UserFacingErrorTests.swift

  • [ ] Step 1: Confirm the test target name

Run: grep -nA2 "^targets:" apple/PhysarumApp/project.yml ; grep -n "Tests" apple/PhysarumApp/project.yml Expected: a target whose name ends in Tests (assumed PhysarumAppTests). If it differs, use that name in every -only-testing: below.

  • [ ] Step 2: Write the failing test

Create apple/PhysarumApp/Tests/UserFacingErrorTests.swift:

import XCTest
@testable import Physarum

final class UserFacingErrorTests: XCTestCase {
    func testEqualityIgnoresID() {
        let a = UserFacingError(title: "T", message: "M", recovery: .openSettings)
        let b = UserFacingError(title: "T", message: "M", recovery: .openSettings)
        XCTAssertEqual(a, b, "two errors with identical content must be equal (id is incidental)")
    }

    func testDefaultRecoveryIsNone() {
        let e = UserFacingError(title: "T", message: "M")
        XCTAssertEqual(e.recovery, .none)
    }
}
  • [ ] Step 3: Run test to verify it fails

Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/UserFacingErrorTests Expected: FAIL — cannot find 'UserFacingError' in scope.

  • [ ] Step 4: Write minimal implementation

Create apple/PhysarumApp/Sources/UI/UserFacingError.swift:

import Foundation

/// A user-presentable error: a short title, an explanatory message, and an
/// optional recovery action. Pure value type — no UI, host-testable.
struct UserFacingError: Identifiable, Equatable {
    let id = UUID()
    let title: String
    let message: String
    var recovery: RecoveryAction = .none

    static func == (a: UserFacingError, b: UserFacingError) -> Bool {
        a.title == b.title && a.message == b.message && a.recovery == b.recovery
    }
}

/// What the alert offers the user beyond a plain dismiss.
enum RecoveryAction: Equatable {
    case none
    /// Deep-link to the app's system settings (e.g. to grant a denied permission).
    case openSettings
}
  • [ ] Step 5: Run test to verify it passes

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/UserFacingErrorTests Expected: PASS (** TEST SUCCEEDED **).

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/UI/UserFacingError.swift apple/PhysarumApp/Tests/UserFacingErrorTests.swift
git commit -m "feat(a11y): add UserFacingError value type"

Task 2: ErrorPresenter (central sink)

Files: - Create: apple/PhysarumApp/Sources/UI/ErrorPresenter.swift - Test: apple/PhysarumApp/Tests/ErrorPresenterTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/ErrorPresenterTests.swift:

import XCTest
@testable import Physarum

@MainActor
final class ErrorPresenterTests: XCTestCase {
    func testReportSetsCurrent() {
        let p = ErrorPresenter()
        XCTAssertNil(p.current)
        p.report(title: "Mic off", message: "Enable it", recovery: .openSettings)
        XCTAssertEqual(p.current, UserFacingError(title: "Mic off", message: "Enable it", recovery: .openSettings))
    }

    func testDismissClearsCurrent() {
        let p = ErrorPresenter()
        p.report(title: "x", message: "y")
        p.dismiss()
        XCTAssertNil(p.current)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ErrorPresenterTests Expected: FAIL — cannot find 'ErrorPresenter' in scope.

  • [ ] Step 3: Write minimal implementation

Create apple/PhysarumApp/Sources/UI/ErrorPresenter.swift:

import Foundation

/// Central sink for user-facing errors. Subsystems and views call `report`;
/// a single `.errorAlert` mounted at each app root presents `current`.
@Observable
final class ErrorPresenter {
    var current: UserFacingError?

    /// Present an error. Must be called on the main thread (the only thread
    /// SwiftUI reads `current` from). The five report sites already hop to main.
    func report(_ error: UserFacingError) {
        dispatchPrecondition(condition: .onQueue(.main))
        current = error
    }

    func report(title: String, message: String, recovery: RecoveryAction = .none) {
        report(UserFacingError(title: title, message: message, recovery: recovery))
    }

    func dismiss() { current = nil }
}
  • [ ] Step 4: Run test to verify it passes

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ErrorPresenterTests Expected: PASS.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/UI/ErrorPresenter.swift apple/PhysarumApp/Tests/ErrorPresenterTests.swift
git commit -m "feat(a11y): add ErrorPresenter central error sink"

Task 3: .errorAlert modifier + mount at both roots

Files: - Create: apple/PhysarumApp/Sources/UI/ErrorAlert.swift - Modify: apple/PhysarumApp/Sources/App/RootView.swift (add @State presenter at line ~26; add .environment + .errorAlert at the end of body, after line 208 .persistentSystemOverlays(...)) - Modify: apple/PhysarumApp/Sources/TV/TVRootView.swift (add @State presenter; chain after TVPlayerView(...))

  • [ ] Step 1: Create the modifier

Create apple/PhysarumApp/Sources/UI/ErrorAlert.swift:

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif

extension View {
    /// Mounts the single app-wide error alert, driven by the shared presenter.
    func errorAlert(_ presenter: ErrorPresenter) -> some View {
        modifier(ErrorAlertModifier(presenter: presenter))
    }
}

private struct ErrorAlertModifier: ViewModifier {
    @Bindable var presenter: ErrorPresenter

    func body(content: Content) -> some View {
        content.alert(
            presenter.current?.title ?? "",
            isPresented: Binding(
                get: { presenter.current != nil },
                set: { if !$0 { presenter.dismiss() } }
            ),
            presenting: presenter.current
        ) { err in
            if err.recovery == .openSettings, let url = Self.settingsURL {
                Button("Open Settings") { Self.open(url) }
                Button("OK", role: .cancel) { presenter.dismiss() }
            } else {
                Button("OK", role: .cancel) { presenter.dismiss() }
            }
        } message: { err in
            Text(err.message)
        }
    }

    private static var settingsURL: URL? {
        #if os(iOS)
        return URL(string: UIApplication.openSettingsURLString)
        #elseif os(macOS)
        return URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")
        #else
        return nil
        #endif
    }

    private static func open(_ url: URL) {
        #if os(iOS)
        UIApplication.shared.open(url)
        #elseif os(macOS)
        NSWorkspace.shared.open(url)
        #endif
    }
}
  • [ ] Step 2: Mount in RootView

In apple/PhysarumApp/Sources/App/RootView.swift, add a presenter @State next to the other @State declarations (after line 26 @State private var snapshotProvider = LiveSnapshotProvider()):

    @State private var errorPresenter = ErrorPresenter()

Then at the very end of body, immediately AFTER the existing block:

        #if !os(macOS)
        // True fullscreen: hiding the topbar alone leaves the system chrome —
        // clock/battery and the home indicator — over the visual. (macOS gets
        // real fullscreen from the native window button.)
        .statusBarHidden(fullscreen)
        .persistentSystemOverlays(fullscreen ? .hidden : .automatic)
        #endif

append:

        .environment(errorPresenter)
        .errorAlert(errorPresenter)
  • [ ] Step 3: Mount in TVRootView

In apple/PhysarumApp/Sources/TV/TVRootView.swift, add near the other @State properties:

    @State private var errorPresenter = ErrorPresenter()

Then chain onto TVPlayerView(...) after its .onDisappear { … } modifier (so the whole body becomes):

    var body: some View {
        TVPlayerView(appState: appState, controller: controller)
            .onAppear {
                UIApplication.shared.isIdleTimerDisabled = true
                appState.migrateLegacyCustomsIfNeeded()
                listener?.stop()
                let listener = AmbientListener(controller: controller)
                listener.start()
                self.listener = listener
            }
            .onDisappear {
                listener?.stop()
                listener = nil
            }
            .environment(errorPresenter)
            .errorAlert(errorPresenter)
    }
  • [ ] Step 4: Build all three platforms

Run:

cd apple/PhysarumApp && xcodegen generate \
 && xcodebuild -scheme Physarum -destination 'platform=macOS' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build
Expected: ** BUILD SUCCEEDED ** three times.

  • [ ] Step 5: Run full test suite (regression)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED ** (existing suite + the 2 new test classes).

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/UI/ErrorAlert.swift apple/PhysarumApp/Sources/App/RootView.swift apple/PhysarumApp/Sources/TV/TVRootView.swift
git commit -m "feat(a11y): mount central errorAlert at both app roots"

Phase 1 — Route the silent failure sites

Task 4: Mic-permission-denied surfaces an alert

Files: - Modify: apple/PhysarumApp/Sources/Audio/AudioInput.swift:20 (make permissionDenied observable) - Modify: apple/PhysarumApp/Sources/App/RootView.swift (observe + report)

  • [ ] Step 1: Make permissionDenied observable

In apple/PhysarumApp/Sources/Audio/AudioInput.swift, change line 20 from:

    @ObservationIgnored private var permissionDenied = false

to:

    // Observable (no @ObservationIgnored) so the UI can surface a denial: the
    // render loop reads it as a plain flag; SwiftUI observes the false→true edge.
    private(set) var permissionDenied = false

(The mutation site at line 44 self.permissionDenied = !granted runs on the main thread — requestPermission hops via DispatchQueue.main.async — so observation is main-thread-safe.)

  • [ ] Step 2: Observe and report in RootView

In apple/PhysarumApp/Sources/App/RootView.swift, add this .onChange to body (place it alongside the other .onChange handlers, e.g. right after the .onChange(of: scenePhase) block ending at line 176):

        .onChange(of: state.audio.permissionDenied) { _, denied in
            guard denied else { return }
            errorPresenter.report(
                title: "Microphone Access Off",
                message: "Physarum uses the microphone to react to sound. Turn it on in Settings to enable audio-reactive visuals.",
                recovery: .openSettings)
            state.setAudioEnabled(false)   // reflect reality: the toggle goes back off
        }
  • [ ] Step 3: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 4: Run full suite (no regression)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED **.

Note: the denial path itself requires a real device with the mic permission denied — that is part of Johannes' manual device pass (no host unit test possible).

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Audio/AudioInput.swift apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(a11y): surface microphone-permission-denied with Open Settings"

Task 5: Typed Photos-save errors + mapping (with test)

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureSaver.swift (return Result<Void, CaptureSaveError>) - Modify: apple/PhysarumApp/Sources/UI/UserFacingError.swift (add a pure mapping init) - Test: apple/PhysarumApp/Tests/CaptureSaveErrorTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/CaptureSaveErrorTests.swift:

import XCTest
@testable import Physarum

final class CaptureSaveErrorTests: XCTestCase {
    func testPermissionDeniedOffersSettings() {
        let e = UserFacingError(captureSaveError: .permissionDenied)
        XCTAssertEqual(e.recovery, .openSettings)
        XCTAssertEqual(e.title, "Couldn’t Save to Photos")
    }

    func testWriteFailedHasNoRecovery() {
        let e = UserFacingError(captureSaveError: .writeFailed)
        XCTAssertEqual(e.recovery, .none)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureSaveErrorTests Expected: FAIL — cannot find 'CaptureSaveError' / no init(captureSaveError:).

  • [ ] Step 3: Add CaptureSaveError + typed returns in CaptureSaver

In apple/PhysarumApp/Sources/Capture/CaptureSaver.swift, add the error type at the top of the file (below imports) and change BOTH functions to return Result. Replace the two functions (current lines 8–32) with:

/// Distinguishes a Photos-permission denial (recoverable via Settings) from a
/// write failure (transient).
enum CaptureSaveError: Error, Equatable {
    case permissionDenied
    case writeFailed
}

extension CaptureSaver {
    static func saveToPhotos(_ url: URL, isVideo: Bool) async -> Result<Void, CaptureSaveError> {
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else { return .failure(.permissionDenied) }
        let ok = await withCheckedContinuation { cont in
            PHPhotoLibrary.shared().performChanges {
                let req = PHAssetCreationRequest.forAsset()
                let opts = PHAssetResourceCreationOptions()
                opts.originalFilename = url.lastPathComponent
                req.addResource(with: isVideo ? .video : .photo, fileURL: url, options: opts)
            } completionHandler: { ok, _ in cont.resume(returning: ok) }
        }
        return ok ? .success(()) : .failure(.writeFailed)
    }

    static func saveLivePhoto(still: URL, video: URL) async -> Result<Void, CaptureSaveError> {
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else { return .failure(.permissionDenied) }
        let ok = await withCheckedContinuation { cont in
            PHPhotoLibrary.shared().performChanges {
                let req = PHAssetCreationRequest.forAsset()
                req.addResource(with: .photo, fileURL: still, options: nil)
                req.addResource(with: .pairedVideo, fileURL: video, options: nil)
            } completionHandler: { ok, _ in cont.resume(returning: ok) }
        }
        return ok ? .success(()) : .failure(.writeFailed)
    }
}

If CaptureSaver is currently declared as enum CaptureSaver { … } with these as direct members, keep them as members instead of the extension — match the existing declaration. The signatures are what matter.

  • [ ] Step 4: Add the pure mapping init

In apple/PhysarumApp/Sources/UI/UserFacingError.swift, append:

extension UserFacingError {
    init(captureSaveError e: CaptureSaveError) {
        switch e {
        case .permissionDenied:
            self.init(title: "Couldn’t Save to Photos",
                      message: "Physarum needs permission to add to your photo library. Turn it on in Settings.",
                      recovery: .openSettings)
        case .writeFailed:
            self.init(title: "Couldn’t Save to Photos",
                      message: "Something went wrong while saving. Please try again.",
                      recovery: .none)
        }
    }
}
  • [ ] Step 5: Run the test (passes) — but the app won't build yet

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureSaveErrorTests Expected: the test compiles + PASSES, but the FULL build will fail until Task 6 updates CaptureSheet.save() (which still expects Bool). That is fixed in the next task. If -only-testing fails to link because of the CaptureSheet call site, proceed to Task 6 first, then re-run.

  • [ ] Step 6: Commit (paired with Task 6 if the build is red — see note)

Do NOT commit a red build. If the app doesn't compile because CaptureSheet.save() still calls the old Bool API, complete Task 6, then commit Tasks 5+6 together:

git add apple/PhysarumApp/Sources/Capture/CaptureSaver.swift apple/PhysarumApp/Sources/UI/UserFacingError.swift apple/PhysarumApp/Tests/CaptureSaveErrorTests.swift
# (+ CaptureSheet.swift from Task 6)
git commit -m "feat(a11y): typed Photos-save errors + UserFacingError mapping"

Task 6: Capture sheet routes save + render failures through the presenter

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift (read presenter from env; route .failed save + service-render failure; drop the inline "check permissions" text)

  • [ ] Step 1: Read the presenter from the environment

In apple/PhysarumApp/Sources/Capture/CaptureSheet.swift, add near the top of the struct (with the other @Environment/@State properties):

    @Environment(ErrorPresenter.self) private var errorPresenter
  • [ ] Step 2: Update save() to map the typed result to the presenter

Replace the save(_:) function (current lines 242–255) with:

    private func save(_ out: CaptureOutput) {
        saveState = .saving
        Task {
            let result: Result<Void, CaptureSaveError>
            switch out {
            case .file(let u, let v): result = await CaptureSaver.saveToPhotos(u, isVideo: v)
            case .livePhoto(let s, let v): result = await CaptureSaver.saveLivePhoto(still: s, video: v)
            }
            switch result {
            case .success:
                saveState = .saved
                #if os(iOS)
                UINotificationFeedbackGenerator().notificationOccurred(.success)
                #endif
            case .failure(let e):
                saveState = .idle   // the alert now carries the message; no stale inline text
                errorPresenter.report(UserFacingError(captureSaveError: e))
            }
        }
    }
  • [ ] Step 3: Remove the now-redundant inline "Couldn't save" branch

In the switch saveState block (current lines 191–202), delete the .failed case (the alert replaces it). The block becomes:

            switch saveState {
            case .saved:
                Label("Saved to Photos", systemImage: "checkmark.circle.fill")
                    .foregroundStyle(.green)
                    .font(Theme.Fonts.label)
            default:
                EmptyView()
            }

The SaveState.failed case in the enum (line 29) is now unused by save(); leave the enum case in place (it's harmless) OR remove it if the compiler warns about exhaustiveness elsewhere. Prefer leaving it to minimise churn.

  • [ ] Step 4: Surface capture-RENDER failure too

The service's .failed(msg) render failure already shows failedView(msg) (with a Try-again button) inline — keep that in-context UI. Additionally report it once so VoiceOver users and anyone not looking at the sheet hear it. Find where service.state is switched (around line 54–64) and in the .failed(let msg) arm add a one-shot report via .onChange. Add this modifier to the sheet's root view:

        .onChange(of: service.state) { _, new in
            if case .failed(let msg) = new {
                errorPresenter.report(title: "Capture Failed", message: msg, recovery: .none)
            }
        }

CaptureService.State must be Equatable for .onChange. The digest shows it has associated values; if it is not already Equatable, add : Equatable to its declaration in CaptureService.swift (all payloads — Double, CaptureOutput, String — are Equatable, so the synthesised conformance compiles).

  • [ ] Step 5: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 6: Run full suite

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED **.

  • [ ] Step 7: Commit (Tasks 5+6 together)
git add apple/PhysarumApp/Sources/Capture/CaptureSaver.swift apple/PhysarumApp/Sources/Capture/CaptureSheet.swift apple/PhysarumApp/Sources/Capture/CaptureService.swift apple/PhysarumApp/Sources/UI/UserFacingError.swift apple/PhysarumApp/Tests/CaptureSaveErrorTests.swift
git commit -m "feat(a11y): route capture save + render failures through ErrorPresenter"

Task 7: Import-decode failures route through the presenter

Files: - Modify: apple/PhysarumApp/Sources/Library/LibraryView.swift (drop importError @State; report instead) - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift (same)

  • [ ] Step 1: LibraryView — read presenter, report on decode/slot-full

In apple/PhysarumApp/Sources/Library/LibraryView.swift:

Add (with the other env/state):

    @Environment(ErrorPresenter.self) private var errorPresenter

Delete line 26 @State private var importError: String? and delete the inline display (lines 107–111):

            if let importError {
                Text(importError)
                    .font(Theme.Fonts.eyebrow)
                    .foregroundStyle(Theme.Colors.fgTertiary)
            }

Replace the .fileImporter handler (lines 175–188) with:

        .fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in
            guard case .success(let url) = result else { return }   // user cancelled → silent
            let scoped = url.startAccessingSecurityScopedResource()
            defer { if scoped { url.stopAccessingSecurityScopedResource() } }
            guard let data = try? Data(contentsOf: url),
                  let preset = try? JSONDecoder().decode(Preset.self, from: data) else {
                errorPresenter.report(title: "Couldn’t Import Preset",
                                      message: "That file isn’t a valid Physarum preset.")
                return
            }
            if !state.importIntoSlot(preset) {
                errorPresenter.report(title: "No Free Slot",
                                      message: "All 24 slots are full. Clear one, then import again.")
            }
        }
  • [ ] Step 2: AudioReactiveSection — same treatment

In apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift:

Add:

    @Environment(ErrorPresenter.self) private var errorPresenter

Delete line 15 @State private var importError: String? and the inline display (lines 112–115):

            if let importError {
                Text(importError)
                    .font(Theme.Fonts.eyebrow).foregroundStyle(Theme.Colors.fgTertiary)
            }

Replace the .fileImporter handler (lines 124–135) with:

        .fileImporter(isPresented: $showImporter, allowedContentTypes: [.json]) { result in
            guard case .success(let url) = result else { return }
            let scoped = url.startAccessingSecurityScopedResource()
            defer { if scoped { url.stopAccessingSecurityScopedResource() } }
            guard let data = try? Data(contentsOf: url),
                  let cfg = try? JSONDecoder().decode(AudioConfig.self, from: data) else {
                errorPresenter.report(title: "Couldn’t Import Audio Config",
                                      message: "That file isn’t a valid Physarum audio config.")
                return
            }
            state.importAudioConfig(cfg)
        }
  • [ ] Step 3: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 4: Run full suite

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED **.

Note: PresetImportTests/AudioConfigImportTests exercise the model paths (importIntoSlot/importAudioConfig) — those are unchanged, so they stay green. Only the error presentation moved.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Library/LibraryView.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift
git commit -m "feat(a11y): route preset/audio import errors through ErrorPresenter"

Phase 2 — Dynamic Type

Task 8: FontRole mapping + scaling Theme.Fonts (with test)

Files: - Modify: apple/PhysarumApp/Sources/UI/PhysarumTheme.swift (replace the enum Fonts block, lines 78–84, add FontRole) - Test: apple/PhysarumApp/Tests/FontRoleTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/FontRoleTests.swift:

import XCTest
import SwiftUI
@testable import Physarum

final class FontRoleTests: XCTestCase {
    func testTextStyleMapping() {
        XCTAssertEqual(FontRole.eyebrow.textStyle, .caption2)
        XCTAssertEqual(FontRole.label.textStyle, .caption)
        XCTAssertEqual(FontRole.value.textStyle, .caption)
        XCTAssertEqual(FontRole.title.textStyle, .subheadline)
        XCTAssertEqual(FontRole.body.textStyle, .footnote)
    }

    func testMonospacedRoles() {
        XCTAssertTrue(FontRole.eyebrow.monospaced)
        XCTAssertTrue(FontRole.label.monospaced)
        XCTAssertTrue(FontRole.value.monospaced)
        XCTAssertFalse(FontRole.title.monospaced)
        XCTAssertFalse(FontRole.body.monospaced)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/FontRoleTests Expected: FAIL — cannot find 'FontRole' in scope.

  • [ ] Step 3: Replace enum Fonts with the scaling FontRole mapping

In apple/PhysarumApp/Sources/UI/PhysarumTheme.swift, replace the current block (lines 78–84):

    enum Fonts {
        static let eyebrow = Font.system(size: 10, weight: .semibold, design: .monospaced)
        static let label = Font.system(size: 11, weight: .medium, design: .monospaced)
        static let value = Font.system(size: 11, weight: .medium, design: .monospaced)
        static let title = Font.system(size: 15, weight: .semibold)
        static let body = Font.system(size: 13)
    }

with:

    enum Fonts {
        static let eyebrow = FontRole.eyebrow.font
        static let label = FontRole.label.font
        static let value = FontRole.value.font
        static let title = FontRole.title.font
        static let body = FontRole.body.font
    }
}

/// The five semantic text roles, each mapped to a Dynamic-Type text style so the
/// UI scales with the user's preferred size. The mono roles keep the SF-Mono look.
/// Defined as a pure mapping so the scaling contract is unit-testable (SwiftUI
/// `Font` itself is opaque). The chosen styles match the previous fixed sizes at
/// the default content size (caption2≈11, caption≈12, subheadline≈15, footnote≈13).
enum FontRole: CaseIterable {
    case eyebrow, label, value, title, body

    var textStyle: Font.TextStyle {
        switch self {
        case .eyebrow: return .caption2
        case .label, .value: return .caption
        case .title: return .subheadline
        case .body: return .footnote
        }
    }

    var weight: Font.Weight {
        switch self {
        case .eyebrow: return .semibold
        case .label, .value: return .medium
        case .title: return .semibold
        case .body: return .regular
        }
    }

    var monospaced: Bool {
        switch self {
        case .eyebrow, .label, .value: return true
        case .title, .body: return false
        }
    }

    var font: Font {
        Font.system(textStyle, design: monospaced ? .monospaced : .default).weight(weight)
    }

Note the structure: the closing } of enum Theme now sits after enum Fonts, and FontRole is a top-level enum inside the same #if !os(tvOS) block. Verify the brace balance after the edit (the file ends with #endif).

  • [ ] Step 4: Run the test (passes)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/FontRoleTests Expected: PASS.

  • [ ] Step 5: Build all three platforms (call sites unchanged)

All Theme.Fonts.* call sites are unchanged (still return Font), so nothing else needs editing. Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build Expected: ** BUILD SUCCEEDED ** (note: PhysarumTheme.swift is #if !os(tvOS), so the tvOS build simply excludes it — confirm tvOS still builds).

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/UI/PhysarumTheme.swift apple/PhysarumApp/Tests/FontRoleTests.swift
git commit -m "feat(a11y): scale Theme.Fonts with Dynamic Type via testable FontRole"

Task 9: Fix layout break-points at large Dynamic-Type sizes

Files: - Modify: apple/PhysarumApp/Sources/App/RootView.swift (topbar HStack — allow truncation/wrap) - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift (route rows — already use ViewThatFits/lineLimit per Inc 11; verify they survive AX sizes) - Modify: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift (segmented pickers + buttons)

SF Symbol icon glyphs stay at their fixed point sizes (they live in fixed 34×30 tap-target frames in the topbar; scaling them would overflow the bar). Dynamic Type targets TEXT.

  • [ ] Step 1: Cap the densest fixed-height rows

The topbar (RootView.topbar, lines 280–326) is a single horizontal row of icons + the preset pill. At AX5 the pill's preset name can crowd the icons. The pill already has .lineLimit(1).truncationMode(.tail) (line 294) — good. Add a Dynamic-Type cap to the topbar only (it cannot grow vertically) so glyph-adjacent text stays bounded. After .glassPanel() (line 324) add:

        .dynamicTypeSize(...DynamicTypeSize.accessibility1)
  • [ ] Step 2: Let the editor panels scroll/wrap (verify, fix if needed)

EditorView content is hosted in a scroll context already (inspector column / sheet with .presentationContentInteraction(.scrolls)). Open AudioReactiveSection.swift and confirm the route rows use ViewThatFits/lineLimit (Inc 11). If any fixed HStack of labels overflows at AX5, wrap its label Text with .lineLimit(2) and .minimumScaleFactor(0.8). Do NOT cap the editor's Dynamic-Type size (it scrolls, so it may grow).

  • [ ] Step 3: Build + visual smoke (macOS, large text)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build Expected: ** BUILD SUCCEEDED **.

Full visual verification at AX sizes is part of Johannes' device pass (headless can't screenshot the running sim meaningfully). This task only prevents structural clipping.

  • [ ] Step 4: Run full suite

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED **.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/App/RootView.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift apple/PhysarumApp/Sources/Capture/CaptureSheet.swift
git commit -m "fix(a11y): keep topbar bounded + editor wrapping at large Dynamic Type"

Phase 3 — VoiceOver

Task 10: Pure A11y value helpers (with test)

Files: - Create: apple/PhysarumApp/Sources/UI/A11y.swift - Test: apple/PhysarumApp/Tests/A11yTests.swift

  • [ ] Step 1: Write the failing test

Create apple/PhysarumApp/Tests/A11yTests.swift:

import XCTest
@testable import Physarum

final class A11yTests: XCTestCase {
    func testOnOff() {
        XCTAssertEqual(A11y.onOff(true), "On")
        XCTAssertEqual(A11y.onOff(false), "Off")
    }
    func testStateWords() {
        XCTAssertEqual(A11y.locked(true), "Locked")
        XCTAssertEqual(A11y.locked(false), "Unlocked")
        XCTAssertEqual(A11y.pinned(true), "Pinned")
        XCTAssertEqual(A11y.pinned(false), "Not pinned")
        XCTAssertEqual(A11y.selected(true), "Selected")
        XCTAssertEqual(A11y.selected(false), "Not selected")
    }
    func testCanvas() {
        XCTAssertEqual(A11y.canvasLabel, "Physarum simulation")
        XCTAssertEqual(A11y.canvasValue(presetName: "Aurora"), "Aurora")
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/A11yTests Expected: FAIL — cannot find 'A11y' in scope.

  • [ ] Step 3: Implement

Create apple/PhysarumApp/Sources/UI/A11y.swift:

import Foundation

/// Pure VoiceOver value/label helpers — the logic-bearing transforms used by the
/// inline `.accessibilityValue`/`.accessibilityLabel` passes. Kept pure so the
/// announced strings are unit-testable (the SwiftUI modifiers themselves are not).
enum A11y {
    static func onOff(_ on: Bool) -> String { on ? "On" : "Off" }
    static func locked(_ locked: Bool) -> String { locked ? "Locked" : "Unlocked" }
    static func pinned(_ pinned: Bool) -> String { pinned ? "Pinned" : "Not pinned" }
    static func selected(_ selected: Bool) -> String { selected ? "Selected" : "Not selected" }

    static let canvasLabel = "Physarum simulation"
    static func canvasValue(presetName: String) -> String { presetName }
}
  • [ ] Step 4: Run the test (passes)

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/A11yTests Expected: PASS.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/UI/A11y.swift apple/PhysarumApp/Tests/A11yTests.swift
git commit -m "feat(a11y): pure VoiceOver value helpers"

Task 11: Canvas accessibility element + topbar labels (RootView)

Files: - Modify: apple/PhysarumApp/Sources/App/RootView.swift

  • [ ] Step 1: Label the canvas

In playerLayer (line 227), after the MetalView(...) modifier chain (.frame/.clipped/.contentShape/.onTapGesture, ending line 236) add:

                    .accessibilityElement()
                    .accessibilityLabel(A11y.canvasLabel)
                    .accessibilityValue(A11y.canvasValue(presetName: state.selectedPreset.name))
                    .accessibilityAddTraits(.updatesFrequently)
  • [ ] Step 2: Label the topbar controls

Apply these (exact strings) in topbar (lines 280–326), formatMenu (484–500), and appleTVButton (361–387). For each control add .accessibilityLabel(...), plus .accessibilityHint(...) and .accessibilityValue(...) where listed:

Control (line) label hint value
sidebar toggle iconButton (283) "Library panel" "Shows or hides the preset browser" A11y.onOff(sidebarShown)
preset pill Button (290) "Current preset" "Opens the library to pick another preset" state.selectedPreset.name
appleTVButton (361) "Apple TV" "Mirror this session to an Apple TV" remote.state == .connected ? "Connected" : "Disconnected"
formatMenu (484) "Canvas format" "Aspect ratio and fill or fit" —
save iconButton (304) "Save preset" "Save the current preset into a slot" —
capture Button (305) "Capture" "Export a photo, video, or embed" —
fullscreen iconButton (316) "Enter fullscreen" — —
inspector iconButton (317) "Editor panel" "Shows or hides the parameter editor" A11y.onOff(inspectorShown)

For iconButton, the cleanest approach is to add an optional label/hint to the helper. Change the helper signature to iconButton(_ name: String, label: String, hint: String? = nil, _ action: …) and apply .accessibilityLabel(label) (+ hint) inside it; update each iconButton(...) call site with its label. The exit/enter fullscreen controls (exitFullscreenControl line 542, captureFullscreenButton line 521) get .accessibilityLabel("Exit fullscreen") / .accessibilityLabel("Capture") directly.

  • [ ] Step 3: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 4: Commit
git add apple/PhysarumApp/Sources/App/RootView.swift
git commit -m "feat(a11y): VoiceOver labels for canvas + topbar"

Task 12: VoiceOver labels for reusable components (Components.swift)

Files: - Modify: apple/PhysarumApp/Sources/UI/Components.swift

Labelling the reusable components covers their many call sites at once.

  • [ ] Step 1: Apply labels

  • PhysSection button (line 20): add .accessibilityLabel(title) + .accessibilityHint("Expand or collapse this section") + .accessibilityValue(A11y.onOff(expanded)) (semantically "expanded/collapsed" → use .accessibilityAddTraits(expanded ? [] : []); simplest: .accessibilityValue(expanded ? "Expanded" : "Collapsed")).

  • PhysButton (line 57): add .accessibilityLabel(title) (the visible text is uppercased/letter-spaced; an explicit label reads the plain title).
  • TagPill (line 122): .accessibilityLabel("\(label) filter") + .accessibilityValue(A11y.selected(active)).
  • PresetCard (line 147): .accessibilityLabel(preset.name) + .accessibilityHint("Select this preset") + .accessibilityValue(A11y.selected(selected)) on the outer Button; mark the thumbnail decorative via .accessibilityHidden(true).

  • [ ] Step 2: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/UI/Components.swift
git commit -m "feat(a11y): VoiceOver labels for reusable components"

Task 13: VoiceOver labels for the editor (EditorView, Sections, ParamSlider)

Files: - Modify: apple/PhysarumApp/Sources/Editor/EditorView.swift - Modify: apple/PhysarumApp/Sources/Editor/Sections.swift - Modify: apple/PhysarumApp/Sources/Editor/ParamSlider.swift

  • [ ] Step 1: Apply labels per the inventory

Use the exact label/hint/value from the inventory (spec §4③ inventory). Highlights: - EditorView buttons: Randomize / Mutate / Morph to preset / Undo (value state.canUndo ? "Available" : "Unavailable") / Redo (value canRedo) / Reset all; autoCycle Toggle label "Auto-cycle" value A11y.onOff(state.autoCycle). - Sections.swift: spawn-pattern Picker/Menu "Spawn pattern"; blend-mode "Blend mode"; background ColorPicker "Background color"; species selector "Species"; species color "Species color"; each LockButton label "Lock " value A11y.locked(state.isLocked(id)); matrix ParamSlider already gets its hint from ParamHelp.matrix. - ParamSlider.swift: ensure the Slider itself has .accessibilityLabel(descriptor.label), .accessibilityValue(<formatted value>), .accessibilityHint(descriptor.tooltip) (the reset button already has its label at line 28).

  • [ ] Step 2: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Editor/EditorView.swift apple/PhysarumApp/Sources/Editor/Sections.swift apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(a11y): VoiceOver labels for the editor controls"

Task 14: VoiceOver labels for AudioReactiveSection

Files: - Modify: apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift

  • [ ] Step 1: Apply labels per the inventory

  • Enabled Toggle: label "Audio reactive", value A11y.onOff(state.audioReactive.enabled).

  • Auto-gain Toggle: label "Auto-gain", value A11y.onOff(state.audioReactive.autoGain).
  • Gain Slider: label "Input gain", value String(format: "%.2f×", ar.gain).
  • Saved-config buttons (Apply/Export/Delete): labels "Apply audio config"/"Export audio config"/"Delete audio config", value cfg.name.
  • Import / Save-current-as / Apply-default-wiring / Add-route / Remove-route buttons: labels per inventory.
  • Source/Target/Mode Pickers already have labels (lines 246/268/284) — leave; add .accessibilityValue of the current selection where missing.
  • Depth Slider has label (299) — add value String(format: "%+.1f", depth).
  • DEBUG tuning sliders (#if DEBUG): label each ("Envelope coefficient"/"Gate low"/"Gate high"), value formatted; Reset-tuning button "Reset tuning".

  • [ ] Step 2: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift
git commit -m "feat(a11y): VoiceOver labels for audio-reactive section"

Task 15: VoiceOver labels for Library, Capture, Morph, Save dialogs

Files: - Modify: apple/PhysarumApp/Sources/Library/LibraryView.swift - Modify: apple/PhysarumApp/Sources/Library/SaveDialog.swift - Modify: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift - Modify: apple/PhysarumApp/Sources/Editor/MorphSheet.swift

  • [ ] Step 1: Apply labels per the inventory

  • LibraryView: search TextField "Search presets"; Save-current / Import / Keep-only-pinned (value state.pinned.isEmpty ? "No pinned presets" : "") / Reset-all / Close buttons; per-card pin star "Pin" value A11y.pinned(state.isPinned(preset.id)); export/clear-slot context buttons with preset.name value.

  • SaveDialog: slot Picker "Save to slot"; Save / Cancel; Overwrite confirmation buttons.
  • CaptureSheet: mode Picker "Capture mode" value mode.rawValue; video-start / resolution / codec / photo-format Pickers; Capture/Render PhysButton; Copy-embed (value copied ? "Copied" : "Copy"); ShareLink "Share"; Done / Cancel / Try-again buttons; Save-to-Photos / Export buttons.
  • MorphSheet: target Picker "Morph to"; blend Slider "Blend factor"; Cancel / Apply.

  • [ ] Step 2: Build (macOS + iOS)

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/Library/LibraryView.swift apple/PhysarumApp/Sources/Library/SaveDialog.swift apple/PhysarumApp/Sources/Capture/CaptureSheet.swift apple/PhysarumApp/Sources/Editor/MorphSheet.swift
git commit -m "feat(a11y): VoiceOver labels for library, capture, morph, save"

Task 16: VoiceOver labels for tvOS focus controls

Files: - Modify: apple/PhysarumApp/Sources/TV/TVOverlayView.swift (preset carousel items) - Modify: apple/PhysarumApp/Sources/TV/TVPlayerView.swift (canvas focus element if applicable)

  • [ ] Step 1: Label the carousel + canvas

Add .accessibilityLabel(preset.name) to each focusable carousel item in TVOverlayView. Give the TV canvas the same A11y.canvasLabel + A11y.canvasValue(presetName:) element as the iOS canvas if a focusable element is appropriate (tvOS VoiceOver reads focused elements). Keep the existing focus-engine wiring (Inc 12) untouched — only add accessibility modifiers.

  • [ ] Step 2: Build tvOS

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 3: Commit
git add apple/PhysarumApp/Sources/TV/TVOverlayView.swift apple/PhysarumApp/Sources/TV/TVPlayerView.swift
git commit -m "feat(a11y): VoiceOver labels for tvOS focus controls"

Phase 4 — Final verification

Task 17: Full three-platform build + full test suite

  • [ ] Step 1: Regenerate + build all three platforms

Run:

cd apple/PhysarumApp && xcodegen generate \
 && xcodebuild -scheme Physarum -destination 'platform=macOS' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build \
 && xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build
Expected: ** BUILD SUCCEEDED ** three times.

  • [ ] Step 2: Full host test suite

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' Expected: ** TEST SUCCEEDED ** — all prior tests plus UserFacingErrorTests, ErrorPresenterTests, CaptureSaveErrorTests, FontRoleTests, A11yTests.

  • [ ] Step 3: Engine smoke (unchanged, sanity)

Run: cd apple/PhysarumEngine && swift test Expected: all engine tests pass (this plan does not touch the engine, but confirm no incidental breakage).

  • [ ] Step 4: Web untouched (sanity)

Run: cd "$(git rev-parse --show-toplevel)" && npm test 2>&1 | tail -5 Expected: vitest 145/145 (the web app is out of scope; confirm no accidental change).

  • [ ] Step 5: Produce the device-pass checklist

Create apple/PhysarumApp/docs/SP1-device-signoff.md with a checkbox list for Johannes: - VoiceOver on (iPhone + iPad): swipe through topbar, editor, library, capture — every control announces a sensible label; the canvas announces "Physarum simulation, ". - Dynamic Type at the largest accessibility size: topbar stays bounded, editor scrolls/wraps without clipping. - Mic permission denied → an alert with "Open Settings" appears; the Audio toggle returns to off. - Photos permission denied during Save → an alert with "Open Settings" appears (no silent failure). - Import a malformed JSON preset / audio config → a clear alert appears. - tvOS: VoiceOver reads the preset carousel; focus navigation still works (Inc 12 unbroken).

  • [ ] Step 6: Commit
git add apple/PhysarumApp/docs/SP1-device-signoff.md
git commit -m "docs(a11y): SP1 device sign-off checklist"

Done criteria

  • [ ] All five silent failure sites route through ErrorPresenter; housekeeping try? untouched.
  • [ ] Theme.Fonts scale with Dynamic Type (testable FontRole mapping); topbar bounded, editor wraps.
  • [ ] Every interactive control + the canvas has a VoiceOver label (iOS/iPad/macOS + tvOS focus items).
  • [ ] New pure-logic tests green: UserFacingErrorTests, ErrorPresenterTests, CaptureSaveErrorTests, FontRoleTests, A11yTests.
  • [ ] macOS + iOS + tvOS build; full host suite + engine swift test + web vitest all green.
  • [ ] Device sign-off checklist written for Johannes.

Follow-on

SP2 — App-Store-Submission-Readiness (Privacy manifest, app-store export config, screenshots, metadata, ASC checklist). Separate spec→plan→build after SP1's device sign-off. See spec §9.


Appendix A — VoiceOver control inventory

Authoritative label/hint/value source for Tasks 11–16. Apply .accessibilityLabel to every row, plus .accessibilityHint/.accessibilityValue where given. Rows marked "HAS" already carry that piece — leave it. Line numbers are at the time of writing; locate the control by its description if drifted.

RootView.swift (Task 11)

Line Control label hint value
283 sidebar toggle Library panel Shows or hides the preset browser A11y.onOff(sidebarShown)
290 preset pill Current preset Opens the library to pick another preset state.selectedPreset.name
301/361 appleTVButton Apple TV Mirror this session to an Apple TV remote.state == .connected ? "Connected" : "Disconnected"
303/484 formatMenu Canvas format Aspect ratio and fill or fit —
304 save icon Save preset Save the current preset into a slot —
305 capture Capture Export a photo, video, or embed —
316 fullscreen Enter fullscreen — —
317 inspector toggle Editor panel Shows or hides the parameter editor A11y.onOff(inspectorShown)
227 canvas (MetalView) A11y.canvasLabel — A11y.canvasValue(presetName: state.selectedPreset.name)
399 Search again Search again Retry Apple TV discovery —
411 device row Connect Mirror to this Apple TV device.name
521 captureFullscreenButton Capture — —
542 exitFullscreenControl Exit fullscreen — —

Components.swift (Task 12)

Line Control label hint value
20 PhysSection toggle title Expand or collapse this section expanded ? "Expanded" : "Collapsed"
57 PhysButton title — —
122 TagPill "\(label) filter" Filter presets by this tag A11y.selected(active)
147 PresetCard preset.name Select this preset A11y.selected(selected) (mark thumbnail .accessibilityHidden(true))

EditorView.swift / Sections.swift / ParamSlider.swift (Task 13)

Line Control label hint value
EditorView 50 Randomize Randomize New random parameters; locked ones stay —
EditorView 51 Mutate Mutate Slightly tweak parameters; locked ones stay —
EditorView 52 Morph Morph to preset Blend smoothly to another preset —
EditorView 54 Undo Undo Revert the last parameter change state.canUndo ? "Available" : "Unavailable"
EditorView 57 Redo Redo Restore the last undone change state.canRedo ? "Available" : "Unavailable"
EditorView 83 autoCycle Toggle Auto-cycle Advance presets every 25 seconds A11y.onOff(state.autoCycle)
EditorView 90 Reset all Reset all Discard edits; restore preset defaults —
Sections 110/118 spawn pattern Spawn pattern How agents are placed when seeding current pattern
Sections 127 lock spawn Lock spawn pattern Keep randomize/mutate from changing this A11y.locked(state.isLocked(id))
Sections 137/145 blend mode Blend mode How agents composite over the background current blend
Sections 154 lock blend Lock blend mode Keep randomize/mutate from changing this A11y.locked(state.isLocked(id))
Sections 163 background color Background color Colour of the empty void RGB hex
Sections 167 lock background Lock background color Keep randomize/mutate from changing this A11y.locked(state.isLocked(id))
Sections 179 species selector Species Pick which species to edit (All/R/G/B) current species
Sections 201 species color Species color Deposit colour for this species RGB hex
Sections 211 lock species color Lock species color Keep randomize/mutate from changing this A11y.locked(state.isLocked(id))
ParamSlider 33 param slider descriptor.label descriptor.tooltip (HAS hint) formatted value

AudioReactiveSection.swift (Task 14)

Line Control label hint value
24 Enabled Toggle Audio reactive React to microphone sound A11y.onOff(state.audioReactive.enabled)
31 Auto-gain Toggle Auto-gain Auto-scale reaction to loudness A11y.onOff(state.audioReactive.autoGain)
43 Gain Slider Input gain Master reaction strength String(format: "%.2f×", ar.gain)
82 apply config Apply audio config Switch to this saved routing cfg.name
87 export config Export audio config Save this config to a file cfg.name
92 delete config Delete audio config Remove this saved config cfg.name
97 Import Import audio config Load a config from a file —
105 Save current as Save audio config Save the current routing as new —
150 Default wiring Apply default wiring Restore the starter routes —
153 Add route Add audio route Create a source→target modulation —
238/246 Source Picker Source (HAS) — current band
259/268 Target Picker Target (HAS) — current target
272/284 Mode Picker Mode (HAS) Level or transient current mode
288/299 Depth Slider Depth (HAS) — String(format: "%+.1f", depth)
308 remove route Remove route Delete this modulation —
377 Env coef (DEBUG) Envelope coefficient Loudness smoothing formatted
380 Gate lo (DEBUG) Gate low Silence lower bound formatted
384 Gate hi (DEBUG) Gate high Silence upper bound formatted
387 Reset tuning (DEBUG) Reset tuning Restore tuning defaults —

Library / Capture / Morph / Save (Task 15)

File:Line Control label hint value
LibraryView 67 search field Search presets Filter by name or tag —
LibraryView 71 save current Save current Open the save dialog —
LibraryView 78 import Import preset Load a preset file —
LibraryView 85 keep pinned Keep only pinned Clear unpinned slots state.pinned.isEmpty ? "No pinned presets" : ""
LibraryView 87 reset all Reset all presets Restore factory defaults —
LibraryView 99 close Close Dismiss the library —
LibraryView 139/153 pin star Pin Add or remove from Apple TV rotation A11y.pinned(state.isPinned(preset.id))
LibraryView 152 export preset Export preset Save preset to a file preset.name
LibraryView 155 clear slot Clear slot Empty this library slot preset.name
SaveDialog 31 slot Picker Save to slot Which slot to write into current slot
SaveDialog 58/59 Save / Cancel Save preset / Cancel — —
SaveDialog 76/77 Overwrite / Cancel Overwrite / Cancel Replace the existing preset —
CaptureSheet 44 mode Picker Capture mode Photo, video, or embed mode.rawValue
CaptureSheet 79 video start Video start From current frame or from start videoFromScratch ? "From start" : "From current"
CaptureSheet 103 resolution Resolution Output pixel size current size
CaptureSheet 84 codec Video codec HEVC or H.264 codec.label
CaptureSheet 89 photo format Photo format PNG or JPEG photoFormat.label
CaptureSheet 92 Capture/Render Capture Start the export —
CaptureSheet 135 copy embed Copy embed code Copy the HTML snippet copied ? "Copied" : "Copy"
CaptureSheet 136 ShareLink Share — —
CaptureSheet 164/218 Cancel / Done Cancel / Done — —
CaptureSheet 204 Save to Photos Save to Photos Save the file to Photos saveState == .saved ? "Saved" : ""
CaptureSheet 208 Export Export Save to a custom location —
CaptureSheet 271 Try again Try again Retry the capture —
MorphSheet 22 target Picker Morph to Pick the target preset target name
MorphSheet 29 blend Slider Blend factor 0 = start, 1 = target percent
MorphSheet 36/40 Cancel / Apply Cancel / Apply morph — —

tvOS (Task 16)

File Control label value
TVOverlayView carousel item preset.name —
TVPlayerView canvas (if focusable) A11y.canvasLabel A11y.canvasValue(presetName:)