Add SECURITY.md, update CONTRIBUTING, issue templates, Lighthouse fixes

This commit is contained in:
Fabricio Giglio
2026-05-06 22:38:17 +02:00
parent 451054c559
commit 4bb718ae08
14 changed files with 1115 additions and 517 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
Got an idea? Check the [roadmap](https://github.com/gigliof/bach-to-basics#roadmap) first — some features are already planned. Got an idea? Check the [roadmap](https://github.com/gigliof/bach-to-basics#roadmap) first - some features are already planned.
- type: textarea - type: textarea
id: problem id: problem
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
frontend: frontend:
name: Frontend — type-check & test name: Frontend - type-check & test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1 -1
View File
@@ -134,7 +134,7 @@ All backend settings come from environment variables. Copy `.env.example` to `.e
| -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated list of allowed CORS origins | | `ALLOWED_ORIGINS` | `http://localhost:5173` | Comma-separated list of allowed CORS origins |
| `BACKEND_API_KEY` | _(unset = open)_ | When set, all requests must include `X-API-Key: <value>` | | `BACKEND_API_KEY` | _(unset = open)_ | When set, all requests must include `X-API-Key: <value>` |
| `REQUIRE_AUTH` | _(unset)_ | Set to `1` to make the server refuse to start if `BACKEND_API_KEY` is not configured — useful for preventing accidental open deployments | | `REQUIRE_AUTH` | _(unset)_ | Set to `1` to make the server refuse to start if `BACKEND_API_KEY` is not configured - useful for preventing accidental open deployments |
| `RATE_LIMIT_PER_MIN` | `60` | Max requests per IP per minute. `0` disables it | | `RATE_LIMIT_PER_MIN` | `60` | Max requests per IP per minute. `0` disables it |
| `HEAVY_RATE_LIMIT_PER_MIN` | `10` | Stricter limit applied to expensive endpoints (`/omr/`, `/youtube/`) | | `HEAVY_RATE_LIMIT_PER_MIN` | `10` | Stricter limit applied to expensive endpoints (`/omr/`, `/youtube/`) |
| `TRUSTED_PROXY_IPS` | _(unset)_ | Comma-separated IPs of trusted reverse proxies. When set, the real client IP is read from `X-Forwarded-For` instead of the connection address | | `TRUSTED_PROXY_IPS` | _(unset)_ | Comma-separated IPs of trusted reverse proxies. When set, the real client IP is read from `X-Forwarded-For` instead of the connection address |
+2 -2
View File
@@ -2,7 +2,7 @@
## Scope ## Scope
Bach to Basics handles no user accounts, no personal data, and no financial information. It is a local music practice tool — the main attack surface is **file processing** (MIDI, MusicXML, PDF uploads) and the **FastAPI backend** when self-hosted. Bach to Basics handles no user accounts, no personal data, and no financial information. It is a local music practice tool - the main attack surface is **file processing** (MIDI, MusicXML, PDF uploads) and the **FastAPI backend** when self-hosted.
## What to report ## What to report
@@ -23,6 +23,6 @@ Use **GitHub's private vulnerability reporting**:
1. Go to the [Security tab](https://github.com/gigliof/bach-to-basics/security) of this repo 1. Go to the [Security tab](https://github.com/gigliof/bach-to-basics/security) of this repo
2. Click **"Report a vulnerability"** 2. Click **"Report a vulnerability"**
3. Fill in the details — steps to reproduce, impact, any suggested fix 3. Fill in the details - steps to reproduce, impact, any suggested fix
You'll get a response within a few days. There's no formal embargo policy given the nature of this project, but please give a reasonable heads-up before going public. You'll get a response within a few days. There's no formal embargo policy given the nature of this project, but please give a reasonable heads-up before going public.
+1
View File
@@ -8,6 +8,7 @@
tighten by moving it to a separate .js file if desired. --> tighten by moving it to a separate .js file if desired. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" />
<title>Bach to Basics</title> <title>Bach to Basics</title>
<meta name="description" content="Browser-based piano practice tool. Import MIDI, MusicXML, or PDF sheet music and learn pieces with falling notes, interactive piano, A/B loop, speed trainer, and wait mode." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-light.png" /> <link rel="icon" type="image/png" href="/favicon-light.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support", "description": "Bach to Basics - browser-based piano learning app with falling notes, sheet music, and MIDI support",
"author": "gigliof", "author": "gigliof",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/gigliof/bach-to-basics#readme", "homepage": "https://github.com/gigliof/bach-to-basics#readme",
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
@@ -1,7 +1,13 @@
import * as PIXI from "pixi.js"; import * as PIXI from "pixi.js";
import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared"; import { isBlackKey, midiToNoteName, midiToPitch } from "@bach-to-basics/shared";
import type { NoteEvent } from "@bach-to-basics/shared"; import type { NoteEvent } from "@bach-to-basics/shared";
import type { NoteFilter, ColorTheme, CustomColors, NoteLabelMode, ImpactStyle } from "../../store/useAppStore"; import type {
NoteFilter,
ColorTheme,
CustomColors,
NoteLabelMode,
ImpactStyle,
} from "../../store/useAppStore";
import type { SustainRange } from "@bach-to-basics/shared"; import type { SustainRange } from "@bach-to-basics/shared";
import { lighten, hexToNum } from "../../utils/colorUtils"; import { lighten, hexToNum } from "../../utils/colorUtils";
@@ -10,7 +16,9 @@ const MIDI_MIN = 21;
const MIDI_MAX = 108; const MIDI_MAX = 108;
const POOL_SIZE = 1200; const POOL_SIZE = 1200;
function isWhite(midi: number) { return !isBlackKey(midi); } function isWhite(midi: number) {
return !isBlackKey(midi);
}
const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0); const whiteKeyIndex: number[] = new Array(MIDI_MAX + 1).fill(0);
let wi = 0; let wi = 0;
@@ -33,24 +41,44 @@ interface ThemePalette {
const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = { const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
violet: { violet: {
left: 0x7c3aed, right: 0x8b5cf6, unknown: 0x8b5cf6, left: 0x7c3aed,
leftBlack: 0xa78bfa, rightBlack: 0xc4b5fd, unknownBlack: 0xc4b5fd, right: 0x8b5cf6,
unknown: 0x8b5cf6,
leftBlack: 0xa78bfa,
rightBlack: 0xc4b5fd,
unknownBlack: 0xc4b5fd,
}, },
classic: { classic: {
left: 0x2563eb, right: 0xdc2626, unknown: 0x7c3aed, left: 0x2563eb,
leftBlack: 0x60a5fa, rightBlack: 0xf87171, unknownBlack: 0xa78bfa, right: 0xdc2626,
unknown: 0x7c3aed,
leftBlack: 0x60a5fa,
rightBlack: 0xf87171,
unknownBlack: 0xa78bfa,
}, },
ocean: { ocean: {
left: 0x0284c7, right: 0x06b6d4, unknown: 0x0ea5e9, left: 0x0284c7,
leftBlack: 0x38bdf8, rightBlack: 0x67e8f9, unknownBlack: 0x7dd3fc, right: 0x06b6d4,
unknown: 0x0ea5e9,
leftBlack: 0x38bdf8,
rightBlack: 0x67e8f9,
unknownBlack: 0x7dd3fc,
}, },
forest: { forest: {
left: 0x15803d, right: 0x16a34a, unknown: 0x22c55e, left: 0x15803d,
leftBlack: 0x4ade80, rightBlack: 0x86efac, unknownBlack: 0xbbf7d0, right: 0x16a34a,
unknown: 0x22c55e,
leftBlack: 0x4ade80,
rightBlack: 0x86efac,
unknownBlack: 0xbbf7d0,
}, },
cascade: { cascade: {
left: 0x9333ea, right: 0x22d3ee, unknown: 0xa855f7, left: 0x9333ea,
leftBlack: 0xb06af5, rightBlack: 0x4dd5ec, unknownBlack: 0xb87af8, right: 0x22d3ee,
unknown: 0xa855f7,
leftBlack: 0xb06af5,
rightBlack: 0x4dd5ec,
unknownBlack: 0xb87af8,
}, },
}; };
@@ -73,9 +101,10 @@ function noteColor(
note: NoteEvent, note: NoteEvent,
showHandColors: boolean, showHandColors: boolean,
theme: ColorTheme, theme: ColorTheme,
customColors?: CustomColors, customColors?: CustomColors
): number { ): number {
const pal = theme === "custom" && customColors const pal =
theme === "custom" && customColors
? paletteFromCustom(customColors) ? paletteFromCustom(customColors)
: THEMES[theme as Exclude<ColorTheme, "custom">]; : THEMES[theme as Exclude<ColorTheme, "custom">];
const black = isBlackKey(note.midi); const black = isBlackKey(note.midi);
@@ -93,9 +122,12 @@ function noteColor(
* Unlike multiplicative lighten(), this NEVER blows light colours to pure white. * Unlike multiplicative lighten(), this NEVER blows light colours to pure white.
*/ */
function mixColor(c1: number, c2: number, t: number): number { function mixColor(c1: number, c2: number, t: number): number {
const r1 = (c1 >> 16) & 0xff, r2 = (c2 >> 16) & 0xff; const r1 = (c1 >> 16) & 0xff,
const g1 = (c1 >> 8) & 0xff, g2 = (c2 >> 8) & 0xff; r2 = (c2 >> 16) & 0xff;
const b1 = c1 & 0xff, b2 = c2 & 0xff; const g1 = (c1 >> 8) & 0xff,
g2 = (c2 >> 8) & 0xff;
const b1 = c1 & 0xff,
b2 = c2 & 0xff;
return ( return (
(Math.round(r1 + (r2 - r1) * t) << 16) | (Math.round(r1 + (r2 - r1) * t) << 16) |
(Math.round(g1 + (g2 - g1) * t) << 8) | (Math.round(g1 + (g2 - g1) * t) << 8) |
@@ -113,7 +145,7 @@ function makeGradient(base: number): PIXI.FillGradient {
{ offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white { offset: 0, color: mixColor(base, 0xffffff, 0.55) }, // bright top - always colored, never pure white
{ offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright { offset: 0.22, color: mixColor(base, 0xffffff, 0.22) }, // slightly bright
{ offset: 0.62, color: base }, // solid base { offset: 0.62, color: base }, // solid base
{ offset: 1, color: mixColor(base, 0x000000, 0.30) }, // deeper bottom { offset: 1, color: mixColor(base, 0x000000, 0.3) }, // deeper bottom
], ],
}); });
} }
@@ -155,7 +187,10 @@ class ImpactFlash {
/** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */ /** dtFrames: PIXI ticker.deltaTime (1.0 = one 60fps frame). Frame-rate independent. */
advance(dtFrames: number): boolean { advance(dtFrames: number): boolean {
this.t += dtFrames * 0.033; // ~500ms total at 60fps this.t += dtFrames * 0.033; // ~500ms total at 60fps
if (this.t >= 1) { this.reset(); return true; } if (this.t >= 1) {
this.reset();
return true;
}
this._draw(); this._draw();
return false; return false;
} }
@@ -172,12 +207,12 @@ class ImpactFlash {
this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 }); this.innerBloom.fill({ color: baseColor, alpha: (1 - t) * 0.28 });
// ── Outer bloom - large diffuse halo, 80ms delayed start ───────────────── // ── Outer bloom - large diffuse halo, 80ms delayed start ─────────────────
const t2 = Math.max(0, t - 0.10); const t2 = Math.max(0, t - 0.1);
const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2); const et2 = 1 - (1 - t2) * (1 - t2) * (1 - t2);
const r2 = noteW * 0.4 + et2 * noteW * 3.0; const r2 = noteW * 0.4 + et2 * noteW * 3.0;
this.outerBloom.clear(); this.outerBloom.clear();
this.outerBloom.circle(0, 0, r2); this.outerBloom.circle(0, 0, r2);
this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.10 }); this.outerBloom.fill({ color: baseColor, alpha: (1 - t2) * 0.1 });
// ── Sparkle - bright note-colored flash that exists only for the first ~100ms ── // ── Sparkle - bright note-colored flash that exists only for the first ~100ms ──
// Using baseColor (the note's own color) instead of white so it's visible in // Using baseColor (the note's own color) instead of white so it's visible in
@@ -188,7 +223,7 @@ class ImpactFlash {
const sr = noteW * 0.35 * (1 - et); const sr = noteW * 0.35 * (1 - et);
if (sr > 0.5) { if (sr > 0.5) {
this.sparkle.circle(0, 0, sr); this.sparkle.circle(0, 0, sr);
this.sparkle.fill({ color: baseColor, alpha: sa * 0.80 }); this.sparkle.fill({ color: baseColor, alpha: sa * 0.8 });
} }
} }
} }
@@ -234,13 +269,15 @@ class NoteBar {
configure( configure(
note: NoteEvent, note: NoteEvent,
x: number, barWidth: number, x: number,
startY: number, barHeight: number, barWidth: number,
startY: number,
barHeight: number,
gradient: PIXI.FillGradient, gradient: PIXI.FillGradient,
baseColor: number, baseColor: number,
labelText: string, labelText: string,
cornerRadius: number, cornerRadius: number,
showOutline: boolean, showOutline: boolean
) { ) {
this.note = note; this.note = note;
this.active = true; this.active = true;
@@ -290,16 +327,22 @@ class NoteBar {
/** Keep label clamped within the visible portion of the bar (for very tall notes). */ /** Keep label clamped within the visible portion of the bar (for very tall notes). */
updateLabelY(containerY: number, barHeight: number, viewportHeight: number) { updateLabelY(containerY: number, barHeight: number, viewportHeight: number) {
if (!this.labelWanted) { this.label.visible = false; return; } if (!this.labelWanted) {
this.label.visible = false;
return;
}
// Visible slice of the bar in local coords // Visible slice of the bar in local coords
const localTop = Math.max(0, -containerY); const localTop = Math.max(0, -containerY);
const localBottom = Math.min(barHeight, viewportHeight - containerY); const localBottom = Math.min(barHeight, viewportHeight - containerY);
if (localBottom <= localTop) { this.label.visible = false; return; } if (localBottom <= localTop) {
this.label.visible = false;
return;
}
this.label.visible = true; this.label.visible = true;
const midLocal = (localTop + localBottom) / 2; const midLocal = (localTop + localBottom) / 2;
// Only reposition if the natural center is outside the visible slice // Only reposition if the natural center is outside the visible slice
const natural = barHeight / 2; const natural = barHeight / 2;
this.label.y = (natural >= localTop && natural <= localBottom) ? natural : midLocal; this.label.y = natural >= localTop && natural <= localBottom ? natural : midLocal;
} }
reset() { reset() {
@@ -370,9 +413,15 @@ export class FallingNotesRenderer {
private measureLayer!: PIXI.Container; private measureLayer!: PIXI.Container;
private particleLayer!: PIXI.Graphics; private particleLayer!: PIXI.Graphics;
private particles: Array<{ private particles: Array<{
x: number; y: number; vx: number; vy: number; x: number;
size: number; life: number; decay: number; y: number;
gravity: number; color: number; vx: number;
vy: number;
size: number;
life: number;
decay: number;
gravity: number;
color: number;
}> = []; }> = [];
private gridLayer!: PIXI.Graphics; private gridLayer!: PIXI.Graphics;
private beatLayer!: PIXI.Graphics; private beatLayer!: PIXI.Graphics;
@@ -380,7 +429,10 @@ export class FallingNotesRenderer {
private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool private sustainTextLayer!: PIXI.Container; // "Ped" / "✱" text pool
/** Ghost indicators for notes that ended while sustain was held */ /** Ghost indicators for notes that ended while sustain was held */
private sustainedNoteLayer!: PIXI.Graphics; private sustainedNoteLayer!: PIXI.Graphics;
private sustainedNotes = new Map<string, { midi: number; baseColor: number; endSeconds: number }>(); private sustainedNotes = new Map<
string,
{ midi: number; baseColor: number; endSeconds: number }
>();
constructor(opts: FallingNotesOptions) { constructor(opts: FallingNotesOptions) {
this.opts = opts; this.opts = opts;
@@ -452,7 +504,12 @@ export class FallingNotesRenderer {
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
const t = new PIXI.Text({ const t = new PIXI.Text({
text: "", text: "",
style: { fontSize: 11, fill: 0x8896b8, fontFamily: "Space Grotesk, system-ui, sans-serif", fontWeight: "600" }, style: {
fontSize: 11,
fill: 0x8896b8,
fontFamily: "Space Grotesk, system-ui, sans-serif",
fontWeight: "600",
},
}); });
t.x = 3; t.x = 3;
t.visible = false; t.visible = false;
@@ -460,10 +517,17 @@ export class FallingNotesRenderer {
} }
this.app.stage.addChild( this.app.stage.addChild(
this.gridLayer, this.sustainLineLayer, this.sustainTextLayer, this.gridLayer,
this.beatLayer, noteLayer, this.particleLayer, this.sustainLineLayer,
this.sustainTextLayer,
this.beatLayer,
noteLayer,
this.particleLayer,
this.sustainedNoteLayer, this.sustainedNoteLayer,
this.measureLayer, this.flashLayer, this.hitLine, this.ghostBar, this.measureLayer,
this.flashLayer,
this.hitLine,
this.ghostBar
); );
this._initialized = true; this._initialized = true;
} }
@@ -501,7 +565,10 @@ export class FallingNotesRenderer {
private getGradient(base: number): PIXI.FillGradient { private getGradient(base: number): PIXI.FillGradient {
let g = this.gradientCache.get(base); let g = this.gradientCache.get(base);
if (!g) { g = makeGradient(base); this.gradientCache.set(base, g); } if (!g) {
g = makeGradient(base);
this.gradientCache.set(base, g);
}
return g; return g;
} }
@@ -529,10 +596,14 @@ export class FallingNotesRenderer {
// Returns false if the note should be hidden by the current filter // Returns false if the note should be hidden by the current filter
private passesFilter(note: NoteEvent): boolean { private passesFilter(note: NoteEvent): boolean {
switch (this.opts.noteFilter) { switch (this.opts.noteFilter) {
case "white": return isWhite(note.midi); case "white":
case "black": return isBlackKey(note.midi); return isWhite(note.midi);
case "c_only": return note.midi % 12 === 0; case "black":
default: return true; return isBlackKey(note.midi);
case "c_only":
return note.midi % 12 === 0;
default:
return true;
} }
} }
@@ -573,7 +644,7 @@ export class FallingNotesRenderer {
} }
private triggerFlash(cx: number, cy: number, w: number, color: number) { private triggerFlash(cx: number, cy: number, w: number, color: number) {
const f = this.flashes.find(f => !f.active); const f = this.flashes.find((f) => !f.active);
if (f) f.trigger(cx, cy, w, color); if (f) f.trigger(cx, cy, w, color);
} }
@@ -595,7 +666,7 @@ export class FallingNotesRenderer {
if (this.active.has(note.id)) return; if (this.active.has(note.id)) return;
if (!this.passesFilter(note)) return; if (!this.passesFilter(note)) return;
// Skip notes whose bottom edge has already scrolled 60 px below the hit line — // Skip notes whose bottom edge has already scrolled 60 px below the hit line -
// they would be recycled on the very next tick anyway, so don't waste a pool slot. // they would be recycled on the very next tick anyway, so don't waste a pool slot.
// Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60. // Threshold mirrors the recycle condition in tick(): bar.container.y > hitY + 60.
// barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight // barBottomPastHit = (currentSeconds - note.startSeconds) * pxPerSec - barHeight
@@ -620,10 +691,26 @@ export class FallingNotesRenderer {
const minH = this.opts.minNoteHeight ?? 8; const minH = this.opts.minNoteHeight ?? 8;
const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec); const barHeight = Math.max(minH, (note.endSeconds - note.startSeconds) * pxPerSec);
const timeUntilHit = note.startSeconds - currentSeconds; const timeUntilHit = note.startSeconds - currentSeconds;
const startY = (height - 4) - barHeight - timeUntilHit * pxPerSec; const startY = height - 4 - barHeight - timeUntilHit * pxPerSec;
const base = noteColor(note, this.opts.showHandColors, this.opts.colorTheme, this.opts.customColors); const base = noteColor(
bar.configure(note, x, barWidth, startY, barHeight, this.getGradient(base), base, this.noteLabel(note), this.opts.noteCornerRadius ?? 4, this.opts.showNoteOutline ?? false); note,
this.opts.showHandColors,
this.opts.colorTheme,
this.opts.customColors
);
bar.configure(
note,
x,
barWidth,
startY,
barHeight,
this.getGradient(base),
base,
this.noteLabel(note),
this.opts.noteCornerRadius ?? 4,
this.opts.showNoteOutline ?? false
);
this.active.set(note.id, bar); this.active.set(note.id, bar);
} }
@@ -652,10 +739,8 @@ export class FallingNotesRenderer {
bar.impactFired = true; bar.impactFired = true;
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
const cx = bar.container.x + bw * 0.5; const cx = bar.container.x + bw * 0.5;
if (impactStyle === "bloom") if (impactStyle === "bloom") this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor);
this.triggerFlash(cx, hitY - 4, bw * 0.8, bar.baseColor); else if (impactStyle === "side") this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
else if (impactStyle === "side")
this.spawnBurst(cx, hitY - 3, bw, bar.baseColor);
// "trail" is handled continuously below // "trail" is handled continuously below
} }
} }
@@ -665,7 +750,12 @@ export class FallingNotesRenderer {
const barTopY = bar.container.y; const barTopY = bar.container.y;
if (barTopY + barHeight < hitY && barTopY < hitY) { if (barTopY + barHeight < hitY && barTopY < hitY) {
const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW; const bw = isWhite(bar.note.midi) ? this.whiteW : this.blackW;
this.spawnTrail(bar.container.x + bw * 0.5, barTopY + barHeight * 0.35, bw, bar.baseColor); this.spawnTrail(
bar.container.x + bw * 0.5,
barTopY + barHeight * 0.35,
bw,
bar.baseColor
);
} }
} }
@@ -719,7 +809,7 @@ export class FallingNotesRenderer {
// ── Particles ────────────────────────────────────────────────────────── // ── Particles ──────────────────────────────────────────────────────────
this.particleLayer.clear(); this.particleLayer.clear();
if (this.particles.length > 0) { if (this.particles.length > 0) {
this.particles = this.particles.filter(p => { this.particles = this.particles.filter((p) => {
p.x += p.vx; p.x += p.vx;
p.y += p.vy; p.y += p.vy;
p.vy += p.gravity; p.vy += p.gravity;
@@ -758,7 +848,7 @@ export class FallingNotesRenderer {
if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) { if ((this.opts.showBeatLines ?? false) && this.opts.beatStarts?.length) {
this.beatLayer.clear(); this.beatLayer.clear();
// Build a Set of measure-start times (×100 for int comparison) for styling // Build a Set of measure-start times (×100 for int comparison) for styling
const mSet = new Set((this.opts.measureStarts ?? []).map(s => Math.round(s * 100))); const mSet = new Set((this.opts.measureStarts ?? []).map((s) => Math.round(s * 100)));
for (const sec of this.opts.beatStarts) { for (const sec of this.opts.beatStarts) {
const timeUntil = sec - currentSeconds; const timeUntil = sec - currentSeconds;
if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue; if (timeUntil < -0.3 || timeUntil > viewportSeconds + 0.3) continue;
@@ -768,7 +858,10 @@ export class FallingNotesRenderer {
const dark = this.opts.darkMode !== false; const dark = this.opts.darkMode !== false;
this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1); this.beatLayer.rect(0, y, this.opts.width, isMeasure ? 1.5 : 1);
// Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity. // Dark mode: light blue-gray at low opacity. Light mode: darker blue-gray at higher opacity.
this.beatLayer.fill({ color: dark ? 0x8896b8 : 0x4a5568, alpha: isMeasure ? (dark ? 0.2 : 0.35) : (dark ? 0.08 : 0.15) }); this.beatLayer.fill({
color: dark ? 0x8896b8 : 0x4a5568,
alpha: isMeasure ? (dark ? 0.2 : 0.35) : dark ? 0.08 : 0.15,
});
} }
} else if (!(this.opts.showBeatLines ?? false)) { } else if (!(this.opts.showBeatLines ?? false)) {
this.beatLayer.clear(); this.beatLayer.clear();
@@ -832,7 +925,10 @@ export class FallingNotesRenderer {
} }
showGhost(note: NoteEvent | null) { showGhost(note: NoteEvent | null) {
if (!note) { this.ghostBar.visible = false; return; } if (!note) {
this.ghostBar.visible = false;
return;
}
const white = isWhite(note.midi); const white = isWhite(note.midi);
const w = (white ? this.whiteW : this.blackW) - 2; const w = (white ? this.whiteW : this.blackW) - 2;
const x = this.noteX(note.midi) + 1; const x = this.noteX(note.midi) + 1;
@@ -843,7 +939,10 @@ export class FallingNotesRenderer {
} }
reset() { reset() {
for (const [, bar] of this.active) { bar.reset(); this.pool.push(bar); } for (const [, bar] of this.active) {
bar.reset();
this.pool.push(bar);
}
this.active.clear(); this.active.clear();
this.particles = []; this.particles = [];
this.particleLayer?.clear(); this.particleLayer?.clear();
@@ -882,7 +981,8 @@ export class FallingNotesRenderer {
("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) || ("noteFilter" in opts && opts.noteFilter !== this.opts.noteFilter) ||
("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) || ("showHandColors" in opts && opts.showHandColors !== this.opts.showHandColors) ||
("useFlats" in opts && opts.useFlats !== this.opts.useFlats) || ("useFlats" in opts && opts.useFlats !== this.opts.useFlats) ||
("fallingNotesLabelMode" in opts && opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) || ("fallingNotesLabelMode" in opts &&
opts.fallingNotesLabelMode !== this.opts.fallingNotesLabelMode) ||
("showFingering" in opts && opts.showFingering !== this.opts.showFingering) || ("showFingering" in opts && opts.showFingering !== this.opts.showFingering) ||
("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline); ("showNoteOutline" in opts && opts.showNoteOutline !== this.opts.showNoteOutline);
@@ -233,7 +233,7 @@ export function SheetMusicView() {
}} }}
> >
{/* {/*
Page-width wrapper — centres the score and caps it at ~A4 screen width Page-width wrapper - centres the score and caps it at ~A4 screen width
(900 px). On wide monitors this creates margins on both sides so the (900 px). On wide monitors this creates margins on both sides so the
score never looks like a stretched banner; on narrow screens it fills score never looks like a stretched banner; on narrow screens it fills
the full width just like before. position:relative is needed so the the full width just like before. position:relative is needed so the
File diff suppressed because it is too large Load Diff
+65 -22
View File
@@ -54,10 +54,10 @@
--color-border-subtle: #eaecf4; --color-border-subtle: #eaecf4;
/* Accent */ /* Accent */
--color-accent: #6366f1; --color-accent: #5e61f0; /* was #6366f1, darkened to pass WCAG AA contrast */
--color-accent-hover: #4f46e5; --color-accent-hover: #4f46e5;
--color-accent-subtle: rgba(99, 102, 241, 0.12); --color-accent-subtle: rgba(94, 97, 240, 0.12);
--color-accent-subtle-border: rgba(99, 102, 241, 0.30); --color-accent-subtle-border: rgba(94, 97, 240, 0.3);
--color-accent-text: #4f46e5; --color-accent-text: #4f46e5;
/* Toggle switch inactive track */ /* Toggle switch inactive track */
@@ -75,10 +75,10 @@
--color-left-hand: #3b82f6; --color-left-hand: #3b82f6;
--color-left-hand-subtle: rgba(59, 130, 246, 0.12); --color-left-hand-subtle: rgba(59, 130, 246, 0.12);
--color-left-hand-subtle-border: rgba(59, 130, 246, 0.30); --color-left-hand-subtle-border: rgba(59, 130, 246, 0.3);
--color-left-hand-text: #1d4ed8; --color-left-hand-text: #1d4ed8;
--color-right-hand: #ef4444; --color-right-hand: #ef4444;
--color-right-hand-subtle: rgba(239, 68, 68, 0.10); --color-right-hand-subtle: rgba(239, 68, 68, 0.1);
--color-right-hand-subtle-border: rgba(239, 68, 68, 0.28); --color-right-hand-subtle-border: rgba(239, 68, 68, 0.28);
--color-right-hand-text: #dc2626; --color-right-hand-text: #dc2626;
@@ -87,18 +87,30 @@
#141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo #141428 content vs #1E1E38 chrome. #EEF1FA has the same blue-indigo
undertone as the dark palette and reads as white at a glance while undertone as the dark palette and reads as white at a glance while
still being visibly distinct from the #FFFFFF header/transport. */ still being visibly distinct from the #FFFFFF header/transport. */
--color-notes-bg: #EEF1FA; --color-notes-bg: #eef1fa;
--color-notes-border: #D4D9EE; --color-notes-border: #d4d9ee;
} }
/* ── Reset ─────────────────────────────────────────────────────────────────── */ /* ── Reset ─────────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body, #root { html,
body,
#root {
height: 100%; height: 100%;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: "Space Grotesk", system-ui, -apple-system, sans-serif; font-family:
"Space Grotesk",
system-ui,
-apple-system,
sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow: hidden; overflow: hidden;
} }
@@ -112,7 +124,10 @@ html, body, #root {
} }
/* ── Inputs & selects ──────────────────────────────────────────────────────── */ /* ── Inputs & selects ──────────────────────────────────────────────────────── */
select, input[type="range"] { font-family: inherit; } select,
input[type="range"] {
font-family: inherit;
}
select { select {
appearance: none; appearance: none;
@@ -127,13 +142,25 @@ select {
} }
/* ── Settings panel rows ───────────────────────────────────────────────────── */ /* ── Settings panel rows ───────────────────────────────────────────────────── */
.settings-row:hover { background: var(--color-surface-2); } .settings-row:hover {
background: var(--color-surface-2);
}
/* ── Scrollbar ─────────────────────────────────────────────────────────────── */ /* ── Scrollbar ─────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; } ::-webkit-scrollbar {
::-webkit-scrollbar-track { background: transparent; } width: 5px;
::-webkit-scrollbar-thumb { background: var(--color-surface-3); border-radius: 99px; } height: 5px;
::-webkit-scrollbar-thumb:hover { background: var(--color-border); } }
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-surface-3);
border-radius: 99px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
/* ── Range slider - fully custom ───────────────────────────────────────────── */ /* ── Range slider - fully custom ───────────────────────────────────────────── */
/* Remove native appearance so our pseudo-element CSS actually takes effect. /* Remove native appearance so our pseudo-element CSS actually takes effect.
@@ -157,7 +184,9 @@ input[type="range"]::-webkit-slider-thumb {
cursor: pointer; cursor: pointer;
margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */ margin-top: -5.5px; /* center on 3px track: -(14-3)/2 */
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
transition: background 0.12s, box-shadow 0.12s; transition:
background 0.12s,
box-shadow 0.12s;
} }
input[type="range"]:hover::-webkit-slider-thumb { input[type="range"]:hover::-webkit-slider-thumb {
background: var(--color-accent-hover); background: var(--color-accent-hover);
@@ -217,13 +246,21 @@ input[type="range"]::-moz-range-progress {
color: var(--color-text-muted); color: var(--color-text-muted);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
cursor: pointer; cursor: pointer;
transition: background 0.12s, opacity 0.12s; transition:
background 0.12s,
opacity 0.12s;
flex-shrink: 0; flex-shrink: 0;
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.t-btn:hover:not(:disabled) { background: var(--color-surface-3); color: var(--color-text); } .t-btn:hover:not(:disabled) {
.t-btn:disabled { opacity: 0.55; cursor: default; } background: var(--color-surface-3);
color: var(--color-text);
}
.t-btn:disabled {
opacity: 0.55;
cursor: default;
}
/* Primary play/pause button - circular, accent-colored */ /* Primary play/pause button - circular, accent-colored */
.t-btn.primary { .t-btn.primary {
@@ -242,10 +279,16 @@ input[type="range"]::-moz-range-progress {
box-shadow: 0 0 24px rgba(147, 51, 234, 0.65); box-shadow: 0 0 24px rgba(147, 51, 234, 0.65);
} }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ── AlphaTab host ─────────────────────────────────────────────────────────── */ /* ── AlphaTab host ─────────────────────────────────────────────────────────── */
.at-surface svg { display: block; } .at-surface svg {
display: block;
}
/* ── AlphaTab playback cursor ─────────────────────────────────────────────── */ /* ── AlphaTab playback cursor ─────────────────────────────────────────────── */
/* Beat cursor - thin accent-colored vertical bar following playhead */ /* Beat cursor - thin accent-colored vertical bar following playhead */
+14 -11
View File
@@ -2,8 +2,8 @@
* Browser-side .mxl extractor. * Browser-side .mxl extractor.
* *
* .mxl (compressed MusicXML) is a ZIP archive containing: * .mxl (compressed MusicXML) is a ZIP archive containing:
* META-INF/container.xml — lists the root MusicXML file * META-INF/container.xml - lists the root MusicXML file
* <root>.xml — the actual MusicXML document * <root>.xml - the actual MusicXML document
* *
* We parse the ZIP central directory to locate files, then decompress each * We parse the ZIP central directory to locate files, then decompress each
* DEFLATE-compressed entry with the browser-native DecompressionStream API * DEFLATE-compressed entry with the browser-native DecompressionStream API
@@ -30,9 +30,12 @@ interface ZipEntry {
function findEocd(bytes: Uint8Array): number { function findEocd(bytes: Uint8Array): number {
for (let i = bytes.length - 22; i >= 0; i--) { for (let i = bytes.length - 22; i >= 0; i--) {
if ( if (
bytes[i] === 0x50 && bytes[i + 1] === 0x4b && bytes[i] === 0x50 &&
bytes[i + 2] === 0x05 && bytes[i + 3] === 0x06 bytes[i + 1] === 0x4b &&
) return i; bytes[i + 2] === 0x05 &&
bytes[i + 3] === 0x06
)
return i;
} }
return -1; return -1;
} }
@@ -66,7 +69,7 @@ function readCentralDirectory(view: DataView, bytes: Uint8Array): ZipEntry[] {
async function readLocalEntry( async function readLocalEntry(
view: DataView, view: DataView,
bytes: Uint8Array, bytes: Uint8Array,
entry: ZipEntry, entry: ZipEntry
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const off = entry.localOffset; const off = entry.localOffset;
if (view.getUint32(off, true) !== SIG_LOCAL) { if (view.getUint32(off, true) !== SIG_LOCAL) {
@@ -82,14 +85,14 @@ async function readLocalEntry(
const compressed = bytes.slice(dataStart, dataStart + compressedSize); const compressed = bytes.slice(dataStart, dataStart + compressedSize);
if (compressionMethod === 0) { if (compressionMethod === 0) {
// Stored — no compression // Stored - no compression
return compressed; return compressed;
} }
if (compressionMethod !== 8) { if (compressionMethod !== 8) {
throw new Error(`Unsupported ZIP compression method ${compressionMethod}`); throw new Error(`Unsupported ZIP compression method ${compressionMethod}`);
} }
// DEFLATE (raw, no zlib header) — decompress with browser-native stream. // DEFLATE (raw, no zlib header) - decompress with browser-native stream.
// Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted // Cap decompressed size to 50 MB to prevent ZIP-bomb attacks: a crafted
// .mxl with a tiny compressed payload that expands to gigabytes would // .mxl with a tiny compressed payload that expands to gigabytes would
// otherwise freeze or crash the browser tab. // otherwise freeze or crash the browser tab.
@@ -109,7 +112,7 @@ async function readLocalEntry(
if (done) break; if (done) break;
totalDecompressed += value.length; totalDecompressed += value.length;
if (totalDecompressed > MAX_DECOMPRESSED) { if (totalDecompressed > MAX_DECOMPRESSED) {
throw new Error("Decompressed MusicXML exceeds 50 MB — possible ZIP bomb"); throw new Error("Decompressed MusicXML exceeds 50 MB - possible ZIP bomb");
} }
chunks.push(value); chunks.push(value);
} }
@@ -133,7 +136,7 @@ export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> {
const view = new DataView(buffer); const view = new DataView(buffer);
const entries = readCentralDirectory(view, bytes); const entries = readCentralDirectory(view, bytes);
const find = (name: string) => entries.find(e => e.name === name); const find = (name: string) => entries.find((e) => e.name === name);
// 1. Read META-INF/container.xml // 1. Read META-INF/container.xml
const containerEntry = find("META-INF/container.xml"); const containerEntry = find("META-INF/container.xml");
@@ -144,7 +147,7 @@ export async function extractXmlFromMxl(buffer: ArrayBuffer): Promise<string> {
// 2. Parse the root-file path (e.g. full-path="score.xml") // 2. Parse the root-file path (e.g. full-path="score.xml")
const match = containerXml.match(/full-path="([^"]+)"/); const match = containerXml.match(/full-path="([^"]+)"/);
if (!match) throw new Error('Malformed container.xml: no full-path attribute'); if (!match) throw new Error("Malformed container.xml: no full-path attribute");
const rootPath = match[1]; const rootPath = match[1];
// 3. Read the root MusicXML entry // 3. Read the root MusicXML entry
+135 -46
View File
@@ -29,10 +29,17 @@ export function PracticeView() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const { const {
loadMidiFile, loadMusicXmlFile, loadPdfFile, loadMidiFile,
settings, updateSettings, loadMusicXmlFile,
status, play, pause, seek, loadPdfFile,
loadError, clearLoadError, settings,
updateSettings,
status,
play,
pause,
seek,
loadError,
clearLoadError,
document: doc, document: doc,
isLoadingDocument, isLoadingDocument,
} = useAppStore(); } = useAppStore();
@@ -85,17 +92,23 @@ export function PracticeView() {
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [seek]); }, [seek]);
const loadFile = useCallback((file: File) => { const loadFile = useCallback(
(file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file); if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
}, [loadMidiFile, loadMusicXmlFile, loadPdfFile]); },
[loadMidiFile, loadMusicXmlFile, loadPdfFile]
);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file) loadFile(file); if (file) loadFile(file);
}, [loadFile]); },
[loadFile]
);
const { layoutMode } = settings; const { layoutMode } = settings;
const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode }); const setLayout = (mode: LayoutMode) => updateSettings({ layoutMode: mode });
@@ -130,14 +143,14 @@ export function PracticeView() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 shrink-0" className="flex items-center gap-2 shrink-0"
style={{ textDecoration: "none" }} style={{ textDecoration: "none" }}
aria-label="Bach to Basics on GitHub"
> >
<div <div
role="img" role="img"
aria-hidden="true" aria-hidden="true"
className="flex items-center justify-center shrink-0 select-none" className="flex items-center justify-center shrink-0 select-none"
style={{ style={{
width: 32, height: 32, width: 32,
height: 32,
background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)", background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)",
borderRadius: 8, borderRadius: 8,
boxShadow: "0 3px 10px rgba(147,51,234,0.45)", boxShadow: "0 3px 10px rgba(147,51,234,0.45)",
@@ -146,10 +159,15 @@ export function PracticeView() {
<PianoKeysIcon /> <PianoKeysIcon />
</div> </div>
{/* Typographic pun: "Bach" and "Basics" bold, "to" muted */} {/* Typographic pun: "Bach" and "Basics" bold, "to" muted */}
<span style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}> <span
style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}
>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span> <span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span>
<span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>to</span> <span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>
to
</span>
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span> <span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span>
<span className="sr-only"> on GitHub</span>
</span> </span>
</a> </a>
@@ -160,7 +178,12 @@ export function PracticeView() {
> >
<div <div
className="flex items-center" className="flex items-center"
style={{ padding: 3, background: "var(--color-surface-2)", borderRadius: 999, border: "1px solid var(--color-border)" }} style={{
padding: 3,
background: "var(--color-surface-2)",
borderRadius: 999,
border: "1px solid var(--color-border)",
}}
> >
{(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => ( {(["piano", "falling", "sheet", "all"] as LayoutMode[]).map((mode) => (
<button <button
@@ -194,7 +217,13 @@ export function PracticeView() {
title={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} title={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"} aria-label={settings.theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="flex items-center justify-center rounded transition-colors shrink-0" className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }} style={{
width: 30,
height: 30,
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
> >
{settings.theme === "dark" ? <SunIcon /> : <MoonIcon />} {settings.theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button> </button>
@@ -203,7 +232,13 @@ export function PracticeView() {
title="Settings" title="Settings"
aria-label="Open settings" aria-label="Open settings"
className="flex items-center justify-center rounded transition-colors shrink-0" className="flex items-center justify-center rounded transition-colors shrink-0"
style={{ width: 30, height: 30, background: "var(--color-surface-2)", border: "1px solid var(--color-border)", color: "var(--color-text-muted)" }} style={{
width: 30,
height: 30,
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
> >
<GearIcon /> <GearIcon />
</button> </button>
@@ -211,7 +246,7 @@ export function PracticeView() {
</header> </header>
{/* ── Main content - layout-aware ────────────────────────────────────── */} {/* ── Main content - layout-aware ────────────────────────────────────── */}
<div className="flex flex-1 overflow-hidden min-h-0 relative"> <main className="flex flex-1 overflow-hidden min-h-0 relative">
{layoutMode === "piano" && ( {layoutMode === "piano" && (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<PianoModeBackground /> <PianoModeBackground />
@@ -244,12 +279,10 @@ export function PracticeView() {
</div> </div>
</> </>
)} )}
</div> </main>
{/* ── Empty state overlay ────────────────────────────────────────────── */} {/* ── Empty state overlay ────────────────────────────────────────────── */}
{!doc && !isLoadingDocument && ( {!doc && !isLoadingDocument && <EmptyState onImport={loadFile} />}
<EmptyState onImport={loadFile} />
)}
{/* ── Error toast ────────────────────────────────────────────────────── */} {/* ── Error toast ────────────────────────────────────────────────────── */}
{loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />} {loadError && <ErrorToast message={loadError} onDismiss={clearLoadError} />}
@@ -262,7 +295,6 @@ export function PracticeView() {
{/* ── Settings panel (managed here, gear is in header) ───────────────── */} {/* ── Settings panel (managed here, gear is in header) ───────────────── */}
<SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} /> <SettingsPanel open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div> </div>
); );
} }
@@ -290,11 +322,15 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
{/* Illustration */} {/* Illustration */}
<div <div
style={{ style={{
width: 72, height: 72, width: 72,
height: 72,
borderRadius: 18, borderRadius: 18,
background: "linear-gradient(135deg, rgba(147,51,234,0.25) 0%, rgba(147,51,234,0.10) 100%), var(--color-notes-bg)", background:
"linear-gradient(135deg, rgba(147,51,234,0.25) 0%, rgba(147,51,234,0.10) 100%), var(--color-notes-bg)",
border: "1px solid rgba(147,51,234,0.25)", border: "1px solid rgba(147,51,234,0.25)",
display: "flex", alignItems: "center", justifyContent: "center", display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--color-accent-text)", color: "var(--color-accent-text)",
}} }}
> >
@@ -315,20 +351,29 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
style={{ style={{
pointerEvents: "all", pointerEvents: "all",
display: "flex", alignItems: "center", gap: 8, display: "flex",
alignItems: "center",
gap: 8,
padding: "11px 28px", padding: "11px 28px",
background: "var(--color-accent)", background: "var(--color-accent)",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: 10, borderRadius: 10,
fontSize: 14, fontWeight: 600, fontSize: 14,
fontWeight: 600,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 0 24px rgba(147,51,234,0.45)", boxShadow: "0 0 24px rgba(147,51,234,0.45)",
fontFamily: "inherit", fontFamily: "inherit",
transition: "all 0.15s", transition: "all 0.15s",
}} }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)"; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)"; }} onMouseEnter={(e) => {
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.transform = ""; (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)"; }} (e.currentTarget as HTMLButtonElement).style.transform = "translateY(-1px)";
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 34px rgba(147,51,234,0.6)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.transform = "";
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 24px rgba(147,51,234,0.45)";
}}
> >
<UploadIconLg /> <UploadIconLg />
Import a file Import a file
@@ -343,7 +388,11 @@ function EmptyState({ onImport }: { onImport: (file: File) => void }) {
type="file" type="file"
accept=".mid,.midi,.xml,.mxl,.pdf" accept=".mid,.midi,.xml,.mxl,.pdf"
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} onChange={(e) => {
const f = e.target.files?.[0];
if (f) onImport(f);
e.target.value = "";
}}
/> />
</div> </div>
); );
@@ -403,11 +452,14 @@ function PianoModeBackground() {
const { settings } = useAppStore(); const { settings } = useAppStore();
// Reusable draw callback // Reusable draw callback
const redraw = useMemo(() => () => { const redraw = useMemo(
() => () => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
drawPianoGrid(canvas); drawPianoGrid(canvas);
}, []); },
[]
);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -449,15 +501,12 @@ function PianoModeBackground() {
className="flex-1 relative" className="flex-1 relative"
style={{ style={{
minHeight: 0, minHeight: 0,
background: "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)", background:
"radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)",
borderTop: "1px solid var(--color-notes-border)", borderTop: "1px solid var(--color-notes-border)",
}} }}
> >
<canvas <canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
ref={canvasRef}
style={{ display: "block", width: "100%", height: "100%" }}
/>
</div> </div>
); );
} }
@@ -484,30 +533,67 @@ function StatusBar() {
const bpm = doc?.tempoMap[0]?.bpm ?? null; const bpm = doc?.tempoMap[0]?.bpm ?? null;
const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null; const effectiveBpm = bpm ? Math.round(bpm * tempoMultiplier) : null;
const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`; const fmt = (s: number) =>
`${Math.floor(s / 60)}:${Math.floor(s % 60)
.toString()
.padStart(2, "0")}`;
const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null; const keySig = doc?.keySignature ? keySignatureToLabel(doc.keySignature) : null;
const isActive = status === "playing" || status === "paused"; const isActive = status === "playing" || status === "paused";
return ( return (
<div <div
className="flex items-center gap-3 border-t flex-shrink-0 min-w-0" className="flex items-center gap-3 border-t flex-shrink-0 min-w-0"
style={{ height: 26, background: "var(--color-surface)", borderColor: "var(--color-border-subtle)", fontSize: 11, color: "var(--color-text-muted)", fontVariantNumeric: "tabular-nums", paddingLeft: 10, paddingRight: 12 }} style={{
height: 26,
background: "var(--color-surface)",
borderColor: "var(--color-border-subtle)",
fontSize: 11,
color: "var(--color-text-muted)",
fontVariantNumeric: "tabular-nums",
paddingLeft: 10,
paddingRight: 12,
}}
> >
{doc ? ( {doc ? (
<> <>
{isActive && ( {isActive && (
<span style={{ color: "var(--color-accent)", fontWeight: 600, flexShrink: 0 }}>Playing</span> <span style={{ color: "var(--color-accent)", fontWeight: 600, flexShrink: 0 }}>
Playing
</span>
)}
<span className="truncate" style={{ minWidth: 0 }}>
{doc.title}
</span>
{keySig && (
<>
<Dot />
<span className="shrink-0">{keySig}</span>
</>
)}
{effectiveBpm !== null && (
<>
<Dot />
<span className="shrink-0">{effectiveBpm} BPM</span>
</>
)}
{lastNote && (
<>
<Dot />
<span className="shrink-0" style={{ color: "var(--color-accent)" }}>
{lastNote}
</span>
</>
)} )}
<span className="truncate" style={{ minWidth: 0 }}>{doc.title}</span>
{keySig && <><Dot /><span className="shrink-0">{keySig}</span></>}
{effectiveBpm !== null && <><Dot /><span className="shrink-0">{effectiveBpm} BPM</span></>}
{lastNote && <><Dot /><span className="shrink-0" style={{ color: "var(--color-accent)" }}>{lastNote}</span></>}
</> </>
) : ( ) : (
<span>No file loaded - drag &amp; drop or click ↑ Import</span> <span>No file loaded - drag &amp; drop or click ↑ Import</span>
)} )}
<div className="flex-1 min-w-0" /> <div className="flex-1 min-w-0" />
{doc && <span className="shrink-0">{fmt(currentSeconds)} / {fmt(doc.totalDuration)}</span>} {doc && (
<span className="shrink-0">
{fmt(currentSeconds)} / {fmt(doc.totalDuration)}
</span>
)}
</div> </div>
); );
} }
@@ -554,7 +640,10 @@ function ErrorToast({ message, onDismiss }: { message: string; onDismiss: () =>
<span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span> <span style={{ color: "#f87171", fontSize: 16, lineHeight: 1.2, flexShrink: 0 }}>⚠</span>
<span style={{ flex: 1, wordBreak: "break-word" }}> <span style={{ flex: 1, wordBreak: "break-word" }}>
{message.split("\n").map((line, i) => ( {message.split("\n").map((line, i) => (
<span key={i}>{line}{i < message.split("\n").length - 1 && <br />}</span> <span key={i}>
{line}
{i < message.split("\n").length - 1 && <br />}
</span>
))} ))}
</span> </span>
<button <button
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "bach-to-basics", "name": "bach-to-basics",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"description": "Bach to Basics — browser-based piano learning app with falling notes, sheet music, and MIDI support", "description": "Bach to Basics - browser-based piano learning app with falling notes, sheet music, and MIDI support",
"author": "gigliof", "author": "gigliof",
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/gigliof/bach-to-basics#readme", "homepage": "https://github.com/gigliof/bach-to-basics#readme",