Parameter Tooltips (paramHelp) 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: Give the native editor per-parameter help — ported from the web PARAM_HELP — surfaced HIG-idiomatically: a subtle inline ⓘ on each slider's label that opens a popover on tap (a small anchored bubble on iPhone), a macOS hover .help, and a VoiceOver .accessibilityHint.
Architecture: A new ParamHelp table maps native ParamDescriptor.label → help text (texts copied verbatim from src/ui/paramHelp.ts); matrix cells share one text via ParamHelp.text(for:). ParamDescriptor gains a tooltip: String populated by the registry builder. ParamSlider renders the label + inline ⓘ as a tappable unit presenting a .popover (with .presentationCompactAdaptation(.popover)), plus .help on macOS and .accessibilityHint on the slider. Scalar rows only (45, incl. 9 matrix); non-scalar controls are out of scope.
Tech Stack: Swift 5 / SwiftUI (.popover, .presentationCompactAdaptation, .help, .accessibilityHint, SF Symbol info.circle), XCTest in the hosted PhysarumAppTests target (macOS), XcodeGen + xcodebuild (macOS + iOS Simulator).
Background / IST-state (verified 2026-06-03)¶
ParamDescriptor(Sources/Model/ParamRegistry.swift) currently hasid,label,section,range,isInt,isRespawn,get,set+ computedidKey— notooltip. Built via an internaladd(...)closure insideParamRegistry.all. There are 45 descriptors; the native labels are:Agents, Exposure, Sim speed(global),Population, Sensor angle, Sensor distance, Turn speed, Move speed, Deposit(×3 species),M[r,c](×9 matrix),Decay, Diffusion, Fade, Trail cap(pheromone),Chaos interval, Chaos strength, Rhythm(dynamics),Head glow, Kaleidoscope, Feedback, Bloom threshold, Bloom, Vignette, Saturation, Contrast(effects).SpeciesSection.broadcast(_:)(Sources/Editor/Sections.swift) constructs aParamDescriptormanually — adding a stored property to the struct means this call site must pass it too.ParamSlider(Sources/Editor/ParamSlider.swift) rendersText(descriptor.label)+LockButton+ value in an HStack, then aSliderwith.help(descriptor.label). Row has a double-tap reset.- Web source of texts:
src/ui/paramHelp.ts—PARAM_HELP: Record<string,string>keyed by the web display label (which differs from several native labels). Matrix cells + audio have no web entry. - Deployment targets macOS 14 / iOS 17 →
.popover,.presentationCompactAdaptation,.help,.accessibilityHintall available. App builds for macOS + iOS Simulator; baseline suite 36 green.
Build & test commands¶
- Regenerate:
cd apple/PhysarumApp && xcodegen generate - Single test class:
xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<Class> 2>&1 | tail -25 - Full suite:
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8 - macOS build:
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -10 - iOS-sim build:
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -12
File Structure¶
Create:
- Sources/Model/ParamHelp.swift — the byLabel table + text(for:) (matrix fallback). One responsibility: help-text lookup.
- Tests/ParamHelpTests.swift — coverage (all 45 non-empty) + text(for:) cases.
Modify:
- Sources/Model/ParamRegistry.swift — add tooltip: String to ParamDescriptor; populate in add(...).
- Sources/Editor/Sections.swift — broadcast(_:) passes tooltip: d.tooltip.
- Sources/Editor/ParamSlider.swift — inline ⓘ + label-tap popover + macOS .help(tooltip) + slider .accessibilityHint(tooltip).
Do NOT modify: RootView, MetalView, RenderCoordinator, AppState, EditorView, MorphSheet, project.yml.
Task 1: ParamHelp table + ParamDescriptor.tooltip¶
Files:
- Create: apple/PhysarumApp/Sources/Model/ParamHelp.swift
- Create: apple/PhysarumApp/Tests/ParamHelpTests.swift
- Modify: apple/PhysarumApp/Sources/Model/ParamRegistry.swift
- Modify: apple/PhysarumApp/Sources/Editor/Sections.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/ParamHelpTests.swift:
import XCTest
@testable import Physarum
final class ParamHelpTests: XCTestCase {
func testEveryDescriptorHasNonEmptyTooltip() {
for d in ParamRegistry.all {
XCTAssertFalse(d.tooltip.isEmpty, "\(d.idKey) (label '\(d.label)') has no tooltip — add it to ParamHelp.byLabel")
}
}
func testKnownLabelsMapToText() {
XCTAssertTrue(ParamHelp.text(for: "Move speed").contains("step each frame"))
XCTAssertTrue(ParamHelp.text(for: "Agents").contains("particles"))
XCTAssertTrue(ParamHelp.text(for: "Decay").contains("survives"))
}
func testMatrixLabelGetsSharedText() {
XCTAssertTrue(ParamHelp.text(for: "M[1,2]").contains("attracts"))
XCTAssertEqual(ParamHelp.text(for: "M[0,0]"), ParamHelp.text(for: "M[2,1]"))
}
func testUnknownLabelIsEmpty() {
XCTAssertEqual(ParamHelp.text(for: "Nonexistent"), "")
}
}
- [ ] Step 2: Run to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ParamHelpTests 2>&1 | tail -20
Expected: compile failure — ParamHelp and d.tooltip not found.
- [ ] Step 3: Create ParamHelp.swift
Create apple/PhysarumApp/Sources/Model/ParamHelp.swift (texts copied verbatim from src/ui/paramHelp.ts, keyed by the native label):
/// Per-parameter help, surfaced by ParamSlider. Texts mirror the web app's
/// `src/ui/paramHelp.ts`, keyed by the native `ParamDescriptor.label` (which
/// differs from the web label for several params). Matrix cells share one text.
enum ParamHelp {
static let byLabel: [String: String] = [
// Global
"Agents": "How many particles run the simulation. Higher = denser trails and slower frame rate.",
"Exposure": "Overall image brightness, applied before tone-mapping. Push higher for glowy / blown-out highlights.",
"Sim speed": "How many physics steps run per rendered frame. 1× is real time; 8× evolves the pattern much faster but costs ~8× GPU.",
// Species
"Population": "Relative fraction of agents that belong to this species. Set one to 0 to hide that species entirely.",
"Sensor angle": "Spread of the left/right antennae relative to the agent’s heading. Wider = sees more of the surroundings.",
"Sensor distance": "How far ahead the antennae sample the pheromone field. Larger = thicker, more cohesive structures.",
"Turn speed": "How sharply agents can change direction per step. Low = smooth curves; high = nervous zig-zag.",
"Move speed": "How far agents step each frame. Higher = faster pattern evolution but less coherent trails.",
"Deposit": "How much pheromone each agent drops every frame. Higher = brighter, more dominant trails.",
// Pheromone
"Decay": "How much of the trail survives each diffusion pass (0.97 = 3 % fades per pass). Higher = persistent ghosts.",
"Diffusion": "Diffuse (blur) the pheromone field every N frames. 1 = constant blur (soft), 12 = sharp trails that rarely smear.",
"Fade": "Extra decay applied on top of decay rate. Useful for thinning persistent bright spots.",
"Trail cap": "Pheromone cap before the renderer clips. Lower = brighter trails saturate faster (washes out highlights).",
// Dynamics
"Chaos interval": "Frames between random kicks to every agent’s heading. 0 disables. Lower = more frequent jolts.",
"Chaos strength": "How violent each chaos kick is. Pairs with Chaos interval: rare strong kicks vs frequent gentle ones.",
"Rhythm": "A per-agent sine-wander layered on top of the steering. Adds a subtle wobble — set to 0 for clean trails.",
// Effects
"Head glow": "Bright stamp at each agent's current position, on top of the persistent trail. 0 = trail only; 1 ≈ matches trail brightness; 2-3 = comet head. Combine with high Decay rate for long-exposure photography feel.",
"Kaleidoscope": "Folds the image into N mirror axes (1-6). 0 disables. Higher values give more symmetry — 6 looks like a snowflake.",
"Feedback": "How much of the previous rendered frame leaks into the current one. Creates a soft, smeary echo — not a directional trail. For an actual moving-comet effect, raise Decay rate in the Pheromone section instead (those trails are intrinsic to the agents).",
"Bloom threshold": "Brightness above which pixels start to bloom. Lower = even mid-bright trails glow; higher = only the brightest peaks.",
"Bloom": "How strongly the bloom halo adds back into the scene. 0 disables bloom; high values make everything pulse.",
"Vignette": "Edge darkening — frames the image like a lens. 0 disables.",
"Saturation": "Colour intensity. 0 = greyscale, 1 = neutral, 2 = ultra-saturated.",
"Contrast": "Stretch around mid-grey. 0 = flat grey, 1 = neutral, 2 = punchy.",
]
/// Matrix cells (`M[r,c]`) have no web entry — they share one explanation.
static let matrix = "How strongly the row species senses the column species' trail. Positive attracts, negative repels; the diagonal is a species' response to its own trail (cohesion)."
static func text(for label: String) -> String {
if label.hasPrefix("M[") { return matrix }
return byLabel[label] ?? ""
}
}
- [ ] Step 4: Add
tooltipto ParamDescriptor + populate it
In apple/PhysarumApp/Sources/Model/ParamRegistry.swift, add the stored property to ParamDescriptor (after the set line):
In the same file, update the add(...) builder's ParamDescriptor(...) construction to populate it:
d.append(ParamDescriptor(id: id, label: label, section: s, range: r,
isInt: int, isRespawn: respawn, get: get, set: set,
tooltip: ParamHelp.text(for: label)))
- [ ] Step 5: Update the manual ParamDescriptor init in broadcast()
In apple/PhysarumApp/Sources/Editor/Sections.swift, SpeciesSection.broadcast(_:), add tooltip: to the returned ParamDescriptor:
return ParamDescriptor(
id: d.id, label: d.label, section: d.section, range: d.range,
isInt: d.isInt, isRespawn: d.isRespawn,
get: d.get,
set: { p, v in for a in analogs { a.set(&p, v) } },
tooltip: d.tooltip
)
- [ ] Step 6: Run the test to verify it passes
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ParamHelpTests 2>&1 | tail -20
Expected: PASS (4 tests). If testEveryDescriptorHasNonEmptyTooltip fails, a native label is missing from byLabel — add it.
- [ ] Step 7: Full suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
Expected: 40 tests, 0 failures.
- [ ] Step 8: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/ParamHelp.swift apple/PhysarumApp/Sources/Model/ParamRegistry.swift apple/PhysarumApp/Sources/Editor/Sections.swift apple/PhysarumApp/Tests/ParamHelpTests.swift
git commit -m "feat(app): ParamHelp table + ParamDescriptor.tooltip (ported from web paramHelp)"
Task 2: ParamSlider help surface (inline ⓘ + popover + a11y)¶
Files:
- Modify (full rewrite): apple/PhysarumApp/Sources/Editor/ParamSlider.swift
UI task — verified by macOS + iOS-sim build + the full suite still green.
- [ ] Step 1: Rewrite ParamSlider.swift
Replace the entire contents of apple/PhysarumApp/Sources/Editor/ParamSlider.swift with:
import SwiftUI
/// One slider for any scalar ParamDescriptor. Tap the value → numeric entry;
/// double-tap the row → reset to the preset default; tap the label (with its ⓘ)
/// → a help popover. macOS also shows the help on hover; VoiceOver reads it as a hint.
struct ParamSlider: View {
@Bindable var state: AppState
let descriptor: ParamDescriptor
@State private var editingText: String?
@State private var showHelp = false
private var value: Double { descriptor.get(state.edited) }
private var hasHelp: Bool { !descriptor.tooltip.isEmpty }
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.s1) {
HStack {
labelView
Spacer()
LockButton(state: state, id: descriptor.id)
valueLabel
}
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)
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { state.pushUndo(); state.reset(descriptor) }
}
/// Label + a subtle inline ⓘ (HIG: an interactive affordance must look interactive).
/// Tap → popover; macOS also gets a hover tooltip.
@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)
}
}
@ViewBuilder private var valueLabel: some View {
if let t = editingText {
TextField("", text: Binding(get: { t }, set: { editingText = $0 }))
.frame(width: 64)
.font(Theme.Fonts.value)
.textFieldStyle(.roundedBorder)
.onSubmit { commitText() }
} else {
Text(formatted)
.font(Theme.Fonts.value)
.foregroundStyle(Theme.Colors.fgPrimary)
.onTapGesture { editingText = formatted }
}
}
private var formatted: String {
descriptor.isInt ? String(Int(value.rounded())) : String(format: "%.3g", value)
}
private func commit(_ v: Double) {
var p = state.edited
descriptor.set(&p, clamp(v))
state.edited = p
}
private func commitText() {
if let t = editingText, let v = Double(t) { state.pushUndo(); commit(v) }
editingText = nil
}
private func clamp(_ v: Double) -> Double {
min(max(v, descriptor.range.lowerBound), descriptor.range.upperBound)
}
}
NOTE: the slider's old
.help(descriptor.label)(which just echoed the label) is intentionally dropped — help now lives on the label via.help(descriptor.tooltip)(macOS) and the popover.
- [ ] Step 2: macOS build
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -10
Expected: BUILD SUCCEEDED.
- [ ] Step 3: iOS Simulator build
Run: xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -12
Expected: BUILD SUCCEEDED.
- [ ] Step 4: Full suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -8
Expected: 40 tests, 0 failures.
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(app): parameter help — inline ⓘ + tap popover, macOS hover, VoiceOver hint"
Task 3: Verification gate + push¶
- [ ] Step 1: All gates green
cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | grep -E "Executed [0-9]+ tests|TEST (SUCCEEDED|FAILED)" | tail -3
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)"
-
[ ] Step 2: Visual check (macOS + iPhone sim, or Johannes on device)
-
macOS: hover a slider label → tooltip; click the label/ⓘ → popover. Confirm the ⓘ is subtle (tertiary) and only present where help exists.
- iPhone sim: tap a slider label/ⓘ → a small anchored popover bubble (NOT a full sheet); tapping outside dismisses. Confirm gesture disambiguation — single-tapping the label shows help, double-tapping the row still resets.
-
VoiceOver (optional): the slider announces the hint.
-
[ ] Step 3: Push
Self-Review (completed by plan author)¶
Spec coverage (2026-06-03-param-tooltips-design.md):
- §2 surface (label-tap popover, macOS hover) → Task 2. ✓
- §2 discoverability (inline ⓘ) → Task 2 labelView. ✓
- §2 popover compact adaptation → Task 2 .presentationCompactAdaptation(.popover). ✓
- §2 accessibility hint → Task 2 .accessibilityHint on the slider. ✓
- §2 dynamic type / sizing → Task 2 popover .font(.callout) + .frame(width: 260) + .padding() (wraps, respects Dynamic Type). ✓
- §3 ParamHelp (native-label-keyed, matrix shared, ported texts, English) → Task 1. ✓
- §4 ParamDescriptor.tooltip + registry population + broadcast() fix → Task 1. ✓
- §6 tests (all 45 non-empty tooltip; text(for:) known/matrix/unknown) → Task 1. ✓
- §8 risk (gesture disambiguation) → Task 3 Step 2 device check; .presentationCompactAdaptation availability OK at deployment targets. ✓
Placeholder scan: none — full code / exact commands throughout.
Type consistency: ParamHelp.byLabel, ParamHelp.matrix, ParamHelp.text(for:), ParamDescriptor.tooltip, the memberwise-init order (…, set:, tooltip:) is used consistently in the registry add(...) (Task 1 Step 4) and broadcast(...) (Task 1 Step 5); ParamSlider.hasHelp/showHelp/labelView consistent in Task 2.
Out-of-scope (correctly deferred): non-scalar control help (Spawn/Blend/colors), audio-param help, auto-sync of texts from the web SSOT.