From a6d5b6f5d2191245c5a52c4af0a7cd4e67c2200a Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 22 Aug 2026 06:11:31 +0000 Subject: [PATCH 01/12] feat(workspace): name using your own song as the first next action The empty rehearsal card now starts tonight's work from a local file instead of a text-only prompt. A licensed demo remains an honest later slice of #964 and is not invented here. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + apps/desktop/src/App.test.tsx | 20 ++++++++ apps/desktop/src/App.tsx | 2 +- .../src/features/workspace/Workspace.test.tsx | 4 +- .../workspace/WorkspaceStates.test.tsx | 51 +++++++++++++++++++ .../features/workspace/WorkspaceStates.tsx | 29 +++++++++-- apps/desktop/src/locales/en/common.json | 5 ++ apps/desktop/src/locales/ko/common.json | 5 ++ 11 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..a8e5fdb35 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, 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 3302a6fc3..eb9650e8a 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 eea696893..05c6b936f 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. - 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 82c2c704a..6be37d079 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.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..6e126ae8a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -514,7 +514,7 @@ export function App() { if (jobResult) { return ; } - return ; + return { void handleChooseLocalAudio(); }} chooseDisabled={analysisInFlight || isStarting || isImporting} />; }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..95ef66237 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -245,10 +245,10 @@ describe("Workspace", () => { it("localizes empty and loading state titles", () => { setNavigatorLanguage("ko-KR"); - render(); + render(); render(); - expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오늘 합주를 시작하세요" })).toBeTruthy(); expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); }); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx new file mode 100644 index 000000000..6b5bf25c5 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +describe("WorkspaceStates empty first-run card", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("names using a local song as the next rehearsal action", () => { + const onUseOwnSong = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Use my own song" })); + + expect(onUseOwnSong).toHaveBeenCalledTimes(1); + expect(screen.getByRole("heading", { name: "Start tonight's rehearsal" })).toBeTruthy(); + expect(screen.getByText(/keeps the file local/i)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /try the demo/i })).toBeNull(); + }); + + it("disables the next action while intake is already running", () => { + const onUseOwnSong = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Use my own song" })).toBeDisabled(); + }); + + it("localizes the empty next-action copy", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + ); + + expect(screen.getByRole("heading", { name: "오늘 합주를 시작하세요" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "내 곡 사용하기" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..a8de2229e 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,9 +1,16 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, Music, AlertCircle } from "lucide-react"; +import { Loader2, Music, AlertCircle, Upload } from "lucide-react"; -/** Documented. */ -export function EmptyState() { +/** Next-action handlers for the empty rehearsal workspace. */ +export interface EmptyStateProps { + onUseOwnSong: () => void; + chooseDisabled?: boolean; +} + +/** Render the empty card that names choosing a local song as the next action. */ +export function EmptyState({ onUseOwnSong, chooseDisabled = false }: EmptyStateProps) { const t = createTranslator(detectPreferredLocale()); return ( @@ -11,8 +18,20 @@ export function EmptyState() {
-

{t("workspaceReadyToAnalyzeTitle")}

-

{t("workspaceEmptyState")}

+

{t("firstRunEmptyTitle")}

+

{t("firstRunEmptyGuidance")}

+

{t("firstRunEmptyLocalFirst")}

+ +

{t("firstRunEmptyDemoNote")}

); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..5933e1720 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -30,6 +30,11 @@ "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 371884abb..7332a0294 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -30,6 +30,11 @@ "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "firstRunEmptyTitle": "오늘 합주를 시작하세요", + "firstRunEmptyGuidance": "이 기기의 곡을 선택하세요. 파일은 기기에만 남고, 분석이 끝나기 전에 BandScope는 합주 지도를 만들지 않습니다.", + "firstRunEmptyLocalFirst": "선택한 곡은 이 기기에만 남습니다.", + "firstRunUseOwnSong": "내 곡 사용하기", + "firstRunEmptyDemoNote": "라이선스가 확인된 데모 곡은 아직 포함되지 않았습니다. 오늘 합주를 시작하려면 내 곡을 사용하세요.", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도", From e929b60e2d5374fd925e73a9fd1071e3f17fb9e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:15:04 -0700 Subject: [PATCH 02/12] test(workspace): prevent concurrent local song intake --- .../App.localSelectionConcurrency.test.tsx | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/desktop/src/App.localSelectionConcurrency.test.tsx diff --git a/apps/desktop/src/App.localSelectionConcurrency.test.tsx b/apps/desktop/src/App.localSelectionConcurrency.test.tsx new file mode 100644 index 000000000..8887321eb --- /dev/null +++ b/apps/desktop/src/App.localSelectionConcurrency.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const analysisMocks = vi.hoisted(() => ({ + getAnalysisJobStatus: vi.fn(), + importYoutubeUrl: vi.fn(), + isSupportedYoutubeUrl: vi.fn(() => false), + loadProject: vi.fn(), + saveProject: vi.fn(), + selectLocalAudioSource: vi.fn(), + 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, + }, +}; + +/** + * 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: { ok: true; bootstrap: typeof selectedBootstrap }) => 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); + expect(analysisMocks.selectLocalAudioSource).toHaveBeenCalledTimes(1); + + resolveSelection?.({ ok: true, bootstrap: selectedBootstrap }); + await waitFor(() => expect(screen.getByText("selected-song.wav")).toBeTruthy()); + }); +}); From 843023f7dd92cc7d190f2f9a7758d55f48bf4abc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:18:33 -0700 Subject: [PATCH 03/12] fix(workspace): serialize local song intake --- apps/desktop/src/App.tsx | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6e126ae8a..3c29dbac3 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); @@ -415,18 +416,27 @@ 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. */ @@ -514,7 +524,7 @@ export function App() { if (jobResult) { return ; } - return { void handleChooseLocalAudio(); }} chooseDisabled={analysisInFlight || isStarting || isImporting} />; + return { void handleChooseLocalAudio(); }} chooseDisabled={isImporting || isChoosingLocalAudio} />; }; const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; @@ -682,7 +692,7 @@ export function App() {