diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..99e43dfc9 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. +- Before a song is selected, customer-facing copy must name the next rehearsal action (use your own song). Do not leave the empty workspace as a text-only prompt. - 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..397400588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. +- Before local or YouTube source admission, the workspace empty card names using a local song as the next action and does not invent a bundled demo. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..dfde410fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Before a song is chosen, the workspace names Use my own song as the next rehearsal action and keeps a licensed demo as an honest later slice. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..3cfbf9042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. +Before a song is selected, the workspace empty card must name Use my own song as the next action rather than leaving a text-only prompt. + Agent execution and delegation rules live in `docs/agents/README.md`. PR canonicalization rules live in `docs/workflow/pr-continuity.md`. ## Common commands diff --git a/apps/desktop/src/App.localSelectionConcurrency.test.tsx b/apps/desktop/src/App.localSelectionConcurrency.test.tsx new file mode 100644 index 000000000..a96c953c4 --- /dev/null +++ b/apps/desktop/src/App.localSelectionConcurrency.test.tsx @@ -0,0 +1,159 @@ +import type { ProjectBootstrapSummary } from "@bandscope/shared-types"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; +import type { LocalAudioSelectionResult } from "./lib/analysis"; + +const analysisMocks = vi.hoisted(() => ({ + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(() => false), + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource: vi.fn<() => Promise>(), + startAnalysisJob: vi.fn(), + subscribeToAnalysisJobUpdates: vi.fn(async () => () => undefined), +})); + +vi.mock("./lib/analysis", () => ({ + createDefaultAnalysisRequest: () => ({ + sourceKind: "demo", + sourceLabel: "Late Night Set", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"], + }), + getAnalysisJobStatus: analysisMocks.getAnalysisJobStatus, + importYoutubeUrl: analysisMocks.importYoutubeUrl, + isSupportedYoutubeUrl: analysisMocks.isSupportedYoutubeUrl, + loadProject: analysisMocks.loadProject, + MAX_YOUTUBE_URL_LENGTH: 2048, + saveProject: analysisMocks.saveProject, + selectLocalAudioSource: analysisMocks.selectLocalAudioSource, + startAnalysisJob: analysisMocks.startAnalysisJob, + subscribeToAnalysisJobUpdates: analysisMocks.subscribeToAnalysisJobUpdates, +})); + +vi.mock("./features/score/ScoreView", () => ({ + ScoreView: () =>
Score view
, +})); + +const selectedBootstrap = { + projectId: "project-local-intake", + sourceMode: "reference", + projectRoot: "/tmp/bandscope/projects/project-local-intake", + cacheRoot: "/tmp/bandscope/cache/project-local-intake", + tempRoot: "/tmp/bandscope/temp/project-local-intake", + source: { + sourcePath: "/Users/test/Music/selected-song.wav", + fileName: "selected-song.wav", + extension: "wav", + fileSizeBytes: 1024, + }, +} satisfies ProjectBootstrapSummary; + +type SuccessfulLocalAudioSelection = Extract; + +/** + * Security Notes: + * - The selected path is a synthetic test fixture and is not asserted as buyer-visible copy. + * - This test mocks the existing local-picker boundary and adds no filesystem, network, or IPC authority. + */ +describe("App local song intake concurrency", () => { + beforeEach(() => { + for (const mock of Object.values(analysisMocks)) { + mock.mockReset(); + } + analysisMocks.isSupportedYoutubeUrl.mockReturnValue(false); + analysisMocks.subscribeToAnalysisJobUpdates.mockResolvedValue(() => undefined); + }); + + it("allows only one local picker while the first selection is pending", async () => { + let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined; + analysisMocks.selectLocalAudioSource.mockImplementation( + () => + new Promise((resolve) => { + resolveSelection = resolve; + }), + ); + + render(); + + const emptyAction = screen.getByRole("button", { name: "Use my own song" }); + const headerAction = screen.getByRole("button", { name: "Choose local audio" }); + fireEvent.click(emptyAction); + + await waitFor(() => { + expect(emptyAction).toBeDisabled(); + expect(headerAction).toBeDisabled(); + }); + + fireEvent.click(emptyAction); + fireEvent.click(headerAction); + emptyAction.removeAttribute("disabled"); + expect(emptyAction).not.toBeDisabled(); + fireEvent.click(emptyAction); + expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(1); + + resolveSelection?.({ ok: true, bootstrap: selectedBootstrap }); + await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy()); + }); + + it("blocks every YouTube source control while the local picker owns source selection", async () => { + let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined; + analysisMocks.isSupportedYoutubeUrl.mockReturnValue(true); + analysisMocks.selectLocalAudioSource.mockImplementation( + () => + new Promise((resolve) => { + resolveSelection = resolve; + }), + ); + + render(); + + const youtubeInput = screen.getByRole("textbox", { name: "YouTube URL" }); + const youtubeImport = screen.getByRole("button", { name: "Import YouTube" }); + fireEvent.change(youtubeInput, { target: { value: "https://www.youtube.com/watch?v=demo" } }); + expect(youtubeImport).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "Clear YouTube URL" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Use my own song" })); + + await waitFor(() => { + expect(youtubeInput).toBeDisabled(); + expect(youtubeImport).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Clear YouTube URL" })).toBeNull(); + }); + + youtubeImport.removeAttribute("disabled"); + expect(youtubeImport).not.toBeDisabled(); + fireEvent.click(youtubeImport); + expect(analysisMocks.importYoutubeUrl).not.toHaveBeenCalled(); + + resolveSelection?.({ ok: true, bootstrap: selectedBootstrap }); + await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy()); + }); + + it("blocks analysis while a replacement local picker is pending", async () => { + let resolveSelection: ((value: SuccessfulLocalAudioSelection) => void) | undefined; + analysisMocks.selectLocalAudioSource + .mockResolvedValueOnce({ ok: true, bootstrap: selectedBootstrap }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSelection = resolve; + }), + ); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Use my own song" })); + await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy()); + + const startAnalysis = screen.getByRole("button", { name: "Start analysis" }); + expect(startAnalysis).not.toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Choose local audio" })); + + await waitFor(() => expect(startAnalysis).toBeDisabled()); + + resolveSelection?.({ ok: true, bootstrap: selectedBootstrap }); + await waitFor(() => expect(startAnalysis).not.toBeDisabled()); + }); +}); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..320088ff3 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -248,6 +248,25 @@ describe("App", () => { expect(screen.getByText(/YouTube only leaves the app when you choose import/i)).toBeTruthy(); }); + it("names using a local song as the empty-workspace next action", () => { + render(); + + expect(screen.getByRole("heading", { name: "Start tonight's rehearsal" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Use my own song" })).toBeTruthy(); + expect(screen.getByText(/licensed demo is not bundled yet/i)).toBeTruthy(); + }); + + it("starts local file intake from the empty-workspace next action", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Use my own song" })); + + await waitFor(() => { + expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy(); + }); + }); + it("renders localized Korean shell copy for buyer-demo surfaces", () => { const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR"); @@ -260,6 +279,7 @@ describe("App", () => { expect(screen.getByRole("button", { name: /^작업 공간$/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /프로젝트 열기/i })).toBeTruthy(); expect(screen.getByRole("button", { name: /유튜브 가져오기/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: "내 곡 사용하기" })).toBeTruthy(); expect(screen.getByText(/로컬 우선/i)).toBeTruthy(); expect(screen.getByText(/합주 지도는 이 기기에 머뭅니다/i)).toBeTruthy(); expect(screen.getByText(/^템포$/i)).toBeTruthy(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..bb689c7df 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -263,6 +263,7 @@ export function App() { const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); const [youtubeUrl, setYoutubeUrl] = useState(""); const [isImporting, setIsImporting] = useState(false); + const [isChoosingLocalAudio, setIsChoosingLocalAudio] = useState(false); const [activeView, setActiveView] = useState("workspace"); const activeJobIdRef = useRef(null); const youtubeInputRef = useRef(null); @@ -387,6 +388,10 @@ export function App() { /** Documented. */ const handleStartAnalysis = async () => { + if (isChoosingLocalAudio) { + return; + } + const submittedBootstrap = selectedBootstrap; setJobError(null); setJobResult(null); @@ -415,22 +420,35 @@ export function App() { /** Documented. */ const handleChooseLocalAudio = async () => { - setSelectionError(null); - setSelectionErrorSource(null); - const selection = await selectLocalAudioSource(); - if (selection.ok) { - setSelectedBootstrap(selection.bootstrap); + if (isChoosingLocalAudio) { return; } - setSelectedBootstrap(null); - setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); - setSelectionErrorSource("local"); - setJobStatus(null); + setSelectionError(null); + setSelectionErrorSource(null); + setIsChoosingLocalAudio(true); + try { + const selection = await selectLocalAudioSource(); + if (selection.ok) { + setSelectedBootstrap(selection.bootstrap); + return; + } + + setSelectedBootstrap(null); + setSelectionError(safeErrorDetail(selection.error.message, t("unsupportedLocalAudio"))); + setSelectionErrorSource("local"); + setJobStatus(null); + } finally { + setIsChoosingLocalAudio(false); + } }; /** Documented. */ const handleImportYoutube = async () => { + if (isChoosingLocalAudio) { + return; + } + setSelectionError(null); setSelectionErrorSource(null); const normalizedUrl = youtubeUrl.trim(); @@ -514,7 +532,7 @@ export function App() { if (jobResult) { return ; } - return ; + return { void handleChooseLocalAudio(); }} chooseDisabled={isImporting || isChoosingLocalAudio} />; }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; @@ -682,7 +700,7 @@ export function App() {
+

{t("firstRunEmptyDemoNote")}

); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..63488283b 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -27,9 +27,12 @@ "confidenceLevelHigh": "Ready to trust", "provenanceSourceModel": "Auto-detected", "provenanceSourceUser": "User-confirmed", - "workspaceReadyToAnalyzeTitle": "Ready to Analyze", "workspaceAnalyzingAudioTitle": "Analyzing Audio", - "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "firstRunEmptyTitle": "Start tonight's rehearsal", + "firstRunEmptyGuidance": "Choose a song on this device. BandScope keeps the file local and will not invent a rehearsal map until you analyze.", + "firstRunEmptyLocalFirst": "Your song stays on this device.", + "firstRunUseOwnSong": "Use my own song", + "firstRunEmptyDemoNote": "A licensed demo is not bundled yet. Use your own song to start tonight.", "workspaceLoadingState": "Analyzing the song's form and instrument roles...", "workspaceErrorState": "An error occurred during analysis. Please try again.", "workspaceRehearsalMapLabel": "Tonight's rehearsal map", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..4ab2f539b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -27,9 +27,12 @@ "confidenceLevelHigh": "믿고 가져가도 됨", "provenanceSourceModel": "자동 추정", "provenanceSourceUser": "사용자 확인", - "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", - "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "firstRunEmptyTitle": "오늘 합주를 시작하세요", + "firstRunEmptyGuidance": "이 기기의 곡을 선택하세요. 파일은 기기에만 남고, 분석이 끝나기 전에 BandScope는 합주 지도를 만들지 않습니다.", + "firstRunEmptyLocalFirst": "선택한 곡은 이 기기에만 남습니다.", + "firstRunUseOwnSong": "내 곡 사용하기", + "firstRunEmptyDemoNote": "라이선스가 확인된 데모 곡은 아직 포함되지 않았습니다. 오늘 합주를 시작하려면 내 곡을 사용하세요.", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도",