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 ? ( - - - - - ) : ( - - )} + {projectId ? ( + isOpening ? ( + + + + + ) : ( + + ) + ) : 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