From baa66c0dc725cc6b38cd0f84c4509fa046bf7ecd Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sat, 19 Sep 2026 23:45:53 +0200 Subject: [PATCH 1/8] UI: reorder game modes (Assist first), rename Sight Reading -> Learn Notes Internal key 'sight_reading' kept for localStorage compatibility; TODO added at gameMode type def for future rename + migration. --- src/App.tsx | 17 +++++++++-------- src/types/SettingsTypes.ts | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7d2b771..cac8cbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -277,11 +277,19 @@ function App() {
+ -
- + {/* Live staff omitted in Assist mode: the Assist card already + shows the played note large above the fretboard. */} + {!isAssist && ( + + )}
{displayedPitch ? ( <> From e43a17efb3af518c74d60687d500b6d80b696aaa Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 20 Sep 2026 00:10:37 +0200 Subject: [PATCH 4/8] Footer tools stacked in all modes; fix silent phrase-mode metronome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout: tuner + metronome now stack vertically everywhere (was phrase-only). Phrase metronome bug: clicks were gated on rhythm.active, but phrase mode has no on/off switch (the sync checkbox replaces it) and the default is active:false — so a synced run stayed silent with no in-mode remedy. Now: phrase mode is always armed (sync checkbox = free vs synced, sound checkbox = clicks), and phrase clickSound reads rhythm.sound only. Single-note modes keep the rhythm.active on/off semantics. --- src/components/Controls.tsx | 4 ++-- src/components/MetronomeWidget.tsx | 8 ++++++-- src/components/PhraseTrainer.tsx | 16 +++++++++------- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 185eb7a..0eab364 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -68,8 +68,8 @@ export const Controls: React.FC = ({ currentPitch }) => { )} {shouldShowFilters && } - {/* Bottom Section: Tools Grid (2 Columns now) */} -
+ {/* Bottom Section: Tools Grid (stacked in all modes) */} +
{/* Tool 1: Tuner */}
diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index 77ae164..e344866 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -55,10 +55,15 @@ export const MetronomeWidget: React.FC = ({ const isSync = syncMode === 'option' && !!rhythm.syncToExercise; const runActive = gate === true; + // Phrase mode (syncMode 'option') has no on/off switch — the sync + // checkbox replaces it (unchecked = free metronome, checked = synced to + // the exercise), so the metronome is always armed there. Gating on + // rhythm.active would leave phrase users with no way to enable clicks. + const armed = syncMode === 'option' ? true : rhythm.active; // Sync: pendulum/click only while the run is active (the run's scheduler // is the click source; the widget's own scheduler stays off). // Free/plain: the widget's own scheduler runs whenever armed. - const running = isSync ? runActive : rhythm.active; + const running = isSync ? runActive : armed; const { restart } = useMetronome({ bpm: rhythm.bpm, @@ -78,7 +83,6 @@ export const MetronomeWidget: React.FC = ({ }, [isSync, runActive]); const beatMs = 60000 / Math.max(1, rhythm.bpm); - const armed = rhythm.active; return (
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index b3c958f..b4958f4 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -85,13 +85,15 @@ export const PhraseTrainer = forwardRef( const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step'; phrase.pace = pace; // Click decision — ONE source: the metronome widget's sound checkbox. - // - Sync (tempo): run clicks when armed && sound (count-in incl.); - // widget silent while idle. - // - Free metronome: widget clicks freely; the run stays silent to - // avoid doubling (sync off in tempo = free widget during runs). - phrase.clickSound = pace === 'tempo' - ? settings.rhythm.active && settings.rhythm.sound - : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise; + // Phrase mode has no metronome on/off switch (the sync checkbox + // replaces it, see MetronomeWidget), so rhythm.active must NOT gate + // clicks here — with the default active:false a synced run would + // stay silent with no way to fix it from this mode. + // - Sync (tempo): run clicks when sound is on (count-in included); + // the widget stays silent while idle. + // - Free metronome (step): the widget clicks freely; the run stays + // silent to avoid doubling. + phrase.clickSound = settings.rhythm.sound; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); }, [updateSettings]); From 7a030f2728800bfa57f19bb64ef19fa026b41d85 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 20 Sep 2026 00:15:55 +0200 Subject: [PATCH 5/8] Single-note modes: switch-style display toggles (like phrase mode) Guitar/Piano and Hint show/hide controls in Learn Notes / Ear Training now use the small switch buttons in a .display-toggles row instead of the big hint-buttons. Play Note / Skip Note stay as action buttons. --- src/components/SingleNoteTrainer.tsx | 77 +++++++++++++++++----------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/src/components/SingleNoteTrainer.tsx b/src/components/SingleNoteTrainer.tsx index 2f896a8..fa90a9a 100644 --- a/src/components/SingleNoteTrainer.tsx +++ b/src/components/SingleNoteTrainer.tsx @@ -149,38 +149,57 @@ export const SingleNoteTrainer = forwardRef - {currentInstrumentDef.showTuning && ( + <> + {/* Display toggles — switch style (like phrase mode): + pure show/hide, no trainer actions. */} +
+ {currentInstrumentDef.showTuning && ( +
+ + + {currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'} + + +
+ )} +
+ + + Hint + + +
+
+ +
- )} - - - -
+ +
+ )}
); From 05d5548d0ca728b5e843dfa4d49425add1bf2c05 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 20 Sep 2026 00:20:58 +0200 Subject: [PATCH 6/8] Phrase setup panel: prominent collapsed header The collapsed Setup row was a small muted 13px text link (plus the default disclosure triangle next to our chevron). Now a proper bar: bordered/rounded, 15px semibold, hover feedback, chevron rotates on open, default marker hidden. --- src/App.css | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/App.css b/src/App.css index 72a4f7f..1e8af14 100644 --- a/src/App.css +++ b/src/App.css @@ -1041,29 +1041,53 @@ button { .phrase-setup { margin-bottom: 16px; - border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); - padding-bottom: 12px; + border: 1px solid color-mix(in srgb, var(--color-text-main) 18%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-text-main) 4%, transparent); } .phrase-setup summary { display: flex; align-items: center; - gap: 6px; + gap: 8px; cursor: pointer; - font-size: 13px; - color: var(--color-text-muted); + font-size: 15px; + font-weight: 600; + color: var(--color-text-main); + padding: 12px 14px; user-select: none; + list-style: none; + border-radius: 10px; + transition: background 0.15s ease; +} + +/* Hide the default disclosure triangle — the chevron icon does the job */ +.phrase-setup summary::-webkit-details-marker { + display: none; } .phrase-setup summary:hover { - color: var(--color-text-main); + background: color-mix(in srgb, var(--color-text-main) 7%, transparent); +} + +.phrase-setup[open] summary { + border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); + border-radius: 10px 10px 0 0; +} + +.phrase-setup summary svg { + transition: transform 0.15s ease; +} + +.phrase-setup[open] summary svg { + transform: rotate(180deg); } .phrase-setup-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px 14px; - margin-top: 12px; + padding: 12px 14px 14px; } .phrase-setup-grid label { From 56ace9efacdbc18feaeb2a844c9d6935146f294b Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 20 Sep 2026 00:25:36 +0200 Subject: [PATCH 7/8] Assist mode: taller live-note staff box (88px) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Assist card wrapped the LiveNoteStaff in .sheet-music-container, which clips (overflow: hidden) — extreme ledger lines poked out of the 64px box by a few px. LiveNoteStaff now takes a height prop; stave y is computed as (h-38)/2 - 40.5 (equals the old -28 for h=64). Assist uses 88px; mic bar keeps 64px. --- src/components/AssistMode.tsx | 1 + src/components/LiveNoteStaff.tsx | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/AssistMode.tsx b/src/components/AssistMode.tsx index fa75613..1100ab0 100644 --- a/src/components/AssistMode.tsx +++ b/src/components/AssistMode.tsx @@ -61,6 +61,7 @@ export const AssistMode: React.FC = ({ pitchData, windowWidth } keySignature={settings.keySignature} clef={clef === 'grand' ? undefined : clef} width={Math.min(240, staffWidth)} + height={88} theme={settings.theme} />
diff --git a/src/components/LiveNoteStaff.tsx b/src/components/LiveNoteStaff.tsx index 3268a3f..ee104d7 100644 --- a/src/components/LiveNoteStaff.tsx +++ b/src/components/LiveNoteStaff.tsx @@ -21,14 +21,19 @@ interface LiveNoteStaffProps { * clef auto-switches treble/bass around middle C (piano-style). */ clef?: 'treble' | 'bass'; width?: number; + /** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in + * Assist mode) give ledger lines room inside instead of being clipped + * by the parent's overflow: hidden. */ + height?: number; theme?: 'light' | 'dark' | 'auto'; } const BOX_H = 64; // mic button height — vertical centering via the bar // VexFlow draws the first stave line ~40.5px BELOW the constructor y -// (default space_above_staff_ln = 4 line-spaces). To center a 38px staff in -// the 64px box the constructor y must be negative. -const STAVE_Y = -28; +// (default space_above_staff_ln = 4 line-spaces). To center the ~38px staff +// in a box of height h the constructor y must be negative. For h = 64 this +// yields -27.5 ≈ -28 (the original hardcoded value). +const staveYFor = (h: number) => (h - 38) / 2 - 40.5; export const LiveNoteStaff: React.FC = ({ midi, @@ -36,6 +41,7 @@ export const LiveNoteStaff: React.FC = ({ keySignature = 'C', clef: clefOverride, width = 130, + height: boxH = BOX_H, theme = 'auto', }) => { const containerRef = useRef(null); @@ -53,7 +59,7 @@ export const LiveNoteStaff: React.FC = ({ container.innerHTML = ''; const renderer = new Renderer(container, Renderer.Backends.SVG); - renderer.resize(width, BOX_H); + renderer.resize(width, boxH); const context = renderer.getContext(); const resolvedColor = @@ -63,7 +69,7 @@ export const LiveNoteStaff: React.FC = ({ context.setFillStyle(resolvedColor); context.setStrokeStyle(resolvedColor); - const stave = new Stave(0, STAVE_Y, width - 2); + const stave = new Stave(0, staveYFor(boxH), width - 2); stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 }); const written0 = midi !== null ? midi + transpose : null; // Instrument-fixed clef when provided (guitar always treble, bass @@ -119,7 +125,7 @@ export const LiveNoteStaff: React.FC = ({
From 71271387ab554630eebcd96b2acc72c30dbe2f75 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Sun, 20 Sep 2026 00:27:59 +0200 Subject: [PATCH 8/8] Fix double metronome click in single-note modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useGameLogic's own scheduler and the footer widget's scheduler both played audible clicks while rhythm.active — assist->learn-notes switch made the second one mount. Now the widget is the single click source in non-zen modes; useGameLogic's scheduler is a silent tick source for auto-advance (full sound only in zen mode, where the widget isn't rendered). Also: widget honors Timer mode (60/seconds) like the trainer, and useMetronome skips audio nodes at volume 0. --- src/components/MetronomeWidget.tsx | 8 ++++++-- src/hooks/useGameLogic.ts | 6 +++++- src/hooks/useMetronome.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index e344866..ed57a6c 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -65,8 +65,12 @@ export const MetronomeWidget: React.FC = ({ // Free/plain: the widget's own scheduler runs whenever armed. const running = isSync ? runActive : armed; + // Timer mode = one beat per N seconds: effective BPM 60/seconds so + // clicks and pendulum match the trainer's auto-advance cadence. + const effectiveBpm = rhythm.mode === 'bpm' ? rhythm.bpm : 60 / Math.max(1, rhythm.seconds); + const { restart } = useMetronome({ - bpm: rhythm.bpm, + bpm: effectiveBpm, volume: rhythm.sound ? rhythm.volume : 0, playing: running && !isSync, onTick: handleTick, @@ -82,7 +86,7 @@ export const MetronomeWidget: React.FC = ({ }); }, [isSync, runActive]); - const beatMs = 60000 / Math.max(1, rhythm.bpm); + const beatMs = 60000 / Math.max(1, effectiveBpm); return (
diff --git a/src/hooks/useGameLogic.ts b/src/hooks/useGameLogic.ts index 75e27ba..bf74f2c 100644 --- a/src/hooks/useGameLogic.ts +++ b/src/hooks/useGameLogic.ts @@ -85,7 +85,11 @@ export const useGameLogic = ( const { restart: restartMetronome } = useMetronome({ bpm: effectiveBpm, - volume: settings.rhythm.sound ? settings.rhythm.volume : 0, + // Audible clicks come from the shared footer widget (Controls) in + // non-zen single-note modes — this scheduler is only the tick source + // for auto-advance. Sounding it here too doubles every beat. In zen + // mode the widget isn't rendered, so this scheduler IS the click. + volume: settings.zenMode && settings.rhythm.sound ? settings.rhythm.volume : 0, playing: settings.rhythm.active, onTick: handleTick }); diff --git a/src/hooks/useMetronome.ts b/src/hooks/useMetronome.ts index 72a8240..b3304d7 100644 --- a/src/hooks/useMetronome.ts +++ b/src/hooks/useMetronome.ts @@ -34,6 +34,8 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions) const playClick = useCallback((time: number) => { if (!audioContext.current) return; + onTickRef.current(currentBeat.current); + if (volume <= 0) return; // silent tick source — no audio nodes needed const osc = audioContext.current.createOscillator(); const gainNode = audioContext.current.createGain();