diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94c1691..034a1f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint --max-warnings 0 + - name: Type-check run: pnpm --filter frontend exec tsc --noEmit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2d31b87 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +backend/ +pnpm-lock.yaml +*.min.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1a88ab1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0a7ce8d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import ts from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import prettierConfig from "eslint-config-prettier"; + +export default ts.config( + { ignores: ["**/dist/**", "**/node_modules/**", "backend/**"] }, + { + extends: [js.configs.recommended, ...ts.configs.recommended], + files: ["frontend/src/**/*.{ts,tsx}"], + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + // TypeScript handles unused-vars via tsconfig strict flags + "@typescript-eslint/no-unused-vars": "off", + // Allow explicit any in a few cases (PixiJS / AlphaTab interop) + "@typescript-eslint/no-explicit-any": "warn", + // Empty catch blocks are intentional in WebMidi / PixiJS teardown paths + "no-empty": ["error", { allowEmptyCatch: true }], + // Control-character regexes are intentional input sanitization + "no-control-regex": "off", + }, + }, + prettierConfig +); diff --git a/frontend/src/components/SheetMusic/SheetMusicView.tsx b/frontend/src/components/SheetMusic/SheetMusicView.tsx index f51f9d2..cfc9f68 100644 --- a/frontend/src/components/SheetMusic/SheetMusicView.tsx +++ b/frontend/src/components/SheetMusic/SheetMusicView.tsx @@ -13,20 +13,18 @@ import type { TempoEvent } from "@bach-to-basics/shared"; // malformed XML. AlphaTab processes MusicXML as data, not HTML, so CDATA // poses no XSS risk here. function sanitizeMusicXml(xml: string): string { - return xml - .replace(//gi, "") - .replace(/\bon\w+\s*=/gi, "data-removed="); + return xml.replace(//gi, "").replace(/\bon\w+\s*=/gi, "data-removed="); } export function SheetMusicView() { - const scrollRef = useRef(null); // outer overflow-auto container - const containerRef = useRef(null); // inner AlphaTab host + const scrollRef = useRef(null); // outer overflow-auto container + const containerRef = useRef(null); // inner AlphaTab host // eslint-disable-next-line @typescript-eslint/no-explicit-any - const apiRef = useRef(null); - const playerReady = useRef(false); // true once soundFont is loaded - const scoreLoaded = useRef(false); // true once score SVG is rendered + const apiRef = useRef(null); + const playerReady = useRef(false); // true once soundFont is loaded + const scoreLoaded = useRef(false); // true once score SVG is rendered const lastScrollTop = useRef(-1); - const lastScrollMs = useRef(0); + const lastScrollMs = useRef(0); // ── AlphaTab init (runs once on mount) ────────────────────────────────── useEffect(() => { @@ -36,11 +34,10 @@ export function SheetMusicView() { import("@coderline/alphatab").then(({ AlphaTabApi }) => { if (!containerRef.current) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const api = new AlphaTabApi(containerRef.current, { core: { engine: "svg", enableLazyLoading: false, logLevel: 0 }, display: { - layoutMode: 0, // page mode - vertical scroll + layoutMode: 0, // page mode - vertical scroll scale: 0.9, // stretchForce 0 = notes keep their natural rhythmic proportions (like // a printed score). A very small value (0.1) avoids a ragged right @@ -57,7 +54,7 @@ export function SheetMusicView() { enableCursor: true, enableUserInteraction: true, soundFont: "/soundfont/sonivox.sf3", - scrollMode: 0, // we drive scrolling ourselves + scrollMode: 0, // we drive scrolling ourselves }, } as never); @@ -69,12 +66,18 @@ export function SheetMusicView() { playerReady.current = true; api.masterVolume = 0; }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } // ── Mark score as rendered so cursor/scroll can activate ───────────── try { - api.scoreLoaded.on(() => { scoreLoaded.current = true; }); - } catch { /* ignore */ } + api.scoreLoaded.on(() => { + scoreLoaded.current = true; + }); + } catch { + /* ignore */ + } // ── Load score if MusicXML is already available ────────────────────── const initialDoc = useAppStore.getState().document; @@ -95,7 +98,9 @@ export function SheetMusicView() { for (const note of beat.notes) syncEngine.stopMidi(note.realValue); }, 500); }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } // ── Transport tick: cursor position + scroll ───────────────────────── // IMPORTANT: do NOT close over `doc` here - it would always be null @@ -120,8 +125,8 @@ export function SheetMusicView() { lastScrollMs.current = now; requestAnimationFrame(() => { - const scrollEl = scrollRef.current; // outer scroll container - const innerEl = containerRef.current; // AlphaTab host (cursor lives here) + const scrollEl = scrollRef.current; // outer scroll container + const innerEl = containerRef.current; // AlphaTab host (cursor lives here) if (!scrollEl || !innerEl) return; // AlphaTab renders absolutely-positioned cursor overlays inside innerEl const cursor = @@ -150,7 +155,9 @@ export function SheetMusicView() { playerReady.current = false; scoreLoaded.current = false; if (apiRef.current) { - try { apiRef.current.destroy(); } catch {} + try { + apiRef.current.destroy(); + } catch {} apiRef.current = null; } }; @@ -158,8 +165,8 @@ export function SheetMusicView() { // ── Load when MusicXML/mxlBuffer arrives after initial mount ──────────── const { document: doc, settings } = useAppStore(); - const isDark = settings.theme === "dark"; - const keepWhite = isDark && settings.sheetMusicWhiteBackground; + const isDark = settings.theme === "dark"; + const keepWhite = isDark && settings.sheetMusicWhiteBackground; const invertSheet = isDark && !settings.sheetMusicWhiteBackground; // ── Adapt stretchForce to layout mode ──────────────────────────────────── @@ -168,6 +175,7 @@ export function SheetMusicView() { // near-natural note spacing (0.1) for a printed-score look. // The value is stored in a ref so the load effect below can read it fresh. const stretchForceRef = useRef(0.1); + // eslint-disable-next-line react-hooks/refs -- intentional: keeps ref in sync with derived state for effect to read fresh stretchForceRef.current = settings.layoutMode === "all" ? 0.5 : 0.1; useEffect(() => { @@ -179,7 +187,9 @@ export function SheetMusicView() { // eslint-disable-next-line @typescript-eslint/no-explicit-any (apiRef.current.settings as any).display.stretchForce = stretchForceRef.current; apiRef.current.render(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, [settings.layoutMode]); // ── Load score when MusicXML/mxlBuffer arrives ─────────────────────────── @@ -190,7 +200,9 @@ export function SheetMusicView() { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any (apiRef.current.settings as any).display.stretchForce = stretchForceRef.current; - } catch { /* ignore */ } + } catch { + /* ignore */ + } if (doc?.musicXml) { scoreLoaded.current = false; apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml))); @@ -211,7 +223,15 @@ export function SheetMusicView() { : "radial-gradient(ellipse at 50% 100%, rgba(147,51,234,0.07) 0%, transparent 65%), var(--color-notes-bg)"; return ( -
+
{/* 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 @@ -220,18 +240,15 @@ export function SheetMusicView() { key-signature badge stays anchored inside the content column. */}
-
+
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
@@ -253,30 +270,30 @@ const AT_PPQ = 960; function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number { if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ); - let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks + let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks let remainingSec = targetSec; for (let i = 0; i < tempoMap.length; i++) { - const ev = tempoMap[i]; + const ev = tempoMap[i]; const next = tempoMap[i + 1]; // seconds per MIDI tick at this tempo - const secPerMidiTick = 60 / (ev.bpm * ppq); + const secPerMidiTick = 60 / (ev.bpm * ppq); // AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ) - const atPerMidi = AT_PPQ / ppq; + const atPerMidi = AT_PPQ / ppq; if (next) { - const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks - const secSpan = midiSpan * secPerMidiTick; + const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks + const secSpan = midiSpan * secPerMidiTick; if (remainingSec <= secSpan) { - atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi); + atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi); return atTick; } remainingSec -= secSpan; - atTick += Math.round(midiSpan * atPerMidi); + atTick += Math.round(midiSpan * atPerMidi); } else { // Last segment - extends to end of piece - atTick += Math.round(remainingSec / secPerMidiTick * atPerMidi); + atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi); return atTick; } } diff --git a/frontend/src/hooks/useAccuracy.ts b/frontend/src/hooks/useAccuracy.ts index 54270bb..859bf88 100644 --- a/frontend/src/hooks/useAccuracy.ts +++ b/frontend/src/hooks/useAccuracy.ts @@ -4,9 +4,9 @@ import { accuracyTracker } from "../engine/AccuracyTracker"; export interface AccuracyState { correct: number; - wrong: number; - missed: number; - score: number; // 0-100 + wrong: number; + missed: number; + score: number; // 0-100 } const INITIAL: AccuracyState = { correct: 0, wrong: 0, missed: 0, score: 0 }; @@ -21,6 +21,7 @@ export function useAccuracy(enabled: boolean): AccuracyState { useEffect(() => { accuracyTracker.enable(enabled); if (!enabled) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: synchronously reset accuracy when disabled setState(INITIAL); return; } diff --git a/package.json b/package.json index 82f0fa2..8f39afd 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,37 @@ "bugs": { "url": "https://github.com/gigliof/bach-to-basics/issues" }, + "type": "module", "scripts": { "dev": "concurrently --names \"frontend,backend\" --prefix-colors \"cyan,yellow\" \"pnpm --filter frontend dev\" \"cd backend && .venv/bin/uvicorn main:app --reload --port 8000\"", "build": "pnpm --filter frontend build", "test": "pnpm --filter frontend test", - "backend:setup": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" + "backend:setup": "cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt", + "lint": "eslint frontend/src", + "lint:fix": "eslint frontend/src --fix", + "format": "prettier --write .", + "prepare": "husky" + }, + "lint-staged": { + "frontend/src/**/*.{ts,tsx}": [ + "eslint --fix --max-warnings 0", + "prettier --write" + ], + "**/*.{json,md,yml,yaml,css}": [ + "prettier --write" + ] }, "devDependencies": { - "concurrently": "^9.0.0" + "@eslint/js": "^10.0.1", + "concurrently": "^9.0.0", + "eslint": "^10.3.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "husky": "^9.1.7", + "lint-staged": "^17.0.2", + "prettier": "^3.8.3", + "typescript-eslint": "^8.59.2" }, "pnpm": { "onlyBuiltDependencies": [