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;