From 116e0c0efb803bf478e17f9c468166941983d0f5 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 02:47:31 -0500 Subject: [PATCH 01/12] =?UTF-8?q?feat(gui):=20wire=20bug:report=20IPC=20ha?= =?UTF-8?q?ndler=20=E2=80=94=20saves=20JSON=20report=20to=20Downloads,=20o?= =?UTF-8?q?pens=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BugReportModal 'Save Report' path was a silent no-op — main had the type but no handler. Now saves score-bug-report-.json to Downloads and opens the folder so testers know where to find it. Co-Authored-By: Claude Sonnet 4.6 --- packages/gui/src/main/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index 93cd318..d884c66 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -572,6 +572,19 @@ ipcMain.on('file:save', (_event, { code }: RendererToMain['file:save']) => { }) }) +// BOUNDARY — IO: bug report — saves JSON report to Downloads folder, notifies renderer of save path +ipcMain.on('bug:report', (_event, payload: RendererToMain['bug:report']) => { + try { + const timestamp = new Date(payload.timestamp).toISOString().replace(/[:.]/g, '-') + const fileName = `score-bug-report-${timestamp}.json` + const filePath = path.join(app.getPath('downloads'), fileName) + writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8') + void shell.openPath(app.getPath('downloads')) + } catch (err) { + send('error:report', { message: `Bug report save failed: ${err instanceof Error ? err.message : String(err)}` }) + } +}) + // BOUNDARY — IO: panel layout persistence (t218) — renderer sends positions on each panel move ipcMain.on('layout:save', (_event, layout: RendererToMain['layout:save']) => { writeLayout(layout) From 4ef80abcf8660866881035882546f170c95cebd7 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 14:28:29 -0500 Subject: [PATCH 02/12] feat: wire solo + stutter engine-to-gui; expand mixer controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine: - Add _solo to InstrumentDescriptor — applied at startup via channel.setSolo() - Add stutter to stepSequencer — repeats step N times evenly within step window - Wire _stutter through partToInstrumentDescriptor + seqPatternExtras - Add solo to PatchProps.tracks, handled in engine.patch() GUI (MixerStrip): - Add solo button (S, blue when active), pan slider to every strip - StripState gains soloed + pan fields - onMixerSolo: toggles solo on clicked track, clears others, patches code + engine - onMixerPan: patches code + debounced re-eval GUI (InstrumentPanel): - UNIVERSAL_CONTROLS row on every instrument: pan, swing, humanize, degrade, stutter, octave - Add entries for: kickHardstyle, kickHardcore, clap909, supersaw, wobble - Add: rhodes, theremin, sax, arp, hihat-open808 entries - Expand bass303 with envDepth and delay controls Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/engine.ts | 6 + packages/dsl/src/types.ts | 2 + .../renderer/components/LiveCode/index.tsx | 30 ++- .../components/shared/InstrumentPanel.tsx | 198 +++++++++++------- .../renderer/components/shared/MixerStrip.tsx | 130 +++++++++--- packages/gui/tests/MixerStrip.test.tsx | 4 + packages/sequencer/src/stepSequencer.ts | 16 ++ 7 files changed, 277 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index ab62e17..cd43dad 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -241,6 +241,7 @@ export const partToInstrumentDescriptor = ( ...(part._fadeOutBars !== undefined ? { _fadeOutBars: part._fadeOutBars } : {}), ...(part._chokeGroup !== undefined ? { _chokeGroup: part._chokeGroup } : {}), ...(part._mute !== undefined ? { _mute: part._mute } : {}), + ...(part._solo !== undefined ? { _solo: part._solo } : {}), props: { ...normalizeVolumeField(part.instrumentType, part._volume), ...(resolvedPattern !== undefined ? { pattern: resolvedPattern } : {}), @@ -272,6 +273,7 @@ export const partToInstrumentDescriptor = ( ...(part._stepProb !== undefined ? { stepProb: part._stepProb } : {}), ...(part._every !== undefined ? { every: { n: part._every.n, transform: part._every.fn } } : {}), ...(part._stretch !== undefined ? { stretch: part._stretch } : {}), + ...(part._stutter !== undefined ? { stutter: part._stutter } : {}), ...part.props, }, } @@ -312,6 +314,7 @@ const seqPatternExtras = (props: CommonProps) => ({ ...(props['stepProb'] !== undefined ? { stepProb: props['stepProb'] as ReadonlyArray } : {}), ...(props['every'] !== undefined ? { every: props['every'] as EveryTransform } : {}), ...(props['stretch'] !== undefined ? { stretch: props['stretch'] as number } : {}), + ...(props['stutter'] !== undefined ? { stutter: props['stutter'] as number } : {}), }) // ── computeNoteDur — note duration for Model B' melodic voice instruments ────── @@ -515,6 +518,7 @@ export type PatchProps = { readonly index: number readonly volume?: number readonly mute?: boolean + readonly solo?: boolean }> } @@ -826,6 +830,7 @@ export const createScoreEngine = async (song: SongDefinition): Promise { useEffect(() => { const unsub = window.scoreBridge.on('song:update', ({ tracks: t }) => { setTracks(t) - setStripStates(prev => t.map((track, i) => prev[i] ?? { volume: track.volume ?? 1, muted: parseMuteState(codeRef.current, i) })) + setStripStates(prev => t.map((track, i) => prev[i] ?? { volume: track.volume ?? 1, muted: parseMuteState(codeRef.current, i), soloed: false, pan: 0 })) setEvalStatus('ok') setEvalTimestamp(Date.now()) // Auto-play after eval when the user clicked play (not standalone Eval btn) @@ -389,6 +391,28 @@ export const LiveCode = ({ hardware, onHome }: Props) => { setCode(prev => patchMute(prev, index, mute)) }, [stripStates]) + const onMixerSolo = useCallback((index: number): void => { + const solo = !(stripStates[index]?.soloed ?? false) + // Toggle solo on clicked track; clear solo on all others + setStripStates(prev => prev.map((s, i) => ({ ...s, soloed: i === index ? solo : false }))) + window.scoreBridge.send('engine:patch', { tracks: [{ index, solo }] }) + setCode(prev => patchChainMethod(prev, index, 'solo', solo ? 1 : 0)) + }, [stripStates]) + + const onMixerPan = useCallback((index: number, pan: number): void => { + setStripStates(prev => updateStrip(prev, index, { pan })) + setCode(prev => patchChainMethod(prev, index, 'pan', pan)) + if (evalDebounceRef.current !== null) clearTimeout(evalDebounceRef.current) + evalDebounceRef.current = setTimeout(() => { + setError(null) + setEvalStatus('pending') + setCode(latest => { + window.scoreBridge.send('engine:eval', { code: latest }) + return latest + }) + }, 300) + }, []) + /** * Optimistic UI: patch code immediately, then re-eval after 300ms idle. * Prevents 60fps re-evals during slider drag while keeping the editor in sync. @@ -822,9 +846,13 @@ export const LiveCode = ({ hardware, onHome }: Props) => { type={track.type} volume={stripStates[i]?.volume ?? 1} muted={stripStates[i]?.muted ?? false} + soloed={stripStates[i]?.soloed ?? false} + pan={stripStates[i]?.pan ?? 0} level={0} onVolume={v => { onMixerVolume(i, v) }} onMute={() => { onMixerMute(i) }} + onSolo={() => { onMixerSolo(i) }} + onPan={v => { onMixerPan(i, v) }} /> - {/* Controls */} + {/* Instrument-specific controls */} +
+ {typeControls.map(ctrl => renderControl(ctrl, trackIndex, params, onChange))} +
+ + {/* Universal controls — pan, swing, humanize, degrade, stutter, octave */} + ) diff --git a/packages/gui/src/renderer/components/shared/MixerStrip.tsx b/packages/gui/src/renderer/components/shared/MixerStrip.tsx index c5d259f..1ead814 100644 --- a/packages/gui/src/renderer/components/shared/MixerStrip.tsx +++ b/packages/gui/src/renderer/components/shared/MixerStrip.tsx @@ -14,12 +14,20 @@ export type MixerStripProps = { readonly volume: number /** When `true` the mute button is lit amber and the track is silenced. */ readonly muted: boolean + /** When `true` the solo button is lit and all other tracks are silenced. */ + readonly soloed: boolean + /** Pan position in [-1, 1]. 0 = centre. */ + readonly pan: number /** Current RMS output level in [0, 1]. Drives the VU bar. */ readonly level: number /** Called with the new volume value (0–1) when the fader moves. */ readonly onVolume: (v: number) => void /** Called when the mute button is clicked. */ readonly onMute: () => void + /** Called when the solo button is clicked. */ + readonly onSolo: () => void + /** Called with the new pan value (-1–1) when the pan slider moves. */ + readonly onPan: (v: number) => void } // ── Constants ───────────────────────────────────────────────────────────────── @@ -112,9 +120,13 @@ export const MixerStrip = ({ type, volume, muted, + soloed, + pan, level, onVolume, onMute, + onSolo, + onPan, }: MixerStripProps) => { const canvasRef = useRef(null) @@ -147,37 +159,59 @@ export const MixerStrip = ({ {name} - {/* Mute button */} - + {/* Solo + Mute buttons */} +
+ + +
- {/* Volume fader */} + {/* Pan slider */} { onVolume(Number(e.target.value)) }} + value={pan} + style={styles.panSlider} + onChange={e => { onPan(Number(e.target.value)) }} /> - {/* VU bar */} - + {/* Volume fader + VU */} +
+ { onVolume(Number(e.target.value)) }} + /> + +
) } @@ -189,7 +223,7 @@ const styles = { display: 'flex', flexDirection: 'column' as const, alignItems: 'center', - width: '56px', + width: '68px', background: '#0d0d10', border: '1px solid #1e1e22', boxSizing: 'border-box' as const, @@ -216,14 +250,41 @@ const styles = { boxSizing: 'border-box' as const, letterSpacing: '0.05em', }, + btnRow: { + display: 'flex', + gap: '3px', + flexShrink: 0, + }, + soloBtn: { + width: '26px', + height: '18px', + background: '#1a1a22', + border: '1px solid #2a2a36', + borderRadius: '2px', + color: '#6a6a7a', + fontSize: '0.6rem', + fontWeight: 700, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: 0, + letterSpacing: '0.05em', + }, + soloBtnActive: { + background: '#22aaff', + border: '1px solid #1188cc', + color: '#001020', + }, muteBtn: { - width: '28px', - height: '20px', + width: '26px', + height: '18px', background: '#1a1a22', border: '1px solid #2a2a36', borderRadius: '2px', color: '#6a6a7a', - fontSize: '0.65rem', + fontSize: '0.6rem', fontWeight: 700, cursor: 'pointer', display: 'flex', @@ -238,6 +299,19 @@ const styles = { border: '1px solid #cc9900', color: '#1a1000', }, + panSlider: { + width: '56px', + height: '14px', + cursor: 'pointer', + accentColor: '#4a8fff', + flexShrink: 0, + }, + faderRow: { + display: 'flex', + gap: '3px', + alignItems: 'center', + flexShrink: 0, + }, fader: { // writingMode makes the range input render vertically in modern browsers. // WebkitAppearance is kept for older Chromium (Electron) builds. diff --git a/packages/gui/tests/MixerStrip.test.tsx b/packages/gui/tests/MixerStrip.test.tsx index 9255ce7..ac1d4d5 100644 --- a/packages/gui/tests/MixerStrip.test.tsx +++ b/packages/gui/tests/MixerStrip.test.tsx @@ -10,9 +10,13 @@ const defaultProps = { type: 'kick', volume: 0.8, muted: false, + soloed: false, + pan: 0, level: 0.4, onVolume: vi.fn(), onMute: vi.fn(), + onSolo: vi.fn(), + onPan: vi.fn(), } const setup = (overrides: Partial = {}) => diff --git a/packages/sequencer/src/stepSequencer.ts b/packages/sequencer/src/stepSequencer.ts index b6a5f74..d08491b 100644 --- a/packages/sequencer/src/stepSequencer.ts +++ b/packages/sequencer/src/stepSequencer.ts @@ -47,6 +47,12 @@ export type StepSequencerProps = { * offset calculation. Should match the transport's `ticksPerBeat`. Defaults to `4`. */ readonly ticksPerBeat?: number + /** + * Stutter repeat count. When > 1, each fired step is repeated N times within the step + * window, with each repeat evenly spaced. E.g. `stutter: 4` fires the step 4 times. + * Defaults to `1` (no stutter). + */ + readonly stutter?: number /** * Boolean gate pattern — steps where `mask[step]` is falsy are silently skipped. * Accepts the same `PatternInput` shape as `pattern`. @@ -221,6 +227,16 @@ export const createStepSequencer = ( : { ...position, time: rawTime < 0 ? 0 : rawTime } onStep(value, currentStepNumber, adjustedPosition) + + // Stutter — repeat N-1 additional times evenly spaced within the step window + if (props.stutter !== undefined && props.stutter > 1) { + const tickDuration = 60 / transport.bpm / (props.ticksPerBeat ?? 4) + const stepDuration = tickDuration * ticksPerStep + for (let i = 1; i < props.stutter; i++) { + const stutterOffset = (stepDuration * i) / props.stutter + onStep(value, currentStepNumber, { ...adjustedPosition, time: adjustedPosition.time + stutterOffset }) + } + } } state.step += 1 // ADVANCE — the only forward-time mutation permitted if (currentStepNumber + 1 >= state.steps) state.cycleCount += 1 From 118eb58db73a364d3dfe3f1f8d8fd287bd132b21 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 15:04:22 -0500 Subject: [PATCH 03/12] feat: add Cowbell808 instrument + DJ-ready starter song MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cowbell808: TR-808 two-oscillator (562+839 Hz square) metallic bell. Full stack: component → instruments registry → DSL factory → GUI controls. Wired: InstrumentPanel (volume, decay, tone, ring), ReferencePanel snippet. Starter song rewritten for DJs: kick, snare, clap, hihat, open hat, cowbell, and a proper 303 bassline with swing. Shows the full instrument palette immediately on launch. Co-Authored-By: Claude Sonnet 4.6 --- packages/components/src/drums/cowbell808.ts | 177 ++++++++++++++++++ packages/components/src/index.ts | 1 + packages/dsl/src/index.ts | 2 +- packages/dsl/src/percussion.ts | 23 +++ packages/gui/src/main/index.ts | 4 +- .../renderer/components/LiveCode/index.tsx | 30 +-- .../components/shared/InstrumentPanel.tsx | 1 + .../components/shared/ReferencePanel.tsx | 2 + packages/instruments/src/index.ts | 3 +- 9 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 packages/components/src/drums/cowbell808.ts diff --git a/packages/components/src/drums/cowbell808.ts b/packages/components/src/drums/cowbell808.ts new file mode 100644 index 0000000..7e9a80f --- /dev/null +++ b/packages/components/src/drums/cowbell808.ts @@ -0,0 +1,177 @@ +// ============================================================================= +// @score/components — cowbell808.ts +// Roland TR-808 cowbell: two detuned square oscillators through a bandpass +// filter shaped by a fast metallic envelope. Classic in house, electro, and +// 808-influenced genres. +// +// Signal path: +// osc1 (562 Hz square) ─┐ +// ├→ mix → bandpass (1.2 kHz) → VCA → outputGain +// osc2 (839 Hz square) ─┘ +// +// The two oscillator frequencies are the classic TR-808 cowbell ratios. +// Bandpass Q controls the metallic ring character — higher Q = more bell-like. +// ============================================================================= + +import type { AudioComponent, ScoreAudioContext, ScoreAudioNode } from '@score/core' +import { uid } from '@score/core' + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Configuration for {@link createCowbell808}. + */ +export type Cowbell808Props = { + /** Output gain 0–1. Default `0.7`. */ + readonly gain?: number + /** + * Decay time in seconds. Controls the metallic ring length. + * Short (0.1) = tight click; long (0.8) = open bell. + * Default `0.3`. + */ + readonly decay?: number + /** + * Bandpass filter centre frequency in Hz. + * Raising this brightens the tone; lowering it adds body. + * Default `1200`. + */ + readonly tone?: number + /** + * Bandpass resonance Q. Higher Q = more resonant, bell-like. + * Default `3`. + */ + readonly q?: number +} + +/** + * Return type of {@link createCowbell808}. + */ +export type Cowbell808Component = AudioComponent & { + readonly trigger: (time?: number) => void +} + +// ============================================================================= +// Constants +// ============================================================================= + +// TR-808 cowbell oscillator frequencies (Hz) +const OSC1_FREQ = 562 +const OSC2_FREQ = 839 + +// ============================================================================= +// Factory +// ============================================================================= + +/** + * Creates a Roland TR-808-style cowbell using two detuned square oscillators. + * + * The classic 808 cowbell timbre comes from beating between two square waves at + * 562 Hz and 839 Hz (a ratio of ~3:2), filtered by a bandpass and shaped by a + * short metallic envelope. Ubiquitous in house, electro, and 808-influenced music. + * + * Signal path: + * `osc1 + osc2 → mix (gain 0.5) → bandpass → VCA envelope → outputGain` + * + * Each `trigger()` spawns fresh oscillator nodes scheduled to stop after + * `decay + 0.05` seconds. Nodes auto-disconnect via `onended`. + * + * // HARDWARE BOUNDARY: oscillator nodes are created and started inside trigger() + * // — this is the intentional audio-graph mutation boundary. + * + * @param context - Backend audio context providing the Web Audio graph. + * @param props - Optional cowbell configuration. All fields have defaults. + * @returns A {@link Cowbell808Component} with `id` prefixed `cowbell808` and `type` `'cowbell808'`. + * + * @example + * ```ts + * const cowbell = createCowbell808(context, { decay: 0.4, tone: 1200 }) + * cowbell.connect(context.destination) + * cowbell.trigger(context.currentTime) + * cowbell.trigger(context.currentTime + 0.5) + * cowbell.dispose() + * ``` + * + * @see {@link Cowbell808Props} — configuration options + * @see {@link Cowbell808Component} — returned component shape + * @throws \{ScoreError\} Never — invalid props are silently clamped. + */ +export const createCowbell808 = ( + context: ScoreAudioContext, + props?: Cowbell808Props, +): Cowbell808Component => { + const gain = Math.max(0, Math.min(1, props?.gain ?? 0.7)) + const decay = Math.max(0.05, props?.decay ?? 0.3) + const tone = Math.max(200, props?.tone ?? 1200) + const q = Math.max(0.1, props?.q ?? 3) + + const outputGain = context.createGain({ gain }) + + const trigger = (time?: number): void => { + const t = time ?? context.currentTime + + const osc1 = context.createOscillator({ type: 'square', frequency: OSC1_FREQ }) + const osc2 = context.createOscillator({ type: 'square', frequency: OSC2_FREQ }) + const mixGain = context.createGain({ gain: 0.5 }) + const bandpass = context.createFilter({ type: 'bandpass', frequency: tone, Q: q }) + const vca = context.createGain({ gain: 0 }) + + osc1.connect(mixGain) + osc2.connect(mixGain) + mixGain.connect(bandpass) + bandpass.connect(vca) + vca.connect(outputGain) + + // Metallic envelope: instant attack, exponential decay + vca.scheduleEnvelope({ + peak: 1.0, + attack: 0.002, + decay, + sustain: 0, + release: 0, + startTime: t, + duration: decay + 0.002, + }) + + const stopTime = t + decay + 0.05 + osc1.start(t) + osc2.start(t) + osc1.stop(stopTime) + osc2.stop(stopTime) + + osc2.onended = () => { + try { osc1.disconnect() } catch { /* ok */ } + try { osc2.disconnect() } catch { /* ok */ } + try { mixGain.disconnect() } catch { /* ok */ } + try { bandpass.disconnect() } catch { /* ok */ } + try { vca.disconnect() } catch { /* ok */ } + } + } + + // ============================================================================= + // Component + // ============================================================================= + + const component: Cowbell808Component = { + id: uid('cowbell808'), + type: 'cowbell808' as const, + trigger, + + connect: (destination: ScoreAudioNode) => { + outputGain.connect(destination) + return component + }, + + disconnect: () => { + try { outputGain.disconnect() } catch { /* already disconnected */ } + return component + }, + + dispose: () => { + try { outputGain.disconnect() } catch { /* already disconnected */ } + }, + } + + return component +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 929d4b7..d78d3bd 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -18,6 +18,7 @@ export { createRhodes, type RhodesProps, type RhodesComponent } from './synths/r export { createPluck, type PluckProps, type PluckComponent } from './synths/pluck.js' export { createBass303, type Bass303Props, type Bass303Component, type Bass303AdsrProps } from './synths/bass303.js' export { createClap909, type Clap909Props, type Clap909Component } from './drums/clap909.js' +export { createCowbell808, type Cowbell808Props, type Cowbell808Component } from './drums/cowbell808.js' export { createKickHardstyle, type KickHardstyleProps, type KickHardstyleComponent } from './drums/kickHardstyle.js' export { createKickHardcore, type KickHardcoreProps, type KickHardcoreComponent } from './drums/kickHardcore.js' export { createSupersaw, type SupersawProps, type SupersawComponent, type SupersawFilterProps } from './synths/supersaw.js' diff --git a/packages/dsl/src/index.ts b/packages/dsl/src/index.ts index 59943ed..0313e9e 100644 --- a/packages/dsl/src/index.ts +++ b/packages/dsl/src/index.ts @@ -3,7 +3,7 @@ export { Intro, Buildup, Drop, Breakdown, Outro } from './sections.js' export { Track } from './track.js' export { Sequence } from './sequence.js' export { Synth, Sample, Theremin, Sax, Arp } from './instruments.js' -export { Kick, Snare, HiHat, Kick808, Kick909, Hihat808, HihatOpen808, Snare909, Clap909, KickHardstyle, KickHardcore } from './percussion.js' +export { Kick, Snare, HiHat, Kick808, Kick909, Hihat808, HihatOpen808, Snare909, Clap909, Cowbell808, KickHardstyle, KickHardcore } from './percussion.js' export { Bass303, SubSynth, FMSynth, Pad, Pluck, Stab, Rhodes, Wurlitzer, Hammond, Clavinet, DX7Lead, WavetableSynth, SuperSaw, WobbleBass, KarplusSynth, Guitar, AcousticGuitar, BassGuitar, Trumpet, Trombone, FrenchHorn, Flugelhorn } from './melodic.js' export type { Bass303Part, FMSynthPart, SubSynthPart } from './melodic.js' export { chord, scale, progression, Scale, Progression } from './theory.js' diff --git a/packages/dsl/src/percussion.ts b/packages/dsl/src/percussion.ts index 943aefb..b66a373 100644 --- a/packages/dsl/src/percussion.ts +++ b/packages/dsl/src/percussion.ts @@ -200,6 +200,29 @@ export const Clap909 = (hits?: number): ChainablePart => ...(hits !== undefined ? { _pattern: euclidean(hits, 16) } : {}), }) +// ── Cowbell808 ──────────────────────────────────────────────────────────────── + +/** + * Roland TR-808-style cowbell — two detuned square oscillators (562 Hz + 839 Hz) + * through a bandpass filter, shaped by a short metallic envelope. Ubiquitous in + * house, electro, and 808-influenced genres. + * + * @param hits - Optional euclidean hit count (1–16). Sets `_pattern` via `euclidean(hits, 16)`. + * Omit for the engine default (off-beat sixteenth pattern). + * @returns A `ChainablePart` for `'cowbell808'`. + * + * @example + * ```ts + * const cowbell = Cowbell808(2).decay(0.4).volume(0.5) + * ``` + */ +export const Cowbell808 = (hits?: number): ChainablePart => + createPart({ + instrumentType: 'cowbell808', + props: {}, + ...(hits !== undefined ? { _pattern: euclidean(hits, 16) } : {}), + }) + // ── KickHardstyle ───────────────────────────────────────────────────────────── /** diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index d884c66..a60219b 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -6,7 +6,7 @@ import { createScoreEngine, isPartDescriptor, partToInstrumentDescriptor } from import type { PatchProps, ScoreEngine } from '@score/cli/engine' import { Kick, Snare, HiHat, Synth, Sample, Theremin, Sax, Arp, - Kick808, Kick909, Snare909, Hihat808, HihatOpen808, Clap909, KickHardstyle, KickHardcore, + Kick808, Kick909, Snare909, Hihat808, HihatOpen808, Clap909, Cowbell808, KickHardstyle, KickHardcore, SubSynth, FMSynth, Bass303, Pad, Pluck, Stab, Rhodes, Wurlitzer, Hammond, Clavinet, DX7Lead, WavetableSynth, SuperSaw, WobbleBass, KarplusSynth, Guitar, @@ -454,7 +454,7 @@ ipcMain.on('engine:eval', (_event, { code }: RendererToMain['engine:eval']) => { const contextObj: Record = { // DSL — percussion + legacy instruments Song, Track, Kick, Snare, HiHat, Synth, Sample, Theremin, Sax, Arp, resolveFreq, - Kick808, Kick909, Snare909, Hihat808, HihatOpen808, Clap909, KickHardstyle, KickHardcore, + Kick808, Kick909, Snare909, Hihat808, HihatOpen808, Clap909, Cowbell808, KickHardstyle, KickHardcore, SubSynth, FMSynth, // DSL — chain API melodic factories Bass303, Pad, Pluck, Stab, Rhodes, Wurlitzer, Hammond, Clavinet, diff --git a/packages/gui/src/renderer/components/LiveCode/index.tsx b/packages/gui/src/renderer/components/LiveCode/index.tsx index 177f68b..f62d5e5 100644 --- a/packages/gui/src/renderer/components/LiveCode/index.tsx +++ b/packages/gui/src/renderer/components/LiveCode/index.tsx @@ -43,18 +43,24 @@ type PanelVisibility = { // ── Starter template ─────────────────────────────────────────────────────────── -const STARTER = `import { Song, Kick808, Snare909, Hihat808, Bass303 } from '@score/dsl' - -// Click a track in the mixer to open its instrument panel (model, decay, reverb…) -// Click a step in the punchcard to toggle it on/off -const kick = Kick808(4).decay(0.7).volume(0.8) -const snare = Snare909(2).decay(0.2).volume(0.55) -const hihat = Hihat808(8).decay(0.08).volume(0.25) -const bass = Bass303('A2').cutoff(600).resonance(0.4) - .pattern(['A2', 0, 0, 0, 'D3', 0, 0, 0, 'A2', 0, 0, 0, 'D3', 0, 0, 0]) - .volume(0.6) - -export default Song({ bpm: 128, tracks: [kick, snare, hihat, bass] })` +const STARTER = `import { Song, Kick808, Snare909, Hihat808, HihatOpen808, Clap909, Cowbell808, Bass303 } from '@score/dsl' + +// ▶ Run to hear it — edit while playing, changes drop in at the next bar +// Click a track in the mixer to open its controls (decay, filter, effects…) +// Click a step in the grid to toggle it on/off + +const kick = Kick808(4).decay(0.8).volume(0.9) +const snare = Snare909(2).decay(0.15).reverb(0.1).volume(0.65) +const clap = Clap909().pattern([0,0,0,0, 1,0,0,0, 0,0,0,0, 1,0,0,0]).volume(0.5) +const hihat = Hihat808(8).decay(0.06).swing(0.04).volume(0.35) +const openhat = HihatOpen808().pattern([0,0,0,0, 0,0,1,0, 0,0,0,0, 0,0,1,0]).decay(0.35).volume(0.28) +const cowbell = Cowbell808().pattern([0,0,0,0, 0,0,0,0, 0,0,1,0, 0,0,0,0]).decay(0.4).volume(0.45) +const bass = Bass303('A2') + .pattern(['A2',0,0,0, 'A2',0,'D3',0, 'A2',0,0,0, 'F3',0,'E3',0]) + .cutoff(700).resonance(10).envDepth(3500) + .swing(0.04).volume(0.75) + +export default Song({ bpm: 128, tracks: [kick, snare, clap, hihat, openhat, cowbell, bass] })` // ── Mixer strip state ────────────────────────────────────────────────────────── diff --git a/packages/gui/src/renderer/components/shared/InstrumentPanel.tsx b/packages/gui/src/renderer/components/shared/InstrumentPanel.tsx index cc94b0e..be88c47 100644 --- a/packages/gui/src/renderer/components/shared/InstrumentPanel.tsx +++ b/packages/gui/src/renderer/components/shared/InstrumentPanel.tsx @@ -103,6 +103,7 @@ const CONTROLS: Record = { snare: [select('Model', '_model', SNARE_MODELS), slider('Volume', 'volume', 0, 1, 0.01, 0.7), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Snappy', 'sustain', 0, 1, 0.01, 0.5), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], snare909: [select('Model', '_model', SNARE_MODELS), slider('Volume', 'volume', 0, 1, 0.01, 0.7), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Snappy', 'sustain', 0, 1, 0.01, 0.5), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], clap909: [slider('Volume', 'volume', 0, 1, 0.01, 0.7), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Decay', 'decay', 0.01, 1, 0.01, 0.15), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], + cowbell808: [slider('Volume', 'volume', 0, 1, 0.01, 0.6), slider('Decay', 'decay', 0.05, 1.5, 0.01, 0.3), slider('Tone', 'tone', 400, 3000, 10, 1200), slider('Ring', 'q', 0.5, 10, 0.1, 3)], hihat: [select('Model', '_model', HIHAT_MODELS), slider('Volume', 'volume', 0, 1, 0.01, 0.4), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Decay', 'decay', 0.05, 2, 0.01, 0.1), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], hihat808: [select('Model', '_model', HIHAT_MODELS), slider('Volume', 'volume', 0, 1, 0.01, 0.4), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Decay', 'decay', 0.05, 2, 0.01, 0.1), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], 'hihat-open808': [slider('Volume', 'volume', 0, 1, 0.01, 0.35), slider('Tune', 'pitch', -24, 24, 1, 0), slider('Decay', 'decay', 0.1, 4, 0.01, 0.5), slider('Reverb', 'reverb', 0, 1, 0.01, 0)], diff --git a/packages/gui/src/renderer/components/shared/ReferencePanel.tsx b/packages/gui/src/renderer/components/shared/ReferencePanel.tsx index 62ff845..8ca168b 100644 --- a/packages/gui/src/renderer/components/shared/ReferencePanel.tsx +++ b/packages/gui/src/renderer/components/shared/ReferencePanel.tsx @@ -24,6 +24,7 @@ const DRUMS: Section = { { name: 'Snare()', desc: '.volume() .decay() .tone()' }, { name: 'Snare909()', desc: '909 tone+noise snare' }, { name: 'Clap909()', desc: '4-layer staggered noise burst' }, + { name: 'Cowbell808()', desc: 'TR-808 cowbell — metallic bell, two square oscs' }, { name: 'HiHat()', desc: 'euclidean hits arg, or .pattern([…])' }, { name: 'Hihat808()', desc: '6 detuned square oscs, closed' }, { name: 'HihatOpen808()', desc: 'same as 808 with longer decay' }, @@ -145,6 +146,7 @@ const INSERT_SNIPPETS: Record = { 'Snare()': 'Snare909().volume(0.75)', 'Snare909()': 'Snare909().volume(0.75)', 'Clap909()': 'Clap909().volume(0.8)', + 'Cowbell808()': 'Cowbell808().pattern([0,0,0,0, 0,0,0,0, 0,0,1,0, 0,0,0,0]).decay(0.4).volume(0.5)', 'HiHat()': 'Hihat808(8).volume(0.5)', 'Hihat808()': 'Hihat808(8).volume(0.5)', 'HihatOpen808()': 'HihatOpen808().pattern([0,0,0,0, 0,0,0,0, 0,0,0,0, 1,0,0,0]).volume(0.6)', diff --git a/packages/instruments/src/index.ts b/packages/instruments/src/index.ts index bbe1553..4d339f6 100644 --- a/packages/instruments/src/index.ts +++ b/packages/instruments/src/index.ts @@ -35,6 +35,7 @@ import { createPluck, createBass303, createClap909, + createCowbell808, createKickHardstyle, createKickHardcore, createSupersaw, @@ -120,9 +121,9 @@ export const INSTRUMENT_REGISTRY = Object.freeze({ 'hihatopen808': { factory: createHihatOpen808, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_HIHAT_PATTERN }, 'snare909': { factory: createSnare909, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_SNARE_PATTERN }, 'clap909': { factory: createClap909, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_SNARE_PATTERN }, + 'cowbell808': { factory: createCowbell808, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_HIHAT_PATTERN }, 'kickHardstyle': { factory: createKickHardstyle, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_KICK_PATTERN }, 'kickHardcore': { factory: createKickHardcore, dispatchModel: 'A' as DispatchModel, volumeRouting: 'percussion' as VolumeRouting, defaultPattern: DEFAULT_KICK_PATTERN }, - // PLANNED: 'cowbell808', 'rimshot' // ── Synth — Model B ────────────────────────────────────────────────────────── 'synth': { factory: createGenericSynth, dispatchModel: 'B' as DispatchModel, volumeRouting: 'melodic' as VolumeRouting }, From 70fc9959a4e25c5970190db8de0cda55e51363c3 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 17:12:35 -0500 Subject: [PATCH 04/12] feat(gui): shortcuts module, instrument picker rebuild, Playwright e2e scaffold, zoom fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract all globalShortcut registrations to src/main/shortcuts.ts (SHORTCUT_MAP pattern) — adding a shortcut is now one entry, no index.ts changes needed - Fix Ctrl+= zoom in (was broken — Ctrl++ requires Shift on most keyboards, = does not) — also adds Ctrl+0 reset; cleanup registered on app will-quit - Rebuild InstrumentPicker from scratch: all 24 instruments across Drums/Bass/Synths/Other — types now match INSTRUMENT_REGISTRY keys exactly (bass-303, hihatopen808, kickHardstyle etc) — removed non-existent crash/ride, added supersaw/wobble/rhodes/theremin/sax/arp/cowbell808 - Fix starter song: remove .envDepth(3500) — not a chain method, caused crash on launch - Enhance bug report: writes to score/debug/report.json (fixed path) + timestamped Downloads archive - Add @playwright/test + playwright.config.ts + tests/e2e/app.e2e.ts smoke tests — launch, transport bar, zoom shortcuts, instrument picker - Update InstrumentPicker tests to match new labels and correct type strings Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + packages/gui/package.json | 6 +- packages/gui/playwright.config.ts | 31 ++ packages/gui/src/main/index.ts | 37 +- packages/gui/src/main/shortcuts.ts | 103 ++++ .../renderer/components/LiveCode/index.tsx | 2 +- .../components/shared/InstrumentPicker.tsx | 67 ++- packages/gui/tests/InstrumentPicker.test.tsx | 40 +- packages/gui/tests/e2e/app.e2e.ts | 135 +++++ packages/gui/tsconfig.json | 2 +- pnpm-lock.yaml | 478 ++++++++++-------- 11 files changed, 634 insertions(+), 270 deletions(-) create mode 100644 packages/gui/playwright.config.ts create mode 100644 packages/gui/src/main/shortcuts.ts create mode 100644 packages/gui/tests/e2e/app.e2e.ts diff --git a/.gitignore b/.gitignore index 2afc64f..6e5774a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ sounds/**/* # Claude Code local state .claude/ +# Bug reports written by the app — not committed +debug/ + # Local only — not ready to publish THESIS.md mcp-servers/score-codebase/node_modules/ diff --git a/packages/gui/package.json b/packages/gui/package.json index 965004a..04553f2 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -18,18 +18,20 @@ "typecheck": "tsc --noEmit", "lint": "eslint src", "test": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test --config playwright.config.ts" }, "dependencies": { "@monaco-editor/react": "^4.7.0", "node-web-audio-api": "^1.0.8" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@score/cli": "workspace:*", "@score/core": "workspace:*", "@score/dsl": "workspace:*", - "@score/session": "workspace:*", "@score/sequencer": "workspace:*", + "@score/session": "workspace:*", "@score/visuals": "workspace:*", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", diff --git a/packages/gui/playwright.config.ts b/packages/gui/playwright.config.ts new file mode 100644 index 0000000..b8ff7e9 --- /dev/null +++ b/packages/gui/playwright.config.ts @@ -0,0 +1,31 @@ +// playwright.config.ts — E2E test configuration for Score Studio. +// +// Uses @playwright/test with Electron's built-in playwright support. +// Tests live in tests/e2e/ and run against a built app (pnpm build first). +// +// Run: pnpm test:e2e +// CI: runs after unit tests pass. + +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + timeout: 30_000, + retries: process.env['CI'] ? 2 : 0, + workers: 1, // Electron: single instance only + + use: { + // Electron does not use a browser — launch config is in each test via electron.launch() + // Screenshots and traces captured on failure for CI debugging + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }, + + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: 'playwright-report' }], + ], + +}) diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index a60219b..b1a0eb2 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -1,7 +1,8 @@ -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import path from 'node:path' -import { readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import vm from 'node:vm' +import { registerShortcuts } from './shortcuts.js' import { createScoreEngine, isPartDescriptor, partToInstrumentDescriptor } from '@score/cli/engine' import type { PatchProps, ScoreEngine } from '@score/cli/engine' import { @@ -360,15 +361,8 @@ process.on('unhandledRejection', (reason: unknown) => { app.on('ready', () => { const win = createWindow() winRef.value = win - // F12 toggles devtools — off by default, no auto-open - globalShortcut.register('F12', () => { - const focused = BrowserWindow.getFocusedWindow() - if (focused) focused.webContents.toggleDevTools() - }) - // t207 — panic key: Cmd/Ctrl+. = instant all-stop (no bar-boundary wait) - globalShortcut.register('CommandOrControl+.', () => { - panicStop() - }) + const { unregister: unregisterShortcuts } = registerShortcuts({ panicStop }) + app.on('will-quit', unregisterShortcuts) // t218 — send saved panel layout once renderer is ready win.webContents.once('did-finish-load', () => { const layout = readLayout() @@ -572,13 +566,24 @@ ipcMain.on('file:save', (_event, { code }: RendererToMain['file:save']) => { }) }) -// BOUNDARY — IO: bug report — saves JSON report to Downloads folder, notifies renderer of save path +// BOUNDARY — IO: bug report — saves JSON to two locations: +// 1. Fixed path Claude can always read: /debug/report.json (overwritten each time) +// 2. Timestamped archive in Downloads for the user ipcMain.on('bug:report', (_event, payload: RendererToMain['bug:report']) => { try { - const timestamp = new Date(payload.timestamp).toISOString().replace(/[:.]/g, '-') - const fileName = `score-bug-report-${timestamp}.json` - const filePath = path.join(app.getPath('downloads'), fileName) - writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8') + const json = JSON.stringify(payload, null, 2) + const timestamp = new Date(payload.timestamp).toISOString().replace(/[:.]/g, '-') + const fileName = `score-bug-report-${timestamp}.json` + + // Fixed path — always the same location so Claude can read it directly + const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..') + const debugDir = path.join(repoRoot, 'debug') + mkdirSync(debugDir, { recursive: true }) + writeFileSync(path.join(debugDir, 'report.json'), json, 'utf8') + + // Timestamped archive in Downloads for the user + writeFileSync(path.join(app.getPath('downloads'), fileName), json, 'utf8') + void shell.openPath(app.getPath('downloads')) } catch (err) { send('error:report', { message: `Bug report save failed: ${err instanceof Error ? err.message : String(err)}` }) diff --git a/packages/gui/src/main/shortcuts.ts b/packages/gui/src/main/shortcuts.ts new file mode 100644 index 0000000..a84e9b8 --- /dev/null +++ b/packages/gui/src/main/shortcuts.ts @@ -0,0 +1,103 @@ +// shortcuts.ts — Global keyboard shortcut registration for Score Studio. +// +// All Electron globalShortcut bindings live here. index.ts calls registerShortcuts() +// once on app ready and calls the returned unregister() on app quit. +// +// Adding a shortcut: add ONE entry to SHORTCUT_MAP below. +// Dependencies (panicStop etc.) are injected via params — no module-level imports. + +import { BrowserWindow, globalShortcut } from 'electron' + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** External actions injected from index.ts — keeps shortcuts.ts free of engine imports. */ +export type ShortcutDeps = { + readonly panicStop: () => void +} + +// ── Shortcut map ────────────────────────────────────────────────────────────── + +type ShortcutHandler = (deps: ShortcutDeps) => void + +type ShortcutEntry = { + readonly accelerator: string + readonly description: string + readonly handler: ShortcutHandler +} + +const SHORTCUT_MAP: readonly ShortcutEntry[] = [ + + // ── Dev tools ────────────────────────────────────────────────────────────── + { + accelerator: 'F12', + description: 'Toggle DevTools', + handler: () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused) focused.webContents.toggleDevTools() + }, + }, + + // ── Transport ────────────────────────────────────────────────────────────── + { + accelerator: 'CommandOrControl+.', + description: 'Panic stop — instant all-stop, no bar-boundary wait (t207)', + handler: ({ panicStop }) => { panicStop() }, + }, + + // ── Zoom ─────────────────────────────────────────────────────────────────── + // Ctrl+= avoids requiring Shift (Ctrl++ would need Shift on most keyboards) + { + accelerator: 'CommandOrControl+=', + description: 'Zoom in', + handler: () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused) focused.webContents.setZoomLevel(focused.webContents.getZoomLevel() + 0.5) + }, + }, + { + accelerator: 'CommandOrControl+-', + description: 'Zoom out', + handler: () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused) focused.webContents.setZoomLevel(focused.webContents.getZoomLevel() - 0.5) + }, + }, + { + accelerator: 'CommandOrControl+0', + description: 'Reset zoom', + handler: () => { + const focused = BrowserWindow.getFocusedWindow() + if (focused) focused.webContents.setZoomLevel(0) + }, + }, + +] + +// ── Registration ────────────────────────────────────────────────────────────── + +/** + * Register all global keyboard shortcuts for Score Studio. + * + * Call once on `app.on('ready')`. Returns an `unregister` function — call it + * on `app.on('will-quit')` to release all shortcuts cleanly. + * + * @param deps - External actions (panicStop etc.) injected from index.ts. + * @returns `{ unregister }` — call on app quit. + * + * @example + * ```ts + * app.on('ready', () => { + * const { unregister } = registerShortcuts({ panicStop }) + * app.on('will-quit', unregister) + * }) + * ``` + */ +export const registerShortcuts = (deps: ShortcutDeps): { readonly unregister: () => void } => { + for (const { accelerator, handler } of SHORTCUT_MAP) { + globalShortcut.register(accelerator, () => { handler(deps) }) + } + + return { + unregister: () => { globalShortcut.unregisterAll() }, + } +} diff --git a/packages/gui/src/renderer/components/LiveCode/index.tsx b/packages/gui/src/renderer/components/LiveCode/index.tsx index f62d5e5..0d3061d 100644 --- a/packages/gui/src/renderer/components/LiveCode/index.tsx +++ b/packages/gui/src/renderer/components/LiveCode/index.tsx @@ -57,7 +57,7 @@ const openhat = HihatOpen808().pattern([0,0,0,0, 0,0,1,0, 0,0,0,0, 0,0,1,0]).dec const cowbell = Cowbell808().pattern([0,0,0,0, 0,0,0,0, 0,0,1,0, 0,0,0,0]).decay(0.4).volume(0.45) const bass = Bass303('A2') .pattern(['A2',0,0,0, 'A2',0,'D3',0, 'A2',0,0,0, 'F3',0,'E3',0]) - .cutoff(700).resonance(10).envDepth(3500) + .cutoff(700).resonance(10) .swing(0.04).volume(0.75) export default Song({ bpm: 128, tracks: [kick, snare, clap, hihat, openhat, cowbell, bass] })` diff --git a/packages/gui/src/renderer/components/shared/InstrumentPicker.tsx b/packages/gui/src/renderer/components/shared/InstrumentPicker.tsx index 807b8d0..15d290c 100644 --- a/packages/gui/src/renderer/components/shared/InstrumentPicker.tsx +++ b/packages/gui/src/renderer/components/shared/InstrumentPicker.tsx @@ -3,6 +3,8 @@ // Renders a grid of instrument type buttons grouped by category. // Clicking a button fires onPick(instrumentType). Escape or close button fires onClose(). // +// Catalogue is defined here as a UI concern (labels + grouping). +// Types must match INSTRUMENT_REGISTRY keys in @score/instruments exactly. // No audio, no IPC — pure props in, callbacks out. import React, { useEffect, useRef } from 'react' @@ -11,37 +13,52 @@ import React, { useEffect, useRef } from 'react' /** Props for {@link InstrumentPicker}. */ export type InstrumentPickerProps = { - /** Called when the user selects an instrument. @param instrumentType - DSL type string, e.g. `'kick'`. */ + /** Called when the user selects an instrument. @param instrumentType - Registry key, e.g. `'kick808'`. */ readonly onPick: (instrumentType: string) => void /** Called when the picker should be dismissed (Escape key or close button). */ readonly onClose: () => void } // ── Instrument catalogue ────────────────────────────────────────────────────── +// Labels are DJ-friendly names. Types must exactly match INSTRUMENT_REGISTRY keys. type InstrumentEntry = { readonly label: string; readonly type: string } const DRUMS: readonly InstrumentEntry[] = [ - { label: 'Kick', type: 'kick' }, - { label: 'Snare', type: 'snare' }, - { label: 'HiHat', type: 'hihat' }, - { label: 'Clap', type: 'clap' }, - { label: 'Crash', type: 'crash' }, - { label: 'Ride', type: 'ride' }, + { label: 'Kick 808', type: 'kick808' }, + { label: 'Kick 909', type: 'kick909' }, + { label: 'Kick Hardstyle',type: 'kickHardstyle' }, + { label: 'Kick Hardcore', type: 'kickHardcore' }, + { label: 'Kick', type: 'kick' }, + { label: 'Snare 909', type: 'snare909' }, + { label: 'Snare', type: 'snare' }, + { label: 'Clap 909', type: 'clap909' }, + { label: 'Hi-Hat 808', type: 'hihat808' }, + { label: 'Open Hat 808', type: 'hihatopen808' }, + { label: 'Hi-Hat', type: 'hihat' }, + { label: 'Cowbell 808', type: 'cowbell808' }, ] -const MELODIC: readonly InstrumentEntry[] = [ - { label: 'Bass303', type: 'bass303' }, - { label: 'SubSynth',type: 'subsynth' }, - { label: 'Synth', type: 'synth' }, - { label: 'Pad', type: 'pad' }, - { label: 'Pluck', type: 'pluck' }, - { label: 'FMSynth', type: 'fmsynth' }, +const BASS: readonly InstrumentEntry[] = [ + { label: 'Bass 303', type: 'bass-303' }, + { label: 'Wobble Bass', type: 'wobble' }, + { label: 'Sub Synth', type: 'subsynth' }, +] + +const SYNTHS: readonly InstrumentEntry[] = [ + { label: 'Supersaw', type: 'supersaw' }, + { label: 'Pad', type: 'pad' }, + { label: 'FM Synth', type: 'fmsynth' }, + { label: 'Rhodes', type: 'rhodes' }, + { label: 'Pluck', type: 'pluck' }, + { label: 'Synth', type: 'synth' }, ] const OTHER: readonly InstrumentEntry[] = [ - { label: 'Sample', type: 'sample' }, - { label: 'Arp', type: 'arp' }, + { label: 'Arp', type: 'arp' }, + { label: 'Theremin', type: 'theremin' }, + { label: 'Sax', type: 'sax' }, + { label: 'Sample', type: 'sample' }, ] // ── Styles ──────────────────────────────────────────────────────────────────── @@ -54,7 +71,8 @@ const styles = { }, modal: { background: '#111', border: '1px solid #333', borderRadius: 6, - padding: '16px 20px', minWidth: 340, position: 'relative' as const, + padding: '16px 20px', minWidth: 400, position: 'relative' as const, + maxHeight: '80vh', overflowY: 'auto' as const, }, header: { display: 'flex', flexDirection: 'row' as const, @@ -66,7 +84,7 @@ const styles = { color: '#888', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace', padding: '2px 8px', }, - section: { marginBottom: 10 }, + section: { marginBottom: 12 }, sectionLabel: { fontSize: 9, color: '#555', fontFamily: 'monospace', textTransform: 'uppercase' as const, letterSpacing: 1, marginBottom: 6, @@ -77,7 +95,7 @@ const styles = { instrBtn: { background: '#1a1a1a', border: '1px solid #333', borderRadius: 3, color: '#aaa', cursor: 'pointer', fontSize: 10, fontFamily: 'monospace', - padding: '4px 10px', minWidth: 56, + padding: '4px 10px', minWidth: 80, }, } as const @@ -87,7 +105,7 @@ const styles = { * Instrument selection modal. * * Renders a dark modal overlay with a grid of instrument type buttons grouped - * by category (Drums, Melodic, Other). Clicking a button fires `onPick(type)`. + * by category (Drums, Bass, Synths, Other). Clicking a button fires `onPick(type)`. * Pressing Escape or clicking the close button fires `onClose()`. * * @example @@ -163,8 +181,13 @@ export const InstrumentPicker = (props: InstrumentPickerProps): React.JSX.Elemen
-
Melodic
- {renderGroup(MELODIC)} +
Bass
+ {renderGroup(BASS)} +
+ +
+
Synths
+ {renderGroup(SYNTHS)}
diff --git a/packages/gui/tests/InstrumentPicker.test.tsx b/packages/gui/tests/InstrumentPicker.test.tsx index e5213b5..dd2390c 100644 --- a/packages/gui/tests/InstrumentPicker.test.tsx +++ b/packages/gui/tests/InstrumentPicker.test.tsx @@ -30,16 +30,22 @@ describe('InstrumentPicker — rendering', () => { expect(screen.getByRole('button', { name: /hi.?hat/i })).toBeInTheDocument() }) - it('renders melodic instrument buttons', () => { + it('renders bass and synth instrument buttons', () => { setup() - expect(screen.getByRole('button', { name: /bass ?303/i })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /^synth$/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Bass 303' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Synth' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Supersaw' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Wobble Bass' })).toBeInTheDocument() }) it('renders all instrument labels', () => { setup() - // Use exact string matching to avoid partial matches (e.g. "Synth" vs "SubSynth") - const labels = ['Kick', 'Snare', 'HiHat', 'Bass303', 'Synth', 'Pad', 'Pluck', 'FMSynth'] + const labels = [ + 'Kick 808', 'Kick 909', 'Snare 909', 'Clap 909', 'Hi-Hat 808', 'Open Hat 808', 'Cowbell 808', + 'Bass 303', 'Wobble Bass', 'Sub Synth', + 'Supersaw', 'Pad', 'Pluck', 'FM Synth', 'Rhodes', + 'Arp', 'Sample', + ] for (const label of labels) { expect(screen.getByRole('button', { name: label })).toBeInTheDocument() } @@ -54,38 +60,38 @@ describe('InstrumentPicker — rendering', () => { // ── Interactions ────────────────────────────────────────────────────────────── describe('InstrumentPicker — interactions', () => { - it('calls onPick with "kick" when Kick button is clicked', async () => { + it('calls onPick with "kick808" when Kick 808 button is clicked', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /kick/i })) - expect(onPick).toHaveBeenCalledWith('kick') + await user.click(screen.getByRole('button', { name: 'Kick 808' })) + expect(onPick).toHaveBeenCalledWith('kick808') }) - it('calls onPick with "snare" when Snare button is clicked', async () => { + it('calls onPick with "snare909" when Snare 909 button is clicked', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /snare/i })) - expect(onPick).toHaveBeenCalledWith('snare') + await user.click(screen.getByRole('button', { name: 'Snare 909' })) + expect(onPick).toHaveBeenCalledWith('snare909') }) - it('calls onPick with "bass303" when Bass303 button is clicked', async () => { + it('calls onPick with "bass-303" when Bass 303 button is clicked', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /bass ?303/i })) - expect(onPick).toHaveBeenCalledWith('bass303') + await user.click(screen.getByRole('button', { name: 'Bass 303' })) + expect(onPick).toHaveBeenCalledWith('bass-303') }) it('calls onPick with "synth" when Synth button is clicked', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /^synth$/i })) + await user.click(screen.getByRole('button', { name: 'Synth' })) expect(onPick).toHaveBeenCalledWith('synth') }) it('calls onPick with "pad" when Pad button is clicked', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /^pad$/i })) + await user.click(screen.getByRole('button', { name: 'Pad' })) expect(onPick).toHaveBeenCalledWith('pad') }) @@ -124,7 +130,7 @@ describe('InstrumentPicker — props variants', () => { it('calls onPick exactly once per click', async () => { const onPick = vi.fn() const { user } = setup({ onPick }) - await user.click(screen.getByRole('button', { name: /^kick$/i })) + await user.click(screen.getByRole('button', { name: 'Kick 808' })) expect(onPick).toHaveBeenCalledOnce() }) }) diff --git a/packages/gui/tests/e2e/app.e2e.ts b/packages/gui/tests/e2e/app.e2e.ts new file mode 100644 index 0000000..93d15e6 --- /dev/null +++ b/packages/gui/tests/e2e/app.e2e.ts @@ -0,0 +1,135 @@ +// app.e2e.ts — Score Studio smoke tests. +// +// These tests launch the real Electron app and assert against the live UI. +// Run after `pnpm build` — tests require out/main/index.js to exist. +// +// Add new test files to tests/e2e/*.e2e.ts. + +import { test, expect, _electron as electron } from '@playwright/test' +import type { ElectronApplication, Page } from '@playwright/test' +import path from 'node:path' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const APP_MAIN = path.resolve(__dirname, '../../out/main/index.js') + +const launchApp = (): Promise => + electron.launch({ args: [APP_MAIN] }) + +// ── Launch + basic rendering ────────────────────────────────────────────────── + +test.describe('Score Studio — launch', () => { + + let app: ElectronApplication + let page: Page + + test.beforeEach(async () => { + app = await launchApp() + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + }) + + test.afterEach(async () => { + await app.close() + }) + + test('window opens with correct title', async () => { + const title = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.getTitle() ?? '' + ) + expect(title).toContain('Score') + }) + + test('transport bar is visible', async () => { + await expect(page.getByRole('toolbar')).toBeVisible() + }) + + test('play button is present and labelled', async () => { + await expect(page.getByRole('button', { name: /play/i })).toBeVisible() + }) + + test('report issue button is present', async () => { + await expect(page.getByRole('button', { name: /report issue/i })).toBeVisible() + }) + +}) + +// ── Zoom shortcuts ──────────────────────────────────────────────────────────── + +test.describe('Score Studio — zoom', () => { + + let app: ElectronApplication + let page: Page + + test.beforeEach(async () => { + app = await launchApp() + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + }) + + test.afterEach(async () => { + await app.close() + }) + + test('Ctrl+= increases zoom level', async () => { + const before = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 + ) + await page.keyboard.press('Control+=') + const after = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 + ) + expect(after).toBeGreaterThan(before) + }) + + test('Ctrl+0 resets zoom level to 0', async () => { + await page.keyboard.press('Control+=') + await page.keyboard.press('Control+=') + await page.keyboard.press('Control+0') + const level = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 + ) + expect(level).toBe(0) + }) + +}) + +// ── Instrument picker ───────────────────────────────────────────────────────── + +test.describe('Score Studio — instrument picker', () => { + + let app: ElectronApplication + let page: Page + + test.beforeEach(async () => { + app = await launchApp() + page = await app.firstWindow() + await page.waitForLoadState('domcontentloaded') + }) + + test.afterEach(async () => { + await app.close() + }) + + test('Add Track button opens instrument picker with all categories', async () => { + await page.getByRole('button', { name: /add track/i }).click() + await expect(page.getByText('Drums', { exact: false })).toBeVisible() + await expect(page.getByText('Bass', { exact: false })).toBeVisible() + await expect(page.getByText('Synths', { exact: false })).toBeVisible() + await expect(page.getByText('Other', { exact: false })).toBeVisible() + }) + + test('picker shows all drum instruments', async () => { + await page.getByRole('button', { name: /add track/i }).click() + await expect(page.getByRole('button', { name: 'Kick 808' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Snare 909' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Cowbell 808' })).toBeVisible() + }) + + test('picker closes on Escape', async () => { + await page.getByRole('button', { name: /add track/i }).click() + await page.keyboard.press('Escape') + await expect(page.getByTestId('instrument-picker-overlay')).not.toBeVisible() + }) + +}) diff --git a/packages/gui/tsconfig.json b/packages/gui/tsconfig.json index 18b6ad9..375a348 100644 --- a/packages/gui/tsconfig.json +++ b/packages/gui/tsconfig.json @@ -17,5 +17,5 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["src", "tests", "vitest.config.ts", "electron.vite.config.ts"] + "include": ["src", "tests", "vitest.config.ts", "electron.vite.config.ts", "playwright.config.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71524c..521e8a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^9.0.0 version: 9.1.7 lint-staged: - specifier: ^16.4.0 - version: 16.4.0 + specifier: ^15.0.0 + version: 15.5.2 prettier: specifier: ^3.0.0 version: 3.8.1 @@ -73,8 +73,8 @@ importers: version: 3.25.76 devDependencies: '@types/acorn': - specifier: ^6.0.4 - version: 6.0.4 + specifier: ^4.0.6 + version: 4.0.6 '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -181,6 +181,9 @@ importers: specifier: ^1.0.8 version: 1.0.8 devDependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@score/cli': specifier: workspace:* version: link:../cli @@ -216,13 +219,13 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1)) + version: 4.7.0(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1)) '@vitest/coverage-v8': specifier: ^2.0.0 - version: 2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) electron: - specifier: ^41.1.1 - version: 41.1.1 + specifier: ^35.7.5 + version: 35.7.5 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -231,7 +234,7 @@ importers: version: 6.8.3 electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1)) + version: 5.0.0(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1)) jsdom: specifier: ^29.0.1 version: 29.0.1 @@ -246,13 +249,13 @@ importers: version: 18.3.1(react@18.3.1) vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@25.5.2)(terser@5.46.1) + version: 5.4.21(@types/node@25.5.0)(terser@5.46.1) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + version: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) vitest-axe: specifier: 1.0.0-pre.5 - version: 1.0.0-pre.5(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 1.0.0-pre.5(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) packages/instruments: dependencies: @@ -300,10 +303,10 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: ^2.0.0 - version: 2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + version: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) packages/midi: dependencies: @@ -313,10 +316,10 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: ^2.0.0 - version: 2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + version: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) packages/mixer: dependencies: @@ -329,10 +332,10 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: ^2.0.0 - version: 2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + version: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) packages/modulation: dependencies: @@ -408,10 +411,10 @@ importers: devDependencies: '@vitest/coverage-v8': specifier: ^2.0.0 - version: 2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)) vitest: specifier: ^2.0.0 - version: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + version: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) packages/visuals: dependencies: @@ -1074,6 +1077,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1272,9 +1280,8 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@types/acorn@6.0.4': - resolution: {integrity: sha512-DafqcBAjbOOmgqIx3EF9EAdBKAKgspv00aQVIW3fVQ0TXo5ZPBeSRey1SboVAUzjw8Ucm7cd1gtTSlosYoEQLA==} - deprecated: This is a stub types definition. acorn provides its own type definitions, so you do not need this installed. + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1318,15 +1325,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} - '@types/node@25.5.2': - resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} - '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -1646,6 +1650,10 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1744,9 +1752,9 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} - cli-truncate@5.2.0: - resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} - engines: {node: '>=20'} + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} @@ -1773,9 +1781,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1955,8 +1963,8 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.1.1: - resolution: {integrity: sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==} + electron@35.7.5: + resolution: {integrity: sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==} engines: {node: '>= 12.20.55'} hasBin: true @@ -2089,6 +2097,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -2140,6 +2152,10 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2190,6 +2206,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2222,6 +2243,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2303,6 +2328,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -2363,6 +2392,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} @@ -2375,9 +2408,17 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -2485,17 +2526,21 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} - lint-staged@16.4.0: - resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} - engines: {node: '>=20.17'} + lint-staged@15.5.2: + resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} + engines: {node: '>=18.12.0'} hasBin: true - listr2@9.0.5: - resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} - engines: {node: '>=20.0.0'} + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} + engines: {node: '>=18.0.0'} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -2517,9 +2562,6 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -2597,6 +2639,13 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2614,6 +2663,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -2742,6 +2795,10 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -2753,6 +2810,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -2803,6 +2864,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2827,10 +2892,29 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -3037,14 +3121,14 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} - slice-ansi@8.0.0: - resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} - engines: {node: '>=20'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -3104,10 +3188,6 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} - engines: {node: '>=20'} - string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3119,6 +3199,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -3174,10 +3258,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} - engines: {node: '>=18'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -3212,6 +3292,10 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3293,8 +3377,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -3495,11 +3579,6 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4105,6 +4184,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -4244,9 +4327,9 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@types/acorn@6.0.4': + '@types/acorn@4.0.6': dependencies: - acorn: 8.16.0 + '@types/estree': 1.0.8 '@types/aria-query@5.0.4': {} @@ -4275,7 +4358,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.5.2 + '@types/node': 25.5.0 '@types/responselike': 1.0.3 '@types/debug@4.1.13': @@ -4286,7 +4369,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.5.0 '@types/hast@3.0.4': dependencies: @@ -4298,25 +4381,21 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.5.0 '@types/ms@2.1.0': {} - '@types/node@24.12.2': + '@types/node@22.19.15': dependencies: - undici-types: 7.16.0 + undici-types: 6.21.0 '@types/node@25.5.0': dependencies: undici-types: 7.18.2 - '@types/node@25.5.2': - dependencies: - undici-types: 7.18.2 - '@types/plist@3.0.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.5.0 xmlbuilder: 15.1.1 optional: true @@ -4333,7 +4412,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.5.0 '@types/trusted-types@2.0.7': optional: true @@ -4345,7 +4424,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.5.0 optional: true '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': @@ -4490,7 +4569,7 @@ snapshots: '@typescript-eslint/types': 8.57.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -4498,7 +4577,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@25.5.2)(terser@5.46.1) + vite: 5.4.21(@types/node@25.5.0)(terser@5.46.1) transitivePeerDependencies: - supports-color @@ -4520,24 +4599,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) - transitivePeerDependencies: - - supports-color - '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -4553,14 +4614,6 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@25.5.0)(terser@5.46.1) - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1))': - dependencies: - '@vitest/spy': 2.1.9 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@25.5.2)(terser@5.46.1) - '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -4745,6 +4798,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.8 @@ -4871,10 +4928,10 @@ snapshots: string-width: 4.2.3 optional: true - cli-truncate@5.2.0: + cli-truncate@4.0.0: dependencies: - slice-ansi: 8.0.0 - string-width: 8.2.0 + slice-ansi: 5.0.0 + string-width: 7.2.0 cliui@8.0.1: dependencies: @@ -4900,7 +4957,7 @@ snapshots: dependencies: delayed-stream: 1.0.0 - commander@14.0.3: {} + commander@13.1.0: {} commander@2.20.3: optional: true @@ -5103,7 +5160,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1)): + electron-vite@5.0.0(vite@5.4.21(@types/node@25.5.0)(terser@5.46.1)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -5111,7 +5168,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 5.4.21(@types/node@25.5.2)(terser@5.46.1) + vite: 5.4.21(@types/node@25.5.0)(terser@5.46.1) transitivePeerDependencies: - supports-color @@ -5120,17 +5177,17 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.18.1 + lodash: 4.17.23 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 transitivePeerDependencies: - supports-color - electron@41.1.1: + electron@35.7.5: dependencies: '@electron/get': 2.0.3 - '@types/node': 24.12.2 + '@types/node': 22.19.15 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -5325,6 +5382,18 @@ snapshots: eventemitter3@5.0.4: {} + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -5354,9 +5423,9 @@ snapshots: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.3 fetch-blob@3.2.0: dependencies: @@ -5371,6 +5440,10 @@ snapshots: dependencies: minimatch: 5.1.9 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -5437,6 +5510,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -5470,6 +5546,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@8.0.1: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -5578,6 +5656,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@5.0.0: {} + husky@9.1.7: {} iconv-corefoundation@1.1.7: @@ -5622,6 +5702,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@4.0.0: {} + is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 @@ -5632,8 +5714,12 @@ snapshots: is-interactive@1.0.0: {} + is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@3.0.0: {} + is-unicode-supported@0.1.0: {} isbinaryfile@4.0.10: {} @@ -5749,22 +5835,30 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lilconfig@3.1.3: {} + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 - lint-staged@16.4.0: + lint-staged@15.5.2: dependencies: - commander: 14.0.3 - listr2: 9.0.5 - picomatch: 4.0.4 + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.3 + micromatch: 4.0.8 + pidtree: 0.6.0 string-argv: 0.3.2 - tinyexec: 1.0.4 - yaml: 2.8.3 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color - listr2@9.0.5: + listr2@8.3.3: dependencies: - cli-truncate: 5.2.0 + cli-truncate: 4.0.0 colorette: 2.0.20 eventemitter3: 5.0.4 log-update: 6.1.0 @@ -5785,8 +5879,6 @@ snapshots: lodash@4.17.23: {} - lodash@4.18.1: {} - log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -5876,6 +5968,13 @@ snapshots: mdurl@2.0.0: {} + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -5886,6 +5985,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} mimic-response@1.0.1: {} @@ -6011,6 +6112,10 @@ snapshots: normalize-url@6.1.0: {} + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + object-keys@1.1.1: optional: true @@ -6022,6 +6127,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -6075,6 +6184,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -6092,7 +6203,19 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.4: {} + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pidtree@0.6.0: {} + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 plist@3.1.0: dependencies: @@ -6314,12 +6437,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true - slice-ansi@7.1.2: + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 + is-fullwidth-code-point: 4.0.0 - slice-ansi@8.0.0: + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 @@ -6383,11 +6506,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 - string-width@8.2.0: - dependencies: - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -6400,6 +6518,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -6462,12 +6582,10 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.0.4: {} - tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 tinypool@1.1.1: {} @@ -6489,6 +6607,10 @@ snapshots: tmp@0.2.5: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + tough-cookie@6.0.1: dependencies: tldts: 7.0.27 @@ -6563,7 +6685,7 @@ snapshots: uc.micro@2.1.0: {} - undici-types@7.16.0: {} + undici-types@6.21.0: {} undici-types@7.18.2: {} @@ -6620,24 +6742,6 @@ snapshots: - supports-color - terser - vite-node@2.1.9(@types/node@25.5.2)(terser@5.46.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 1.1.2 - vite: 5.4.21(@types/node@25.5.2)(terser@5.46.1) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - vite@5.4.21(@types/node@25.5.0)(terser@5.46.1): dependencies: esbuild: 0.21.5 @@ -6648,23 +6752,13 @@ snapshots: fsevents: 2.3.3 terser: 5.46.1 - vite@5.4.21(@types/node@25.5.2)(terser@5.46.1): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.8 - rollup: 4.59.0 - optionalDependencies: - '@types/node': 25.5.2 - fsevents: 2.3.3 - terser: 5.46.1 - - vitest-axe@1.0.0-pre.5(vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1)): + vitest-axe@1.0.0-pre.5(vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1)): dependencies: '@vitest/pretty-format': 3.2.4 axe-core: 4.11.1 chalk: 5.6.2 lodash-es: 4.17.23 - vitest: 2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1) + vitest: 2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1) vitest@2.1.9(@types/node@25.5.0)(jsdom@29.0.1)(terser@5.46.1): dependencies: @@ -6702,42 +6796,6 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@25.5.2)(jsdom@29.0.1)(terser@5.46.1): - dependencies: - '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@25.5.2)(terser@5.46.1)) - '@vitest/pretty-format': 2.1.9 - '@vitest/runner': 2.1.9 - '@vitest/snapshot': 2.1.9 - '@vitest/spy': 2.1.9 - '@vitest/utils': 2.1.9 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 1.1.2 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.1.1 - tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@25.5.2)(terser@5.46.1) - vite-node: 2.1.9(@types/node@25.5.2)(terser@5.46.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 25.5.2 - jsdom: 29.0.1 - transitivePeerDependencies: - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -6813,8 +6871,6 @@ snapshots: yaml@2.8.2: {} - yaml@2.8.3: {} - yargs-parser@21.1.1: {} yargs@17.7.2: From e1e822eb981ce3f3e9cd50c62e099f082b15488d Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 17:19:43 -0500 Subject: [PATCH 05/12] fix(pnpm): whitelist electron in onlyBuiltDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents electron binary install from being blocked when running pnpm add in any workspace package. Applies to all environments (CI, new clones, other machines) — no more manual approve-builds. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 327f984..e2f6610 100644 --- a/package.json +++ b/package.json @@ -41,5 +41,8 @@ "typescript": "^5.5.0", "typescript-eslint": "^8.57.0" }, - "packageManager": "pnpm@10.32.1" + "packageManager": "pnpm@10.32.1", + "pnpm": { + "onlyBuiltDependencies": ["electron"] + } } From 1410cb8ae2c29dee9af7c956db9006d41c2c0dc6 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 20:26:25 -0500 Subject: [PATCH 06/12] =?UTF-8?q?feat(gui):=20full=20e2e=20Playwright=20su?= =?UTF-8?q?ite=20=E2=80=94=20106=20tests=20passing,=20WCAG=20AA,=20code-sy?= =?UTF-8?q?nc,=20axe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Playwright e2e: 106 tests across splash, transport, keyboard, instrument picker, bug report, mixer, live-code, accessibility, and code-sync - Fixed strict-mode violations (exact:true on all role/button lookups) - Wired InstrumentPicker modal into LiveCode (was using inline dropdown) - WCAG AA contrast: raised all muted text to #7a7a8a+ on dark backgrounds - BarCounter: removed opacity-based dimming, now uses explicit computed colors - DraggablePanel: added aria-valuenow/min/max to resize handle (WCAG) - axe-core: injected directly via page.evaluate() (Electron-compatible) - Code-sync: exposed window.__scoreEditor in CodeEditorPanel for test reads - Bug report path: fixed off-by-one (5 levels up to 4) for debug/report.json - Audio muting: belt-and-suspenders SCORE_TEST=1 mute in transport:play handler - Tab navigation: .toPass() polling for headless Electron focus reliability Co-Authored-By: Claude Sonnet 4.6 --- packages/gui/package.json | 1 + packages/gui/playwright.config.ts | 25 ++- packages/gui/src/main/index.ts | 14 +- .../renderer/components/LiveCode/index.tsx | 39 ++--- .../components/shared/BugReportModal.tsx | 10 +- .../components/shared/CodeEditorPanel.tsx | 3 + .../components/shared/ConsoleLogPanel.tsx | 8 +- .../components/shared/DraggablePanel.tsx | 4 + .../components/shared/InstrumentPicker.tsx | 2 +- .../components/shared/MasterLevel.tsx | 2 +- .../renderer/components/shared/MixerStrip.tsx | 2 +- .../components/shared/TransportBar.tsx | 6 +- .../renderer/components/status/BarCounter.tsx | 32 ++-- .../renderer/components/status/EvalStatus.tsx | 4 +- packages/gui/tests/e2e/accessibility.e2e.ts | 146 +++++++++++++++++ packages/gui/tests/e2e/app.e2e.ts | 135 ++-------------- packages/gui/tests/e2e/bug-report.e2e.ts | 82 ++++++++++ packages/gui/tests/e2e/code-sync.e2e.ts | 147 ++++++++++++++++++ packages/gui/tests/e2e/fixtures.ts | 68 ++++++++ .../gui/tests/e2e/instrument-picker.e2e.ts | 114 ++++++++++++++ packages/gui/tests/e2e/keyboard.e2e.ts | 80 ++++++++++ packages/gui/tests/e2e/live-code.e2e.ts | 91 +++++++++++ packages/gui/tests/e2e/mixer.e2e.ts | 105 +++++++++++++ packages/gui/tests/e2e/splash.e2e.ts | 50 ++++++ packages/gui/tests/e2e/transport.e2e.ts | 61 ++++++++ pnpm-lock.yaml | 13 ++ 26 files changed, 1062 insertions(+), 182 deletions(-) create mode 100644 packages/gui/tests/e2e/accessibility.e2e.ts create mode 100644 packages/gui/tests/e2e/bug-report.e2e.ts create mode 100644 packages/gui/tests/e2e/code-sync.e2e.ts create mode 100644 packages/gui/tests/e2e/fixtures.ts create mode 100644 packages/gui/tests/e2e/instrument-picker.e2e.ts create mode 100644 packages/gui/tests/e2e/keyboard.e2e.ts create mode 100644 packages/gui/tests/e2e/live-code.e2e.ts create mode 100644 packages/gui/tests/e2e/mixer.e2e.ts create mode 100644 packages/gui/tests/e2e/splash.e2e.ts create mode 100644 packages/gui/tests/e2e/transport.e2e.ts diff --git a/packages/gui/package.json b/packages/gui/package.json index 04553f2..413a9dc 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -26,6 +26,7 @@ "node-web-audio-api": "^1.0.8" }, "devDependencies": { + "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.59.1", "@score/cli": "workspace:*", "@score/core": "workspace:*", diff --git a/packages/gui/playwright.config.ts b/packages/gui/playwright.config.ts index b8ff7e9..dacf0a6 100644 --- a/packages/gui/playwright.config.ts +++ b/packages/gui/playwright.config.ts @@ -10,11 +10,17 @@ import { defineConfig } from '@playwright/test' export default defineConfig({ - testDir: './tests/e2e', - testMatch: '**/*.e2e.ts', - timeout: 30_000, - retries: process.env['CI'] ? 2 : 0, - workers: 1, // Electron: single instance only + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + timeout: 30_000, // per-test limit + globalTimeout: 900_000, // 15 min total safety cap + retries: process.env['CI'] ? 2 : 0, + workers: 1, // Electron: single instance only + + expect: { + // Electron startup (~5-7s) + render time — give assertions room to breathe + timeout: 8_000, + }, use: { // Electron does not use a browser — launch config is in each test via electron.launch() @@ -23,6 +29,15 @@ export default defineConfig({ trace: 'on-first-retry', }, + // Pass SCORE_TEST=1 so the main process hides the BrowserWindow. + // Playwright interacts via DevTools Protocol — the window does not need to be visible. + projects: [ + { + name: 'electron', + use: { }, + }, + ], + reporter: [ ['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }], diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index b1a0eb2..c20637c 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -236,6 +236,10 @@ const boot = async (song: SongDefinition, barOffset = 0): Promise => { } teardown() slotRef.value = { engine, playing: false, bpm: song.bpm, bars: barOffset } + + // In test mode (SCORE_TEST=1) mute master output — engine still runs, IPC still fires, + // but no audio comes out of the speakers. + if (process.env['SCORE_TEST'] === '1') engine.patch({ masterVolume: 0 }) // Per-step cache write — fires at full audio tick rate. // Does NOT send IPC directly — writes to tickCache so the display tick loop // can send 'display:tick' at ~60fps without IPC at the full audio rate. @@ -269,6 +273,7 @@ const boot = async (song: SongDefinition, barOffset = 0): Promise => { const next = slotRef.value if (next && !next.playing) { next.engine.start() + if (process.env['SCORE_TEST'] === '1') next.engine.patch({ masterVolume: 0 }) next.playing = true pushState() startAnalysis() @@ -286,11 +291,16 @@ const boot = async (song: SongDefinition, barOffset = 0): Promise => { // ── Window factory ───────────────────────────────────────────────────────────── const createWindow = (): BrowserWindow => { + // In test mode (SCORE_TEST=1) suppress the visible window — Playwright still + // interacts via DevTools Protocol regardless of show state. + const isTest = process.env['SCORE_TEST'] === '1' + const win = new BrowserWindow({ width: 1280, height: 800, minWidth: 900, minHeight: 600, + show: !isTest, backgroundColor: '#0c0c0e', titleBarStyle: 'hiddenInset', webPreferences: { @@ -408,6 +418,8 @@ ipcMain.on('transport:play', () => { const slot = slotRef.value if (!slot || slot.playing) return slot.engine.start() + // Belt-and-suspenders: re-apply mute after start() in case any initialization gap + if (process.env['SCORE_TEST'] === '1') slot.engine.patch({ masterVolume: 0 }) slot.playing = true pushState() startAnalysis() @@ -576,7 +588,7 @@ ipcMain.on('bug:report', (_event, payload: RendererToMain['bug:report']) => { const fileName = `score-bug-report-${timestamp}.json` // Fixed path — always the same location so Claude can read it directly - const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..') + const repoRoot = path.resolve(__dirname, '..', '..', '..', '..') const debugDir = path.join(repoRoot, 'debug') mkdirSync(debugDir, { recursive: true }) writeFileSync(path.join(debugDir, 'report.json'), json, 'utf8') diff --git a/packages/gui/src/renderer/components/LiveCode/index.tsx b/packages/gui/src/renderer/components/LiveCode/index.tsx index 0d3061d..ea916b6 100644 --- a/packages/gui/src/renderer/components/LiveCode/index.tsx +++ b/packages/gui/src/renderer/components/LiveCode/index.tsx @@ -21,6 +21,7 @@ import { BarCounter } from '../status/index.js' import { PendingSwapBadge } from '../status/index.js' import { patchBpm, patchTrackPattern, insertTrackPattern, patchTrackVolume, patchTrackNote, patchChainMethod, parseTrackChainParams, parseTrackModel, patchInstrumentModel, patchMute, parseMuteState, patchAddInstrument, uniqueVarName } from '../../lib/codePatcher.js' import { InstrumentPanel } from '../shared/InstrumentPanel.js' +import { InstrumentPicker } from '../shared/InstrumentPicker.js' import type { PianoRollNote } from '../visualizer/PianoRoll.js' import type { PanelLayoutMap } from '../../../main/ipc-types.js' @@ -95,7 +96,7 @@ const PanelToggle = ({ label, active, onClick }: PanelToggleProps) => ( background: active ? '#152035' : 'none', border: active ? '1px solid #2a4a7a' : '1px solid #1e1e22', borderRadius: '2px', - color: active ? '#6a9fff' : '#3a3a46', + color: active ? '#6a9fff' : '#7a7a8a', // was #3a3a46 — 1.77:1 on #090909; now 5.02:1 (WCAG AA) fontSize: '0.65rem', fontFamily: 'system-ui, sans-serif', letterSpacing: '0.06em', @@ -801,34 +802,16 @@ export const LiveCode = ({ hardware, onHome }: Props) => { style={styles.addTrackBtn} aria-label="Add track" title="Add a new instrument track" - onClick={() => { setAddTrackOpen(v => !v) }} + onClick={() => { setAddTrackOpen(true) }} > + - ADD TRACK + ADD TRACK {addTrackOpen && ( -
- {([ - ['kick808', 'Kick 808'], - ['kick909', 'Kick 909'], - ['snare909', 'Snare 909'], - ['hihat808', 'HiHat 808'], - ['bass303', 'Bass 303'], - ['synth', 'Synth'], - ['subsynth', 'SubSynth'], - ['fmsynth', 'FM Synth'], - ['pad', 'Pad'], - ['pluck', 'Pluck'], - ] as const).map(([type, label]) => ( - - ))} -
+ { setAddTrackOpen(false) }} + /> )}
@@ -864,7 +847,7 @@ export const LiveCode = ({ hardware, onHome }: Props) => { style={{ fontSize: 8, fontFamily: 'monospace', - color: isSelected ? '#4a8fff' : '#3a3a4a', + color: isSelected ? '#4a8fff' : '#7a7a8a', // was #3a3a4a — 1.74:1 on #0d0d10; now 5.43:1 (WCAG AA) letterSpacing: '0.08em', paddingBottom: 3, userSelect: 'none', @@ -887,7 +870,7 @@ export const LiveCode = ({ hardware, onHome }: Props) => { )} {selectedTrack === null && tracks.length > 0 && ( -
+
▼ EDIT — click a strip above
)} @@ -999,7 +982,7 @@ const styles = { background: 'none', border: '1px solid #1e1e28', borderRadius: '2px', - color: '#3a3a50', + color: '#7a7a8a', // was #3a3a50 — 1.78:1 on #0a0a0d; now 5.43:1 (WCAG AA) fontSize: '0.68rem', letterSpacing: '0.06em', cursor: 'pointer', diff --git a/packages/gui/src/renderer/components/shared/BugReportModal.tsx b/packages/gui/src/renderer/components/shared/BugReportModal.tsx index 802b72b..bcf4d53 100644 --- a/packages/gui/src/renderer/components/shared/BugReportModal.tsx +++ b/packages/gui/src/renderer/components/shared/BugReportModal.tsx @@ -251,7 +251,7 @@ const styles = { closeBtn: { background: 'none', border: 'none', - color: '#556688', + color: '#7090c0', // was #556688 — 3.34:1 on #0e0e11; now 6.27:1 (WCAG AA) fontSize: 16, cursor: 'pointer', padding: '2px 6px', @@ -279,7 +279,7 @@ const styles = { disclosureBtn: { background: 'none', border: 'none', - color: '#556688', + color: '#7090c0', // was #556688 — 3.34:1 on #0e0e11; now 6.27:1 (WCAG AA) fontSize: 12, cursor: 'pointer', padding: 0, @@ -290,7 +290,7 @@ const styles = { }, disclosureArrow: { fontSize: 10, - color: '#445577', + color: '#7090c0', // was #445577 — insufficient contrast on #0e0e11 }, details: { background: '#0a0a10', @@ -309,7 +309,7 @@ const styles = { }, detailKey: { fontSize: 11, - color: '#445577', + color: '#7090c0', // was #445577 — insufficient contrast on #0a0a10 fontWeight: 600, textTransform: 'uppercase' as const, letterSpacing: '0.05em', @@ -352,7 +352,7 @@ const styles = { cursor: 'pointer', }, saveBtn: { - background: '#4a8fff', + background: '#1a5fb4', // was #4a8fff — 3.15:1 with white; now 6.14:1 (WCAG AA) border: 'none', color: '#fff', borderRadius: 6, diff --git a/packages/gui/src/renderer/components/shared/CodeEditorPanel.tsx b/packages/gui/src/renderer/components/shared/CodeEditorPanel.tsx index 452b677..2eefbd9 100644 --- a/packages/gui/src/renderer/components/shared/CodeEditorPanel.tsx +++ b/packages/gui/src/renderer/components/shared/CodeEditorPanel.tsx @@ -414,6 +414,9 @@ const CodeEditorPanelInner = ({ value, onChange, onEval, decorations, stepBadges () => { onEval() }, ) + // Expose editor on window for e2e test access + ;(window as unknown as { __scoreEditor?: { getValue: () => string } }).__scoreEditor = editor + // Focus editor on mount editor.focus() diff --git a/packages/gui/src/renderer/components/shared/ConsoleLogPanel.tsx b/packages/gui/src/renderer/components/shared/ConsoleLogPanel.tsx index 2e3ee2b..fb894c9 100644 --- a/packages/gui/src/renderer/components/shared/ConsoleLogPanel.tsx +++ b/packages/gui/src/renderer/components/shared/ConsoleLogPanel.tsx @@ -200,13 +200,13 @@ const styles = { fontSize: '0.6rem', fontFamily: "'JetBrains Mono', 'Fira Code', monospace", letterSpacing: '0.1em', - color: '#2a3a52', + color: '#7a7a8a', // was #2a3a52 — 1.71:1 on #0a0a0c; now 5.02:1 (WCAG AA) textTransform: 'uppercase' as const, }, clearBtn: { background: 'none', border: '1px solid #1e1e22', - color: '#3a3a46', + color: '#7a7a8a', // was #3a3a46 — 1.76:1 on #0a0a0c; now 5.02:1 (WCAG AA) fontSize: '0.58rem', fontFamily: 'system-ui, sans-serif', letterSpacing: '0.06em', @@ -225,7 +225,7 @@ const styles = { gap: '1px', }, empty: { - color: '#2a2a36', + color: '#7a7a8a', // was #2a2a36 — insufficient contrast on #080809 padding: '0.25rem 0', }, row: { @@ -236,7 +236,7 @@ const styles = { }, timestamp: { flexShrink: 0, - color: '#3a3a46', + color: '#7a7a8a', // was #3a3a46 — 1.78:1 on #080809; now 5.02:1 (WCAG AA) fontSize: '0.65rem', fontVariantNumeric: 'tabular-nums', }, diff --git a/packages/gui/src/renderer/components/shared/DraggablePanel.tsx b/packages/gui/src/renderer/components/shared/DraggablePanel.tsx index 85c29fc..432a8f7 100644 --- a/packages/gui/src/renderer/components/shared/DraggablePanel.tsx +++ b/packages/gui/src/renderer/components/shared/DraggablePanel.tsx @@ -296,9 +296,13 @@ export const DraggablePanel = (props: DraggablePanelProps) => {
{/* Resize handle — Tab to focus, Arrow keys to resize */} + {/* aria-valuenow/min/max required by WCAG for interactive role="separator" */}
* @param playing - Whether the transport is rolling */ export const BarCounter = ({ bars, step, stepCount, bpm, playing }: Props) => { - const color = playing ? '#6a9fff' : '#2a2a3a' - const beatNum = playing ? Math.floor(step / 4) + 1 : 0 + // Explicit colors to avoid opacity-based dimming which fails WCAG AA on dark backgrounds. + // When playing: bright blue for numbers, muted blue for labels. + // When stopped: visible gray for numbers, muted gray for labels. + const numColor = playing ? '#6a9fff' : '#7a7a8a' + const dimColor = playing ? '#7090c0' : '#7a7a8a' + const sepColor = playing ? '#4a6090' : '#555566' + const beatNum = playing ? Math.floor(step / 4) + 1 : 0 const beatsTotal = Math.max(Math.ceil(stepCount / 4), 1) return ( -
+
- BAR - {playing ? bars + 1 : '—'} + BAR + {playing ? bars + 1 : '—'} - + - BEAT - + BEAT + {playing ? `${String(beatNum)}/${String(beatsTotal)}` : '—'} - + - {bpm} - BPM + {bpm} + BPM {playing && }
@@ -100,17 +105,18 @@ const styles = { }, dimLabel: { fontSize: '0.6rem', - opacity: 0.5, + // color set inline — opacity-based dimming fails WCAG AA on dark backgrounds fontWeight: 400, }, bigNum: { fontSize: '1.2rem', fontWeight: 700, lineHeight: 1, + // color set inline — inheriting opacity from root fails WCAG AA }, separator: { - opacity: 0.3, fontSize: '0.9rem', + // color set inline }, // Beat clock beatClock: { diff --git a/packages/gui/src/renderer/components/status/EvalStatus.tsx b/packages/gui/src/renderer/components/status/EvalStatus.tsx index e96017e..ec57ab5 100644 --- a/packages/gui/src/renderer/components/status/EvalStatus.tsx +++ b/packages/gui/src/renderer/components/status/EvalStatus.tsx @@ -117,7 +117,7 @@ const styles = { }, time: { fontSize: '0.65rem', - color: '#5a5a6a', + color: '#7a7a8a', // was #5a5a6a — 2.88:1 on #0c0c0e; now 5.02:1 (WCAG AA) marginLeft: '0.25rem', }, errorMsg: { @@ -133,7 +133,7 @@ const styles = { hint: { display: 'block', fontSize: '0.62rem', - color: '#444454', + color: '#7a7a8a', // was #444454 — insufficient contrast on #0c0c0e marginTop: '0.05rem', }, } as const diff --git a/packages/gui/tests/e2e/accessibility.e2e.ts b/packages/gui/tests/e2e/accessibility.e2e.ts new file mode 100644 index 0000000..25a7a3d --- /dev/null +++ b/packages/gui/tests/e2e/accessibility.e2e.ts @@ -0,0 +1,146 @@ +// accessibility.e2e.ts — WCAG 2.1 AA axe audits for Score Studio. +// +// Runs axe-core against each major view. Color contrast checks are enabled — +// dark theme palette must meet 4.5:1 text / 3:1 UI targets. +// +// NOTE: @axe-core/playwright uses browserContext.newPage() which Electron does +// not support (Protocol error: Target.createTarget). We inject axe-core directly +// via page.evaluate() instead — same analysis, Electron-compatible. + +import { test, expect } from './fixtures.js' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +// ── Axe injection ───────────────────────────────────────────────────────────── + +type AxeViolation = { + id: string + impact: string | null + description: string + nodes: ReadonlyArray<{ html: string }> +} +type AxeResults = { violations: AxeViolation[] } + +/** + * Resolve the axe-core min bundle. @axe-core/playwright lists axe-core as a + * peer/direct dependency, so it's accessible from the packages/gui node_modules + * via pnpm's symlink structure. Walk up until we find it. + */ +const resolveAxeSource = (): string => { + // Try common locations relative to tests/e2e/ + const candidates = [ + // Monorepo root (4 up from tests/e2e) + resolve(__dirname, '../../../../node_modules/axe-core/axe.min.js'), + // pnpm virtual store (monorepo root) + resolve(__dirname, '../../../../node_modules/.pnpm/axe-core@4.11.1/node_modules/axe-core/axe.min.js'), + // packages/gui local (2 up from tests/e2e) + resolve(__dirname, '../../node_modules/axe-core/axe.min.js'), + ] + for (const p of candidates) { + try { return readFileSync(p, 'utf8') } catch { /* try next */ } + } + throw new Error('axe-core/axe.min.js not found — run pnpm install') +} + +const AXE_SOURCE = resolveAxeSource() + +const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] + +const runAxe = async ( + page: import('@playwright/test').Page, + include?: string, +): Promise => { + const results: AxeResults = await page.evaluate( + ([src, tags, selector]) => { + // Inject axe-core if not already present + if (!(window as unknown as { axe?: unknown }).axe) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function(src)() + } + const axe = (window as unknown as { axe: { run: (context: unknown, opts: unknown) => Promise } }).axe + const context = selector ? document.querySelector(selector) ?? document : document + return axe.run(context, { runOnly: { type: 'tag', values: tags } }) + }, + [AXE_SOURCE, WCAG_TAGS, include ?? null] as const, + ) + return results.violations + .filter(v => v.impact === 'critical' || v.impact === 'serious') +} + +// ── Splash screen ───────────────────────────────────────────────────────────── + +test.describe('Accessibility — splash screen', () => { + + test('splash has no critical axe violations', async ({ raw: { page } }) => { + const critical = await runAxe(page) + if (critical.length > 0) { + console.log('Splash axe violations:', JSON.stringify(critical.map(v => ({ + id: v.id, impact: v.impact, description: v.description, + nodes: v.nodes.map(n => n.html), + })), null, 2)) + } + expect(critical).toHaveLength(0) + }) + +}) + +// ── Live Code mode ──────────────────────────────────────────────────────────── + +test.describe('Accessibility — Live Code mode', () => { + + test('transport bar has no critical axe violations', async ({ livecode: { page } }) => { + const critical = await runAxe(page, '[role="toolbar"]') + if (critical.length > 0) { + console.log('Transport axe violations:', JSON.stringify(critical.map(v => ({ + id: v.id, impact: v.impact, description: v.description, + })), null, 2)) + } + expect(critical).toHaveLength(0) + }) + + test('full Live Code view has no critical axe violations', async ({ livecode: { page } }) => { + const critical = await runAxe(page) + if (critical.length > 0) { + console.log('Live Code axe violations:', JSON.stringify(critical.map(v => ({ + id: v.id, impact: v.impact, description: v.description, + nodes: v.nodes.slice(0, 2).map(n => n.html), + })), null, 2)) + } + expect(critical).toHaveLength(0) + }) + +}) + +// ── Modals ──────────────────────────────────────────────────────────────────── + +test.describe('Accessibility — modals', () => { + + test('instrument picker has no critical axe violations', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + // Use 'attached' — fixed-position dialog may report invisible in hidden window + await dialog.waitFor({ state: 'attached', timeout: 8000 }) + + const critical = await runAxe(page, '[role="dialog"]') + if (critical.length > 0) { + console.log('Picker axe violations:', JSON.stringify(critical.map(v => ({ + id: v.id, impact: v.impact, description: v.description, + })), null, 2)) + } + expect(critical).toHaveLength(0) + }) + + test('bug report modal has no critical axe violations', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await page.getByRole('dialog').waitFor() + + const critical = await runAxe(page, '[role="dialog"]') + if (critical.length > 0) { + console.log('Bug report axe violations:', JSON.stringify(critical.map(v => ({ + id: v.id, impact: v.impact, description: v.description, + })), null, 2)) + } + expect(critical).toHaveLength(0) + }) + +}) diff --git a/packages/gui/tests/e2e/app.e2e.ts b/packages/gui/tests/e2e/app.e2e.ts index 93d15e6..b7357b4 100644 --- a/packages/gui/tests/e2e/app.e2e.ts +++ b/packages/gui/tests/e2e/app.e2e.ts @@ -1,135 +1,34 @@ -// app.e2e.ts — Score Studio smoke tests. +// app.e2e.ts — Score Studio launch smoke tests. // -// These tests launch the real Electron app and assert against the live UI. -// Run after `pnpm build` — tests require out/main/index.js to exist. -// -// Add new test files to tests/e2e/*.e2e.ts. - -import { test, expect, _electron as electron } from '@playwright/test' -import type { ElectronApplication, Page } from '@playwright/test' -import path from 'node:path' - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const APP_MAIN = path.resolve(__dirname, '../../out/main/index.js') - -const launchApp = (): Promise => - electron.launch({ args: [APP_MAIN] }) - -// ── Launch + basic rendering ────────────────────────────────────────────────── +// Minimal set: confirms the app opens, title is correct, and the splash renders. +// All deeper coverage lives in the per-area files: +// splash.e2e.ts, transport.e2e.ts, live-code.e2e.ts, mixer.e2e.ts, +// instrument-picker.e2e.ts, keyboard.e2e.ts, bug-report.e2e.ts -test.describe('Score Studio — launch', () => { +import { test, expect } from './fixtures.js' - let app: ElectronApplication - let page: Page +test.describe('Score Studio — smoke', () => { - test.beforeEach(async () => { - app = await launchApp() - page = await app.firstWindow() - await page.waitForLoadState('domcontentloaded') - }) - - test.afterEach(async () => { - await app.close() - }) - - test('window opens with correct title', async () => { + test('window opens with Score in title', async ({ raw: { app } }) => { const title = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.getTitle() ?? '' ) expect(title).toContain('Score') }) - test('transport bar is visible', async () => { - await expect(page.getByRole('toolbar')).toBeVisible() - }) - - test('play button is present and labelled', async () => { - await expect(page.getByRole('button', { name: /play/i })).toBeVisible() - }) - - test('report issue button is present', async () => { - await expect(page.getByRole('button', { name: /report issue/i })).toBeVisible() - }) - -}) - -// ── Zoom shortcuts ──────────────────────────────────────────────────────────── - -test.describe('Score Studio — zoom', () => { - - let app: ElectronApplication - let page: Page - - test.beforeEach(async () => { - app = await launchApp() - page = await app.firstWindow() - await page.waitForLoadState('domcontentloaded') - }) - - test.afterEach(async () => { - await app.close() - }) - - test('Ctrl+= increases zoom level', async () => { - const before = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 - ) - await page.keyboard.press('Control+=') - const after = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 - ) - expect(after).toBeGreaterThan(before) - }) - - test('Ctrl+0 resets zoom level to 0', async () => { - await page.keyboard.press('Control+=') - await page.keyboard.press('Control+=') - await page.keyboard.press('Control+0') - const level = await app.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? 0 - ) - expect(level).toBe(0) - }) - -}) - -// ── Instrument picker ───────────────────────────────────────────────────────── - -test.describe('Score Studio — instrument picker', () => { - - let app: ElectronApplication - let page: Page - - test.beforeEach(async () => { - app = await launchApp() - page = await app.firstWindow() - await page.waitForLoadState('domcontentloaded') - }) - - test.afterEach(async () => { - await app.close() - }) - - test('Add Track button opens instrument picker with all categories', async () => { - await page.getByRole('button', { name: /add track/i }).click() - await expect(page.getByText('Drums', { exact: false })).toBeVisible() - await expect(page.getByText('Bass', { exact: false })).toBeVisible() - await expect(page.getByText('Synths', { exact: false })).toBeVisible() - await expect(page.getByText('Other', { exact: false })).toBeVisible() + test('renders without crashing', async ({ raw: { page } }) => { + // Body has content — app did not white-screen + const body = await page.locator('body').textContent() + expect(body?.length).toBeGreaterThan(0) }) - test('picker shows all drum instruments', async () => { - await page.getByRole('button', { name: /add track/i }).click() - await expect(page.getByRole('button', { name: 'Kick 808' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Snare 909' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Cowbell 808' })).toBeVisible() + test('splash screen is the initial view', async ({ raw: { page } }) => { + await expect(page.getByText(/score studio/i).first()).toBeVisible() }) - test('picker closes on Escape', async () => { - await page.getByRole('button', { name: /add track/i }).click() - await page.keyboard.press('Escape') - await expect(page.getByTestId('instrument-picker-overlay')).not.toBeVisible() + test('single window open on launch', ({ raw: { app } }) => { + const windows = app.windows() + expect(windows.length).toBe(1) }) }) diff --git a/packages/gui/tests/e2e/bug-report.e2e.ts b/packages/gui/tests/e2e/bug-report.e2e.ts new file mode 100644 index 0000000..58da12f --- /dev/null +++ b/packages/gui/tests/e2e/bug-report.e2e.ts @@ -0,0 +1,82 @@ +// bug-report.e2e.ts — Bug report modal e2e tests. + +import { test, expect } from './fixtures.js' +import path from 'node:path' +import fs from 'node:fs' + +test.describe('Bug report modal — open/close', () => { + + test('Report Issue button opens modal', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await expect(page.getByRole('dialog', { name: /report an issue/i })).toBeVisible() + }) + + test('modal has correct ARIA', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + test('close button dismisses modal', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + // Scope to dialog — other panels also have Close buttons (Close Step Grid, Close Mixer) + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click() + await expect(page.getByRole('dialog')).not.toBeVisible() + }) + + test('Escape key dismisses modal', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).not.toBeVisible() + }) + + test('Cancel button dismisses modal', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await page.getByRole('button', { name: /cancel/i }).click() + await expect(page.getByRole('dialog')).not.toBeVisible() + }) + +}) + +test.describe('Bug report modal — content', () => { + + test('description textarea is present', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await expect(page.getByRole('textbox', { name: /what happened/i })).toBeVisible() + }) + + test('Copy Report button is present', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await expect(page.getByRole('button', { name: /copy report/i })).toBeVisible() + }) + + test('Save Report button is present', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await expect(page.getByRole('button', { name: /save report/i })).toBeVisible() + }) + +}) + +test.describe('Bug report modal — submission', () => { + + test('Save Report writes to debug/report.json', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /report.*issue/i }).click() + await page.getByRole('textbox', { name: /what happened/i }).fill('e2e test report') + await page.getByRole('button', { name: /save report/i }).click() + + // Wait a moment for file write + await page.waitForTimeout(1000) + + const reportPath = path.resolve(__dirname, '../../../../debug/report.json') + const exists = fs.existsSync(reportPath) + expect(exists).toBe(true) + + if (exists) { + const content = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as Record + expect(content).toHaveProperty('description') + expect(content).toHaveProperty('code') + expect(content).toHaveProperty('timestamp') + } + }) + +}) diff --git a/packages/gui/tests/e2e/code-sync.e2e.ts b/packages/gui/tests/e2e/code-sync.e2e.ts new file mode 100644 index 0000000..a6654eb --- /dev/null +++ b/packages/gui/tests/e2e/code-sync.e2e.ts @@ -0,0 +1,147 @@ +// code-sync.e2e.ts — GUI→code bidirectional sync tests. +// +// Verifies that GUI actions (add track, BPM change, step click) immediately +// patch the code in the Monaco editor without requiring a manual save. +// This is the core Live Code contract: GUI and code are always in sync. +// +// Reading code: Monaco exposes its model value via window.monaco.editor.getModels(). +// Fallback: read the visible line text from .view-lines if Monaco API isn't exposed. + +import { test, expect } from './fixtures.js' + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Read current code from the Monaco editor. */ +const readEditorCode = (page: import('@playwright/test').Page) => + page.evaluate(() => { + // Primary: use the exposed editor handle (set in CodeEditorPanel handleMount) + const handle = (window as unknown as { __scoreEditor?: { getValue?: () => string } }).__scoreEditor + if (handle?.getValue) return handle.getValue() + // Fallback: read visible text from Monaco's content layer + return document.querySelector('.view-lines')?.textContent ?? '' + }) + +/** Wait for code to contain a substring (up to 3s after action). */ +const waitForCode = async ( + page: import('@playwright/test').Page, + substring: string, +) => { + await expect(async () => { + const code = await readEditorCode(page) + expect(code).toContain(substring) + }).toPass({ timeout: 3000 }) +} + +// ── Add Track → code updated ────────────────────────────────────────────────── + +test.describe('Code sync — add track', () => { + + test('picking Kick 808 inserts Kick808 into the editor', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Kick 808', exact: true }).click() + + // patchAddInstrument adds a new const + appends to tracks array + await waitForCode(page, 'Kick808') + // picker closed and new code is in editor + await expect(dialog).not.toBeVisible() + }) + + test('picking Bass 303 inserts Bass303 into the editor', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Bass 303', exact: true }).click() + + await waitForCode(page, 'Bass303') + }) + + test('adding two tracks keeps both in code', async ({ livecode: { page } }) => { + // Add Kick 808 + await page.getByRole('button', { name: /add track/i }).click() + let dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Kick 808', exact: true }).click() + await expect(dialog).not.toBeVisible() + + // Add Snare 909 + await page.getByRole('button', { name: /add track/i }).click() + dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Snare 909', exact: true }).click() + await expect(dialog).not.toBeVisible() + + const code = await readEditorCode(page) + expect(code).toContain('Kick808') + expect(code).toContain('Snare909') + }) + + test('added instrument variable has a unique name', async ({ livecode: { page } }) => { + // Starter already has a kick variable — uniqueVarName should produce kick1 or kick2 + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Kick 808', exact: true }).click() + + await expect(async () => { + const code = await readEditorCode(page) + // uniqueVarName appends a number when the base name is taken + expect(code).toMatch(/const kick\d\s*=/) + }).toPass({ timeout: 3000 }) + }) + + test('added instrument is appended to tracks array', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + await dialog.getByRole('button', { name: 'Pad', exact: true }).click() + + await expect(async () => { + const code = await readEditorCode(page) + // Pad should appear inside the tracks array, not just as a declaration + const tracksSection = code.slice(code.indexOf('tracks:')) + expect(tracksSection).toContain('pad') + }).toPass({ timeout: 3000 }) + }) + +}) + +// ── BPM change → code updated ───────────────────────────────────────────────── + +test.describe('Code sync — BPM change', () => { + + test('changing BPM in transport updates bpm: in editor', async ({ livecode: { page } }) => { + const bpmInput = page.getByRole('spinbutton', { name: /bpm/i }) + await bpmInput.fill('140') + await bpmInput.press('Enter') + + // patchBpm rewrites the bpm: value in the Song() call + await waitForCode(page, 'bpm: 140') + }) + + test('BPM defaults to 128 in starter code', async ({ livecode: { page } }) => { + // Monaco mounts async — poll until editor is populated + await expect(async () => { + const code = await readEditorCode(page) + expect(code).toContain('bpm: 128') + }).toPass({ timeout: 5000 }) + }) + + test('multiple BPM changes reflect the latest value', async ({ livecode: { page } }) => { + const bpmInput = page.getByRole('spinbutton', { name: /bpm/i }) + + await bpmInput.fill('120') + await bpmInput.press('Enter') + await waitForCode(page, 'bpm: 120') + + await bpmInput.fill('160') + await bpmInput.press('Enter') + await waitForCode(page, 'bpm: 160') + + const code = await readEditorCode(page) + // Code should contain the latest value, not both + expect(code).not.toContain('bpm: 120') + }) + +}) diff --git a/packages/gui/tests/e2e/fixtures.ts b/packages/gui/tests/e2e/fixtures.ts new file mode 100644 index 0000000..e5a0dd7 --- /dev/null +++ b/packages/gui/tests/e2e/fixtures.ts @@ -0,0 +1,68 @@ +// fixtures.ts — Shared Playwright helpers and fixtures for Score Studio e2e tests. +// +// Usage: +// import { test, expect } from './fixtures.js' +// test('...', async ({ livecode: { page } }) => { ... }) +// +// Architecture: ONE Electron instance per worker (scope: 'worker'), reset via +// page.reload() between tests. This cuts suite time from ~11 min to ~3-4 min +// vs per-test launch (5-7s Electron startup × 106 tests = 10+ min). + +import { test as base, expect, _electron as electron } from '@playwright/test' +import type { ElectronApplication, Page } from '@playwright/test' +import path from 'node:path' + +export { expect } + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const APP_MAIN = path.resolve(__dirname, '../../out/main/index.js') + +// SCORE_TEST=1 tells the main process to use show:false on BrowserWindow. +// Playwright still interacts via DevTools Protocol — window visibility not required. +const TEST_ENV = { ...process.env, SCORE_TEST: '1' } + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type AppFixture = { app: ElectronApplication; page: Page } +type SharedApp = { app: ElectronApplication } + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +export const test = base.extend< + { raw: AppFixture; livecode: AppFixture }, + { sharedApp: SharedApp } +>({ + + // Worker-scoped: single Electron launch for the entire suite. + // page.reload() in each test fixture resets renderer state cheaply (~1s) + // vs launching a new Electron process (~5-7s startup). + sharedApp: [async ({}, use) => { + const app = await electron.launch({ args: [APP_MAIN], env: TEST_ENV }) + await use({ app }) + await app.close() + }, { scope: 'worker' }], + + // raw — fresh splash screen state (reload renderer) + raw: async ({ sharedApp: { app } }, use) => { + const page = await app.firstWindow() + await page.reload() + await page.waitForLoadState('domcontentloaded') + await use({ app, page }) + }, + + // livecode — fresh Live Code mode state (reload + navigate) + livecode: async ({ sharedApp: { app } }, use) => { + const page = await app.firstWindow() + await page.reload() + await page.waitForLoadState('domcontentloaded') + + // Select Live Code mode and start — exact:true avoids matching "Start Live Code" + await page.getByRole('button', { name: 'Live Code', exact: true }).click() + await page.getByRole('button', { name: 'Start Live Code', exact: true }).click() + await page.waitForSelector('[role="toolbar"]', { timeout: 5000 }) + + await use({ app, page }) + }, + +}) diff --git a/packages/gui/tests/e2e/instrument-picker.e2e.ts b/packages/gui/tests/e2e/instrument-picker.e2e.ts new file mode 100644 index 0000000..166fb6c --- /dev/null +++ b/packages/gui/tests/e2e/instrument-picker.e2e.ts @@ -0,0 +1,114 @@ +// instrument-picker.e2e.ts — InstrumentPicker modal e2e tests. +// +// NOTE: exact:true required for ambiguous names — "Kick" is a substring of +// "Kick 808", "Kick 909", etc. Without exact the locator resolves to many elements. +// Category labels scoped to the dialog to avoid matching button text ("Bass" appears +// in "Wobble Bass", "Bass 303", etc.). + +import { test, expect } from './fixtures.js' + +// Helper — open the picker and wait for the dialog to be ready +const openPicker = async (page: import('@playwright/test').Page) => { + await page.getByRole('button', { name: /add track/i }).click() + const dialog = page.getByRole('dialog', { name: /pick an instrument/i }) + await dialog.waitFor({ state: 'visible', timeout: 8000 }) + return dialog +} + +test.describe('Instrument picker — open/close', () => { + + test('Add Track button opens picker', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog).toBeVisible() + }) + + test('picker has dialog role and aria-modal', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + test('close button dismisses picker', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + await expect(dialog).not.toBeVisible() + }) + + test('Escape key dismisses picker', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await page.keyboard.press('Escape') + await expect(dialog).not.toBeVisible() + }) + +}) + +test.describe('Instrument picker — categories', () => { + + test('shows Drums category', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog.getByText('Drums', { exact: true })).toBeVisible() + }) + + test('shows Bass category', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog.getByText('Bass', { exact: true })).toBeVisible() + }) + + test('shows Synths category', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog.getByText('Synths', { exact: true })).toBeVisible() + }) + + test('shows Other category', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await expect(dialog.getByText('Other', { exact: true })).toBeVisible() + }) + +}) + +test.describe('Instrument picker — instruments', () => { + + const drums = ['Kick 808', 'Kick 909', 'Kick Hardstyle', 'Kick Hardcore', 'Kick', + 'Snare 909', 'Snare', 'Clap 909', 'Hi-Hat 808', 'Open Hat 808', 'Hi-Hat', 'Cowbell 808'] + const bass = ['Bass 303', 'Wobble Bass', 'Sub Synth'] + const synths = ['Supersaw', 'Pad', 'FM Synth', 'Rhodes', 'Pluck', 'Synth'] + const other = ['Arp', 'Theremin', 'Sax', 'Sample'] + + for (const name of [...drums, ...bass, ...synths, ...other]) { + test(`shows ${name} button`, async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + // exact:true — "Kick" must not match "Kick 808", "Synth" must not match "FM Synth", etc. + await expect(dialog.getByRole('button', { name, exact: true })).toBeVisible() + }) + } + +}) + +test.describe('Instrument picker — selection', () => { + + test('selecting Kick 808 closes picker', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await dialog.getByRole('button', { name: 'Kick 808', exact: true }).click() + await expect(dialog).not.toBeVisible() + }) + + test('selecting Bass 303 closes picker', async ({ livecode: { page } }) => { + const dialog = await openPicker(page) + await dialog.getByRole('button', { name: 'Bass 303', exact: true }).click() + await expect(dialog).not.toBeVisible() + }) + +}) + +test.describe('Instrument picker — focus trap', () => { + + test('Tab cycles within dialog', async ({ livecode: { page } }) => { + await openPicker(page) + // Tab several times — focus should stay within the dialog + for (let i = 0; i < 10; i++) await page.keyboard.press('Tab') + const focused = await page.evaluate( + () => document.activeElement?.closest('[role="dialog"]') !== null + ) + expect(focused).toBe(true) + }) + +}) diff --git a/packages/gui/tests/e2e/keyboard.e2e.ts b/packages/gui/tests/e2e/keyboard.e2e.ts new file mode 100644 index 0000000..fb939e4 --- /dev/null +++ b/packages/gui/tests/e2e/keyboard.e2e.ts @@ -0,0 +1,80 @@ +// keyboard.e2e.ts — Global keyboard shortcut e2e tests. +// +// NOTE: globalShortcut (zoom, panic) is OS-level — page.keyboard.press() sends +// events to the renderer process, not the OS, so it cannot trigger globalShortcut +// handlers. These tests call Electron's API directly via app.evaluate() instead. + +import { test, expect } from './fixtures.js' + +test.describe('Zoom — direct API', () => { + // Tests call webContents.setZoomLevel directly — validates the zoom state API + // that the globalShortcut handlers use. The shortcuts themselves are smoke-tested + // manually since they require OS-level key injection. + + test('zoom level starts at 0', async ({ livecode: { app } }) => { + const level = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? -1 + ) + expect(level).toBe(0) + }) + + test('setting zoom level to 1 is reflected immediately', async ({ livecode: { app } }) => { + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.setZoomLevel(1) + }) + const level = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? -1 + ) + expect(level).toBe(1) + }) + + test('zoom level can be reset to 0', async ({ livecode: { app } }) => { + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.setZoomLevel(2) + }) + await app.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.webContents.setZoomLevel(0) + }) + const level = await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.webContents.getZoomLevel() ?? -1 + ) + expect(level).toBe(0) + }) + +}) + +test.describe('Panic — transport stop', () => { + // NOTE: exact:true required — "Eval song (load without playing)" matches /play/i + + test('play button is visible when stopped', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible() + }) + + test('clicking play shows stop button', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).click() + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible() + }) + + test('clicking stop returns to play state', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).click() + await page.getByRole('button', { name: 'Stop', exact: true }).click() + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible({ timeout: 3000 }) + }) + +}) + +test.describe('Tab navigation', () => { + + test('Tab key moves focus within transport', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).focus() + const before = await page.evaluate(() => document.activeElement?.getAttribute('aria-label')) + await page.keyboard.press('Tab') + // Headless Electron may be slow to process keyboard focus changes — poll + await expect(async () => { + const after = await page.evaluate(() => document.activeElement?.getAttribute('aria-label')) + // Focus must have moved somewhere (null means body — not ideal but headless limitation) + expect(after).not.toBe(before) + }).toPass({ timeout: 3000 }) + }) + +}) diff --git a/packages/gui/tests/e2e/live-code.e2e.ts b/packages/gui/tests/e2e/live-code.e2e.ts new file mode 100644 index 0000000..d0390d2 --- /dev/null +++ b/packages/gui/tests/e2e/live-code.e2e.ts @@ -0,0 +1,91 @@ +// live-code.e2e.ts — Live Code mode e2e tests. + +import { test, expect } from './fixtures.js' + +test.describe('Live Code — editor toolbar', () => { + + test('New button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /new song/i })).toBeVisible() + }) + + test('Open button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /open song/i })).toBeVisible() + }) + + test('Save button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /save song/i })).toBeVisible() + }) + + test('Run button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /run song/i })).toBeVisible() + }) + + test('Add track button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /add track/i })).toBeVisible() + }) + +}) + +test.describe('Live Code — code editor', () => { + + test('editor area is visible', async ({ livecode: { page } }) => { + // Monaco editor renders as contenteditable or textarea + const editor = page.locator('[aria-label="Song code editor"], .monaco-editor, textarea').first() + await expect(editor).toBeVisible() + }) + + test('starter song code is pre-populated', async ({ livecode: { page } }) => { + // Starter contains at least Song and Kick + const body = await page.content() + expect(body).toContain('Song') + }) + +}) + +test.describe('Live Code — console', () => { + + test('console panel is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('log')).toBeVisible() + }) + + test('console has clear button', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /clear console/i })).toBeVisible() + }) + +}) + +test.describe('Live Code — eval flow', () => { + + test('running starter song does not crash the app', async ({ livecode: { page, app } }) => { + await page.getByRole('button', { name: /run song/i }).click() + // App should still be responsive — play button should be visible or stop button + await expect( + page.getByRole('button', { name: /play|stop/i }).first() + ).toBeVisible({ timeout: 5000 }) + + // No fatal crash — window still exists + const windows = app.windows() + expect(windows.length).toBeGreaterThan(0) + }) + + test('eval error shows in console without crashing', async ({ livecode: { page } }) => { + // Inject broken code via keyboard shortcut — this is hard without Monaco access + // Just verify the console log panel is still rendering after a run + await page.getByRole('button', { name: /run song/i }).click() + await expect(page.getByRole('log')).toBeVisible({ timeout: 3000 }) + }) + +}) + +test.describe('Live Code — visualizer panels', () => { + + test('punchcard grid or step grid is visible', async ({ livecode: { page } }) => { + // The step grid panel is open by default + await expect( + page.getByRole('application', { name: /punchcard/i }) + .or(page.locator('[aria-label*="grid"]')) + .first() + ).toBeVisible({ timeout: 3000 }) + }) + +}) diff --git a/packages/gui/tests/e2e/mixer.e2e.ts b/packages/gui/tests/e2e/mixer.e2e.ts new file mode 100644 index 0000000..24131d0 --- /dev/null +++ b/packages/gui/tests/e2e/mixer.e2e.ts @@ -0,0 +1,105 @@ +// mixer.e2e.ts — Mixer and MixerStrip e2e tests. + +import { test, expect } from './fixtures.js' + +// NOTE: Mixer strip tests click "Run" to populate tracks. +// SCORE_TEST=1 is set in test fixtures — the engine mutes master output (volume=0) +// so the app processes the song but produces no audio from speakers. + +test.describe('Mixer — rendering', () => { + + test('mixer panel is visible', async ({ livecode: { page } }) => { + await expect( + page.getByRole('heading', { name: /mixer/i }) + .or(page.locator('[aria-label*="mixer"]')) + .first() + ).toBeVisible({ timeout: 3000 }) + }) + + test('starter song renders mixer strips after Run', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + // After eval the starter song has 7 tracks — at least one volume slider visible + await expect( + page.getByRole('slider', { name: /volume/i }).first() + ).toBeVisible({ timeout: 8000 }) + }) + +}) + +test.describe('Mixer — mute', () => { + + test('mute button is present per strip', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + await expect( + page.getByRole('button', { name: /mute/i }).first() + ).toBeVisible({ timeout: 5000 }) + }) + + test('mute button toggles aria-pressed', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + const muteBtn = page.getByRole('button', { name: /mute/i }).first() + await muteBtn.waitFor({ timeout: 5000 }) + + await expect(muteBtn).toHaveAttribute('aria-pressed', 'false') + await muteBtn.click() + await expect(muteBtn).toHaveAttribute('aria-pressed', 'true') + await muteBtn.click() + await expect(muteBtn).toHaveAttribute('aria-pressed', 'false') + }) + +}) + +test.describe('Mixer — solo', () => { + + test('solo button is present per strip', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + await expect( + page.getByRole('button', { name: /solo/i }).first() + ).toBeVisible({ timeout: 5000 }) + }) + + test('solo button toggles aria-pressed', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + const soloBtn = page.getByRole('button', { name: /solo/i }).first() + await soloBtn.waitFor({ timeout: 5000 }) + + await expect(soloBtn).toHaveAttribute('aria-pressed', 'false') + await soloBtn.click() + await expect(soloBtn).toHaveAttribute('aria-pressed', 'true') + }) + + test('soloing one track clears solo on another', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + const soloButtons = page.getByRole('button', { name: /solo/i }) + await soloButtons.first().waitFor({ timeout: 5000 }) + + const count = await soloButtons.count() + if (count < 2) test.skip() + + await soloButtons.nth(0).click() + await expect(soloButtons.nth(0)).toHaveAttribute('aria-pressed', 'true') + + await soloButtons.nth(1).click() + await expect(soloButtons.nth(1)).toHaveAttribute('aria-pressed', 'true') + await expect(soloButtons.nth(0)).toHaveAttribute('aria-pressed', 'false') + }) + +}) + +test.describe('Mixer — volume', () => { + + test('volume fader is present per strip', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + await expect( + page.getByRole('slider', { name: /volume/i }).first() + ).toBeVisible({ timeout: 5000 }) + }) + + test('pan slider is present per strip', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: /run song/i }).click() + await expect( + page.getByRole('slider', { name: /pan/i }).first() + ).toBeVisible({ timeout: 5000 }) + }) + +}) diff --git a/packages/gui/tests/e2e/splash.e2e.ts b/packages/gui/tests/e2e/splash.e2e.ts new file mode 100644 index 0000000..adc1b8e --- /dev/null +++ b/packages/gui/tests/e2e/splash.e2e.ts @@ -0,0 +1,50 @@ +// splash.e2e.ts — Splash screen / mode selector e2e tests. + +import { test, expect } from './fixtures.js' + +test.describe('Splash screen', () => { + + test('renders title and tagline', async ({ raw: { page } }) => { + await expect(page.getByText('Score Studio')).toBeVisible() + await expect(page.getByText(/what are you doing today/i)).toBeVisible() + }) + + test('shows all four mode buttons', async ({ raw: { page } }) => { + // Live Code is the only enabled button. Produce/DJ Set/Jam Session are disabled + // with aria-label="X — coming soon" — match on the group container instead. + const modeGroup = page.getByRole('group', { name: /studio modes/i }) + await expect(modeGroup).toBeVisible() + await expect(modeGroup.getByRole('button', { name: 'Live Code', exact: true })).toBeVisible() + await expect(modeGroup.getByText('Produce')).toBeVisible() + await expect(modeGroup.getByText('DJ Set')).toBeVisible() + await expect(modeGroup.getByText('Jam Session')).toBeVisible() + }) + + test('shows hardware selection buttons', async ({ raw: { page } }) => { + await expect(page.getByRole('button', { name: /pc only/i })).toBeVisible() + await expect(page.getByRole('button', { name: /\+ controller/i })).toBeVisible() + await expect(page.getByRole('button', { name: /\+ aio/i })).toBeVisible() + }) + + test('start button is present', async ({ raw: { page } }) => { + await expect(page.getByRole('button', { name: /start/i })).toBeVisible() + }) + + test('selecting Live Code updates start button label', async ({ raw: { page } }) => { + await page.getByRole('button', { name: 'Live Code', exact: true }).click() + await expect(page.getByRole('button', { name: 'Start Live Code', exact: true })).toBeVisible() + }) + + test('Live Code mode aria-pressed is true when selected', async ({ raw: { page } }) => { + await page.getByRole('button', { name: 'Live Code', exact: true }).click() + const btn = page.getByRole('button', { name: 'Live Code', exact: true }) + await expect(btn).toHaveAttribute('aria-pressed', 'true') + }) + + test('navigates to Live Code mode on start', async ({ raw: { page } }) => { + await page.getByRole('button', { name: 'Live Code', exact: true }).click() + await page.getByRole('button', { name: 'Start Live Code', exact: true }).click() + await expect(page.getByRole('toolbar')).toBeVisible() + }) + +}) diff --git a/packages/gui/tests/e2e/transport.e2e.ts b/packages/gui/tests/e2e/transport.e2e.ts new file mode 100644 index 0000000..2806db2 --- /dev/null +++ b/packages/gui/tests/e2e/transport.e2e.ts @@ -0,0 +1,61 @@ +// transport.e2e.ts — Transport bar e2e tests. +// +// NOTE: exact:true on play/stop buttons — "Eval song (load without playing)" +// also matches /play/i without exact, causing strict mode violations. + +import { test, expect } from './fixtures.js' + +test.describe('Transport bar', () => { + + test('renders with correct role', async ({ livecode: { page } }) => { + await expect(page.getByRole('toolbar')).toBeVisible() + }) + + test('play button is present and labelled', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible() + }) + + test('play button has aria-pressed false when stopped', async ({ livecode: { page } }) => { + const btn = page.getByRole('button', { name: 'Play', exact: true }) + await expect(btn).toHaveAttribute('aria-pressed', 'false') + }) + + test('clicking play toggles to stop button', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).click() + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible() + }) + + test('clicking stop after play returns to play button', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).click() + await page.getByRole('button', { name: 'Stop', exact: true }).click() + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible() + }) + + test('BPM input is present and has default value', async ({ livecode: { page } }) => { + const bpm = page.getByRole('spinbutton', { name: /bpm/i }) + await expect(bpm).toBeVisible() + const value = await bpm.inputValue() + expect(Number(value)).toBeGreaterThan(0) + expect(Number(value)).toBeLessThanOrEqual(300) + }) + + test('BPM input accepts valid value', async ({ livecode: { page } }) => { + const bpm = page.getByRole('spinbutton', { name: /bpm/i }) + await bpm.fill('140') + await bpm.press('Enter') + await expect(bpm).toHaveValue('140') + }) + + test('report issue button is present', async ({ livecode: { page } }) => { + await expect(page.getByRole('button', { name: /report.*issue/i })).toBeVisible() + }) + + test('Ctrl+. triggers panic stop', async ({ livecode: { page } }) => { + await page.getByRole('button', { name: 'Play', exact: true }).click() + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toBeVisible() + await page.keyboard.press('Control+.') + // Renderer keydown handler sends transport:stop — play button should reappear + await expect(page.getByRole('button', { name: 'Play', exact: true })).toBeVisible({ timeout: 5000 }) + }) + +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 521e8a5..e21a4a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: specifier: ^1.0.8 version: 1.0.8 devDependencies: + '@axe-core/playwright': + specifier: ^4.11.1 + version: 4.11.1(playwright-core@1.59.1) '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -470,6 +473,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.11.1': + resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3626,6 +3634,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.11.1(playwright-core@1.59.1)': + dependencies: + axe-core: 4.11.1 + playwright-core: 1.59.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 From 2f9551f2332e2b0a115839c27dc0762668d2bea8 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 20:44:36 -0500 Subject: [PATCH 07/12] ci: add Playwright e2e step with Xvfb for Electron on Ubuntu Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8678cf6..87f94b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,9 @@ jobs: - name: Test with coverage run: pnpm test:coverage + + - name: Install Playwright browsers + run: pnpm --filter @score/gui exec playwright install --with-deps chromium + + - name: E2e tests (Electron via Xvfb) + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" pnpm --filter @score/gui test:e2e From c4ab3df79375e838d3cd8039153bf5bf519e52a6 Mon Sep 17 00:00:00 2001 From: bwyard Date: Sun, 5 Apr 2026 21:22:53 -0500 Subject: [PATCH 08/12] fix: beat highlighting, SubSynth type, .tone() chain method, bug report wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodeHighlight: fix TRACK_LINE_RE to include all instruments (Cowbell808, HihatOpen808, Clap909, KickHardstyle/Hardcore, SuperSaw, WobbleBass, etc.) HihatOpen808 had lowercase 'open' causing track-to-line mapping cascade bug - DSL: add .tone(hz) chain method (20-20000 Hz, maps to Snare bandpass filter) - DSL: fix SubSynth instrumentType 'sub-synth' → 'subsynth' to match registry - Engine: map _tone → props.tone in partToInstrumentDescriptor - LiveCode: wire getCurrentCode + bugEngineState into TransportBar for reports - Bug report: remove shell.openPath (no longer opens Explorer on save) - Main: hard-cut audio on panic stop (kills reverb tails), restore 0.72 on play - Main: belt-and-suspenders SCORE_TEST mute in transport:play handler - ReferencePanel: remove .tone() from Snare docs (was not previously implemented) - CodeHighlight tests: 68 new instrument recognition tests (Track + const styles) Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/engine.ts | 2 + packages/dsl/src/chain.ts | 7 ++- packages/dsl/src/chain.types.ts | 7 +++ packages/dsl/src/melodic.ts | 2 +- packages/dsl/src/validators.ts | 4 ++ packages/gui/src/main/index.ts | 13 ++++-- .../renderer/components/LiveCode/index.tsx | 10 ++++- .../components/shared/CodeHighlight.tsx | 2 +- .../components/shared/ReferencePanel.tsx | 2 +- packages/gui/tests/CodeHighlight.test.tsx | 44 +++++++++++++++++++ 10 files changed, 84 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index cd43dad..8e7b103 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -274,6 +274,8 @@ export const partToInstrumentDescriptor = ( ...(part._every !== undefined ? { every: { n: part._every.n, transform: part._every.fn } } : {}), ...(part._stretch !== undefined ? { stretch: part._stretch } : {}), ...(part._stutter !== undefined ? { stutter: part._stutter } : {}), + // Map _tone chain field → props.tone (Snare, Snare909 bandpass frequency) + ...(part._tone !== undefined ? { tone: part._tone } : {}), ...part.props, }, } diff --git a/packages/dsl/src/chain.ts b/packages/dsl/src/chain.ts index 3d31db9..8197f92 100644 --- a/packages/dsl/src/chain.ts +++ b/packages/dsl/src/chain.ts @@ -29,7 +29,7 @@ import { validateBarNumber, validateFadeBars, validateNote, validateNotes, validateScale, validatePitch, validateOctave, validateGlide, - validateDur, validateVolume, validateAdsrTime, + validateDur, validateVolume, validateAdsrTime, validateTone, validateSustain, validatePan, validateWiden, validateFilter, validateEq, validateBit, validateSaturate, validateReverbWet, validateDelay, @@ -244,6 +244,11 @@ export const createPart = ( dur: (time) => cp({ ...desc, _dur: validateDur(time) }), mapNotes: (fn) => cp({ ...desc, _mapNotesFn: fn }), + // ── Timbre ──────────────────────────────────────────────────────────────── + // tone: set the bandpass filter centre frequency (Hz) on instruments that + // support it (Snare, Snare909). Silently ignored on other instruments. + tone: (hz) => cp({ ...desc, _tone: validateTone(hz) }), + // ── Amplitude ───────────────────────────────────────────────────────────── volume: (v) => cp({ ...desc, _volume: validateVolume(v) }), attack: (s) => cp({ ...desc, _adsr: { ...desc._adsr, attack: validateAdsrTime(s, 'attack') } }), diff --git a/packages/dsl/src/chain.types.ts b/packages/dsl/src/chain.types.ts index dd52291..d705f52 100644 --- a/packages/dsl/src/chain.types.ts +++ b/packages/dsl/src/chain.types.ts @@ -100,6 +100,9 @@ export type PartDescriptor = { readonly release?: number } readonly _sidechain?: SidechainDescriptor + // ── Timbre ───────────────────────────────────────────────────────────────── + /** Bandpass filter centre frequency in Hz (Hz). Used by Snare, Snare909. */ + readonly _tone?: number // ── Tone ─────────────────────────────────────────────────────────────────── readonly _filter?: { readonly frequency: number; readonly Q?: number } readonly _eq?: { readonly lo: number; readonly mid: number; readonly hi: number } @@ -238,6 +241,10 @@ export type ChainMethods = { readonly dur: (time: number) => T /** Custom note sequence transform: `(notes, ctx) => notes`. */ readonly mapNotes: (fn: (notes: (string | number)[], ctx: PatternCtx) => (string | number)[]) => T + // ── Timbre ─────────────────────────────────────────────────────────────── + /** Bandpass filter centre frequency in Hz (20–20000). Applies to Snare, Snare909. */ + readonly tone: (hz: number) => T + // ── Amplitude ──────────────────────────────────────────────────────────── /** Output gain 0–2. */ readonly volume: (v: number) => T diff --git a/packages/dsl/src/melodic.ts b/packages/dsl/src/melodic.ts index b6ffee4..4b8ae03 100644 --- a/packages/dsl/src/melodic.ts +++ b/packages/dsl/src/melodic.ts @@ -76,7 +76,7 @@ const makeSubSynth = (base: ChainablePart): SubSynthPart => ({ export const SubSynth = (pitch?: string): SubSynthPart => makeSubSynth( createPart({ - instrumentType: 'sub-synth', + instrumentType: 'subsynth', props: {}, ...( pitch !== undefined ? { _notes: [pitch] } : {}), }, makeSubSynth), diff --git a/packages/dsl/src/validators.ts b/packages/dsl/src/validators.ts index 6051387..432ea4f 100644 --- a/packages/dsl/src/validators.ts +++ b/packages/dsl/src/validators.ts @@ -236,6 +236,10 @@ export const validateDur = (time: unknown): number => export const validateVolume = (v: unknown): number => parse(schemaVolume, v, 'volume', 'Use a number 0–2 e.g. .volume(0.8). Values above 1 amplify.') +/** `.tone(hz)` — bandpass filter frequency in Hz (20–20000). */ +export const validateTone = (hz: unknown): number => + parse(schemaFrequency, hz, 'tone', 'Use a frequency in Hz e.g. .tone(4000). Range 20–20000.') + /** `.attack(s)` / `.decay(s)` / `.release(s)` — seconds >= 0. */ export const validateAdsrTime = (s: unknown, method: string): number => parse(schemaTimingSeconds, s, method, `Use a non-negative number in seconds e.g. .${method}(0.01).`) diff --git a/packages/gui/src/main/index.ts b/packages/gui/src/main/index.ts index c20637c..55c4a87 100644 --- a/packages/gui/src/main/index.ts +++ b/packages/gui/src/main/index.ts @@ -214,6 +214,9 @@ const panicStop = (): void => { stopAnalysis() pendingRef.value = null try { slot.engine.stop() } catch { /* ignore */ } + // Hard-cut all audio immediately — kills reverb/delay tails on panic stop. + // masterVolume is restored to 0.72 on next transport:play. + try { slot.engine.patch({ masterVolume: 0 }) } catch { /* ignore */ } slot.playing = false slot.bars = 0 pushState() @@ -417,9 +420,13 @@ ipcMain.on('mode:selected', (_event, payload: RendererToMain['mode:selected']) = ipcMain.on('transport:play', () => { const slot = slotRef.value if (!slot || slot.playing) return + // Restore master volume (may have been zeroed by panicStop to kill reverb tails) + if (process.env['SCORE_TEST'] === '1') { + slot.engine.patch({ masterVolume: 0 }) + } else { + slot.engine.patch({ masterVolume: 0.72 }) + } slot.engine.start() - // Belt-and-suspenders: re-apply mute after start() in case any initialization gap - if (process.env['SCORE_TEST'] === '1') slot.engine.patch({ masterVolume: 0 }) slot.playing = true pushState() startAnalysis() @@ -595,8 +602,6 @@ ipcMain.on('bug:report', (_event, payload: RendererToMain['bug:report']) => { // Timestamped archive in Downloads for the user writeFileSync(path.join(app.getPath('downloads'), fileName), json, 'utf8') - - void shell.openPath(app.getPath('downloads')) } catch (err) { send('error:report', { message: `Bug report save failed: ${err instanceof Error ? err.message : String(err)}` }) } diff --git a/packages/gui/src/renderer/components/LiveCode/index.tsx b/packages/gui/src/renderer/components/LiveCode/index.tsx index ea916b6..0c0366a 100644 --- a/packages/gui/src/renderer/components/LiveCode/index.tsx +++ b/packages/gui/src/renderer/components/LiveCode/index.tsx @@ -603,7 +603,15 @@ export const LiveCode = ({ hardware, onHome }: Props) => { return (
- + codeRef.current} + bugEngineState={{ playing: engineState.playing, bpm: engineState.bpm, bars: engineState.bars }} + /> {/* Status bar */}
diff --git a/packages/gui/src/renderer/components/shared/CodeHighlight.tsx b/packages/gui/src/renderer/components/shared/CodeHighlight.tsx index 373fc25..d178ea0 100644 --- a/packages/gui/src/renderer/components/shared/CodeHighlight.tsx +++ b/packages/gui/src/renderer/components/shared/CodeHighlight.tsx @@ -55,7 +55,7 @@ const TRACK_COLOR_DEFAULT = '#404040' * - Legacy: lines containing `Track(` * - Const: lines containing `= Kick(` / `= Kick808(` / `= Snare(` etc. */ -const TRACK_LINE_RE = /(?:Track\(|=\s*(?:Kick(?:808|909)?|Snare(?:909)?|HiHat(?:808)?|Hihat(?:808)?|Bass303|Pad|Rhodes|Pluck|Synth|Sample|Theremin|Sax|Arp|SubSynth|FMSynth)\s*\()/ +const TRACK_LINE_RE = /(?:Track\(|=\s*(?:Kick(?:808|909|Hardstyle|Hardcore)?|Snare(?:909)?|HiHat(?:808)?|Hihat(?:Open808|808)?|Clap(?:909)?|Cowbell(?:808)?|Bass303|Pad|Rhodes|Wurlitzer|Hammond|Clavinet|Pluck|Stab|Synth|Sample|Theremin|Sax|Arp|SubSynth|FMSynth|SuperSaw|WobbleBass|KarplusSynth|Guitar|DX7Lead|WavetableSynth)\s*\()/ /** * Returns `{ lineIndex, trackIndex }` pairs for each instrument line found in diff --git a/packages/gui/src/renderer/components/shared/ReferencePanel.tsx b/packages/gui/src/renderer/components/shared/ReferencePanel.tsx index 8ca168b..2a7369a 100644 --- a/packages/gui/src/renderer/components/shared/ReferencePanel.tsx +++ b/packages/gui/src/renderer/components/shared/ReferencePanel.tsx @@ -21,7 +21,7 @@ const DRUMS: Section = { { name: 'Kick909()', desc: '909 click kick — .volume() .decay()' }, { name: 'KickHardstyle()', desc: 'reverse-bass + tanh distortion' }, { name: 'KickHardcore()', desc: 'hard-clip gabber, 160–200 BPM' }, - { name: 'Snare()', desc: '.volume() .decay() .tone()' }, + { name: 'Snare()', desc: '.volume() .decay()' }, { name: 'Snare909()', desc: '909 tone+noise snare' }, { name: 'Clap909()', desc: '4-layer staggered noise burst' }, { name: 'Cowbell808()', desc: 'TR-808 cowbell — metallic bell, two square oscs' }, diff --git a/packages/gui/tests/CodeHighlight.test.tsx b/packages/gui/tests/CodeHighlight.test.tsx index 588818f..a8a8255 100644 --- a/packages/gui/tests/CodeHighlight.test.tsx +++ b/packages/gui/tests/CodeHighlight.test.tsx @@ -383,4 +383,48 @@ describe('getBlockBounds (t330)', () => { // kick is line 3 — must NOT extend to line 4 (snare's const line) expect(kickBounds?.endLine).toBe(3) }) + +}) + +// ── TRACK_LINE_RE coverage — every instrument must match ───────────────────── +// Ensures that adding new instruments to @score/dsl does not silently break +// beat highlighting. Each instrument factory name must appear in TRACK_LINE_RE. + +describe('getTrackLines — instrument recognition (TRACK_LINE_RE coverage)', () => { + + const makeCode = (factory: string) => + `import { Song, Track, ${factory} } from '@score/dsl'\n\nexport default Song({ bpm: 128, tracks: [Track(${factory}(4))] })` + + const makeConstCode = (factory: string) => + `const x = ${factory}(4).volume(0.8)\nexport default Song({ bpm: 128, tracks: [Track(x)] })` + + const instruments = [ + // percussion + 'Kick', 'Kick808', 'Kick909', 'KickHardstyle', 'KickHardcore', + 'Snare', 'Snare909', + 'HiHat', 'Hihat808', 'HihatOpen808', 'Clap909', 'Cowbell808', + // bass / melodic + 'Bass303', 'SubSynth', 'FMSynth', 'SuperSaw', 'WobbleBass', + 'Pad', 'Pluck', 'Stab', 'Rhodes', 'Wurlitzer', 'Hammond', 'Clavinet', + 'DX7Lead', 'WavetableSynth', 'KarplusSynth', 'Guitar', + // other + 'Synth', 'Sample', 'Theremin', 'Sax', 'Arp', + ] + + instruments.forEach(name => { + it(`recognises ${name} in Track() style`, () => { + const code = makeCode(name) + const tracks = [{ type: name.toLowerCase(), pattern: [1, 0] }] + const lines = getTrackLines(code, tracks) + expect(lines.length).toBe(1) + }) + + it(`recognises ${name} in const-assignment style`, () => { + const code = makeConstCode(name) + const tracks = [{ type: name.toLowerCase(), pattern: [1, 0] }] + const lines = getTrackLines(code, tracks) + expect(lines.length).toBe(1) + }) + }) + }) From b4fff26e171e55b14fc1343f5439042d80aa1025 Mon Sep 17 00:00:00 2001 From: bwyard Date: Wed, 8 Apr 2026 22:14:16 -0500 Subject: [PATCH 09/12] fix(instruments): remove double-gain in createGenericSynth, fix chantVoice clip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit outputGain was initialized with `props.gain` AND the VCA envelope was also peaking at `props.gain` — resulting in `props.gain²` output at peak. Changed VCA peak to 1.0 so outputGain is the sole level control (0–1 range as documented). chantVoice in example-techno.js had gain: 4 (square wave, 4× = hard clip). Changed to 0.45. This was the source of the 50-bar static/noise artifact reported as t457. Co-Authored-By: Claude Sonnet 4.6 --- packages/instruments/src/synths/trigger.ts | 5 +++-- songs/example-techno.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/instruments/src/synths/trigger.ts b/packages/instruments/src/synths/trigger.ts index 9243b62..be24348 100644 --- a/packages/instruments/src/synths/trigger.ts +++ b/packages/instruments/src/synths/trigger.ts @@ -92,6 +92,8 @@ export const createGenericSynth = ( context: ScoreAudioContext, props: GenericSynthProps = {}, ): GenericSynthComponent => { + // outputGain holds the static output level — the VCA envelope peaks at 1.0 + // so gain controls the instrument level without double-applying. const outputGain = context.createGain({ gain: props.gain ?? 0.25 }) const triggerNote = (freq: number, time: number): void => { @@ -100,7 +102,6 @@ export const createGenericSynth = ( const decay = env.decay ?? 0.08 const sustain = env.sustain ?? 0.7 const release = env.release ?? 0.05 - const peak = props.gain ?? 0.25 const noteDur = attack + decay + release + 0.02 const osc = context.createOscillator({ type: props.wave ?? 'sawtooth', frequency: freq }) @@ -128,7 +129,7 @@ export const createGenericSynth = ( } vca.connect(outputGain) - vca.scheduleEnvelope({ peak, attack, decay, sustain, release, startTime: time, duration: noteDur }) + vca.scheduleEnvelope({ peak: 1.0, attack, decay, sustain, release, startTime: time, duration: noteDur }) osc.start(time) osc.stop(time + noteDur) } diff --git a/songs/example-techno.js b/songs/example-techno.js index 20f30c7..0ea7c58 100644 --- a/songs/example-techno.js +++ b/songs/example-techno.js @@ -72,7 +72,7 @@ const harpsichord = Synth({ const chantVoice = Synth({ wave: 'square', // pure tone — closest to unadorned vocal resonance - gain: 4, + gain: 0.45, envelope: { attack: 1.0, // slow bloom — voices don't punch, they swell From 70d4c2b5c0da9e79371f4e2946bc57240acabff6 Mon Sep 17 00:00:00 2001 From: bwyard Date: Wed, 8 Apr 2026 23:06:08 -0500 Subject: [PATCH 10/12] =?UTF-8?q?feat(instruments):=20production-quality?= =?UTF-8?q?=20generic=20hihat=20=E2=80=94=20parallel=20bandpass=20bank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces single HPF with 3-band parallel bandpass filter bank (3.5/6/10 kHz) + 4 kHz HPF. Models the complex overtone structure of real cymbal metal. Per-hit ±2% random frequency detune on each band prevents the machine-gun effect on rapid hi-hat patterns. Adds explicit `decay` prop alongside `open` for finer control. Syncs triggerHiHat in engine.ts (offline renderer) to the same signal path. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/engine.ts | 43 ++++++++-- packages/instruments/src/drums/cymbal.ts | 101 +++++++++++++++++++---- 2 files changed, 117 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index 8e7b103..99a667a 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -406,22 +406,47 @@ export const triggerSnare = (ctx: Context, time: number, props: SnareProps, dest } } +// Bandpass bands matching createGenericHihat in @score/instruments. +// Three resonant peaks + ±2% per-hit detune for machine-gun prevention. +const HIHAT_BANDS = [ + { frequency: 3500, Q: 1.5 }, + { frequency: 6000, Q: 2.0 }, + { frequency: 10000, Q: 1.5 }, +] as const +const HIHAT_HPF = 4000 +const HIHAT_DETUNE = 0.02 + export const triggerHiHat = (ctx: Context, time: number, props: HiHatProps, dest: GainNode): void => { const gain = props.volume ?? 0.25 - const dur = props.open ? 0.3 : 0.06 - const noise = ctx.createNoise({ type: 'white' }) - const filter = ctx.createFilter({ type: 'highpass', frequency: 8000 }) - const vol = ctx.createGain({ gain: 0 }) - noise.connect(filter) - filter.connect(vol) + const dur = props.open ? 0.3 : 0.06 + + const noise = ctx.createNoise({ type: 'white' }) + const sumGain = ctx.createGain({ gain: 1 / HIHAT_BANDS.length }) + + const bands = HIHAT_BANDS.map(({ frequency, Q }) => { + const detunedFreq = frequency * (1 + (Math.random() * HIHAT_DETUNE * 2 - HIHAT_DETUNE)) + const bp = ctx.createFilter({ type: 'bandpass', frequency: detunedFreq, Q }) + noise.connect(bp) + bp.connect(sumGain) + return bp + }) + + const hpf = ctx.createFilter({ type: 'highpass', frequency: HIHAT_HPF }) + const vol = ctx.createGain({ gain: 0 }) + + sumGain.connect(hpf) + hpf.connect(vol) vol.connect(dest) + vol.scheduleEnvelope({ peak: gain, attack: 0.001, decay: dur - 0.001, sustain: 0, release: 0, startTime: time, duration: dur }) noise.start(time) noise.stop(time + dur) noise.onended = () => { - try { noise.disconnect() } catch { /* ok */ } - try { filter.disconnect() } catch { /* ok */ } - try { vol.disconnect() } catch { /* ok */ } + try { noise.disconnect() } catch { /* ok */ } + for (const bp of bands) { try { bp.disconnect() } catch { /* ok */ } } + try { sumGain.disconnect() } catch { /* ok */ } + try { hpf.disconnect() } catch { /* ok */ } + try { vol.disconnect() } catch { /* ok */ } } } diff --git a/packages/instruments/src/drums/cymbal.ts b/packages/instruments/src/drums/cymbal.ts index 90a34c5..fd56f28 100644 --- a/packages/instruments/src/drums/cymbal.ts +++ b/packages/instruments/src/drums/cymbal.ts @@ -1,5 +1,12 @@ // @score/instruments — drums/cymbal.ts -// Generic hi-hat factory — highpass-filtered white noise with short amplitude decay. +// Generic hi-hat factory — parallel bandpass filter bank + HPF over white noise. +// +// Signal path: +// white noise → 3× bandpass (3.5 kHz / 6 kHz / 10 kHz, ±2% detune per hit) +// → summing gain → HPF (4 kHz) → amp envelope → outputGain +// +// Three resonant peaks model the complex overtone structure of real cymbal metal. +// Per-hit frequency detune prevents the machine-gun effect on rapid patterns. // // Variants in INSTRUMENT_REGISTRY: // 'hihat' → createGenericHihat (this file) @@ -11,12 +18,42 @@ import type { ScoreAudioContext, ScoreAudioNode } from '@score/core' import { uid } from '@score/core' import type { PercussionComponent } from '@score/components' +// ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Three resonant bands that model hi-hat cymbal character. + * + * - 3500 Hz: body — the cymbal "crack" on attack + * - 6000 Hz: presence — mid sizzle and wire rattle + * - 10000 Hz: air — top-end shimmer and wash + * + * Each band is randomly detuned ±2% on every trigger to prevent the + * machine-gun effect when the same hit fires rapidly in sequence. + */ +const BANDPASS_BANDS = [ + { frequency: 3500, Q: 1.5 }, + { frequency: 6000, Q: 2.0 }, + { frequency: 10000, Q: 1.5 }, +] as const + +/** HPF cutoff removes low-end mud that bleeds through the bandpass bank. */ +const HPF_FREQUENCY = 4000 + +/** Maximum random detune per hit — ±2% of each band's centre frequency. */ +const DETUNE_RANGE = 0.02 + // ── Props ───────────────────────────────────────────────────────────────────── /** Configuration for {@link createGenericHihat}. */ export type GenericHihatProps = { - /** When `true`, uses longer open hi-hat decay. Default `false`. */ + /** When `true`, uses longer open hi-hat decay. Overridden by an explicit `decay`. Default `false`. */ readonly open?: boolean + /** + * Amplitude decay time in seconds. + * Typical range: closed `0.04–0.08`, open `0.2–0.5`. + * Defaults to `0.06` (closed) or `0.3` (open). + */ + readonly decay?: number /** Output gain 0–1. Default `0.25`. */ readonly gain?: number } @@ -26,11 +63,18 @@ export type GenericHihatProps = { /** * Create a generic synthesized hi-hat component. * - * Signal path: white noise → highpass filter (8 kHz) → amp envelope → output. - * Each `trigger()` spawns ephemeral nodes. Open hi-hat decay = `0.3 s`; closed = `0.06 s`. + * Signal path: + * ``` + * white noise → 3× bandpass (3.5 / 6 / 10 kHz, ±2% detune per hit) + * → summing gain → HPF (4 kHz) → amp envelope → outputGain + * ``` + * + * Three resonant peaks model the complex overtone structure of real cymbal metal. + * Per-hit frequency detune (±2%) prevents the machine-gun effect on rapid patterns. + * Open hi-hat uses a longer default decay (`0.3 s`); closed defaults to `0.06 s`. * * Use {@link createHihat808} from `@score/components` for the TR-808 model - * (6 detuned square oscillators). + * (6 detuned square oscillators matched to Roland hardware ratios). * * @param context - Backend audio context. * @param props - Optional hi-hat configuration. @@ -38,7 +82,8 @@ export type GenericHihatProps = { * * @example * ```ts - * const hat = createGenericHihat(context, { open: true, gain: 0.3 }) + * const hat = createGenericHihat(context, { gain: 0.3 }) + * const openHat = createGenericHihat(context, { open: true, gain: 0.25 }) * hat.connect(context.destination) * hat.trigger(context.currentTime) * ``` @@ -51,17 +96,32 @@ export const createGenericHihat = ( const trigger = (time?: number): void => { const t = time ?? context.currentTime - const gain = props.gain ?? 0.25 - const dur = props.open ? 0.3 : 0.06 - - const noise = context.createNoise({ type: 'white' }) - const filter = context.createFilter({ type: 'highpass', frequency: 8000 }) - const vol = context.createGain({ gain: 0 }) - noise.connect(filter) - filter.connect(vol) + const gain = props.gain ?? 0.25 + const dur = props.decay ?? (props.open ? 0.3 : 0.06) + + // Noise source — all bandpass bands share one noise node + const noise = context.createNoise({ type: 'white' }) + + // Summing bus — normalises level across the parallel bank + const sumGain = context.createGain({ gain: 1 / BANDPASS_BANDS.length }) + + // Parallel bandpass bank — each band detuned ±2% per hit + const bands = BANDPASS_BANDS.map(({ frequency, Q }) => { + const detunedFreq = frequency * (1 + (Math.random() * DETUNE_RANGE * 2 - DETUNE_RANGE)) + const bp = context.createFilter({ type: 'bandpass', frequency: detunedFreq, Q }) + noise.connect(bp) + bp.connect(sumGain) + return bp + }) + + // HPF strips residual low-end mud after the bandpass bank + const hpf = context.createFilter({ type: 'highpass', frequency: HPF_FREQUENCY }) + const vol = context.createGain({ gain: 0 }) + + sumGain.connect(hpf) + hpf.connect(vol) vol.connect(outputGain) - // 1 ms attack preserves crisp transient; decay by open/closed mode vol.scheduleEnvelope({ peak: gain, attack: 0.001, @@ -71,12 +131,17 @@ export const createGenericHihat = ( startTime: t, duration: dur, }) + noise.start(t) noise.stop(t + dur) + + // Clean up all nodes once noise stops — noise.onended fires after stop() noise.onended = () => { - try { noise.disconnect() } catch { /* ok */ } - try { filter.disconnect() } catch { /* ok */ } - try { vol.disconnect() } catch { /* ok */ } + try { noise.disconnect() } catch { /* ok */ } + for (const bp of bands) { try { bp.disconnect() } catch { /* ok */ } } + try { sumGain.disconnect() } catch { /* ok */ } + try { hpf.disconnect() } catch { /* ok */ } + try { vol.disconnect() } catch { /* ok */ } } } From f228cded9e88bd2bf2e24961d431e201e809fc9c Mon Sep 17 00:00:00 2001 From: bwyard Date: Thu, 9 Apr 2026 00:01:02 -0500 Subject: [PATCH 11/12] fix(engine): guard division by zero in rotatePattern and arp dispatchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs where % 0 would crash at runtime: 1. rotatePattern — empty pattern array caused % pattern.length = NaN. Guard: return early if pattern.length === 0. 2. dispatchArp (engine.ts) — empty notes array caused % notes.length crash. Guard: log error and return before sequencer setup. 3. createArp / advanceNoteIndex (sequenced.ts) — same issue in the instruments layer. Guard: skip step and advance if notes.length === 0. Also updates engine-pop-prevention test to reflect the new hihat signal chain (sumGain routing node + vol VCA — only VCA is required to init at 0). Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/engine.ts | 6 ++++++ packages/cli/tests/engine-pop-prevention.test.ts | 13 ++++++++----- packages/instruments/src/melodic/sequenced.ts | 3 ++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/engine.ts b/packages/cli/src/engine.ts index 99a667a..f3595cb 100644 --- a/packages/cli/src/engine.ts +++ b/packages/cli/src/engine.ts @@ -153,6 +153,7 @@ const applyPitchTransforms = ( // function patterns are passed through as-is and evaluated per-tick in the sequencer. const rotatePattern = (pattern: number[], phase: number): number[] => { + if (pattern.length === 0) return pattern const offset = ((Math.round(phase * pattern.length) % pattern.length) + pattern.length) % pattern.length return offset === 0 ? pattern : [...pattern.slice(offset), ...pattern.slice(0, offset)] } @@ -769,6 +770,11 @@ const dispatchArp = ( const mode = props.mode ?? 'up' const rate = props.rate ?? 1 + if (notes.length === 0) { + console.error('[score-engine] arp: notes array is empty — track skipped') + return + } + // HARDWARE BOUNDARY: mutable step counter — sequential arpeggio state across callbacks const arpState = { noteIndex: 0, pingDir: 1 } diff --git a/packages/cli/tests/engine-pop-prevention.test.ts b/packages/cli/tests/engine-pop-prevention.test.ts index 636e10f..99478f6 100644 --- a/packages/cli/tests/engine-pop-prevention.test.ts +++ b/packages/cli/tests/engine-pop-prevention.test.ts @@ -63,18 +63,21 @@ describe('trigger functions — zero-gain onset invariant', () => { }) }) - it('triggerHiHat — internal GainNode initialises at gain 0 (regression: was full amplitude)', () => { + it('triggerHiHat — VCA initialises at gain 0, sumGain is a routing node (regression: was full amplitude)', () => { withGainSpy((ctx, dest, gains) => { triggerHiHat(ctx, 0, { volume: 0.4 } satisfies HiHatProps, dest) - expect(gains().every(g => g === 0)).toBe(true) - expect(gains().length).toBe(1) + const all = gains() + // Signal chain: sumGain (routing, non-zero) + vol (VCA, must be 0) + expect(all.length).toBe(2) + expect(all[all.length - 1]).toBe(0) }) }) - it('triggerHiHat open — open hi-hat also initialises at gain 0', () => { + it('triggerHiHat open — VCA also initialises at gain 0', () => { withGainSpy((ctx, dest, gains) => { triggerHiHat(ctx, 0, { volume: 0.4, open: true } satisfies HiHatProps, dest) - expect(gains().every(g => g === 0)).toBe(true) + const all = gains() + expect(all[all.length - 1]).toBe(0) }) }) diff --git a/packages/instruments/src/melodic/sequenced.ts b/packages/instruments/src/melodic/sequenced.ts index 6fd17dd..f339d6c 100644 --- a/packages/instruments/src/melodic/sequenced.ts +++ b/packages/instruments/src/melodic/sequenced.ts @@ -118,6 +118,7 @@ export const createArp = ( } const advanceNoteIndex = (): void => { + if (notes.length === 0) return if (mode === 'up') { arpState.noteIndex += 1 } else if (mode === 'down') { @@ -140,7 +141,7 @@ export const createArp = ( type: 'arp' as const, step: (active: number, time: number) => { - if (active <= 0) return + if (active <= 0 || notes.length === 0) return const idx = Math.floor(arpState.noteIndex / rate) % notes.length // active is the pre-resolved frequency from the engine's resolveFreq // When active === 1 (generic trigger), fall back to the indexed note's From bf2d304a048134f72cbc8651e554bae1e309dd13 Mon Sep 17 00:00:00 2001 From: bwyard Date: Thu, 9 Apr 2026 03:37:48 -0500 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20overhaul=20README=20=E2=80=94=20b?= =?UTF-8?q?adges,=20architecture=20section,=20clearer=20positioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CI/license/node/TypeScript badges. Rewrites opening to lead with confidence. Adds architecture section explaining functional patterns. Expands package table with instruments and musical packages. Adds placeholder comments for screenshot and demo link. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 118 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 00da30c..1ba183b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,29 @@ # Score -> A production-level, component-based EDM audio framework for creating music as code in TypeScript/JavaScript. +[![CI](https://github.com/bwyard/score/actions/workflows/ci.yml/badge.svg)](https://github.com/bwyard/score/actions/workflows/ci.yml) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) +[![Node ≥20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org) -**Author:** Bree Yard -**Status:** Active development — Phase 13 (Score Studio GUI). Tester release in progress. +> A production-grade, component-based EDM audio framework. Write music as code in TypeScript — no samples, no MIDI files, no drag-and-drop. Every note, pattern, and arrangement is a pure function of time. + +**Author:** Bree Yard — [breeyard.dev](https://breeyard.dev) +**Status:** Phase 13 of 17 — stable core, Score Studio GUI in active development + + + --- ## What is Score? -Score is two things simultaneously: +Score is a professional music production framework and a live coding environment built on the Web Audio API. + +**As a framework** — 15 TypeScript packages covering synthesis, effects, sequencing, mixing, modulation, MIDI, and an Electron GUI. Built with a strict functional architecture: no classes, no mutation, every component is a factory function. -1. **A framework** (TypeScript) — the engine, instruments, effects, sequencer, mixer, CLI, and GUI -2. **A language** (plain ESM) — the song file format that musicians and developers use to write music as code +**As a language** — song files are plain ESM. Import instruments, chain methods, export a `Song`. The engine evaluates it and plays it. Live reload while you write. -The core philosophy: **a song is a pure function of time.** Every note, pattern, effect value, and arrangement decision is written by hand. No AI generates any musical content — ever. +The core thesis: **a song is a pure function of time.** No AI generates musical content — ever. --- @@ -23,15 +32,10 @@ The core philosophy: **a song is a pure function of time.** Every note, pattern, ```js import { Song, Kick808, Snare909, Hihat808, Bass303, Pad } from '@score/dsl' -// Drums — euclidean shorthand: Kick808(n) spreads n hits over 16 steps const kick = Kick808(4).volume(0.8) const snare = Snare909(2).volume(0.55) const hihat = Hihat808(8).volume(0.25) -// Or explicit step indices -// const kick = Kick808().hits(0, 4, 8, 12).volume(0.8) - -// Melodic — note array pattern: strings are pitches, 0 is a rest const bass = Bass303('A2') .cutoff(600) .resonance(0.4) @@ -47,22 +51,25 @@ const pad = Pad('A3') export default Song({ bpm: 128, tracks: [kick, snare, hihat, bass, pad] }) ``` -Chain methods are fully type-safe. `Bass303().cutoff(600).volume(0.8)` returns `Bass303Part` — sub-type methods are preserved through every composition step. +Chain methods are fully type-safe — `Bass303().cutoff(600).volume(0.8)` returns `Bass303Part`, sub-type methods preserved through every composition step. --- -## Score Studio (GUI) +## Score Studio + +Score Studio is the Electron-based live coding environment that ships with Score. -Score Studio is the Electron-based GUI that ships with Score. Edit code on the left, hear and see changes in real time. + + -- **Live Code mode** — code editor wired to the engine. Ctrl+Enter evals. BPM, step toggles, and mixer changes write back to the code. -- **Performance mode** — full-screen audio visualizer. Theme declared in song file. -- **Punchcard grid** — click steps to toggle. Changes write back to the editor. -- **Mixer** — per-track volume/mute faders. Drag to update `.volume()` in code. -- **Import toggle** — hide/show the import block for a cleaner editing view. +- **Live Code editor** — edit code, hit Ctrl+Enter, hear changes immediately +- **Punchcard grid** — click steps to toggle hits; changes write back to the editor +- **Mixer** — per-track volume and mute faders; drag to update `.volume()` in code +- **Performance mode** — full-screen audio visualizer, theme declared in the song file +- **Bug reporting** — in-app report button captures logs, code, and engine state ```bash -# From the score repo root +# Start Score Studio pnpm --filter @score/gui dev ``` @@ -71,13 +78,13 @@ pnpm --filter @score/gui dev ## CLI ```bash -score play # Play a song file -score play --watch # Live reload on every save -score new song # Create a new song from template -score list # Show song info and track list -score export # Render to WAV -score repl # Interactive REPL -score doctor # Check system requirements +score play # Play a song file +score play --watch # Live reload on save +score new song # Create a new song from template +score list # Show song info and track list +score export # Render to WAV +score repl # Interactive REPL +score doctor # Check system requirements ``` --- @@ -86,28 +93,41 @@ score doctor # Check system requirements | Package | Purpose | |---|---| -| `@score/core` | AudioContext backend, ScoreError, UID | -| `@score/components` | Kick808/909, Snare909, Hihat808, FMSynth, SubtractiveSynth, Bass303, Pad, Rhodes, Pluck | +| `@score/core` | Audio context backend, ScoreError, UID | +| `@score/components` | Kick808/909, Snare909, Hihat808, FMSynth, SubtractiveSynth, Bass303, Pad, Rhodes, Pluck, WobbleBass | | `@score/effects` | Reverb, Delay, Filter, Compressor, EQ, Distortion, Chorus, Phaser, Flanger, Limiter, Gate, Saturation, AutoPan, BitCrusher, StereoWidener | -| `@score/dsl` | Song, Track, chain API (Bass303, Pad, Pluck, Rhodes, Kick808, etc.), `ChainMethods` | -| `@score/sequencer` | Transport, step sequencer, bar callbacks | -| `@score/mixer` | Mixer, channel strips, return bus, master chain | -| `@score/math` | Chaos math — Lorenz, logistic map, OUProcess (stochastic basis) | -| `@score/modulation` | LFO, ADSR, ramp/sine/OU, automation wiring | -| `@score/pattern` | Euclidean rhythms, combinators, reverse, shift, degrade | -| `@score/visuals` | Visual themes, rendering targets, per-song canvas | -| `@score/cli` | play, repl, list, export, new, doctor | -| `@score/midi` | WebMIDI bridge, Pioneer XDJ profiles | -| `@score/session` | Jam session, WebSocket sync | -| `@score/mcp` | MCP tools for Claude Code integration | -| `@score/gui` | Score Studio — Electron DAW interface | +| `@score/instruments` | Instrument registry — kick, snare, hihat, synth, arp, subsynth, pad, sample | +| `@score/dsl` | Song, chain API (Bass303, Pad, Kick808, etc.), arrangement blocks, modulation descriptors | +| `@score/sequencer` | Transport, clock, step sequencer | +| `@score/mixer` | Channel strips, return bus, master chain | +| `@score/modulation` | LFO, ADSR, automation, ramp, chaos sources | +| `@score/math` | Chaos math — Lorenz attractor, logistic map, Ornstein-Uhlenbeck process | +| `@score/pattern` | Euclidean rhythms, reverse, shift, degrade, swing | +| `@score/musical` | Music theory — scales, chord resolution, frequency mapping | +| `@score/visuals` | Visual themes, audio-reactive canvas rendering | +| `@score/midi` | WebMIDI bridge, Pioneer XDJ-RX3 profile | +| `@score/session` | Jam session, live patch updates | +| `@score/cli` | play, repl, list, export, new, doctor commands | +| `@score/mcp` | MCP server for Claude Code integration | +| `@score/gui` | Score Studio — Electron DAW | + +--- + +## Architecture + +Score is a strict functional TypeScript monorepo built with Turborepo and pnpm workspaces. + +- **Zero classes** — every component is a factory function returning a plain object +- **No mutation** — config objects are never mutated; state is threaded explicitly +- **Web Audio API** — all synthesis runs in the audio thread, scheduled against `audioContext.currentTime` +- **Hardware boundary pattern** — audio nodes are the only mutable state; everything above is pure +- **Test coverage** — 90/85/90/90 thresholds enforced in CI (statements/branches/functions/lines) --- ## Development ```bash -# Install dependencies pnpm install # Build all library packages (Turborepo, cached) @@ -119,7 +139,10 @@ pnpm test # Type-check everything pnpm typecheck -# Start Score Studio (Electron dev server) +# Lint +pnpm lint + +# Start Score Studio pnpm --filter @score/gui dev ``` @@ -129,12 +152,11 @@ pnpm --filter @score/gui dev ## Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md). - -Please read our [Code of Conduct](./CODE_OF_CONDUCT.md) before participating. To report a security issue, see [SECURITY.md](./SECURITY.md). +See [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md). +To report a security issue see [SECURITY.md](./SECURITY.md). --- ## License -Apache License 2.0. Use freely — personal, commercial, open source. See [LICENSE](./LICENSE). Author: Bree Yard. +Apache License 2.0 — use freely in personal, commercial, and open source projects. See [LICENSE](./LICENSE).