circle of fifths: tool, explorer, reactive playground params
Created by: jedmund
Summary
Adds a new Circle of Fifths tool and explorer to the Playground, and replaces PlaygroundParams's struct + @Binding pattern with @Observable class after it started losing sidebar updates to an attribute-graph cycle. Minor dev-toolbar housekeeping along the way.
Circle of Fifths tool + explorer
- New
CircleOfFifthsView(tool) andCircleOfFifthsExplorerView(explorer) underViews/Tools/CircleOfFifthsandViews/Tools/CircleOfFifthsExplorer. Wheel has an outer ring of major keys (C G D A E B F♯/G♭ D♭ A♭ E♭ B♭ F) and an inner ring of relative minors. Center shows the selected tonic + its relative pair + the key signature's specific accidentals (F♯ C♯for D,B♭for F,no sharps or flatsfor C). - Persistent 7-position diatonic arc glow on the outer ring: the contiguous arc property of the circle made permanent, not transient, so the learner sees that the 7 adjacent keys ARE the scale notes. Arc shifts when the tonic changes.
- Thin 4pt orange arc with rounded caps traces the 7 arc positions on the outer perimeter, outside the glass. Rotates only clockwise on key change, matching the fifths cycle. Going from C back to F takes the 330° long way around instead of snapping counterclockwise.
- Third outer ring of Roman numerals (
I ii iii IV V vi vii°for majors,i ii° III iv v VI VIIfor minors) on its own thin glass annulus. Each numeral counter-rotates to stay upright while the ring spins with the arc. - Glass layering: lighter-frost outer annulus for the major pills, full-frost inner disc for the center content, thinnest-frost narrow annulus for the Roman numeral ring. No keyboard docked under the wheel; audio comes from tapping positions directly.
- Two play modes in the sidebar:
-
Play scale: tapping a pill becomes the new tonic, the scale sweeps, and the arc + numerals rotate to match. -
Play tonic: tapping plays only that pitch, leaving the selected key anchored. Lets the learner auditionI / IV / V / vipitches against a fixed tonal center. Key changes are done from the sidebar'sInitial keypicker.
-
- Minor-ring taps play the natural-minor scale (e.g. tap
Em→ playsE F♯ G A B C D), reusing the shared 7-position arc viaCircleOfFifthsMath.wheelIndex(forMinorScaleDegree:minorIndex:). -
CircleOfFifthsMathholds the rotation math: key ordering, key signatures, scale spellings, scale-degree → wheel-position mappings for major and natural minor, diatonic triad indices. Pure data + pure functions, unit-testable when we get a test target. - Wired through
ToolType.circleOfFifths,ToolViewFactory,PlaygroundCatalog(flipped to.ready),PlaygroundScreendispatch, and aPlaygroundConfiguratorsidebar section (Initial key+On tapmenu pickers).docs/tools.mdupdated with the final config schema.
Playground params reactivity
-
PlaygroundParamsconverted fromstruct + Equatableto@Observable final class. EveryPicker/Buttonin the configurator that writes a property now propagates through Observation tracking; every view that reads a property (directly, or transitively throughcurrentConfig) is invalidated automatically. - Got here the hard way. The old pattern paired
@State PlaygroundParams(struct) with.onChange(of: params) { configVersion += 1 }to force.id(configVersion)remounts. Once there were multiple observers plus aBool-tagged Picker in the Circle of Fifths section, SwiftUI detected an attribute-graph cycle and broke it by suppressing downstream invalidation: sidebar writes landed in@State, reads from PlaygroundConfigurator could see the new value, but PlaygroundScreen's body stopped running entirely.@Observablereplaces the whole invalidation chain with Observation's per-property tracking, no cycle. -
PlaygroundConfigurator.paramsswitched from@Bindingto@Bindable. Everything that used$params.fookeeps working because@Bindableprovides the same$-projection for@Observableclasses. -
PlaygroundScreen.currentConfigchanged from@Staterebuilt inonChangeto a simple computed property. No more stale-config failures; removes the.onChange(of: params) { ... }blocks from bothexerciseBodyandplaygroundBody.
Dev toolbar + lesson picker
- Dev toolbar (book / gallery / hero media) un-gated from
#if DEBUG, so the whole team (and anyone poking at the build) has access to the lesson picker and design system gallery. -
Reset progressbutton removed from the toolbar and, alongside a newUnlock all lessonsbutton, moved into theDebugLessonPickersheet with confirmation alerts. -
ProgressStore.unlockAllLessons(in:)walks the curriculum and writes aLessonRecordwith a sentinelfurthestPagefor every lesson soisCompleteholds across the board and the sequential gate inlessonStatuses(in:)falls open.
Test plan
-
Open Playground → Circle of Fifths. Wheel renders with outer majors, inner minors, persistent orange arc over the 7 diatonic keys, Roman numerals between the pills and the arc. -
Tap each of the 12 outer positions in scale mode. Arc rotates clockwise only (from C → F takes the long way). Selected key's accidentals appear as specific glyphs in the center ( F♯ C♯for D,B♭ E♭ A♭for E♭,no sharps or flatsfor C). -
Tap an inner minor position (e.g. Em). Natural minor plays, arc stays on the relative major's 7-position sweep, center switches focus to the minor name. -
Switch On taptoPlay tonicin the sidebar. Tap each outer position; only the single tonic plays, arc and numerals do not move. Change keys via sidebar'sInitial keypicker: arc rotates, numerals follow, selection updates. -
Switch back to Play scale, pick a different initial key, confirm scale sweep starts from new key. -
Landscape and portrait on iPad Pro 13" sim; dark mode on/off: wheel scales, orange reads, glass rings remain distinct. -
Open any other playground tool (Scale Explorer, Mode Explorer, Waveform Generator, Filter Playground) and confirm sidebar changes still update the tool view reactively — the @Observableswitch should not have regressed anything. -
Restart button in the playground side panel still forces a clean remount; Reset button repopulates defaults. -
Dev toolbar is visible on the home screen in a Release build as well (no more #if DEBUG). -
Tap the book icon → sheet opens; tap Unlock all lessons→ confirm → every track / module / lesson becomes navigable (no lock icons onTrackDetailScreen). TapReset progress→ confirm → hero flips back toStart, lessons re-lock.