diff --git a/TOOLS_LOCAL.md b/TOOLS_LOCAL.md index 3a49250..aa21a44 100644 --- a/TOOLS_LOCAL.md +++ b/TOOLS_LOCAL.md @@ -1,5 +1,7 @@ # Local deployment (nyx server) — beta channel - Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart) -- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081), image `smt-beta-image` -- Tailnet URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081) +- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image` +- Tailnet URLs: + - http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081; needs MagicDNS; Chrome history may autocomplete a broken https:// variant — delete it from history) + - **http://100.64.0.4:8081/ (preferred: Chrome never HTTPS-upgrades IP:port URLs, no MagicDNS needed)** - Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only 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 { diff --git a/src/App.tsx b/src/App.tsx index 7d2b771..b3be39d 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 ? ( <> 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/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/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 = ({
diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index 77ae164..ed57a6c 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -55,13 +55,22 @@ 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; + + // 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, @@ -77,8 +86,7 @@ export const MetronomeWidget: React.FC = ({ }); }, [isSync, runActive]); - const beatMs = 60000 / Math.max(1, rhythm.bpm); - const armed = rhythm.active; + const beatMs = 60000 / Math.max(1, effectiveBpm); 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]); 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 + + +
+
+ +
- )} - - - -
+ +
+ )}
); 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(); diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index 203285e..e1040cf 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -134,6 +134,11 @@ export interface AppSettings { instrument: string; rhythm: RhythmSettings; zenMode: boolean; + /** + * TODO: Rename 'sight_reading' to 'learn_notes' internally (UI label is + * already "Learn Notes"). Deferred because the value is persisted in + * localStorage — needs a settings migration for existing users. + */ gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist'; customMinFret?: number; customMaxFret?: number;