Skip to content

feat: PLAY-surface micro-interactions and harmonic character cues - #41

Merged
sp80808 merged 5 commits into
mainfrom
cursor/play-micro-interactions-2d96
Sep 2, 2026
Merged

sp80808 merged 5 commits into
mainfrom
cursor/play-micro-interactions-2d96

Conversation

@sp80808

@sp80808 sp80808 commented Aug 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds PLAY-surface micro-interactions, then a containment pass so settings menus no longer clip or overflow.

Merge conflicts with main are resolved: this branch keeps main's Play/Practice shell, theme tokens, compact viewport, splitter layout, and per-pad ripple, and restores sector fill, risk rings, stick comet, harmonic-character cues, hover/press motion, and settings containment from the PLAY work.

PLAY control surface

  • Harmonic compass: selected sector fill, rest-zone ring, risk-radius outer ring, stick comet, hover/settle on chord nodes
  • Chord pads, progression blocks, suggestion chips, and workspace tabs: hover, press, and selection motion with Reduce Motion fallbacks
  • Hero chord display: content transition, sounding glow, and an empty-state prompt
  • Controller HUD: LIVE pulse, technique/chord content changes, face-button role scale, virtual-string vibration on attack
  • Harmonic character is no longer colour-only (HarmonicTensionCharacter label + SF Symbol + ring weight)

Settings containment

  • Controller settings sheet, MAP scheme pane, and nested learn/calibration sheets wrap or scroll instead of expanding past the window
  • Segmented pickers fall back to a menu when labels would overflow (XWidthSafePicker)
  • Tabs compact, then scroll; remap Rebind controls stay on-screen; live-test and calibration stacks go vertical when narrow
  • Quick-control popovers grow with content instead of using a rigid clipped frame
  • String-gauge segments use compact labels (Light / Regular / Heavy / Bass)

Test plan

  • Open Controller & Hardware Configuration and resize the sheet narrower: header, scheme toolbar, tabs, and cards stay inside the rounded window
  • Feel & Ergonomics: stick/trigger segmented controls either fit or become menus; helper text wraps
  • Action Remapping: Rebind stays visible; descriptions wrap to two lines
  • Live Test and Calibration: pairs stack vertically instead of clipping
  • Open Learn and Calibration wizard sheets at larger accessibility text sizes: copy and buttons remain fully visible
  • MAP Control Schemes pane: same settings view fits the split without horizontal overflow
  • PLAY quick-control popovers (Gate, Velocity, Triggers, EQ): segmented controls and sliders stay inside the popover
  • Launch PLAY and move the left stick: compass sector, stick cursor, and hero chord update immediately
  • Enable Reduce Motion: looping LIVE pulse and hover lift disappear; stick/trigger position still tracks instantly
  • swift test (macOS CI) including HarmonicTensionCharacter and StringGauge.compactName tests
Open in Web Open in Cursor 

Summary by Sourcery

Add responsive PLAY-surface interactions and harmonic character cues while keeping controller and workspace settings contained across compact layouts.

New Features:

  • Add harmonic character labels, symbols, and ring-weight cues so harmonic tension is legible beyond colour.
  • Introduce PLAY-surface micro-interactions for harmonic navigation, chord controls, progression tools, controller feedback, and musical content changes with Reduce Motion support.

Bug Fixes:

  • Prevent controller, MAP, calibration, remapping, and quick-control settings from clipping or overflowing in narrow windows, sheets, and popovers.

Enhancements:

  • Improve responsive settings layouts with wrapping, scrolling, compact labels, adaptive stacking, and width-safe picker fallbacks.
  • Enhance chord and controller feedback with selected-sector and risk-radius visualization, stick trails, sounding glows, attack vibration, accessibility labels, and empty-state guidance.

Tests:

  • Add coverage for harmonic tension character thresholds and non-colour cues.
  • Add coverage for compact string-gauge labels used in constrained settings controls.

cursoragent and others added 2 commits August 17, 2026 12:16
Add hover, press, and selection motion to the harmonic compass, chord pads,
and controller HUD so gestures read as musical cause-and-effect. Harmonic
character now uses icon and ring weight in addition to colour.

Co-authored-by: Harry Speight <sp80808@users.noreply.github.com>
The hover chrome must take its label as a built view so the shared
button style compiles on macOS 14.

Co-authored-by: Harry Speight <sp80808@users.noreply.github.com>
@sp80808
sp80808 marked this pull request as ready for review August 17, 2026 12:17
@sourcery-ai

sourcery-ai Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Implements a reusable motion system and harmonic tension character model, then wires them through PLAY workspace views, harmonic wheel, controller HUD, and instrument selector to add hover/press/selection micro-interactions, content transitions, and non-color accessibility cues, with Reduce Motion-aware animation behavior and tests for the new tension character thresholds and semantics.

Sequence diagram for left-stick chord selection and harmonic character cues

sequenceDiagram
    actor User
    participant ControllerManager
    participant AppState
    participant HarmonicWheelView
    participant EnhancedChordDisplayView

    User->>ControllerManager: move_left_stick
    ControllerManager->>AppState: update controllerState.leftStickMagnitude
    ControllerManager->>AppState: update selectedChordIndex
    AppState->>HarmonicWheelView: diatonicChords, selectedChordIndex
    HarmonicWheelView->>HarmonicWheelView: selectedSector(center, innerRadius, outerRadius)
    HarmonicWheelView->>AppState: chordSegments onTapGesture
    AppState->>AppState: set currentChord
    AppState->>EnhancedChordDisplayView: currentChord, activeNotes
    EnhancedChordDisplayView->>EnhancedChordDisplayView: accessibilityChordLabel
    EnhancedChordDisplayView->>HarmonicTensionCharacter: init(tension: chord.tension(...))
    HarmonicTensionCharacter-->>EnhancedChordDisplayView: label
    Note over EnhancedChordDisplayView: Accessibility label now includes chord name, roman numeral, and harmonic tension character label
Loading

File-Level Changes

Change Details Files
Introduce reusable motion utilities for hover/press/selection, content transitions, and LIVE pulses and apply them to PLAY surface controls.
  • Add XMusicalPadChrome and XMusicalPadButtonStyle for musical pads and tiles, with hover lift, press squash, selection emphasis, and tint/cornerRadius configuration.
  • Add XHoverFillModifier for compact hover styling on tabs and chips, and apply it to tab buttons instead of manual background/overlay styling.
  • Add XMusicalContentModifier for animating musical text/content changes with contentTransition and transitionShort animation, guarded by accessibilityReduceMotion.
  • Add XLivePulseModifier and xLivePulse view helper to provide a subtle repeating pulse for LIVE/connected status indicators, used on the controller connection capsule.
  • Expose xHoverFill, xMusicalContent, and xLivePulse helpers on View via extension.
Sources/XPadUI/XMotion.swift
Sources/XPadUI/PlayWorkspaceView.swift
Sources/XPadUI/ControllerVisualizerView.swift
Sources/XPadUI/ControllerPerformanceHUD.swift
Sources/XPadUI/InstrumentSelectorView.swift
Refine PLAY workspace UI elements (hero chord, tabs, pads, progression controls, suggestions, hints, active notes) with micro-interactions, accessibility labels, and Reduce Motion-aware animation.
  • Enhance ContextualHintSlot with asymmetric insertion transition and short animations keyed off contextualHint and activeNotes, respecting accessibilityReduceMotion.
  • Upgrade EnhancedChordDisplayView with sounding glow, musical content transitions, an empty-state prompt, and consolidated accessibilityLabel that includes harmonic tension character.
  • Switch harmonic and DSP tabbed workspace content to use .id(tab) with opacity transition and transitionShort animation on tab change.
  • Refactor TabButton, ToggleButton, diatonic chord pads, progression builder buttons/blocks, and suggestion strip to use XMusicalPadButtonStyle or XTactileButtonStyle, add help tooltips, and improve accessibility traits/labels.
  • Add SuggestionChip component with hover lift, hover stroke, Reduce Motion-aware animation, and per-chip help strings.
  • Animate ActiveNotesView glyph entry/exit via scale+opacity transitions and springSnappy, and mark theory text as xMusicalContent.
  • Enrich TechniqueHintBanner with an icon, accent stroke, and capsule background.
Sources/XPadUI/PlayWorkspaceView.swift
Sources/XPadUI/GreenTheme.swift
Enhance harmonic wheel visuals and chord nodes using controller input-driven rings, selected sector fill, tension-based styling, and tension character cues.
  • Update backgroundRings to react to left stick magnitude with a risk-radius outer ring and magnitude-scaled radial gradient.
  • Add selectedSector drawing around the currently selected diatonic chord index with animated primary fill, respecting reduceMotion.
  • Change chord segment tap handling to use springSnappy when selecting chords.
  • Improve centerDisplay with an inner active border overlay and xMusicalContent on key and scale labels.
  • Revise stickIndicator to draw a comet-like line plus tail circle, simplifying previous per-coordinate animations.
  • Rework ChordNodeView to include HarmonicTensionCharacter symbol, tensionTint-based stroke and hover styling, hover/selection scale behavior, help text, accessibility labels including character, and Reduce Motion-aware animations.
Sources/XPadUI/HarmonicWheelView.swift
Extend button and HUD chrome with hover interactions and micro motion, including tactile buttons and virtual string vibration in the controller HUD.
  • Refactor XTactileButtonStyle into a composition using XTactileButtonChrome, adding hover state, shared motion logic, and Reduce Motion awareness, while maintaining active/pressed styling.
  • Apply XTactileButtonStyle to progression mutate/play/send buttons with updated foreground colors and help descriptions.
  • Add xMusicalContent usage across HUD labels (technique playLabel, musicalResult, chordName, technique text) for smooth content transitions.
  • Enhance VirtualStringResponse with per-string vertical vibration based on attackPulse and attack, giving visual feedback on strumming dynamics.
  • Animate FaceRoleButton label scale on press with feedbackFast, gated by accessibilityReduceMotion.
Sources/XPadUI/GreenTheme.swift
Sources/XPadUI/ControllerPerformanceHUD.swift
Introduce HarmonicTensionCharacter model for non-color harmonic tension cues and integrate it into tension badges, chord accessibility, and compass nodes, with tests.
  • Add HarmonicTensionCharacter enum in XPadCore with tension-based initializer, label, SF Symbol name, and ringWeight to express harmonic character via text, icon, and outline weight.
  • Update TensionBadge to derive its label, symbol, color, and ring stroke thickness from HarmonicTensionCharacter and XTheme.tensionColor, and to expose an accessibilityLabel including harmonic character.
  • Use HarmonicTensionCharacter in EnhancedChordDisplayView accessibilityChordLabel to speak the current chord’s character alongside name and roman numeral.
  • Use HarmonicTensionCharacter in ChordNodeView to drive icon, stroke width, tint opacity, help text, and accessibilityLabel.
  • Add unit tests verifying HarmonicTensionCharacter thresholds, symbolName uniqueness, ringWeight mapping, and labels.
Sources/XPadCore/HarmonicTensionCharacter.swift
Sources/XPadUI/PlayWorkspaceView.swift
Sources/XPadUI/HarmonicWheelView.swift
Tests/XPadCoreTests/XPadCoreTests.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • Several views now stack multiple .animation modifiers on the same container (e.g. ContextualHintSlot, EnhancedChordDisplayView, HarmonicWheelView.selectedSector), which can be hard to reason about and may produce unexpected interactions; consider consolidating these into a single animation per view or driving them via explicit withAnimation where needed.
  • The tension-to-character mapping in HarmonicTensionCharacter is now used in multiple UI surfaces (badge, chord nodes, accessibility labels); if you expect these thresholds or categories to evolve, consider centralizing the boundary values as named constants or documenting their musical intent so future tweaks stay consistent across all call sites.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Several views now stack multiple `.animation` modifiers on the same container (e.g. `ContextualHintSlot`, `EnhancedChordDisplayView`, `HarmonicWheelView.selectedSector`), which can be hard to reason about and may produce unexpected interactions; consider consolidating these into a single animation per view or driving them via explicit `withAnimation` where needed.
- The tension-to-character mapping in `HarmonicTensionCharacter` is now used in multiple UI surfaces (badge, chord nodes, accessibility labels); if you expect these thresholds or categories to evolve, consider centralizing the boundary values as named constants or documenting their musical intent so future tweaks stay consistent across all call sites.

## Individual Comments

### Comment 1
<location path="Sources/XPadUI/XMotion.swift" line_range="174-175" />
<code_context>
+        content
+            .opacity(pulseOpacity)
+            .shadow(color: isActive ? color.opacity(0.45) : .clear, radius: isActive ? 5 : 0)
+            .onAppear { phase = true }
+            .animation(
+                isActive && !reduceMotion
+                    ? .easeInOut(duration: 1.15).repeatForever(autoreverses: true)
</code_context>
<issue_to_address>
**issue (bug_risk):** Live pulse never actually animates when `isActive` becomes true after appear.

`phase` is only set to `true` in `onAppear` and never updated, but the animation is keyed off `phase`. If `isActive` changes to `true` after the view appears, there’s no state change to start the animation, so opacity stays fixed. Consider driving the animation from `isActive` (e.g. update `phase` when `isActive` becomes true or key the animation directly off `isActive`) so the pulse begins when the active state toggles, not just on initial appearance.
</issue_to_address>

### Comment 2
<location path="Sources/XPadUI/PlayWorkspaceView.swift" line_range="216-222" />
<code_context>
             Spacer()
         }
         .frame(height: 36)
+        .animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.contextualHint)
+        .animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.activeNotes.isEmpty)
     }
 }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Stacking multiple `.animation` modifiers on the same container can be hard to reason about.

With two `.animation` modifiers on the same `HStack`, SwiftUI will typically apply only the last one, so changes to `contextualHint` or `activeNotes` may not animate as you expect. Consider using a single animation keyed off both values (e.g., a tuple) or attaching the animation to the specific subview that should animate (such as the banner) to keep behavior predictable.

```suggestion
            Spacer()
        }
        .frame(height: 36)
        .animation(
            reduceMotion ? nil : XTheme.transitionShort,
            value: (appState.contextualHint, appState.activeNotes.isEmpty)
        )
    }
}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +174 to +175
.onAppear { phase = true }
.animation(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Live pulse never actually animates when isActive becomes true after appear.

phase is only set to true in onAppear and never updated, but the animation is keyed off phase. If isActive changes to true after the view appears, there’s no state change to start the animation, so opacity stays fixed. Consider driving the animation from isActive (e.g. update phase when isActive becomes true or key the animation directly off isActive) so the pulse begins when the active state toggles, not just on initial appearance.

Comment on lines 216 to 222
Spacer()
}
.frame(height: 36)
.animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.contextualHint)
.animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.activeNotes.isEmpty)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Stacking multiple .animation modifiers on the same container can be hard to reason about.

With two .animation modifiers on the same HStack, SwiftUI will typically apply only the last one, so changes to contextualHint or activeNotes may not animate as you expect. Consider using a single animation keyed off both values (e.g., a tuple) or attaching the animation to the specific subview that should animate (such as the banner) to keep behavior predictable.

Suggested change
Spacer()
}
.frame(height: 36)
.animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.contextualHint)
.animation(reduceMotion ? nil : XTheme.transitionShort, value: appState.activeNotes.isEmpty)
}
}
Spacer()
}
.frame(height: 36)
.animation(
reduceMotion ? nil : XTheme.transitionShort,
value: (appState.contextualHint, appState.activeNotes.isEmpty)
)
}
}

Settings sheets, popovers, and MAP pickers now wrap, scroll, or fall back
to a menu when space is tight so tabs, segmented controls, and nested
wizard sheets stay inside their parent.

Co-authored-by: Harry Speight <sp80808@users.noreply.github.com>
@sp80808

sp80808 commented Aug 19, 2026

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts in this pull request

Co-authored-by: sp80808 <211996837+sp80808@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

Code review — PLAY-surface micro-interactions and harmonic character cues

Reviewed the full diff (XMotion motion tokens, XSettingsLayout width-safe chrome, HarmonicTensionCharacter, harmonic wheel / PLAY / HUD / settings call sites, theme additions, tests). Centralising hover/press/selection animation into reusable tokens and gating all of them on accessibilityReduceMotion is exactly right, and encoding tension with label + SF Symbol + ring weight (not just hue) is a genuine accessibility win. Findings below.

Note: this head has no macOS build/test run — only Sourcery and GitGuardian reported. Please push a no-op commit (or re-run the workflow) so Build, test and package macOS alpha covers this head before merging; the sibling branch #44 failed to compile on the same kind of change.

1. The LIVE pulse never starts for anything that becomes active later (medium)

Sources/XPadUI/XMotion.swift

@State private var phase = false
...
.onAppear { phase = true }
.animation(
    isActive && !reduceMotion ? .easeInOut(duration: 1.15).repeatForever(autoreverses: true) : nil,
    value: phase
)

The repeating animation is attached to a single false → true transition of phase that happens in onAppear. If isActive is false at that moment — which is the normal case for "controller connected" / "LIVE" badges, since the controller connects after the view appears — the only phase change has already been consumed with a nil animation, and phase never changes again. Result: the badge jumps to pulseOpacity == 0.58 and sits there instead of pulsing.

Suggested shape:

.onChange(of: isActive) { active in phase = active ? true : false }

or drive the pulse from TimelineView(.animation), or move the state toggle into a .task(id: isActive) so activation re-arms it. Please verify visually on a controller that connects after launch.

2. XMusicalContentModifier changes view identity with Reduce Motion (low)

if reduceMotion { content } else { content.contentTransition(.opacity).animation(...) }

Returning structurally different bodies from an Environment-driven branch gives the two branches different identities, so toggling Reduce Motion while the app is open tears down and rebuilds the subtree (losing @State, focus, and in-flight gestures inside it). Applying the modifiers unconditionally with a nil animation keeps one identity:

content
    .contentTransition(reduceMotion ? .identity : .opacity)
    .animation(reduceMotion ? nil : XTheme.transitionShort, value: value)

Same pattern is worth checking wherever reduceMotion gates a whole subtree in this diff.

3. XWidthSafePicker builds its options twice (low)

ViewThatFits instantiates both the segmented and menu Picker, each with the same stored content value. That's correct, but it doubles option-view construction on every layout pass for pickers that live in hot views (the settings popovers re-lay out on hover animations from this same PR). If any option label does non-trivial work (string formatting, AttributedString), hoist it. Also consider .pickerStyle(.menu) losing the segmented control's keyboard traversal — worth a .accessibilityHint since the control's affordance silently changes with window width.

4. HarmonicTensionCharacter(tension:) and NaN (low)

switch tension {
case ..<0.15: self = .stable
...
default: self = .outside
}

A NaN tension (easy to produce from a 0/0 normalisation upstream) fails every ..< comparison and lands in default, so the UI reports the most alarming state, "Outside". Add guard tension.isFinite else { self = .stable } (or .natural) so a computation bug can't masquerade as a musical one.

5. Boundary/ordering nits (low)

  • The thresholds are exclusive-upper (..<0.15), so tension == 0.15 is .natural. Fine, but the doc comment should state the buckets since three call sites in this PR render them.
  • ringWeight returns 1 for both .stable and .natural and 2 for both .colourful and .adventurous, so the "non-colour cue" only distinguishes three of the five states. If ring weight is meant to be the colour-free encoding, either give five distinct weights or lean on symbolName (which is distinct) in the places where weight is the only cue.
  • RealismMode gains explicit Hashable: it already had it via String RawRepresentable, so the added conformance is a no-op — harmless, but worth removing to keep the diff honest.

6. Verification

Everything here is visual, and the changes span PLAY, the harmonic wheel, HUDs, and every settings popover. Given finding 1 is only observable with a controller that connects after launch, and finding 3 only at narrow window widths, please attach before/after screenshots at (a) default width, (b) the narrowest supported window, and (c) with Reduce Motion enabled, so the width-safe fallbacks and the motion gating are reviewable without a local build.

Keep main's Play/Practice shell, theme tokens, and wheel layout while
restoring sector fill, risk rings, stick comet, harmonic character
cues, and settings containment from the PLAY micro-interactions work.

Co-authored-by: Harry Speight <sp80808@users.noreply.github.com>
@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_a8db106d-cebe-4190-8fd0-f39b17e73b0e)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a non-blocking comment: Cursor Bugbot was present but skipped (usage limit), so this head is not approved. Human review is needed; no reviewers were assigned because the only eligible human is the PR author.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@sp80808
sp80808 merged commit e1850e6 into main Sep 2, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants