Zum Inhalt

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 has id,label,section,range,isInt,isRespawn,get,set + computed idKey — no tooltip. Built via an internal add(...) closure inside ParamRegistry.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 a ParamDescriptor manually — adding a stored property to the struct means this call site must pass it too.
  • ParamSlider (Sources/Editor/ParamSlider.swift) renders Text(descriptor.label) + LockButton + value in an HStack, then a Slider with .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, .accessibilityHint all 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 tooltip to ParamDescriptor + populate it

In apple/PhysarumApp/Sources/Model/ParamRegistry.swift, add the stored property to ParamDescriptor (after the set line):

    let set: (inout PresetParams, Double) -> Void
    let tooltip: String
    var idKey: String { "\(id)" }

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)"
Expected: macOS BUILD SUCCEEDED · TEST SUCCEEDED (40) · iOS-sim BUILD SUCCEEDED.

  • [ ] 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

cd "$(git rev-parse --show-toplevel)"
git push   # main → Codeberg (+ GitHub mirror)

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.