Zum Inhalt

Native Finishing Touches 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: Three polish items — macOS slider Shift/Alt fine-coarse (scroll + arrows), custom-preset rename/reorder, and tooltips on non-scalar editor controls.

Architecture: #1 a pure precisionStep + a macOS-only .precisionControls modifier on ParamSlider's Slider (a .background NSView local scroll-wheel monitor that nudges when the pointer is over the slider — no overlay, so native drag stays intact — plus .focusable()/.onKeyPress arrows). #2 wires the already-existing AppState.rename/PresetStore.reorder backend into the Library context menu + a RenameDialog. #3 extracts ParamSlider's label+ⓘ into a reusable HelpLabel and attaches it to the non-scalar controls, with help texts ported from the web.

Tech Stack: Swift 5 / SwiftUI, AppKit (NSEvent scroll monitor, macOS), XcodeGen, XCTest.

Spec: docs/superpowers/specs/2026-06-05-native-finishing-touches-design.md


File Structure

  • Create apple/PhysarumApp/Sources/Editor/SliderPrecision.swift — precisionStep + the macOS .precisionControls modifier (scroll monitor + arrow keys).
  • Create apple/PhysarumApp/Sources/UI/HelpLabel.swift — reusable label + inline ⓘ + popover.
  • Create apple/PhysarumApp/Sources/Library/RenameDialog.swift — rename sheet.
  • Modify apple/PhysarumApp/Sources/Editor/ParamSlider.swift — use HelpLabel; attach .precisionControls.
  • Modify apple/PhysarumApp/Sources/Model/ParamHelp.swift — non-scalar entries.
  • Modify apple/PhysarumApp/Sources/Model/AppState.swift — moveCustom(id:up:) + reorderedIDs helper.
  • Modify apple/PhysarumApp/Sources/Editor/Sections.swift — wrap non-scalar rows with HelpLabel.
  • Modify apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift — wrap toggles/gain with HelpLabel.
  • Modify apple/PhysarumApp/Sources/Library/LibraryView.swift — context menu (Rename/Move Up/Move Down/Delete) + rename sheet.
  • Tests: Tests/SliderPrecisionTests.swift, Tests/PresetReorderTests.swift, Tests/ParamHelpTests.swift.

PART A — Increment 5a: Slider precision (macOS)

Task 1: precisionStep (pure)

Files: Create apple/PhysarumApp/Sources/Editor/SliderPrecision.swift; Test apple/PhysarumApp/Tests/SliderPrecisionTests.swift.

  • [ ] Step 1: Write the failing test
// apple/PhysarumApp/Tests/SliderPrecisionTests.swift
import XCTest
@testable import Physarum

final class SliderPrecisionTests: XCTestCase {
    func testBaseStepIsRangeOver100WhenNil() {
        XCTAssertEqual(precisionStep(range: 0...10, baseStep: nil, fine: false, coarse: false), 0.1, accuracy: 1e-9)
    }
    func testExplicitBaseStep() {
        XCTAssertEqual(precisionStep(range: 0...10, baseStep: 1, fine: false, coarse: false), 1, accuracy: 1e-9)
    }
    func testFineIsTenthOfBase() {
        XCTAssertEqual(precisionStep(range: 0...10, baseStep: 1, fine: true, coarse: false), 0.1, accuracy: 1e-9)
    }
    func testCoarseIsTenTimesBase() {
        XCTAssertEqual(precisionStep(range: 0...10, baseStep: 1, fine: false, coarse: true), 10, accuracy: 1e-9)
    }
    func testFineTakesPrecedenceOverCoarse() {
        XCTAssertEqual(precisionStep(range: 0...10, baseStep: 1, fine: true, coarse: true), 0.1, accuracy: 1e-9)
    }
}
  • [ ] Step 2: Run to verify it fails

cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/SliderPrecisionTests 2>&1 | tail -20 Expected: FAIL to compile (precisionStep undefined).

  • [ ] Step 3: Write SliderPrecision.swift (pure fn first; the modifier is added in Task 2)
// apple/PhysarumApp/Sources/Editor/SliderPrecision.swift
import SwiftUI

/// The value delta for one fine/coarse precision step. base = baseStep ?? range/100.
/// fine → ×0.1 (Shift), coarse → ×10 (Option). fine takes precedence if both held.
func precisionStep(range: ClosedRange<Double>, baseStep: Double?, fine: Bool, coarse: Bool) -> Double {
    let span = range.upperBound - range.lowerBound
    let base = baseStep ?? (span / 100)
    let mult: Double = fine ? 0.1 : (coarse ? 10 : 1)
    return base * mult
}
  • [ ] Step 4: Run to verify it passes

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/SliderPrecisionTests 2>&1 | tail -20 Expected: TEST SUCCEEDED (5).

  • [ ] Step 5: Commit

cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/SliderPrecision.swift apple/PhysarumApp/Tests/SliderPrecisionTests.swift
git commit -m "feat(app): precisionStep — fine/coarse slider step (web parity)"
(Do NOT git add the gitignored .xcodeproj.)


Task 2: macOS precision modifier on ParamSlider (scroll + arrows)

Files: Modify apple/PhysarumApp/Sources/Editor/SliderPrecision.swift; Modify apple/PhysarumApp/Sources/Editor/ParamSlider.swift.

GPU-free but AppKit/interaction — build-verified + Johannes-manual on macOS.

  • [ ] Step 1: Append the .precisionControls modifier to SliderPrecision.swift
// append to apple/PhysarumApp/Sources/Editor/SliderPrecision.swift

#if os(macOS)
import AppKit

/// A zero-size background NSView that installs a local scroll-wheel monitor and
/// nudges when the pointer is over the host view's frame. Placed as a `.background`
/// (behind the Slider) so it NEVER intercepts the slider's clicks/drag — it only
/// reads scroll events and consumes them when they land on this slider.
private struct SliderScrollMonitor: NSViewRepresentable {
    let onTick: (_ up: Bool, _ fine: Bool, _ coarse: Bool, _ newGesture: Bool) -> Void
    func makeNSView(context: Context) -> NSView { MonitorView(onTick: onTick) }
    func updateNSView(_ nsView: NSView, context: Context) { (nsView as? MonitorView)?.onTick = onTick }

    final class MonitorView: NSView {
        var onTick: (Bool, Bool, Bool, Bool) -> Void
        private var monitor: Any?
        private var lastScroll: TimeInterval = 0
        init(onTick: @escaping (Bool, Bool, Bool, Bool) -> Void) { self.onTick = onTick; super.init(frame: .zero) }
        required init?(coder: NSCoder) { nil }
        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            if window == nil { removeMonitor(); return }
            guard monitor == nil else { return }
            monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] e in
                guard let self, let win = self.window, e.window === win else { return e }
                let frameInWin = self.convert(self.bounds, to: nil)
                guard NSPointInRect(e.locationInWindow, frameInWin) else { return e }
                let dy = e.scrollingDeltaY != 0 ? e.scrollingDeltaY : e.deltaY
                if dy == 0 { return e }
                let now = ProcessInfo.processInfo.systemUptime
                let newGesture = (now - self.lastScroll) > 0.3
                self.lastScroll = now
                self.onTick(dy > 0, e.modifierFlags.contains(.shift), e.modifierFlags.contains(.option), newGesture)
                return nil   // consume so the editor list doesn't also scroll
            }
        }
        private func removeMonitor() { if let m = monitor { NSEvent.removeMonitor(m); monitor = nil } }
        deinit { removeMonitor() }
    }
}
#endif

extension View {
    /// macOS precision input on a Slider: scroll-wheel + arrow keys nudge `value` by a
    /// fine/coarse step (Shift ×0.1, Option ×10); Home/End jump to the range ends.
    /// `onEditStart` fires once per gesture (a fresh scroll burst, or each key press) so
    /// undo captures the pre-value. iOS: a no-op passthrough (no modifier keys).
    func precisionControls(value: Binding<Double>, range: ClosedRange<Double>, baseStep: Double?,
                           onEditStart: @escaping () -> Void) -> some View {
        #if os(macOS)
        func clamp(_ v: Double) -> Double { min(max(v, range.lowerBound), range.upperBound) }
        return self
            .background(SliderScrollMonitor { up, fine, coarse, newGesture in
                if newGesture { onEditStart() }
                let step = precisionStep(range: range, baseStep: baseStep, fine: fine, coarse: coarse)
                value.wrappedValue = clamp(value.wrappedValue + (up ? step : -step))
            })
            .focusable()
            .onKeyPress(keys: [.leftArrow, .rightArrow, .home, .end]) { press in
                let fine = press.modifiers.contains(.shift)
                let coarse = press.modifiers.contains(.option)
                let step = precisionStep(range: range, baseStep: baseStep, fine: fine, coarse: coarse)
                let cur = value.wrappedValue
                let nv: Double
                switch press.key {
                case .leftArrow: nv = cur - step
                case .rightArrow: nv = cur + step
                case .home: nv = range.lowerBound
                case .end: nv = range.upperBound
                default: return .ignored
                }
                onEditStart()
                value.wrappedValue = clamp(nv)
                return .handled
            }
        #else
        return self
        #endif
    }
}
  • [ ] Step 2: Attach it to ParamSlider's Slider

In ParamSlider.swift, the Slider(...) block currently is:

            Slider(value: Binding(get: { value }, set: { commit($0) }),
                   in: descriptor.range,
                   onEditingChanged: { editing in if editing { state.pushUndo() } })
                .tint(Theme.Colors.accent)
                .controlSize(.small)
                .accessibilityHint(descriptor.tooltip)
Append the precision modifier (the binding's set already routes through commit, which clamps; reads are live via value):
                .precisionControls(value: Binding(get: { value }, set: { commit($0) }),
                                   range: descriptor.range,
                                   baseStep: descriptor.isInt ? 1 : nil,
                                   onEditStart: { state.pushUndo() })

  • [ ] Step 3: Regenerate + build (macOS + iOS sim)

cd apple/PhysarumApp && xcodegen generate \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -6 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -6
Expected: BUILD SUCCEEDED (both). If .onKeyPress(keys:) or KeyPress.modifiers/.key signatures differ on this SDK, adapt to the available .onKeyPress overload (macOS 14 has onKeyPress(keys:action:) returning KeyPress.Result); keep the semantics (←/→ ±step, Home/End ends, Shift/Option = fine/coarse). Do NOT change the .background scroll-monitor approach (it's what preserves slider drag).

  • [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/SliderPrecision.swift apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(app): macOS slider precision — scroll-wheel + arrow keys with Shift/Option fine/coarse"

Manual (Johannes, macOS): hover a slider, scroll → nudges; Shift = fine, Option = coarse; the editor list still scrolls when the pointer is NOT over a slider; the slider still drags normally; focus a slider, ←/→/Home/End step; undo restores the pre-value.


PART B — Increment 5b: Custom-preset rename + reorder

Task 3: reorderedIDs (pure) + AppState.moveCustom

Files: Modify apple/PhysarumApp/Sources/Model/AppState.swift (add reorderedIDs free function + moveCustom); Test apple/PhysarumApp/Tests/PresetReorderTests.swift.

  • [ ] Step 1: Write the failing test
// apple/PhysarumApp/Tests/PresetReorderTests.swift
import XCTest
@testable import Physarum

final class PresetReorderTests: XCTestCase {
    func testMoveUpSwapsWithPrevious() {
        XCTAssertEqual(reorderedIDs(["a", "b", "c"], moving: "b", up: true), ["b", "a", "c"])
    }
    func testMoveDownSwapsWithNext() {
        XCTAssertEqual(reorderedIDs(["a", "b", "c"], moving: "b", up: false), ["a", "c", "b"])
    }
    func testMoveUpAtTopIsNoOp() {
        XCTAssertEqual(reorderedIDs(["a", "b", "c"], moving: "a", up: true), ["a", "b", "c"])
    }
    func testMoveDownAtBottomIsNoOp() {
        XCTAssertEqual(reorderedIDs(["a", "b", "c"], moving: "c", up: false), ["a", "b", "c"])
    }
    func testUnknownIdIsNoOp() {
        XCTAssertEqual(reorderedIDs(["a", "b"], moving: "z", up: true), ["a", "b"])
    }
}
  • [ ] Step 2: Run to verify it fails

cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PresetReorderTests 2>&1 | tail -20 Expected: FAIL to compile (reorderedIDs undefined).

  • [ ] Step 3: Add the free function + the AppState method

At file scope in AppState.swift (top level, outside the class — e.g. just above final class AppState):

/// Move `id` one slot up/down within `ids`, returning the new order. No-op at the
/// boundary or for an unknown id.
func reorderedIDs(_ ids: [String], moving id: String, up: Bool) -> [String] {
    guard let i = ids.firstIndex(of: id) else { return ids }
    let j = up ? i - 1 : i + 1
    guard ids.indices.contains(j) else { return ids }
    var r = ids
    r.swapAt(i, j)
    return r
}
Inside AppState, near rename(id:to:) / deleteCustom(id:):
func moveCustom(id: String, up: Bool) {
    let ids = reorderedIDs(customs.map { $0.id }, moving: id, up: up)
    store.reorder(ids)
    customs = store.loadCustom()
}

  • [ ] Step 4: Run to verify it passes

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/PresetReorderTests 2>&1 | tail -20 Expected: TEST SUCCEEDED (5).

  • [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Tests/PresetReorderTests.swift
git commit -m "feat(app): custom-preset reorder logic (moveCustom + reorderedIDs)"

Task 4: RenameDialog + Library context menu

Files: Create apple/PhysarumApp/Sources/Library/RenameDialog.swift; Modify apple/PhysarumApp/Sources/Library/LibraryView.swift.

  • [ ] Step 1: Write RenameDialog.swift

// apple/PhysarumApp/Sources/Library/RenameDialog.swift
import SwiftUI
import PhysarumEngine

/// Rename a custom preset (mirrors SaveDialog's look). Dismisses itself via the
/// SwiftUI environment dismiss action; commits through AppState.rename(id:to:).
struct RenameDialog: View {
    @Bindable var state: AppState
    let preset: Preset
    @Environment(\.dismiss) private var dismiss
    @State private var name = ""

    var body: some View {
        VStack(alignment: .leading, spacing: Theme.Space.s4) {
            Text("RENAME PRESET")
                .font(Theme.Fonts.eyebrow).tracking(1.6)
                .foregroundStyle(Theme.Colors.fgTertiary)
            VStack(alignment: .leading, spacing: Theme.Space.s1) {
                Text("Name").physLabel()
                TextField("Name", text: $name).textFieldStyle(.roundedBorder).onSubmit(commit)
            }
            HStack(spacing: Theme.Space.s3) {
                PhysButton(title: "Cancel", kind: .ghost) { dismiss() }
                PhysButton(title: "Rename", kind: .accent) { commit() }
            }
        }
        .padding(Theme.Space.s5)
        .frame(width: 360)
        .background(Theme.Colors.surface)
        .foregroundStyle(Theme.Colors.fgPrimary)
        .tint(Theme.Colors.accent)
        .onAppear { name = preset.name }
    }

    private func commit() {
        let trimmed = String(name.trimmingCharacters(in: .whitespaces).prefix(64))
        if !trimmed.isEmpty { state.rename(id: preset.id, to: trimmed) }
        dismiss()
    }
}
(Text("Name").physLabel() is the same helper SaveDialog uses for its field labels — it exists.)

  • [ ] Step 2: Wire the context menu + rename sheet in LibraryView

In LibraryView.swift, add a file-scope identifiable wrapper (above struct LibraryView):

private struct RenameTarget: Identifiable { let id: String; let preset: Preset }
Add a state var with the other @States:
    @State private var renameTarget: RenameTarget?
Replace the existing .contextMenu { ... } block (the one with only Delete) with the full custom-preset menu. Compute the custom index inline:
                        .contextMenu {
                            if let idx = state.customs.firstIndex(where: { $0.id == preset.id }) {
                                Button("Rename") { renameTarget = RenameTarget(id: preset.id, preset: preset) }
                                Button("Move Up") { state.moveCustom(id: preset.id, up: true) }
                                    .disabled(idx == 0)
                                Button("Move Down") { state.moveCustom(id: preset.id, up: false) }
                                    .disabled(idx == state.customs.count - 1)
                                Divider()
                                Button("Delete", role: .destructive) { state.deleteCustom(id: preset.id) }
                            }
                        }
Add the rename sheet (after the outer VStack { ... }'s existing modifiers, e.g. alongside .background(...)):
        .sheet(item: $renameTarget) { t in
            RenameDialog(state: state, preset: t.preset)
        }

  • [ ] Step 3: Regenerate + build (macOS + iOS sim)

cd apple/PhysarumApp && xcodegen generate \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -6 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -6
Expected: BUILD SUCCEEDED (both).

  • [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Library/RenameDialog.swift apple/PhysarumApp/Sources/Library/LibraryView.swift
git commit -m "feat(app): custom-preset rename + reorder via Library context menu"

Manual (Johannes): long-press/right-click a custom card → Rename / Move Up / Move Down / Delete; order + name persist across relaunch.


PART C — Increment 5c: Non-scalar help

Task 5: HelpLabel component + ParamSlider refactor

Files: Create apple/PhysarumApp/Sources/UI/HelpLabel.swift; Modify apple/PhysarumApp/Sources/Editor/ParamSlider.swift.

  • [ ] Step 1: Write HelpLabel.swift (extracted from ParamSlider's labelView)
// apple/PhysarumApp/Sources/UI/HelpLabel.swift
import SwiftUI

/// A control label with an optional inline ⓘ that reveals a help popover on tap
/// (macOS also shows it on hover; VoiceOver reads it as a hint). Empty `help`
/// renders just the label with no ⓘ. Extracted from ParamSlider so any non-scalar
/// control row can carry the same help affordance.
struct HelpLabel: View {
    let label: String
    let help: String
    var font: Font = Theme.Fonts.label
    @State private var showHelp = false
    private var hasHelp: Bool { !help.isEmpty }

    var body: some View {
        HStack(spacing: 4) {
            Text(label).font(font).foregroundStyle(Theme.Colors.fgSecondary)
            if hasHelp {
                Image(systemName: "info.circle")
                    .font(.system(size: 10))
                    .foregroundStyle(Theme.Colors.fgTertiary)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture { if hasHelp { showHelp = true } }
        #if os(macOS)
        .help(help)
        #endif
        .popover(isPresented: $showHelp) {
            Text(help)
                .font(.callout)
                .multilineTextAlignment(.leading)
                .frame(width: 260)
                .padding()
                .presentationCompactAdaptation(.popover)
        }
        .accessibilityHint(help)
    }
}
  • [ ] Step 2: Refactor ParamSlider.labelView to use HelpLabel (DRY)

In ParamSlider.swift, replace the whole labelView computed property:

    @ViewBuilder private var labelView: some View {
        HStack(spacing: 4) {
            Text(descriptor.label)
                .font(Theme.Fonts.label)
                .foregroundStyle(Theme.Colors.fgSecondary)
            if hasHelp {
                Image(systemName: "info.circle")
                    .font(.system(size: 10))
                    .foregroundStyle(Theme.Colors.fgTertiary)
            }
        }
        .contentShape(Rectangle())
        .onTapGesture { if hasHelp { showHelp = true } }
        #if os(macOS)
        .help(descriptor.tooltip)
        #endif
        .popover(isPresented: $showHelp) {
            Text(descriptor.tooltip)
                .font(.callout)
                .multilineTextAlignment(.leading)
                .frame(width: 260)
                .padding()
                .presentationCompactAdaptation(.popover)
        }
    }
with:
    private var labelView: some View {
        HelpLabel(descriptor.label, help: descriptor.tooltip)
    }
Add a memberwise-style init to HelpLabel so HelpLabel(descriptor.label, help:) works (positional first arg): change the struct to
struct HelpLabel: View {
    let label: String
    let help: String
    var font: Font = Theme.Fonts.label
    init(_ label: String, help: String, font: Font = Theme.Fonts.label) {
        self.label = label; self.help = help; self.font = font
    }
    @State private var showHelp = false
    // ... rest unchanged
}
Now the @State private var showHelp and hasHelp on ParamSlider are unused — remove @State private var showHelp = false and the private var hasHelp from ParamSlider (HelpLabel owns them now). The .accessibilityHint(descriptor.tooltip) on the Slider stays (the Slider keeps its own a11y hint; HelpLabel adds the label's).

  • [ ] Step 3: Regenerate + build (macOS + iOS sim)

cd apple/PhysarumApp && xcodegen generate \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -6 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -6
Expected: BUILD SUCCEEDED (both). (If hasHelp/showHelp are referenced elsewhere in ParamSlider after removal, the compiler will flag it — they aren't; only labelView used them.)

  • [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/UI/HelpLabel.swift apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(app): reusable HelpLabel (extracted from ParamSlider)"

Task 6: Non-scalar help texts + attach HelpLabel to non-scalar controls

Files: Modify apple/PhysarumApp/Sources/Model/ParamHelp.swift, Sources/Editor/Sections.swift, Sources/Editor/AudioReactiveSection.swift; Test apple/PhysarumApp/Tests/ParamHelpTests.swift.

  • [ ] Step 1: Write the failing test
// apple/PhysarumApp/Tests/ParamHelpTests.swift
import XCTest
@testable import Physarum

final class ParamHelpTests: XCTestCase {
    func testNonScalarKeysHaveHelp() {
        for key in ["Spawn pattern", "Blend mode", "Background color", "Species color",
                    "Audio reactive", "Auto-gain", "Input gain"] {
            XCTAssertFalse(ParamHelp.text(for: key).isEmpty, "missing help for \(key)")
        }
    }
    func testBlendModeMentionsAdditiveAndPaint() {
        let t = ParamHelp.text(for: "Blend mode")
        XCTAssertTrue(t.contains("Additive")); XCTAssertTrue(t.contains("Paint"))
    }
    func testUnknownKeyIsEmpty() {
        XCTAssertEqual(ParamHelp.text(for: "Nope"), "")
    }
}
  • [ ] Step 2: Run to verify it fails

cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ParamHelpTests 2>&1 | tail -20 Expected: FAIL (testNonScalarKeysHaveHelp / testBlendModeMentionsAdditiveAndPaint fail — keys not present yet).

  • [ ] Step 3: Add the non-scalar entries to ParamHelp.byLabel

In ParamHelp.swift, add these entries to the byLabel dictionary (verbatim from the web src/ui/paramHelp.ts):

        // Non-scalar controls
        "Spawn pattern": "How agents are placed when the simulation seeds. Affects only the initial frames — patterns emerge from the dynamics.",
        "Blend mode": "How agents compose over the background. Additive: agents emit light (works on dark bg, saturates with bright bg). Paint: agents are ink on paper (works at any bg, no glow).",
        "Background color": "Colour of the empty void. Trails are added on top — pick something dark for high contrast, bright for soft pastels.",
        "Species color": "The colour this species deposits into its pheromone channel. Where two species overlap, colours add.",
        "Audio reactive": "Master switch for audio modulation. When off, routes below are ignored and factors stay at 1.",
        "Auto-gain": "Continuously normalises the input so a quiet source still drives motion. Disable for raw, dynamics-preserving response.",
        "Input gain": "Manual multiplier applied to every band before routing. Use 1× with Auto-gain on; raise it for raw quiet sources.",

  • [ ] Step 4: Run to verify the test passes

cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' test -only-testing:PhysarumAppTests/ParamHelpTests 2>&1 | tail -20 Expected: TEST SUCCEEDED (3).

  • [ ] Step 5: Attach HelpLabel to the GlobalSection + SpeciesSection non-scalar rows

In Sections.swift GlobalSection, replace the three trailing HStack { ... } blocks (Spawn / Blend / Background) with help-labelled VStacks:

        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HelpLabel("Spawn", help: ParamHelp.text(for: "Spawn pattern"))
            HStack {
                Picker("", selection: Binding(
                    get: { state.edited.spawnPattern },
                    set: { state.edited.spawnPattern = $0 })) {
                    ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                }.labelsHidden()
                LockButton(state: state, id: .spawnPattern)
            }
        }
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HelpLabel("Blend", help: ParamHelp.text(for: "Blend mode"))
            HStack {
                Picker("", selection: Binding(
                    get: { state.edited.blendMode },
                    set: { state.edited.blendMode = $0 })) {
                    ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
                }.labelsHidden()
                LockButton(state: state, id: .blendMode)
            }
        }
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HelpLabel("Background", help: ParamHelp.text(for: "Background color"))
            HStack {
                RGBColorPicker(title: "",
                               rgb: Binding(get: { state.edited.backgroundColor },
                                            set: { state.edited.backgroundColor = $0 }))
                LockButton(state: state, id: .backgroundColor)
            }
        }
In SpeciesSection, replace the HStack { RGBColorPicker(title: "Color", ...) LockButton(...) } with:
        VStack(alignment: .leading, spacing: Theme.Space.s1) {
            HelpLabel("Color", help: ParamHelp.text(for: "Species color"))
            HStack {
                RGBColorPicker(title: "",
                               rgb: Binding(get: { state.edited.species[focus].color },
                                            set: { newC in
                                                if tab == 3 { for i in 0..<3 { state.edited.species[i].color = newC } }
                                                else { state.edited.species[focus].color = newC }
                                            }))
                LockButton(state: state, id: .speciesColor(focus))
            }
        }

  • [ ] Step 6: Attach HelpLabel to the AudioReactiveSection toggles + gain

In AudioReactiveSection.swift, replace the two Toggle(...) and the Gain Text("Gain") header with help-labelled rows. The Enabled toggle:

            HStack {
                HelpLabel("Enabled", help: ParamHelp.text(for: "Audio reactive"))
                Spacer()
                Toggle("", isOn: Binding(
                    get: { state.edited.audioReactive.enabled },
                    set: { state.pushUndo(); state.edited.audioReactive.enabled = $0 })).labelsHidden()
            }
            HStack {
                HelpLabel("Auto-gain", help: ParamHelp.text(for: "Auto-gain"))
                Spacer()
                Toggle("", isOn: Binding(
                    get: { state.edited.audioReactive.autoGain },
                    set: { state.pushUndo(); state.edited.audioReactive.autoGain = $0 })).labelsHidden()
            }
And in the Gain VStack, replace the Text("Gain").font(Theme.Fonts.label) with HelpLabel("Gain", help: ParamHelp.text(for: "Input gain")):
            VStack(alignment: .leading, spacing: Theme.Space.s1) {
                HStack {
                    HelpLabel("Gain", help: ParamHelp.text(for: "Input gain"))
                    Spacer()
                    Text(String(format: "%.2fx", ar.gain)).font(Theme.Fonts.value)
                        .foregroundStyle(Theme.Colors.fgSecondary)
                }
                Slider(value: Binding(
                    get: { Double(state.edited.audioReactive.gain) },
                    set: { state.edited.audioReactive.gain = Float($0) }),
                    in: 0.1...10,
                    onEditingChanged: { editing in if editing { state.pushUndo() } })
                    .tint(Theme.Colors.accent).controlSize(.small)
            }

  • [ ] Step 7: Regenerate + build (macOS + iOS sim) + full app suite

cd apple/PhysarumApp && xcodegen generate \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -5 \
  && xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5 \
  && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | tail -12
Expected: BUILD SUCCEEDED (both); TEST SUCCEEDED (69 + SliderPrecision 5 + PresetReorder 5 + ParamHelp 3 = 82).

  • [ ] Step 8: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/ParamHelp.swift apple/PhysarumApp/Sources/Editor/Sections.swift apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift apple/PhysarumApp/Tests/ParamHelpTests.swift
git commit -m "feat(app): tooltips on non-scalar editor controls (spawn/blend/bg/species/audio)"

Task 7: Gate (sim + device)

Files: none (verification).

  • [ ] Step 1: Build/install/launch on the iPhone simulator + screenshot

xcrun simctl boot 'iPhone 17 Pro' 2>/dev/null; open -a Simulator
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build 2>&1 | tail -3
APP=$(xcodebuild -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -showBuildSettings 2>/dev/null | awk '/ BUILT_PRODUCTS_DIR =/{d=$3}/ FULL_PRODUCT_NAME =/{n=substr($0,index($0,"=")+2)}END{print d"/"n}')
xcrun simctl install booted "$APP" && xcrun simctl launch booted de.jkaindl.physarum
sleep 4 && xcrun simctl io booted screenshot /tmp/finishing-sim.png
Expected: app launches; editor shows ⓘ on the non-scalar controls; Library context menu (long-press a custom card) shows Rename/Move/Delete. (iOS has no slider modifiers — that's macOS-only by design.)

  • [ ] Step 2: Headless deploy to the iPhone

UDID=1D819185-3910-5389-A9BE-C377B8010156
cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination "platform=iOS,id=$UDID" -allowProvisioningUpdates build 2>&1 | tail -3
APP=$(xcodebuild -scheme Physarum -destination "platform=iOS,id=$UDID" -showBuildSettings 2>/dev/null | awk '/ BUILT_PRODUCTS_DIR =/{d=$3}/ FULL_PRODUCT_NAME =/{n=substr($0,index($0,"=")+2)}END{print d"/"n}')
xcrun devicectl device install app --device "$UDID" "$APP"
Expected: install succeeds.

  • [ ] Step 3: Hand-off note for Johannes

macOS sign-off (the modifier feature is macOS-only): slider scroll/arrow fine-coarse with Shift/Option; drag still works; editor list scrolls off-slider. iOS/macOS: rename/reorder custom presets persist; ⓘ popovers on the non-scalar controls.


Verification Gate (whole feature)

  • [ ] App suite green (82: 69 + SliderPrecision 5 + PresetReorder 5 + ParamHelp 3).
  • [ ] Engine swift test green (21 — engine untouched).
  • [ ] macOS + iOS-sim BUILD SUCCEEDED.
  • [ ] Sim screenshot; deployed to iPhone JK.
  • [ ] Johannes: macOS slider scroll/arrow fine-coarse (drag intact, list scroll off-slider) · rename/reorder persist · ⓘ on non-scalar controls.

Self-Review (against the spec)

Spec coverage: §3 slider precision (precisionStep Task 1; macOS scroll+arrow modifier Task 2) · §4 rename/reorder (reorderedIDs+moveCustom Task 3; RenameDialog + context menu Task 4) · §5 non-scalar help (HelpLabel + ParamSlider refactor Task 5; ParamHelp texts + wrap controls Task 6) · §7 tests (Tasks 1,3,6) + gate (Task 7).

Placeholder scan: none — real code/commands throughout.

Type consistency: precisionStep(range:baseStep:fine:coarse:) consistent Tasks 1,2. precisionControls(value:range:baseStep:onEditStart:) Task 2. reorderedIDs(_:moving:up:) + moveCustom(id:up:) consistent Tasks 3,4. RenameTarget/RenameDialog(state:preset:) consistent Task 4. HelpLabel(_:help:font:) consistent Tasks 5,6. ParamHelp.text(for:) keys consistent Tasks 6. The non-scalar control rewrites preserve the existing bindings + LockButton ids verbatim.