From b7c8187aef9164395208805698e20d1e90965f79 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 21:05:44 +0000 Subject: [PATCH] UI reorg: settings grouped by usage frequency - Filter (key/strings/fret window) collapsed behind Filter button with active-count badge; opens the unified TargetNoteControls panel - Display key signature moved into the filter panel (all modes) - Instrument + Tuning selects moved into Settings modal (Instrument section); removed from footer - Settings modal restructured into collapsible sections: Instrument, Appearance, Audio, Microphone - Phrase setup panel: removed cross-reference hint texts - PracticeFilter is now the single source of truth: removed mirrored keyTonic/keyMode/fretWindow fields from PhraseSettings - Dark mode: neutral gray with slight blue tint (was warm brown) --- src/components/Controls.tsx | 143 +++++--------------------- src/components/PhraseTrainer.tsx | 70 ++++--------- src/components/SettingsModal.tsx | 85 +++++++++++++-- src/components/TargetNoteControls.tsx | 29 ++++++ src/styles/variables.css | 24 ++--- src/types/SettingsTypes.ts | 11 -- 6 files changed, 168 insertions(+), 194 deletions(-) diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index f11b553..9090d65 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,14 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Guitar, Music, Gauge } from 'lucide-react'; -import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; -import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; - +import { Gauge, SlidersHorizontal } from 'lucide-react'; import { TuningMeter } from './TuningMeter'; import { TargetNoteControls } from './TargetNoteControls'; import { MetronomeWidget } from './MetronomeWidget'; import { phraseRunBus } from '../hooks/phraseRunBus'; import { useSettings } from '../context/useSettings'; -import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; +import { getPracticeFilter, type RhythmSettings } from '../types/SettingsTypes'; @@ -18,35 +15,13 @@ interface ControlsProps { export const Controls: React.FC = ({ currentPitch }) => { const { settings, updateSettings } = useSettings(); - // Alias to minimize refactor, or just use updateSettings. const onUpdateSettings = updateSettings; // Phrase run gate: the metronome only ticks while the phrase trainer is // counting in / playing (armed by the user toggle, fired by the run). const [phraseRunActive, setPhraseRunActive] = useState(false); useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []); - const handleTuningChange = (e: React.ChangeEvent) => { - onUpdateSettings({ ...settings, tuningId: e.target.value }); - }; - - const handleInstrumentChange = (e: React.ChangeEvent) => { - const newInstrumentId = e.target.value; - const instDef = INSTRUMENT_DEFINITIONS[newInstrumentId]; - - const defaultRange = instDef.ranges[0].id; - - // Reset tuning if applicable, or just keep as is (it won't be shown/used) - let newTuningId = settings.tuningId; - if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) { - newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0]; - } - - onUpdateSettings({ - ...settings, - instrument: newInstrumentId, - difficulty: defaultRange as Difficulty, - tuningId: newTuningId - }); - }; + // Note-filter panel: hidden by default (minimal UI), toggled here. + const [filtersOpen, setFiltersOpen] = useState(false); const updateRhythm = (updates: Partial) => { onUpdateSettings({ @@ -55,98 +30,36 @@ export const Controls: React.FC = ({ currentPitch }) => { }); }; - // Cast instrument to specific key if needed, or use generic record access - const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || []; - const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; + const pf = getPracticeFilter(settings); + const activeFilterCount = + (pf.keyEnabled ? 1 : 0) + + (pf.strings.length > 0 ? 1 : 0) + + (pf.fretWindowEnabled ? 1 : 0); - - - // Phrase Mode brings its own key/tempo machinery; the single-note - // key-signature select and metronome tool would be competing controls. const inPhraseMode = settings.gameMode === 'phrase'; return (
- {/* Top Section: Settings Grid (2 Columns) */} -
- {/* Row 1, Col 1 */} -
- - -
- - {/* Row 1, Col 2 (Conditional) */} - {currentInstrumentDef.showTuning ? ( -
- - -
- ) :
} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows. - If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows? - The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item? - I'll output an empty div if I want to FORCE strict placement, but generally flow is better. - However, "Second row: note set..." suggests structure. - I will use an empty div if tuning is hidden to push Note Set to next row? - Actually, grid-template-columns: 1fr 1fr. - If Tuning is hidden, Note Set becomes Item 2. - I'll forgo complexity and just render what's available. */} - - {/* Row 2, Col 2 */} - {!inPhraseMode && ( -
- - -
- )} - + {/* Note filter: collapsed behind a button — visible only when needed */} +
+
- - {/* Target-note filter: unified controls, all modes */} - + {filtersOpen && } {/* Bottom Section: Tools Grid (2 Columns now) */}
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 79f81e5..2d4ff56 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -13,7 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { useSettings } from '../context/useSettings'; import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; -import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; +import { getPhraseSettings, getPracticeFilter, type PhraseSettings } from '../types/SettingsTypes'; import { rawFrameCount } from '../hooks/rawFrameBus'; import { audioEngine } from '../audio/AudioEngine'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange'; @@ -71,14 +71,9 @@ export const PhraseTrainer = forwardRef( const setSettings = updateSettings; const { playNote } = useAudioPlayer(); const phrase = getPhraseSettings(settings); - // Unified target-note controls: Phrase Mode reads/writes the shared - // practice filter so all modes expose identical key/fret-window state. + // Unified target-note controls: Phrase Mode reads the shared practice + // filter (single source of truth across all modes). const pf = getPracticeFilter(settings); - phrase.keyTonic = pf.keyTonic; - phrase.keyMode = pf.keyMode; - phrase.fretWindowEnabled = pf.fretWindowEnabled; - phrase.fretMin = pf.fretMin; - phrase.fretMax = pf.fretMax; // Unified metronome: phrase tempo reads from the shared rhythm BPM // (single source of truth across modes; the phrase-local BPM control // was replaced by the common metronome widget in Controls). @@ -96,18 +91,7 @@ export const PhraseTrainer = forwardRef( ? settings.rhythm.active && settings.rhythm.sound : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise; const setPhrase = useCallback((updates: Partial) => { - updateSettings(s => { - const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; - // Mirror shared filter fields into the unified practice filter. - const shared: Partial = {}; - if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic; - if ('keyMode' in updates) shared.keyMode = updates.keyMode; - if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled; - if ('fretMin' in updates) shared.fretMin = updates.fretMin; - if ('fretMax' in updates) shared.fretMax = updates.fretMax; - if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared }; - return next; - }); + updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); }, [updateSettings]); const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentTuning = TUNINGS[settings.tuningId]; @@ -140,7 +124,6 @@ export const PhraseTrainer = forwardRef( customMaxFret: settings.customMaxFret, }); }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning, - phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]); // ---- Imported score (session-only) -------------------------------------- @@ -173,8 +156,8 @@ export const PhraseTrainer = forwardRef( const baseScore: Result | null = useMemo(() => { if (phrase.material === 'melody') { const result = generateMelody({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, bars: phrase.bars, meter: { numerator: phrase.meterNumerator, denominator: 4 }, rhythmLevel: phrase.rhythmLevel, @@ -184,8 +167,8 @@ export const PhraseTrainer = forwardRef( } if (phrase.material === 'scale') { return generateScaleDrill({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, direction: phrase.scaleDirection, coverage: phrase.scaleCoverage, meter: { numerator: phrase.meterNumerator, denominator: 4 }, @@ -196,8 +179,8 @@ export const PhraseTrainer = forwardRef( // Meter is auto-derived inside the generator from the figure // length (always eighths) — the user no longer picks a meter. return generateGiulianiStudy({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, pattern: phrase.giulianiPattern, tuningId: settings.tuningId, bars: phrase.giulianiBars, @@ -209,8 +192,8 @@ export const PhraseTrainer = forwardRef( } if (phrase.material === 'arpeggio') { return generateArpeggio({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', pattern: phrase.arpeggioPattern, coverage: phrase.arpeggioCoverage, @@ -231,7 +214,7 @@ export const PhraseTrainer = forwardRef( return { ok: true, value: importedScore }; } return null; - }, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, + }, [phrase.material, pf.keyTonic, pf.keyMode, phrase.bars, phrase.meterNumerator, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioBars, phrase.arpeggioProgression, @@ -355,11 +338,11 @@ export const PhraseTrainer = forwardRef( const hintPositions = useMemo(() => { if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return []; - if (phrase.fretWindowEnabled) { - return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax); + if (pf.fretWindowEnabled) { + return positionsWithinWindow(currentMidi, currentTuning, pf.fretMin, pf.fretMax); } return getFretboardPositions(currentMidi, currentTuning); - }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax]); + }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]); const handleVirtualPlay = useCallback((midi: number) => { if (!settings.virtualGuitarMute) { @@ -579,11 +562,7 @@ export const PhraseTrainer = forwardRef( - {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && ( - - Key/mode: use the "Target key" controls below the sheet — shared across modes. - - )} + {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && null} {phrase.material === 'melody' && ( <> @@ -799,11 +778,7 @@ export const PhraseTrainer = forwardRef( )} - {fretted && ( - - Fret window: use the "Fret window" controls below the sheet — shared across modes. - - )} + {fretted && null} {/* Pace lives in the metronome widget now: 'Sync to exercise' checked = tempo pace, @@ -816,11 +791,6 @@ export const PhraseTrainer = forwardRef( /> Virtual input only - {phrase.pace === 'tempo' && ( - - BPM + click: on the metronome widget below. - - )}
{phrase.pace === 'tempo' && ( -

The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.

+

Tempo and click sound live on the metronome tool (Sync checked = the run sets the pace; unchecked = play at your own pace with a free metronome).

)}

Matching checks pitch, not fingering — several fretboard positions produce the same pitch. @@ -878,7 +848,7 @@ export const PhraseTrainer = forwardRef( onPlayNote={handleVirtualPlay} onHover={setHoveredMidi} showHints={settings.showHint} - maxFrets={Math.max(15, phrase.fretWindowEnabled ? phrase.fretMax + 1 : 0)} + maxFrets={Math.max(15, pf.fretWindowEnabled ? pf.fretMax + 1 : 0)} /> ) )} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 25cd710..a960f3b 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,8 +1,32 @@ import React, { useState } from 'react'; -import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock } from 'lucide-react'; +import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock, Guitar } from 'lucide-react'; import type { AppSettings } from '../types/SettingsTypes'; import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; +import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; +import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; + +/** Collapsible settings section. First section open by default. */ +const Section: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = false, children }) => { + const [open, setOpen] = useState(defaultOpen); + return ( +

+ + {open &&
{children}
} +
+ ); +}; interface SettingsModalProps { isOpen: boolean; @@ -78,7 +102,52 @@ export const SettingsModal: React.FC = ({
e.stopPropagation()}> -

Settings

+

Settings

+ + {/* Instrument & Tuning (rarely changed — lives here now) */} +
+
+ + +
+ {INSTRUMENT_DEFINITIONS[settings.instrument]?.showTuning && INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] && ( +
+ + +
+ )} +
+ +
{/* Theme Settings */}
@@ -119,8 +188,6 @@ export const SettingsModal: React.FC = ({
-
- {/* Animation Settings */}
@@ -140,7 +207,9 @@ export const SettingsModal: React.FC = ({

-
+ + +
@@ -261,7 +330,9 @@ export const SettingsModal: React.FC = ({

-
+
+ +
{/* Microphone Toggle */}
@@ -434,6 +505,8 @@ export const SettingsModal: React.FC = ({ )}
)} + +
diff --git a/src/components/TargetNoteControls.tsx b/src/components/TargetNoteControls.tsx index 5df73d7..79d1641 100644 --- a/src/components/TargetNoteControls.tsx +++ b/src/components/TargetNoteControls.tsx @@ -101,6 +101,35 @@ export const TargetNoteControls: React.FC = () => { )}
+ {/* Display key signature (sheet notation) */} +
+ + +
+ {/* Key filter */}
diff --git a/src/styles/variables.css b/src/styles/variables.css index fac8fc6..d10f121 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -59,24 +59,24 @@ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; - --color-bg: hsl(30, 20%, 10%); - --color-surface: hsl(30, 20%, 15%); - --color-text-main: hsl(30, 10%, 90%); - --color-text-muted: hsl(30, 10%, 60%); - --color-primary: hsl(0, 0%, 90%); - --color-primary-dark: hsl(0, 0%, 100%); + --color-bg: hsl(220, 6%, 10%); + --color-surface: hsl(220, 6%, 15%); + --color-text-main: hsl(220, 4%, 90%); + --color-text-muted: hsl(220, 4%, 60%); + --color-primary: hsl(220, 4%, 90%); + --color-primary-dark: hsl(220, 4%, 100%); } } /* Manual Dark Mode Override */ html[data-theme="dark"] { color-scheme: dark; - --color-bg: hsl(30, 20%, 10%); - --color-surface: hsl(30, 20%, 15%); - --color-text-main: hsl(30, 10%, 90%); - --color-text-muted: hsl(30, 10%, 60%); - --color-primary: hsl(0, 0%, 90%); - --color-primary-dark: hsl(0, 0%, 100%); + --color-bg: hsl(220, 6%, 10%); + --color-surface: hsl(220, 6%, 15%); + --color-text-main: hsl(220, 4%, 90%); + --color-text-muted: hsl(220, 4%, 60%); + --color-primary: hsl(220, 4%, 90%); + --color-primary-dark: hsl(220, 4%, 100%); } /* Manual Light Mode Override */ diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index d0b318a..e13e347 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -49,8 +49,6 @@ export interface PhraseSettings { material: PhraseMaterial; libraryId: string; /** Imported score id (the parsed score lives in component state, session-only). */ - keyTonic: string; - keyMode: string; // ModeId bars: number; // 1–8 (melodies) meterNumerator: 3 | 4 | 6; rhythmLevel: 1 | 2 | 3; @@ -69,10 +67,6 @@ export interface PhraseSettings { /** Giuliani study material (own category — voicing-enforced). */ giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim'; giulianiBars: number; - /** Phrase-local fret window (guitar/bass) — overrides the note set. */ - fretWindowEnabled: boolean; - fretMin: number; - fretMax: number; /** Practice range for library/import material (1-based, inclusive). */ startBar: number; barCount: number; @@ -95,8 +89,6 @@ export interface PhraseSettings { export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { material: 'melody', libraryId: '', - keyTonic: 'C', - keyMode: 'major', bars: 2, meterNumerator: 4, rhythmLevel: 1, @@ -111,9 +103,6 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { arpeggioProgression: 'functional', giulianiPattern: 'pima', giulianiBars: 4, - fretWindowEnabled: false, - fretMin: 0, - fretMax: 4, startBar: 1, barCount: 2, pace: 'step',