diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..93ad8498b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..c0bd11879 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# ARCHITECTURE.md
-Last updated: 2026-03-11
+Last updated: 2026-08-25
## Brand source
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..64787304f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..1d0d78be6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/apps/desktop/src/features/score/ScoreView.project-context.test.tsx b/apps/desktop/src/features/score/ScoreView.project-context.test.tsx
new file mode 100644
index 000000000..b92d0100b
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.project-context.test.tsx
@@ -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: () =>
score viewer
+}));
+
+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();
+
+ 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();
+ });
+});
diff --git a/apps/desktop/src/features/score/ScoreView.project-scope.test.tsx b/apps/desktop/src/features/score/ScoreView.project-scope.test.tsx
new file mode 100644
index 000000000..b4847c194
--- /dev/null
+++ b/apps/desktop/src/features/score/ScoreView.project-scope.test.tsx
@@ -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 }) => (
+
+ {data ? `bytes:${data.length}` : "no-data"}
+ {fileName ? `:${fileName}` : ""}
+
+ )
+}));
+
+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) => Promise;
+};
+
+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(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
+ await waitFor(() => {
+ expect(screen.getByTestId("score-viewer")).toHaveTextContent("bytes:3:opener.pdf");
+ });
+
+ rerender();
+ expect(screen.queryByTestId("score-viewer")).not.toBeInTheDocument();
+
+ rerender();
+ 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(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open score: opener.pdf" }));
+ expect(await screen.findByText("Opening score PDF...")).toBeInTheDocument();
+
+ rerender();
+ await act(async () => {
+ resolveRead([9, 9]);
+ });
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data");
+ });
+ });
+});
diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx
index de4ccb95c..33c2aed94 100644
--- a/apps/desktop/src/features/score/ScoreView.test.tsx
+++ b/apps/desktop/src/features/score/ScoreView.test.tsx
@@ -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",
@@ -84,7 +84,8 @@ describe("ScoreView", () => {
render();
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();
diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx
index 72732450f..7398d2d9d 100644
--- a/apps/desktop/src/features/score/ScoreView.tsx
+++ b/apps/desktop/src/features/score/ScoreView.tsx
@@ -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";
@@ -47,6 +47,14 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
const [error, setError] = useState(null);
const readRequestRef = useRef(0);
+ useEffect(() => {
+ readRequestRef.current += 1;
+ setSelected(null);
+ setPdfBytes(null);
+ setIsOpening(false);
+ setError(null);
+ }, [projectId]);
+
/**
* 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
@@ -149,7 +157,10 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
{!projectId && (
-
+
{t("scoreRequiresProject")}
)}
@@ -169,7 +180,11 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
{t("scoreListTitle")}
{attachments.length === 0 ? (
- {t("scoreListEmpty")}
+ projectId ? (
+
+ {t("scoreListEmpty")}
+
+ ) : null
) : (
{attachments.map((attachment) => (
@@ -210,21 +225,23 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
- {isOpening ? (
-
-
-
- {t("scoreOpening")}
-
-
- ) : (
-
- )}
+ {projectId ? (
+ isOpening ? (
+
+
+
+ {t("scoreOpening")}
+
+
+ ) : (
+
+ )
+ ) : null}
);
}
diff --git a/apps/desktop/src/features/score/ScoreViewer.test.tsx b/apps/desktop/src/features/score/ScoreViewer.test.tsx
index 3ac2dd605..d1ab2c6a0 100644
--- a/apps/desktop/src/features/score/ScoreViewer.test.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.test.tsx
@@ -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",
@@ -90,8 +91,9 @@ describe("ScoreViewer", () => {
const onStatusChange = vi.fn();
render();
+ 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();
diff --git a/apps/desktop/src/features/score/ScoreViewer.tsx b/apps/desktop/src/features/score/ScoreViewer.tsx
index 82692469e..85650721e 100644
--- a/apps/desktop/src/features/score/ScoreViewer.tsx
+++ b/apps/desktop/src/features/score/ScoreViewer.tsx
@@ -190,6 +190,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
+ {t("scoreViewerEmptyTitle")}
{t("scoreViewerEmpty")}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..89a314a99 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -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", () => {
@@ -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");
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..b68558388 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -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",
@@ -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",
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..65206a0cc 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -63,7 +63,8 @@
"roleSwitcherTitle": "악기/보컬 역할",
"allRoles": "전체 보기",
"overlapWarning": "충돌 주의",
- "scoreViewerEmpty": "첨부된 악보 PDF가 없습니다. 검증된 악보 PDF를 첨부하면 여기에 표시됩니다.",
+ "scoreViewerEmpty": "열린 악보가 없습니다. 위에서 악보를 추가한 다음 열어 합주 중에 보세요.",
+ "scoreViewerEmptyTitle": "열린 악보가 없습니다",
"scoreViewerLoading": "악보 PDF를 불러오는 중...",
"scoreViewerFailedTitle": "악보를 표시할 수 없습니다",
"scoreViewerRetry": "다시 시도",
@@ -77,7 +78,7 @@
"scoreViewTitle": "악보",
"scoreViewSubtitle": "현재 곡에 검증된 PDF 악보를 첨부하고 합주 중에 바로 펼쳐 볼 수 있습니다.",
"scoreListTitle": "첨부된 악보",
- "scoreListEmpty": "이 곡에 첨부된 악보가 아직 없습니다.",
+ "scoreListEmpty": "합주 중에 보려면 악보를 추가하세요.",
"scoreAttach": "악보 추가",
"scoreAttaching": "첨부하는 중...",
"scoreRemove": "삭제",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..5d9f520a6 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -80,6 +80,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
- `LoadingState` keeps `role="status"`, `aria-live="polite"`, `aria-atomic="true"`, and `aria-busy="true"`.
- `ErrorState` keeps `role="alert"`, `aria-live="assertive"`, and visible safe error detail copy.
- `EmptyState` must remain an actionable state card, not a blank placeholder panel.
+- Score list empty copy and `ScoreViewer` empty copy must name **Add a score** (or analyze first when no project is active) rather than describing a missing PDF.
- If a new workspace state is added in code, update Figma page 34 and page 33 audit evidence before merging.
## Pattern Backlog