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(allSources/…andTests/…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 namedPhysarumAppTests(confirm inproject.ymltargets: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()):
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:
- [ ] Step 3: Mount in TVRootView
In apple/PhysarumApp/Sources/TV/TVRootView.swift, add near the other @State properties:
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
** 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
permissionDeniedobservable
In apple/PhysarumApp/Sources/Audio/AudioInput.swift, change line 20 from:
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
CaptureSaveris currently declared asenum CaptureSaver { … }with these as direct members, keep them as members instead of theextension— 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):
- [ ] 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.failedcase 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.Statemust beEquatablefor.onChange. The digest shows it has associated values; if it is not alreadyEquatable, add: Equatableto its declaration inCaptureService.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):
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:
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/AudioConfigImportTestsexercise 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 Fontswith the scalingFontRolemapping
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
}ofenum Themenow sits afterenum Fonts, andFontRoleis 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:
- [ ] 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 toiconButton(_ name: String, label: String, hint: String? = nil, _ action: …)and apply.accessibilityLabel(label)(+ hint) inside it; update eachiconButton(...)call site with its label. The exit/enter fullscreen controls (exitFullscreenControlline 542,captureFullscreenButtonline 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
-
PhysSectionbutton (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 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
.accessibilityValueof 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 (valuestate.pinned.isEmpty ? "No pinned presets" : "") / Reset-all / Close buttons; per-card pin star "Pin" valueA11y.pinned(state.isPinned(preset.id)); export/clear-slot context buttons withpreset.namevalue. SaveDialog: slot Picker "Save to slot"; Save / Cancel; Overwrite confirmation buttons.CaptureSheet: mode Picker "Capture mode" valuemode.rawValue; video-start / resolution / codec / photo-format Pickers; Capture/Render PhysButton; Copy-embed (valuecopied ? "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
** 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,
- [ ] 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; housekeepingtry?untouched. - [ ]
Theme.Fontsscale with Dynamic Type (testableFontRolemapping); 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:) |