UI improvements

This commit is contained in:
2025-12-27 12:45:04 +01:00
parent 4640ef961b
commit a89e017be7
5 changed files with 290 additions and 212 deletions

View File

@@ -10,7 +10,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--spacing-md) var(--spacing-xl); padding: var(--spacing-sm) var(--spacing-xl);
background-color: var(--color-surface); background-color: var(--color-surface);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@@ -183,22 +183,49 @@ button {
background-color: var(--color-surface); background-color: var(--color-surface);
padding: var(--spacing-md); padding: var(--spacing-md);
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center;
gap: var(--spacing-md);
border-top: 1px solid rgba(0, 0, 0, 0.05); border-top: 1px solid rgba(0, 0, 0, 0.05);
} }
.controls-container { .controls-container {
display: flex; display: flex;
gap: var(--spacing-xl); flex-direction: column;
align-items: flex-end; gap: var(--spacing-lg);
flex-wrap: wrap; width: 100%;
justify-content: center; max-width: 800px;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-md);
width: 100%;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
width: 100%;
align-items: start;
}
/* Mobile: Stack everything */
@media (max-width: 600px) {
.settings-grid,
.tools-grid {
grid-template-columns: 1fr;
}
} }
.control-group { .control-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: var(--spacing-xs);
width: 100%;
} }
.control-label { .control-label {
@@ -441,7 +468,6 @@ button {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-muted); color: var(--color-text-muted);
margin-top: -4px;
} }
.app-subtitle a { .app-subtitle a {

View File

@@ -48,7 +48,8 @@ function App() {
zenMode: false, zenMode: false,
gameMode: 'sight_reading', gameMode: 'sight_reading',
customMinFret: 0, customMinFret: 0,
customMaxFret: 12 customMaxFret: 12,
autoPlaySightReading: false
}); });
const [matchStartTime, setMatchStartTime] = useState<number | null>(null); const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
@@ -154,14 +155,16 @@ function App() {
// Audio Playback trigger // Audio Playback trigger
useEffect(() => { useEffect(() => {
if (settings.gameMode === 'ear_training' && !revealed) { const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
if (shouldAutoPlay && !revealed) {
// Add a small delay to ensure state settles or allow UI to update // Add a small delay to ensure state settles or allow UI to update
const timer = setTimeout(() => { const timer = setTimeout(() => {
playNote(targetMidi, 1.0); // Play for 1 second playNote(targetMidi, 1.0); // Play for 1 second
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [targetMidi, settings.gameMode, revealed, playNote]); }, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote]);
// Initial note, and whenever difficulty/tuning/gamemode changes // Initial note, and whenever difficulty/tuning/gamemode changes
useEffect(() => { useEffect(() => {
@@ -309,9 +312,6 @@ function App() {
<header className="app-header"> <header className="app-header">
<div className="app-header-left"> <div className="app-header-left">
<div className="logo">Sheet music trainer</div> <div className="logo">Sheet music trainer</div>
<div className="app-subtitle">
<a href="http://jensmohrmann.de" target="_blank" rel="noopener noreferrer">jensmohrmann.de</a>
</div>
</div> </div>
<div className="header-controls"> <div className="header-controls">
@@ -368,18 +368,7 @@ function App() {
</div> </div>
)} )}
{settings.gameMode === 'ear_training' && !settings.zenMode && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '16px' }}>
<button
className="control-button"
onClick={() => playNote(targetMidi, 1.0)}
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 16px' }}
>
<Volume2 size={24} />
Play Note
</button>
</div>
)}
{settings.showHint && ( {settings.showHint && (
<div className="hint-card"> <div className="hint-card">
@@ -404,6 +393,16 @@ function App() {
<HelpCircle size={18} /> <HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"} {settings.showHint ? "Hide Hint" : "Show Hint"}
</button> </button>
<button
className="hint-button"
onClick={() => playNote(targetMidi, 1.0)}
title="Keyboard Shortcut: P or R"
>
<Volume2 size={18} />
Play Note
</button>
<button className="skip-button" onClick={generateNewNote} title="Keyboard Shortcut: Space"> <button className="skip-button" onClick={generateNewNote} title="Keyboard Shortcut: Space">
<SkipForward size={18} /> <SkipForward size={18} />
Skip Note Skip Note
@@ -448,6 +447,9 @@ function App() {
{!settings.zenMode && ( {!settings.zenMode && (
<footer className="settings-footer"> <footer className="settings-footer">
<Controls settings={settings} onUpdateSettings={setSettings} /> <Controls settings={settings} onUpdateSettings={setSettings} />
<div className="app-subtitle">
<a href="http://jensmohrmann.de" target="_blank" rel="noopener noreferrer">jensmohrmann.de</a>
</div>
</footer> </footer>
) )
} }

View File

@@ -26,6 +26,7 @@ export interface AppSettings {
gameMode: 'sight_reading' | 'ear_training'; gameMode: 'sight_reading' | 'ear_training';
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;
autoPlaySightReading?: boolean;
} }
interface ControlsProps { interface ControlsProps {
@@ -77,213 +78,253 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
return ( return (
<div className="controls-container"> <div className="controls-container">
<div className="control-group"> {/* Top Section: Settings Grid (2 Columns) */}
<label className="control-label"> <div className="settings-grid">
<Music size={18} /> {/* Row 1, Col 1 */}
<span>Instrument</span>
</label>
<select
value={settings.instrument}
onChange={handleInstrumentChange}
className="control-select"
>
{Object.values(INSTRUMENT_DEFINITIONS).map(def => (
<option key={def.id} value={def.id}>{def.displayName}</option>
))}
</select>
</div>
{currentInstrumentDef.showTuning && (
<div className="control-group"> <div className="control-group">
<label className="control-label"> <label className="control-label">
<Guitar size={18} /> <Music size={18} />
<span>Tuning</span> <span>Instrument</span>
</label> </label>
<select <select
value={settings.tuningId} value={settings.instrument}
onChange={handleTuningChange} onChange={handleInstrumentChange}
className="control-select" className="control-select"
> >
{availableTunings.map(id => ( {Object.values(INSTRUMENT_DEFINITIONS).map(def => (
<option key={id} value={id}>{TUNINGS[id].name}</option> <option key={def.id} value={def.id}>{def.displayName}</option>
))} ))}
</select> </select>
</div> </div>
)}
<div className="control-group"> {/* Row 1, Col 2 (Conditional) */}
<label className="control-label"> {currentInstrumentDef.showTuning ? (
<Settings size={18} /> <div className="control-group">
<span>Note Set</span> <label className="control-label">
</label> <Guitar size={18} />
<select <span>Tuning</span>
value={settings.difficulty} </label>
onChange={handleDifficultyChange} <select
className="control-select" value={settings.tuningId}
> onChange={handleTuningChange}
{currentInstrumentDef.ranges.map(r => ( className="control-select"
<option key={r.id} value={r.id}>{r.label}</option> >
))} {availableTunings.map(id => (
</select> <option key={id} value={id}>{TUNINGS[id].name}</option>
</div> ))}
</select>
</div>
) : <div />} {/* 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. */}
{/* Custom Fret Range Inputs */} {/* Row 2, Col 1 */}
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
<div className="control-group"> <div className="control-group">
<label className="control-label"> <label className="control-label">
<span>Fret Range</span> <Settings size={18} />
<span>Note Set</span>
</label> </label>
<div style={{ display: 'flex', gap: '8px', width: '100%' }}> <select
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}> value={settings.difficulty}
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span> onChange={handleDifficultyChange}
<input className="control-select"
type="number"
min="0"
max="24"
value={settings.customMinFret ?? 0}
onChange={(e) => onUpdateSettings({ ...settings, customMinFret: parseInt(e.target.value) || 0 })}
className="control-input"
style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
<input
type="number"
min="0"
max="24"
value={settings.customMaxFret ?? 12}
onChange={(e) => onUpdateSettings({ ...settings, customMaxFret: parseInt(e.target.value) || 0 })}
className="control-input"
style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/>
</div>
</div>
</div>
)}
<div className="control-group">
<label className="control-label">
<span>Key</span>
</label>
<select
value={settings.keySignature}
onChange={(e) => onUpdateSettings({ ...settings, keySignature: e.target.value })}
className="control-select"
>
<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>
{/* Rhythm Controls */}
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px', marginBottom: '8px' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<span>Metronome / Timer</span>
</label>
<button
className={`switch-button ${settings.rhythm.active ? 'active' : ''}`}
onClick={() => updateRhythm({ active: !settings.rhythm.active })}
title={settings.rhythm.active ? "Turn Off" : "Turn On"}
> >
<div className="switch-thumb" /> {currentInstrumentDef.ranges.map(r => (
</button> <option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div> </div>
{settings.rhythm.active && ( {/* Row 2, Col 2 */}
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div className="control-group">
<div style={{ display: 'flex', gap: '8px' }}> <label className="control-label">
<button <span>Key signature</span>
className={`control-button small ${settings.rhythm.mode === 'bpm' ? 'active' : ''}`} </label>
onClick={() => updateRhythm({ mode: 'bpm' })} <select
>BPM</button> value={settings.keySignature}
<button onChange={(e) => onUpdateSettings({ ...settings, keySignature: e.target.value })}
className={`control-button small ${settings.rhythm.mode === 'seconds' ? 'active' : ''}`} className="control-select"
onClick={() => updateRhythm({ mode: 'seconds' })} >
>Timer</button> <optgroup label="Major Keys">
</div> <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>
{settings.rhythm.mode === 'bpm' ? ( {/* Row 3: Fret Range (Full Width) */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> {currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
<span style={{ fontSize: '12px', minWidth: '40px' }}>{settings.rhythm.bpm} BPM</span> <div className="control-group" style={{ gridColumn: '1 / -1' }}>
<label className="control-label">
<span>Fret Range</span>
</label>
<div style={{ display: 'flex', gap: '8px', width: '100%' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span>
<input <input
type="range" type="number"
min="30" min="0"
max="240" max="24"
step="5" value={settings.customMinFret ?? 0}
value={settings.rhythm.bpm} onChange={(e) => onUpdateSettings({ ...settings, customMinFret: parseInt(e.target.value) || 0 })}
onChange={(e) => updateRhythm({ bpm: Number(e.target.value) })} className="control-input"
style={{ flex: 1 }} style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/> />
</div> </div>
) : ( <div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
<span style={{ fontSize: '12px', minWidth: '40px' }}>{settings.rhythm.seconds}s</span>
<input <input
type="range" type="number"
min="1" min="0"
max="60" max="24"
step="1" value={settings.customMaxFret ?? 12}
value={settings.rhythm.seconds} onChange={(e) => onUpdateSettings({ ...settings, customMaxFret: parseInt(e.target.value) || 0 })}
onChange={(e) => updateRhythm({ seconds: Number(e.target.value) })} className="control-input"
style={{ flex: 1 }} style={{ width: '100%', padding: '4px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/> />
</div> </div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.autoAdvance}
onChange={(e) => updateRhythm({ autoAdvance: e.target.checked })}
/>
Auto-Adv
</label>
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.sound}
onChange={(e) => updateRhythm({ sound: e.target.checked })}
/>
Sound
</label>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="control-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label className="control-label" style={{ marginBottom: 0 }}> {/* Bottom Section: Tools Grid (3 Columns) */}
<Gauge size={18} /> <div className="tools-grid">
<span>Tuning Meter</span> {/* Tool 1: Tuner */}
</label> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<button <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
className={`switch-button ${settings.showTuningMeter ? 'active' : ''}`} <label className="control-label" style={{ marginBottom: 0 }}>
onClick={() => onUpdateSettings({ ...settings, showTuningMeter: !settings.showTuningMeter })} <Gauge size={18} />
title={settings.showTuningMeter ? "Hide Tuner" : "Show Tuner"} <span>Tuner</span>
> </label>
<div className="switch-thumb" /> <button
</button> className={`switch-button ${settings.showTuningMeter ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, showTuningMeter: !settings.showTuningMeter })}
title={settings.showTuningMeter ? "Hide Tuner" : "Show Tuner"}
>
<div className="switch-thumb" />
</button>
</div>
</div>
{/* Tool 2: Metronome */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<span>Metronome</span>
</label>
<button
className={`switch-button ${settings.rhythm.active ? 'active' : ''}`}
onClick={() => updateRhythm({ active: !settings.rhythm.active })}
title={settings.rhythm.active ? "Turn Off" : "Turn On"}
>
<div className="switch-thumb" />
</button>
</div>
{settings.rhythm.active && (
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button
className={`control-button small ${settings.rhythm.mode === 'bpm' ? 'active' : ''}`}
onClick={() => updateRhythm({ mode: 'bpm' })}
style={{ flex: 1, fontSize: '10px' }}
>BPM</button>
<button
className={`control-button small ${settings.rhythm.mode === 'seconds' ? 'active' : ''}`}
onClick={() => updateRhythm({ mode: 'seconds' })}
style={{ flex: 1, fontSize: '10px' }}
>Timer</button>
</div>
{settings.rhythm.mode === 'bpm' ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', minWidth: '32px' }}>{settings.rhythm.bpm}</span>
<input
type="range"
min="30"
max="240"
step="5"
value={settings.rhythm.bpm}
onChange={(e) => updateRhythm({ bpm: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', minWidth: '32px' }}>{settings.rhythm.seconds}s</span>
<input
type="range"
min="1"
max="60"
step="1"
value={settings.rhythm.seconds}
onChange={(e) => updateRhythm({ seconds: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.autoAdvance}
onChange={(e) => updateRhythm({ autoAdvance: e.target.checked })}
/>
Auto
</label>
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.sound}
onChange={(e) => updateRhythm({ sound: e.target.checked })}
/>
Sound
</label>
</div>
</div>
)}
</div>
{/* Tool 3: Auto-play */}
{settings.gameMode === 'sight_reading' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0, textTransform: 'none' }}>
<span>Auto-play</span>
</label>
<button
className={`switch-button ${settings.autoPlaySightReading ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, autoPlaySightReading: !settings.autoPlaySightReading })}
style={{ transform: 'scale(1)' }} /* Reset scale for consistency */
>
<div className="switch-thumb" />
</button>
</div>
</div>
) : (
<div /> /* Empty placeholder for grid 3rd column if not sight reading? Or just 2 cols? */
)}
</div> </div>
</div> </div>
); );
}; };

View File

@@ -15,11 +15,13 @@
a { a {
font-weight: 500; font-weight: 500;
color: #646cff; color: inherit;
text-decoration: inherit; text-decoration: inherit;
} }
a:hover { a:hover {
color: #535bf2; text-decoration: underline;
color: inherit;
} }
body { body {
@@ -46,12 +48,14 @@ button {
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
button:hover { button:hover {
border-color: #646cff; border-color: #333;
} }
button:focus, button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; outline: 4px auto #333;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@@ -59,9 +63,11 @@ button:focus-visible {
color: #213547; color: #213547;
background-color: #ffffff; background-color: #ffffff;
} }
a:hover { a:hover {
color: #747bff; color: #333;
} }
button { button {
background-color: #f9f9f9; background-color: #f9f9f9;
} }

View File

@@ -13,9 +13,9 @@
/* Warm paper-like bg */ /* Warm paper-like bg */
--color-surface: hsl(0, 0%, 100%); --color-surface: hsl(0, 0%, 100%);
/* Deep Classic Red */ /* Neutral / Black Accent */
--color-primary: hsl(var(--hue-primary), 65%, 40%); --color-primary: hsl(0, 0%, 20%);
--color-primary-dark: hsl(var(--hue-primary), 65%, 30%); --color-primary-dark: hsl(0, 0%, 0%);
--color-text-main: hsl(var(--hue-text), 10%, 20%); --color-text-main: hsl(var(--hue-text), 10%, 20%);
--color-text-muted: hsl(var(--hue-text), 5%, 60%); --color-text-muted: hsl(var(--hue-text), 5%, 60%);
@@ -58,6 +58,9 @@
--color-surface: hsl(30, 20%, 15%); --color-surface: hsl(30, 20%, 15%);
--color-text-main: hsl(30, 10%, 90%); --color-text-main: hsl(30, 10%, 90%);
--color-text-muted: hsl(30, 10%, 60%); --color-text-muted: hsl(30, 10%, 60%);
/* In dark mode, 'primary' (black) matches background too much, so we invert or use white */
--color-primary: hsl(0, 0%, 90%);
--color-primary-dark: hsl(0, 0%, 100%);
} }
} }