Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
- Customer-facing empty and error copy must enable the next action (choose local audio, paste a YouTube URL, choose another file, or start over). Do not leave those states as text-only cards.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.

Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Last updated: 2026-03-11

- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
- Empty and failed workspace cards are actionable state cards. They must expose the next rehearsal action instead of describing the gap and stopping.

## Security source

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Empty and failed workspace cards now expose the next rehearsal action: choose a local audio file, paste a YouTube URL, choose another file, or start over.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

`AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win.

Workspace empty and error cards must keep a visible next action (choose local audio, paste a YouTube URL, choose another file, or start over).

Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`.

## Common commands
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src/App.recovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App";

vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

const mockLoadProject = vi.fn();
const mockSelectLocalAudioSource = vi.fn();

vi.mock("./lib/analysis", async (importActual) => {
const actual = await importActual<typeof import("./lib/analysis")>();

return {
...actual,
loadProject: () => mockLoadProject(),
selectLocalAudioSource: () => mockSelectLocalAudioSource()
};
});

describe("App workspace recovery actions", () => {
beforeEach(() => {
mockLoadProject.mockReset();
mockSelectLocalAudioSource.mockReset();
});

it("focuses the existing YouTube field from the empty workspace card", () => {
render(<App />);
const youtubeInput = screen.getByRole("textbox", { name: /YouTube URL/i });

fireEvent.click(screen.getByRole("button", { name: "Paste a YouTube URL" }));

expect(document.activeElement).toBe(youtubeInput);
});

it("returns a failed workspace to the empty state when starting over", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("project load failed"));
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Start over" }));

await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy();
});

it("clears a failed workspace before accepting another local audio file", async () => {
mockLoadProject.mockRejectedValueOnce(new Error("project load failed"));
mockSelectLocalAudioSource.mockResolvedValueOnce({
ok: true,
bootstrap: {
projectId: "proj-recovery",
source: {
sourceKind: "local-audio",
sourceMode: "reference",
fileName: "recovery-take.wav",
format: "wav"
}
}
});
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /open project/i }));
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: "Choose another file" }));

await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByTitle("recovery-take.wav")).toBeTruthy();
expect(mockSelectLocalAudioSource).toHaveBeenCalledTimes(1);
});
});
72 changes: 47 additions & 25 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,28 @@ describe("App", () => {
expect(sourceControls).toHaveTextContent(/Import YouTube/i);
});

it("lets the empty workspace card start a local-audio pick", async () => {
mockLocalAudioSelectionResult = {
ok: true,
bootstrap: {
projectId: "proj-empty-next",
source: {
sourceKind: "local-audio",
sourceMode: "reference",
fileName: "rehearsal-take.wav",
format: "wav"
}
}
};
render(<App />);

fireEvent.click(screen.getByRole("button", { name: "Choose a local audio file" }));

await waitFor(() => {
expect(screen.getByTitle("rehearsal-take.wav")).toBeTruthy();
});
});

it("caps the YouTube URL input before import-path validation", () => {
render(<App />);

Expand Down Expand Up @@ -400,7 +422,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));

await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
Expand All @@ -425,7 +447,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));

await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
Expand All @@ -445,7 +467,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));

await waitFor(() => {
expect(screen.getByText(/choose a wav, mp3, flac, or m4a file/i)).toBeTruthy();
Expand All @@ -458,7 +480,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));

await waitFor(() => {
expect(screen.getByText(/could not read the selected audio file/i)).toBeTruthy();
Expand All @@ -478,7 +500,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
Expand Down Expand Up @@ -518,7 +540,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
Expand Down Expand Up @@ -552,7 +574,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -585,7 +607,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -622,7 +644,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -659,7 +681,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -714,7 +736,7 @@ describe("App", () => {

const { unmount } = render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -749,7 +771,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -774,7 +796,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -809,7 +831,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -838,7 +860,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => {
expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy();
});
Expand Down Expand Up @@ -912,15 +934,15 @@ describe("App", () => {
try {
render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy();
});

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /export handoff/i }));
Expand Down Expand Up @@ -956,7 +978,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -983,7 +1005,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -1006,7 +1028,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -1029,7 +1051,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -1046,7 +1068,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -1066,7 +1088,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand All @@ -1083,7 +1105,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy());

fireEvent.click(screen.getByRole("button", { name: /start analysis/i }));
Expand Down Expand Up @@ -1130,7 +1152,7 @@ describe("App", () => {

render(<App />);

fireEvent.click(screen.getByRole("button", { name: /choose local audio/i }));
fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/choose a wav, mp3, flac, or m4a file/i);
});
Expand Down
Loading
Loading