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.precisionControlsmodifier (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— useHelpLabel; attach.precisionControls. - Modify
apple/PhysarumApp/Sources/Model/ParamHelp.swift— non-scalar entries. - Modify
apple/PhysarumApp/Sources/Model/AppState.swift—moveCustom(id:up:)+reorderedIDshelper. - Modify
apple/PhysarumApp/Sources/Editor/Sections.swift— wrap non-scalar rows withHelpLabel. - Modify
apple/PhysarumApp/Sources/Editor/AudioReactiveSection.swift— wrap toggles/gain withHelpLabel. - 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)"
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
.precisionControlsmodifier 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)
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
.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
}
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):
@States:
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) }
}
}
VStack { ... }'s existing modifiers, e.g. alongside .background(...)):
- [ ] 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
- [ ] 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)
}
}
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
}
@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
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)
}
}
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()
}
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
- [ ] 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
- [ ] 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"
- [ ] 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 testgreen (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.