Move Setup panel above notation; host note filter inside it; fix key signature semantics

- Phrase mode: Setup panel now sits between the title row and the
  sheet music (was below the transport). It hosts the material options
  plus the shared TargetNoteControls (key/strings/fret window).
- TargetNoteControls: in Phrase Mode the Key section binds to the
  generator key (tonic+mode always applied, no enable toggle) and the
  display-signature sync checkbox is hidden — phrase notation takes
  its signature from the score's key, so a display-signature select
  there would be a no-op.
- Footer filter button hidden in Phrase Mode (lives in Setup now);
  still present in single-note modes.
- Remove leftover '&& null' placeholder blocks.
This commit is contained in:
OpenClaw
2026-09-15 21:24:09 +00:00
parent 1ec1353c0b
commit b4ba788d8e
4 changed files with 165 additions and 178 deletions

View File

@@ -1040,9 +1040,9 @@ button {
} }
.phrase-setup { .phrase-setup {
margin-top: 16px; margin-bottom: 16px;
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
padding-top: 12px; padding-bottom: 12px;
} }
.phrase-setup summary { .phrase-setup summary {

View File

@@ -30,17 +30,23 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
}); });
}; };
const inPhraseMode = settings.gameMode === 'phrase';
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
const activeFilterCount = const activeFilterCount =
(pf.keyEnabled ? 1 : 0) + (pf.keyEnabled ? 1 : 0) +
(pf.strings.length > 0 ? 1 : 0) + (pf.strings.length > 0 ? 1 : 0) +
(pf.fretWindowEnabled ? 1 : 0); (pf.fretWindowEnabled ? 1 : 0);
const inPhraseMode = settings.gameMode === 'phrase'; // Phrase Mode hosts the filter inside its Setup panel (above the
// notation) — no footer filter button there.
const showFilterButton = !inPhraseMode;
const shouldShowFilters = showFilterButton && filtersOpen;
return ( return (
<div className="controls-container"> <div className="controls-container">
{/* Note filter: collapsed behind a button — visible only when needed */} {/* Note filter: collapsed behind a button — visible only when needed */}
{showFilterButton && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<button <button
className={`hint-button ${filtersOpen ? 'active' : ''}`} className={`hint-button ${filtersOpen ? 'active' : ''}`}
@@ -59,7 +65,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)} )}
</button> </button>
</div> </div>
{filtersOpen && <TargetNoteControls />} )}
{shouldShowFilters && <TargetNoteControls />}
{/* Bottom Section: Tools Grid (2 Columns now) */} {/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}> <div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>

View File

@@ -28,6 +28,7 @@ import { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library'; import { EXERCISES, loadExercise } from '../exercises/library';
import { parseAbc } from '../exercises/abcParser'; import { parseAbc } from '../exercises/abcParser';
import { PhraseSheetMusic } from './PhraseSheetMusic'; import { PhraseSheetMusic } from './PhraseSheetMusic';
import { TargetNoteControls } from './TargetNoteControls';
import { Fretboard } from './Fretboard'; import { Fretboard } from './Fretboard';
import { PianoKeys } from './PianoKeys'; import { PianoKeys } from './PianoKeys';
import { TUNINGS, getFretboardPositions } from '../music/Tunings'; import { TUNINGS, getFretboardPositions } from '../music/Tunings';
@@ -435,118 +436,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</span> </span>
</div> </div>
<div className="sheet-music-container"> {/* Setup panel — above the notation, holds material options
{scoreOk ? ( plus the shared note filter (key/strings/fret window) */}
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
</>
)}
{!settings.zenMode && currentInstrumentDef.showTuning && (
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
>
<Guitar size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button>
)}
{!settings.zenMode && (
<button
className={`hint-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
)}
</div>
{/* Setup panel */}
{!settings.zenMode && ( {!settings.zenMode && (
<details className="phrase-setup"> <details className="phrase-setup">
<summary><ChevronDown size={16} /> Setup</summary> <summary><ChevronDown size={16} /> Setup</summary>
@@ -562,8 +453,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && null}
{phrase.material === 'melody' && ( {phrase.material === 'melody' && (
<> <>
<label>Bars <label>Bars
@@ -778,8 +667,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
)} )}
{fretted && null}
{/* Pace lives in the metronome widget now: {/* Pace lives in the metronome widget now:
'Sync to exercise' checked = tempo pace, 'Sync to exercise' checked = tempo pace,
unchecked = at-your-pace (step). */} unchecked = at-your-pace (step). */}
@@ -815,9 +702,122 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
Matching checks pitch, not fingering — several fretboard positions produce the same pitch. Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
For repeated identical notes, briefly release between notes so each attack is detected. For repeated identical notes, briefly release between notes so each attack is detected.
</p> </p>
<TargetNoteControls />
</details> </details>
)} )}
<div className="sheet-music-container">
{scoreOk ? (
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
</>
)}
{!settings.zenMode && currentInstrumentDef.showTuning && (
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
>
<Guitar size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button>
)}
{!settings.zenMode && (
<button
className={`hint-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
)}
</div>
{/* Hint instruments */} {/* Hint instruments */}
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && ( {(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
<div className="hint-card"> <div className="hint-card">

View File

@@ -6,8 +6,10 @@
* - Strings: pick which strings contribute target notes (fretted instruments). * - Strings: pick which strings contribute target notes (fretted instruments).
* - Fret window: restrict target notes to a fret range (fretted instruments). * - Fret window: restrict target notes to a fret range (fretted instruments).
* *
* In Phrase Mode the same controls bind to the shared practice filter * In Phrase Mode the Key section binds to the generator key (tonic+mode are
* (mirrored into phrase settings), so state stays consistent across modes. * always applied; no toggle), and the display-signature sync checkbox is
* hidden (phrase notation takes its signature from the score's key).
* Rendered inside the per-mode Setup panel above the notation.
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
@@ -46,6 +48,8 @@ const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
export const TargetNoteControls: React.FC = () => { export const TargetNoteControls: React.FC = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
// Phrase Mode: key tonic/mode are the generator key — always in effect.
const inPhraseMode = settings.gameMode === 'phrase';
const setFilter = (updates: Partial<PracticeFilter>) => { const setFilter = (updates: Partial<PracticeFilter>) => {
updateSettings(s => { updateSettings(s => {
@@ -81,7 +85,8 @@ export const TargetNoteControls: React.FC = () => {
const allSelected = pf.strings.length === 0; const allSelected = pf.strings.length === 0;
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0; const anyActive =
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
return ( return (
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}> <div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
{/* Section header: unified with Tuner/Metronome style */} {/* Section header: unified with Tuner/Metronome style */}
@@ -101,35 +106,6 @@ export const TargetNoteControls: React.FC = () => {
)} )}
</div> </div>
{/* Display key signature (sheet notation) */}
<div className="control-group">
<label className="control-label">
<span>Key signature (display)</span>
</label>
<select
value={settings.keySignature}
onChange={e => updateSettings(s => ({ ...s, keySignature: e.target.value }))}
className="control-select"
aria-label="Display key signature"
>
<optgroup label="Major Keys">
<option value="C">C Major</option>
<option value="G">G Major</option>
<option value="D">D Major</option>
<option value="A">A Major</option>
<option value="E">E Major</option>
<option value="F">F Major</option>
<option value="Bb">Bb Major</option>
<option value="Eb">Eb Major</option>
</optgroup>
<optgroup label="Minor Keys">
<option value="Am">A Minor</option>
<option value="Em">E Minor</option>
<option value="Dm">D Minor</option>
</optgroup>
</select>
</div>
{/* Key filter */} {/* Key filter */}
<div className="control-group"> <div className="control-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -137,15 +113,17 @@ export const TargetNoteControls: React.FC = () => {
<Key size={18} /> <Key size={18} />
<span>Key</span> <span>Key</span>
</label> </label>
<button {!inPhraseMode && (
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`} <button
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })} className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'} onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
> title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
<div className="switch-thumb" /> >
</button> <div className="switch-thumb" />
</button>
)}
</div> </div>
{pf.keyEnabled && ( {(pf.keyEnabled || inPhraseMode) && (
<> <>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<select <select
@@ -169,23 +147,25 @@ export const TargetNoteControls: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}> {!inPhraseMode && (
<input <label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
type="checkbox" <input
checked={settings.keyFollowsTarget ?? false} type="checkbox"
onChange={e => updateSettings(s => { checked={settings.keyFollowsTarget ?? false}
const next = { ...s, keyFollowsTarget: e.target.checked }; onChange={e => updateSettings(s => {
// When enabling sync, snap the display signature now. const next = { ...s, keyFollowsTarget: e.target.checked };
if (e.target.checked) { // When enabling sync, snap the display signature now.
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE; if (e.target.checked) {
const sig = table[pf.keyTonic]; const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
if (sig) next.keySignature = sig; const sig = table[pf.keyTonic];
} if (sig) next.keySignature = sig;
return next; }
})} return next;
/> })}
Sync display key signature with target key />
</label> Sync display key signature with target key
</label>
)}
</> </>
)} )}
</div> </div>