Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 name the next action. Score empty states name **Add a score** (or analyze first when no project is active); do not leave a text-only “no PDF yet” dead end.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ARCHITECTURE.md

Last updated: 2026-03-11
Last updated: 2026-08-25
Comment thread
seonghobae marked this conversation as resolved.

## Brand source

Expand Down Expand Up @@ -68,6 +68,7 @@ Last updated: 2026-03-11
- BandScope is not only a shell around chord labels, stems, and ranges.
- The technical scope includes rehearsal-facing outputs for harmony, section roadmap, groove cues, role entry and dropout cues, simplification guidance, transposition or setup guidance, confidence flags, and rehearsal priority.
- These outputs must stay aligned with `docs/brand-story.md` rather than drifting back to a song-summary-only analyzer.
- Score empty states name **Add a score** as the next rehearsal action (or analyze first when no project workspace is active). They are not text-only placeholders.

## Analysis target model

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

- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
- Name **Add a score** as the next rehearsal action when the score list or viewer is empty, instead of describing the missing PDF.

## [0.1.3] - 2026-04-29

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Supporting packages:
## Key conventions

- Coverage is a hard gate: the Python engine requires 100% test coverage and 100% docstring coverage (Ruff `D100`–`D107` across `src`, `tests`, and repo scripts). Exported TypeScript declarations in `packages/shared-types` and `apps/desktop/src` require JSDoc with a description; `no-console` is an error.
- Empty score list and viewer copy must name **Add a score** (or analyze first when no project is active). Do not ship text-only “no PDF attached” dead ends.
- Gitflow: `develop` is the default branch; `feature/*` targets `develop`, `main` is the protected release branch. Direct pushes to protected branches are not allowed, and every merge needs the required checks plus a passing CodeRabbit review (see `CONTRIBUTING.md` and `docs/repository/gitflow.md`).
- The PR template (`.github/PULL_REQUEST_TEMPLATE.md`) requires a quickcheck confirmation, `Security Notes` (attack surface, trust boundary, mitigations, test points), a dependency/supply-chain checklist, and i18n impact.
- i18n: the UI ships Korean and English locales (`apps/desktop/src/locales/ko`, `en`). Any user-visible string change must update both.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { RehearsalSong } from "@bandscope/shared-types";
import { ScoreView } from "./ScoreView";

vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn()
}));

vi.mock("./ScoreViewer", () => ({
ScoreViewer: () => <div data-testid="score-viewer">score viewer</div>
}));

vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
scoreListTitle: "Attached scores",
scoreListEmpty: "Add a score to read it during rehearsal.",
scoreAttach: "Add score",
scoreRequiresProject:
"Scores attach to the active analysis project. Analyze local audio or a YouTube import first."
})[key] ?? key,
detectPreferredLocale: () => "en"
}));

function makeSong(): RehearsalSong {
return {
id: "song-1",
title: "Late Night Set",
sections: [],
exportSummary: { format: "cue-sheet", headline: "", focusSections: [] }
} as RehearsalSong;
}

describe("ScoreView project authority", () => {
it("names analyze-first instead of unavailable score actions without a project", () => {
render(<ScoreView song={makeSong()} projectId={null} onSongUpdate={vi.fn()} />);

expect(screen.getByRole("status")).toHaveTextContent(
"Analyze local audio or a YouTube import first."
);
expect(screen.queryByText("Add a score to read it during rehearsal.")).not.toBeInTheDocument();
expect(screen.queryByTestId("score-viewer")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add score" })).toBeDisabled();
});
});
122 changes: 122 additions & 0 deletions apps/desktop/src/features/score/ScoreView.project-scope.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RehearsalSong } from "@bandscope/shared-types";
import { invoke } from "@tauri-apps/api/core";
import { ScoreView } from "./ScoreView";

vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn()
}));

vi.mock("./ScoreViewer", () => ({
ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => (
<div data-testid="score-viewer">
{data ? `bytes:${data.length}` : "no-data"}
{fileName ? `:${fileName}` : ""}
</div>
)
}));

vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
scoreListTitle: "Attached scores",
scoreListEmpty: "Add a score to read it during rehearsal.",
scoreAttach: "Add score",
scoreAttaching: "Attaching...",
scoreRemove: "Remove",
scoreRemoveConfirm: "Remove {fileName} from this song?",
scoreOpen: "Open score",
scoreOpening: "Opening score PDF...",
scoreAttachFailed: "Could not attach the score PDF.",
scoreReadFailed: "Could not open the score PDF.",
scoreRemoveFailed: "Could not remove the score PDF.",
scoreRequiresProject: "Scores attach to the active analysis project."
})[key] ?? key,
detectPreferredLocale: () => "en"
}));

type TauriWindow = Window & {
__TAURI_INTERNALS__?: unknown;
__TAURI_INVOKE__?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
};

const tauriWindow = window as TauriWindow;
const mockInvoke = vi.mocked(invoke);
const SCORE_ID = "3f2c8f0e-1a2b-4c3d-8e9f-001122334455";

function makeSong(): RehearsalSong {
return {
id: "song-1",
title: "Late Night Set",
sections: [],
exportSummary: { format: "cue-sheet", headline: "", focusSections: [] },
scoreAttachments: [{ id: SCORE_ID, fileName: "opener.pdf" }]
} as RehearsalSong;
}

describe("ScoreView project-scoped viewer state", () => {
beforeEach(() => {
mockInvoke.mockReset();
tauriWindow.__TAURI_INTERNALS__ = { invoke: () => Promise.resolve(null) };
delete tauriWindow.__TAURI_INVOKE__;
});

afterEach(() => {
delete tauriWindow.__TAURI_INTERNALS__;
delete tauriWindow.__TAURI_INVOKE__;
vi.restoreAllMocks();
});

it("does not reuse opened PDF bytes after the active project changes", async () => {
mockInvoke.mockResolvedValueOnce([1, 2, 3]);
const song = makeSong();
const onSongUpdate = vi.fn();
const { rerender } = render(
<ScoreView song={song} projectId="project-a" onSongUpdate={onSongUpdate} />
);

fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
await waitFor(() => {
expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf");
});

rerender(<ScoreView song={song} projectId={null} onSongUpdate={onSongUpdate} />);
expect(screen.queryByTestId("score-viewer")).not.toBeInTheDocument();

rerender(<ScoreView song={song} projectId="project-b" onSongUpdate={onSongUpdate} />);
await waitFor(() => {
expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data");
});
expect(mockInvoke).toHaveBeenCalledTimes(1);
});

it("ignores a previous project's read completion after project authority is removed", async () => {
let resolveRead!: (value: unknown) => void;
mockInvoke.mockImplementationOnce(
() => new Promise((resolve) => {
resolveRead = resolve;
})
);
const song = makeSong();
const onSongUpdate = vi.fn();
const { rerender } = render(
<ScoreView song={song} projectId="project-a" onSongUpdate={onSongUpdate} />
);

fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
expect(await screen.findByText("Opening score PDF...")).toBeInTheDocument();

rerender(<ScoreView song={song} projectId={null} onSongUpdate={onSongUpdate} />);
await act(async () => {
resolveRead([9, 9]);
});
rerender(<ScoreView song={song} projectId="project-b" onSongUpdate={onSongUpdate} />);

await waitFor(() => {
expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data");
});
});
});
5 changes: 3 additions & 2 deletions apps/desktop/src/features/score/ScoreView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ vi.mock("../../i18n", () => ({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
scoreListTitle: "Attached scores",
scoreListEmpty: "No scores attached to this song yet.",
scoreListEmpty: "Add a score to read it during rehearsal.",
scoreAttach: "Add score",
scoreAttaching: "Attaching...",
scoreRemove: "Remove",
Expand Down Expand Up @@ -84,7 +84,8 @@ describe("ScoreView", () => {
render(<ScoreView song={makeSong()} projectId="project-1-2" onSongUpdate={vi.fn()} />);

expect(screen.getByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument();
expect(screen.getByText("No scores attached to this song yet.")).toBeInTheDocument();
expect(screen.getByText("Add a score to read it during rehearsal.")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Add a score to read it during rehearsal.");
expect(screen.getByRole("button", { name: "Add score" })).toBeEnabled();
expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data");
expect(mockInvoke).not.toHaveBeenCalled();
Expand Down
53 changes: 35 additions & 18 deletions apps/desktop/src/features/score/ScoreView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { FileMusic, FilePlus2, Loader2, Trash2 } from "lucide-react";
import type { RehearsalSong, ScoreAttachment } from "@bandscope/shared-types";
import { createTranslator, detectPreferredLocale } from "../../i18n";
Expand Down Expand Up @@ -47,6 +47,14 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
const [error, setError] = useState<string | null>(null);
const readRequestRef = useRef(0);

useEffect(() => {
readRequestRef.current += 1;
setSelected(null);
setPdfBytes(null);
setIsOpening(false);
setError(null);
}, [projectId]);
Comment thread
seonghobae marked this conversation as resolved.

/**
* Load the stored PDF bytes for an attachment into the viewer. Callers pass
* the active project id explicitly; the storage controls are only wired up
Expand Down Expand Up @@ -149,7 +157,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
</div>

{!projectId && (
<p className="rounded-xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-medium text-amber-100">
<p
className="rounded-xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm font-medium text-amber-100"
role="status"
>
{t("scoreRequiresProject")}
</p>
)}
Expand All @@ -169,7 +180,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
{t("scoreListTitle")}
</h3>
{attachments.length === 0 ? (
<p className="text-sm text-slate-400">{t("scoreListEmpty")}</p>
projectId ? (
<p className="text-sm text-slate-300" role="status">
{t("scoreListEmpty")}
</p>
) : null
) : (
<ul className="flex flex-col gap-2">
{attachments.map((attachment) => (
Expand Down Expand Up @@ -210,21 +225,23 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
</CardContent>
</Card>

{isOpening ? (
<Card
className="border-cyan-300/20 bg-slate-950/75 backdrop-blur-xl"
role="status"
aria-live="polite"
aria-busy="true"
>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="mb-4 size-10 animate-spin text-cyan-300" aria-hidden="true" />
<p className="animate-pulse text-slate-400">{t("scoreOpening")}</p>
</CardContent>
</Card>
) : (
<ScoreViewer data={pdfBytes} fileName={selected?.fileName} />
)}
{projectId ? (
isOpening ? (
<Card
className="border-cyan-300/20 bg-slate-950/75 backdrop-blur-xl"
role="status"
aria-live="polite"
aria-busy="true"
>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="mb-4 size-10 animate-spin text-cyan-300" aria-hidden="true" />
<p className="animate-pulse text-slate-400">{t("scoreOpening")}</p>
</CardContent>
</Card>
) : (
<ScoreViewer data={pdfBytes} fileName={selected?.fileName} />
)
) : null}
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
</section>
);
}
6 changes: 4 additions & 2 deletions apps/desktop/src/features/score/ScoreViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ vi.mock("./pdfjs", () => ({
vi.mock("../../i18n", () => ({
createTranslator: () => (key: string) =>
({
scoreViewerEmpty: "No score PDF attached. Attach a validated score PDF to view it here.",
scoreViewerEmpty: "No score is open. Add a score above, then open it to read during rehearsal.",
scoreViewerEmptyTitle: "No score is open",
scoreViewerLoading: "Loading score PDF...",
scoreViewerFailedTitle: "Could not display the score",
scoreViewerRetry: "Retry",
Expand Down Expand Up @@ -90,8 +91,9 @@ describe("ScoreViewer", () => {
const onStatusChange = vi.fn();
render(<ScoreViewer data={null} onStatusChange={onStatusChange} />);

expect(screen.getByRole("heading", { name: "No score is open" })).toBeInTheDocument();
expect(
screen.getByText("No score PDF attached. Attach a validated score PDF to view it here.")
screen.getByText("No score is open. Add a score above, then open it to read during rehearsal.")
).toBeInTheDocument();
expect(loadScorePdf).not.toHaveBeenCalled();
expect(onStatusChange).not.toHaveBeenCalled();
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/features/score/ScoreViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
<div className="mb-4 rounded-full border border-cyan-300/30 bg-cyan-300/10 p-4 text-cyan-200">
<FileMusic className="size-8" aria-hidden="true" />
</div>
<h3 className="mb-2 text-lg font-black text-white">{t("scoreViewerEmptyTitle")}</h3>
<p className="max-w-sm text-slate-400">{t("scoreViewerEmpty")}</p>
</CardContent>
</Card>
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { createTranslator, detectPreferredLocale } from "./index";
import enCommon from "../locales/en/common.json";
import koCommon from "../locales/ko/common.json";

describe("i18n", () => {
Expand Down Expand Up @@ -46,6 +47,18 @@ describe("i18n", () => {
});

describe("createTranslator", () => {
it("keeps English and Korean dictionaries aligned for score empty next actions", () => {
expect(Object.keys(enCommon).sort()).toEqual(Object.keys(koCommon).sort());
const tEn = createTranslator("en");
const tKo = createTranslator("ko");
expect(tEn("scoreListEmpty")).toMatch(/Add a score/);
expect(tEn("scoreViewerEmpty")).toMatch(/Add a score above/);
expect(tEn("scoreViewerEmptyTitle")).toBe("No score is open");
expect(tKo("scoreListEmpty")).toMatch(/악보를 추가/);
expect(tKo("scoreViewerEmpty")).toMatch(/악보를 추가한 다음/);
expect(tKo("scoreViewerEmptyTitle")).toBe("열린 악보가 없습니다");
});

it("translates to English by default", () => {
const t = createTranslator();
expect(t("appTitle")).toBe("BandScope");
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
"roleSwitcherTitle": "Role-specific View",
"allRoles": "All Roles",
"overlapWarning": "Clash warning",
"scoreViewerEmpty": "No score PDF attached. Attach a validated score PDF to view it here.",
"scoreViewerEmpty": "No score is open. Add a score above, then open it to read during rehearsal.",
"scoreViewerEmptyTitle": "No score is open",
"scoreViewerLoading": "Loading score PDF...",
"scoreViewerFailedTitle": "Could not display the score",
"scoreViewerRetry": "Retry",
Expand All @@ -77,7 +78,7 @@
"scoreViewTitle": "Score",
"scoreViewSubtitle": "Attach validated PDF scores to the current song and read them during rehearsal.",
"scoreListTitle": "Attached scores",
"scoreListEmpty": "No scores attached to this song yet.",
"scoreListEmpty": "Add a score to read it during rehearsal.",
"scoreAttach": "Add score",
"scoreAttaching": "Attaching...",
"scoreRemove": "Remove",
Expand Down
Loading
Loading