From 822a44576223fd620c05c44498ea1353edaffc16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:21:08 +0000 Subject: [PATCH 1/9] feat(workspace): start analysis from the first-run card After a local or YouTube source is admitted, replace the empty-file prompt with a first-run card that names Analyze this song as the next action and records tonight's part before the job starts. This is a #964 first-run buyer slice. It does not add a mocked demo analysis path and does not invent a parallel MIR product. Security Notes: - Untrusted input: selected file names are reduced to a basename before any first-run attribute is emitted; role IDs are a closed allowlist. - Trust boundary: UI role choice maps onto existing analysis roleFocus values. Local paths never render. - Safe failure: analysis still starts only after an explicit click. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + apps/desktop/src/App.test.tsx | 94 ++++++++++++++++ apps/desktop/src/App.tsx | 24 +++- .../workspace/WorkspaceStates.test.tsx | 87 ++++++++++++++ .../features/workspace/WorkspaceStates.tsx | 106 +++++++++++++++++- .../features/workspace/firstRunRoles.test.ts | 33 ++++++ .../src/features/workspace/firstRunRoles.ts | 47 ++++++++ apps/desktop/src/locales/en/common.json | 11 ++ apps/desktop/src/locales/ko/common.json | 11 ++ 12 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/features/workspace/WorkspaceStates.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstRunRoles.test.ts create mode 100644 apps/desktop/src/features/workspace/firstRunRoles.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..fb69d1288 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. +- After a song is selected, customer-facing copy must enable the next rehearsal action (pick tonight's part and analyze). Do not leave a selected source on the empty-file 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..c79b14659 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. +- After local or YouTube source admission, the workspace first-run card names analyze as the next action and records an explicit role focus before the job starts. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..af1747fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After a local song is chosen, the workspace names Analyze this song as the next action and lets the player pick tonight's part before analysis starts. - 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..52a011df6 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. +After a local or YouTube source is selected, the workspace must show a first-run card that starts analysis for the chosen part rather than repeating the empty-file 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..0f7f83b9c 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1616,4 +1616,98 @@ describe("App", () => { expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); + + it("turns a selected local song into a first-run analyze card", async () => { + tauriInvoke.mockResolvedValueOnce(bootstrapResponse()); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Analyze this song" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "Whole band" })).toHaveAttribute("aria-checked", "true"); + expect(screen.queryByText("/Users/test/Music/late-night-set.wav")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Ready to Analyze" })).toBeNull(); + }); + + it("starts analysis from the first-run card with the chosen part", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-first-run", + state: "queued", + progressLabel: "Queued for analysis" + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("radio", { name: "Lead vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Analyze this song" })); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["lead-vocal"] + } + }); + }); + }); + + it("lets the first-run card choose a different local file", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(bootstrapResponse({ + projectId: "project-2", + source: { + sourcePath: "/Users/test/Music/next-song.wav", + fileName: "next-song.wav", + extension: "wav", + fileSizeBytes: 2048000 + } + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Choose a different file" })); + + await waitFor(() => { + expect(screen.getByText(/next-song\.wav/i)).toBeTruthy(); + }); + expect(screen.getByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + expect(screen.queryByText("/Users/test/Music/next-song.wav")).toBeNull(); + }); + + it("keeps header start-analysis on the whole-band role until a part is chosen", async () => { + tauriInvoke + .mockResolvedValueOnce(bootstrapResponse()) + .mockResolvedValueOnce(jobStatusResponse({ + jobId: "job-header-role", + state: "queued", + progressLabel: "Queued for analysis" + })); + render(); + + fireEvent.click(screen.getByRole("button", { name: /^Choose local audio$/i })); + expect(await screen.findByRole("heading", { name: "Tonight's song is ready" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /^Start analysis$/i })); + + await waitFor(() => { + expect(tauriInvoke).toHaveBeenNthCalledWith(2, "start_analysis_job", { + request: { + sourceKind: "local_audio", + projectId: "project-1", + sourceLabel: "late-night-set.wav", + roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + } + }); + }); + }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..5f2ec53c0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -46,7 +46,8 @@ import { import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; -import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { EmptyState, ErrorState, FirstRunState, LoadingState } from "./features/workspace/WorkspaceStates"; +import { roleFocusForFirstRun, type FirstRunRoleId } from "./features/workspace/firstRunRoles"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; @@ -258,6 +259,7 @@ export function App() { const [renderedProgressPercent, setRenderedProgressPercent] = useState(undefined); const [isStarting, setIsStarting] = useState(false); const [selectedBootstrap, setSelectedBootstrap] = useState(null); + const [firstRunRoleId, setFirstRunRoleId] = useState("whole-band"); const [activeAnalysisBootstrap, setActiveAnalysisBootstrap] = useState(null); const [selectionError, setSelectionError] = useState(null); const [selectionErrorSource, setSelectionErrorSource] = useState<"local" | "youtube" | null>(null); @@ -273,7 +275,7 @@ export function App() { sourceKind: "local_audio", projectId: selectedBootstrap.projectId, sourceLabel: selectedBootstrap.source.fileName, - roleFocus: defaultRequest.roleFocus + roleFocus: roleFocusForFirstRun(firstRunRoleId) } : defaultRequest; @@ -420,6 +422,7 @@ export function App() { const selection = await selectLocalAudioSource(); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setFirstRunRoleId("whole-band"); return; } @@ -451,6 +454,7 @@ export function App() { const selection = await importYoutubeUrl(normalizedUrl); if (selection.ok) { setSelectedBootstrap(selection.bootstrap); + setFirstRunRoleId("whole-band"); setYoutubeUrl(""); } else { setSelectionError(safeErrorDetail(selection.error.message, t("youtubeImportFailed"))); @@ -514,6 +518,22 @@ export function App() { if (jobResult) { return ; } + if (selectedBootstrap) { + return ( + { + void handleStartAnalysis(); + }} + onChooseDifferentFile={() => { + void handleChooseLocalAudio(); + }} + analysisDisabled={analysisInFlight || isStarting || isImporting} + /> + ); + } return ; }; 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..ab6abe5b2 --- /dev/null +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EmptyState, FirstRunState, LoadingState } from "./WorkspaceStates"; + +const originalLanguage = window.navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: language + }); +} + +describe("WorkspaceStates first-run card", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps the empty card text-only until a song is selected", () => { + render(); + expect(screen.getByRole("heading", { name: "Ready to Analyze" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Analyze this song" })).toBeNull(); + }); + + it("names analyze as the next action after a song is selected", () => { + const onSelectRole = vi.fn(); + const onStartAnalysis = vi.fn(); + const onChooseDifferentFile = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("radio", { name: "Lead vocal" })); + fireEvent.click(screen.getByRole("button", { name: "Analyze this song" })); + fireEvent.click(screen.getByRole("button", { name: "Choose a different file" })); + + expect(onSelectRole).toHaveBeenCalledWith("lead-vocal"); + expect(onStartAnalysis).toHaveBeenCalledTimes(1); + expect(onChooseDifferentFile).toHaveBeenCalledTimes(1); + expect(screen.queryByText("/Users/test/Music/rehearsal-take.wav")).toBeNull(); + }); + + it("does not render local path segments from a selected file name", () => { + render( + + ); + + expect(screen.queryByText(/\/Users\/test/)).toBeNull(); + expect(document.querySelector('[data-selected-audio="late-night-set.wav"]')).toBeTruthy(); + }); + + it("localizes the first-run next-action copy", () => { + setNavigatorLanguage("ko-KR"); + render( + <> + + + + + ); + + expect(screen.getByRole("heading", { name: "분석 준비 완료" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오디오 분석 중" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "오늘 합주할 곡이 준비됐어요" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "이 곡 분석하기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "다른 파일 선택" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "리드 보컬" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 8f9aba1b1..e7ff77a13 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,6 +1,19 @@ -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, type TranslationKey } 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, Play, Upload } from "lucide-react"; +import { + FIRST_RUN_ROLE_OPTIONS, + displaySelectedAudioName, + type FirstRunRoleId +} from "./firstRunRoles"; + +const FIRST_RUN_ROLE_LABELS = { + "whole-band": "firstRunRoleWholeBand", + "lead-vocal": "firstRunRoleLeadVocal", + "bass-guitar": "firstRunRoleBass", + "keys-right": "firstRunRoleKeys" +} as const satisfies Record; /** Documented. */ export function EmptyState() { @@ -18,6 +31,95 @@ export function EmptyState() { ); } +/** Next-action handlers after a local or YouTube source is admitted. */ +export interface FirstRunStateProps { + fileName: string; + selectedRoleId: FirstRunRoleId; + onSelectRole: (roleId: FirstRunRoleId) => void; + onStartAnalysis: () => void; + onChooseDifferentFile: () => void; + analysisDisabled?: boolean; +} + +/** Render the first-run card that names analyze as the next rehearsal action. */ +export function FirstRunState({ + fileName, + selectedRoleId, + onSelectRole, + onStartAnalysis, + onChooseDifferentFile, + analysisDisabled = false +}: FirstRunStateProps) { + const t = createTranslator(detectPreferredLocale()); + const safeFileName = displaySelectedAudioName(fileName); + + return ( + + +
+
+

{t("firstRunTitle")}

+

{t("firstRunGuidance")}

+

{t("firstRunLocalFirst")}

+ +
+ {FIRST_RUN_ROLE_OPTIONS.map((option) => { + const checked = selectedRoleId === option.id; + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} + /** Documented. */ export function LoadingState() { const t = createTranslator(detectPreferredLocale()); diff --git a/apps/desktop/src/features/workspace/firstRunRoles.test.ts b/apps/desktop/src/features/workspace/firstRunRoles.test.ts new file mode 100644 index 000000000..81ab4b8f7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRunRoles.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + FIRST_RUN_WHOLE_BAND_ROLE_FOCUS, + displaySelectedAudioName, + isFirstRunRoleId, + roleFocusForFirstRun +} from "./firstRunRoles"; + +describe("firstRunRoles", () => { + it("admits only the closed first-run role choices", () => { + expect(isFirstRunRoleId("whole-band")).toBe(true); + expect(isFirstRunRoleId("lead-vocal")).toBe(true); + expect(isFirstRunRoleId("bass-guitar")).toBe(true); + expect(isFirstRunRoleId("keys-right")).toBe(true); + expect(isFirstRunRoleId("drums")).toBe(false); + expect(isFirstRunRoleId("")).toBe(false); + }); + + it("maps each admitted role onto existing analysis role IDs", () => { + expect(roleFocusForFirstRun("whole-band")).toEqual([...FIRST_RUN_WHOLE_BAND_ROLE_FOCUS]); + expect(roleFocusForFirstRun("lead-vocal")).toEqual(["lead-vocal"]); + expect(roleFocusForFirstRun("bass-guitar")).toEqual(["bass-guitar"]); + expect(roleFocusForFirstRun("keys-right")).toEqual(["keys-right"]); + }); + + it("never renders local path segments in the selected-song label", () => { + expect(displaySelectedAudioName("rehearsal-take.wav")).toBe("rehearsal-take.wav"); + expect(displaySelectedAudioName("/Users/test/Music/late-night-set.wav")).toBe("late-night-set.wav"); + expect(displaySelectedAudioName("C:\\Users\\test\\Music\\late-night-set.wav")).toBe("late-night-set.wav"); + expect(displaySelectedAudioName("..")).toBe(""); + expect(displaySelectedAudioName("")).toBe(""); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstRunRoles.ts b/apps/desktop/src/features/workspace/firstRunRoles.ts new file mode 100644 index 000000000..050b73663 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstRunRoles.ts @@ -0,0 +1,47 @@ +/** Closed first-run role choices that map onto admitted analysis role IDs. */ + +export const FIRST_RUN_WHOLE_BAND_ROLE_FOCUS = [ + "bass-guitar", + "keys-right", + "lead-vocal" +] as const; + +export const FIRST_RUN_ROLE_OPTIONS = [ + { id: "whole-band", roleFocus: FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }, + { id: "lead-vocal", roleFocus: ["lead-vocal"] }, + { id: "bass-guitar", roleFocus: ["bass-guitar"] }, + { id: "keys-right", roleFocus: ["keys-right"] } +] as const; + +export type FirstRunRoleId = (typeof FIRST_RUN_ROLE_OPTIONS)[number]["id"]; + +const FIRST_RUN_ROLE_IDS: ReadonlySet = new Set( + FIRST_RUN_ROLE_OPTIONS.map((option) => option.id) +); + +/** Return whether the value is an admitted first-run role choice. */ +export function isFirstRunRoleId(value: string): value is FirstRunRoleId { + return FIRST_RUN_ROLE_IDS.has(value); +} + +/** Resolve the analysis roleFocus for an admitted first-run choice. */ +export function roleFocusForFirstRun(roleId: FirstRunRoleId): string[] { + const option = FIRST_RUN_ROLE_OPTIONS.find((entry) => entry.id === roleId); + return option ? [...option.roleFocus] : [...FIRST_RUN_WHOLE_BAND_ROLE_FOCUS]; +} + +/** Keep only a basename so first-run copy never renders a local path. */ +export function displaySelectedAudioName(fileName: string): string { + const trimmed = fileName.trim(); + if (!trimmed) { + return ""; + } + + const segments = trimmed.split(/[\\/]+/u).filter((segment) => segment.length > 0); + const base = segments.at(-1) ?? ""; + if (!base || base === "." || base === "..") { + return ""; + } + + return base.replaceAll("\0", "").slice(0, 255); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..1a6cef8b7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -30,6 +30,17 @@ "workspaceReadyToAnalyzeTitle": "Ready to Analyze", "workspaceAnalyzingAudioTitle": "Analyzing Audio", "workspaceEmptyState": "Choose an audio file to prepare for your rehearsal.", + "firstRunTitle": "Tonight's song is ready", + "firstRunGuidance": "Pick the part you are playing, then analyze. BandScope will not invent a rehearsal map until that analysis finishes.", + "firstRunLocalFirst": "Your song stays on this device.", + "firstRunFileLabel": "Selected song", + "firstRunRoleLabel": "Tonight's part", + "firstRunRoleWholeBand": "Whole band", + "firstRunRoleLeadVocal": "Lead vocal", + "firstRunRoleBass": "Bass", + "firstRunRoleKeys": "Keys", + "firstRunAnalyze": "Analyze this song", + "firstRunChooseDifferent": "Choose a different file", "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..8c62d9384 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -30,6 +30,17 @@ "workspaceReadyToAnalyzeTitle": "분석 준비 완료", "workspaceAnalyzingAudioTitle": "오디오 분석 중", "workspaceEmptyState": "합주할 곡의 오디오 파일을 선택해주세요.", + "firstRunTitle": "오늘 합주할 곡이 준비됐어요", + "firstRunGuidance": "맡는 파트를 고른 다음 분석을 시작하세요. 분석이 끝나기 전에 BandScope는 합주 지도를 만들어내지 않습니다.", + "firstRunLocalFirst": "선택한 곡은 이 기기에만 남습니다.", + "firstRunFileLabel": "선택한 곡", + "firstRunRoleLabel": "오늘 맡을 파트", + "firstRunRoleWholeBand": "밴드 전체", + "firstRunRoleLeadVocal": "리드 보컬", + "firstRunRoleBass": "베이스", + "firstRunRoleKeys": "키보드", + "firstRunAnalyze": "이 곡 분석하기", + "firstRunChooseDifferent": "다른 파일 선택", "workspaceLoadingState": "곡의 폼과 악기별 역할을 분석하고 있습니다...", "workspaceErrorState": "분석 중 오류가 발생했습니다. 다시 시도해주세요.", "workspaceRehearsalMapLabel": "오늘의 합주 지도", From a80d79ed08f662948f4b5470f4e8e23a9b4523d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:24:07 -0700 Subject: [PATCH 2/9] docs(workspace): document first-run role exports --- apps/desktop/src/features/workspace/firstRunRoles.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstRunRoles.ts b/apps/desktop/src/features/workspace/firstRunRoles.ts index 050b73663..9b2bd298a 100644 --- a/apps/desktop/src/features/workspace/firstRunRoles.ts +++ b/apps/desktop/src/features/workspace/firstRunRoles.ts @@ -1,11 +1,11 @@ -/** Closed first-run role choices that map onto admitted analysis role IDs. */ - +/** Role-focus IDs used when first-run analysis covers the whole band. */ export const FIRST_RUN_WHOLE_BAND_ROLE_FOCUS = [ "bass-guitar", "keys-right", "lead-vocal" ] as const; +/** Closed first-run role choices and their admitted analysis role-focus IDs. */ export const FIRST_RUN_ROLE_OPTIONS = [ { id: "whole-band", roleFocus: FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }, { id: "lead-vocal", roleFocus: ["lead-vocal"] }, @@ -13,6 +13,7 @@ export const FIRST_RUN_ROLE_OPTIONS = [ { id: "keys-right", roleFocus: ["keys-right"] } ] as const; +/** Identifier for an admitted first-run role choice. */ export type FirstRunRoleId = (typeof FIRST_RUN_ROLE_OPTIONS)[number]["id"]; const FIRST_RUN_ROLE_IDS: ReadonlySet = new Set( From 5cd3e9f423f79a861b1960659e3f9d533c4d9451 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 00:24:42 -0700 Subject: [PATCH 3/9] test(workspace): require visible selected-song basename --- apps/desktop/src/features/workspace/WorkspaceStates.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx index ab6abe5b2..1a9637142 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -46,7 +46,7 @@ describe("WorkspaceStates first-run card", () => { expect(screen.queryByText("/Users/test/Music/rehearsal-take.wav")).toBeNull(); }); - it("does not render local path segments from a selected file name", () => { + it("shows the selected song basename without exposing local path segments", () => { render( { ); expect(screen.queryByText(/\/Users\/test/)).toBeNull(); + expect(screen.getByText("Selected song")).toBeTruthy(); + expect(screen.getByText("late-night-set.wav")).toBeTruthy(); expect(document.querySelector('[data-selected-audio="late-night-set.wav"]')).toBeTruthy(); }); From 2f222f4a12fbad4f5edcbaed394ff91bf78c0f3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:18:37 -0700 Subject: [PATCH 4/9] fix(first-run): satisfy exported role contract lint --- apps/desktop/src/features/workspace/firstRunRoles.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstRunRoles.ts b/apps/desktop/src/features/workspace/firstRunRoles.ts index 9b2bd298a..a1c68dc04 100644 --- a/apps/desktop/src/features/workspace/firstRunRoles.ts +++ b/apps/desktop/src/features/workspace/firstRunRoles.ts @@ -1,18 +1,21 @@ /** Role-focus IDs used when first-run analysis covers the whole band. */ -export const FIRST_RUN_WHOLE_BAND_ROLE_FOCUS = [ +const FIRST_RUN_WHOLE_BAND_ROLE_FOCUS = [ "bass-guitar", "keys-right", "lead-vocal" ] as const; /** Closed first-run role choices and their admitted analysis role-focus IDs. */ -export const FIRST_RUN_ROLE_OPTIONS = [ +const FIRST_RUN_ROLE_OPTIONS = [ { id: "whole-band", roleFocus: FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }, { id: "lead-vocal", roleFocus: ["lead-vocal"] }, { id: "bass-guitar", roleFocus: ["bass-guitar"] }, { id: "keys-right", roleFocus: ["keys-right"] } ] as const; +/** Export the immutable first-run role contracts without duplicating declaration authority. */ +export { FIRST_RUN_ROLE_OPTIONS, FIRST_RUN_WHOLE_BAND_ROLE_FOCUS }; + /** Identifier for an admitted first-run role choice. */ export type FirstRunRoleId = (typeof FIRST_RUN_ROLE_OPTIONS)[number]["id"]; From 53c42c629541ea18b16be66ca46c778041cbfb8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:19:52 -0700 Subject: [PATCH 5/9] fix(first-run): show the selected song on the card --- apps/desktop/src/features/workspace/WorkspaceStates.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index e7ff77a13..5d1d93c02 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -65,6 +65,12 @@ export function FirstRunState({

{t("firstRunTitle")}

{t("firstRunGuidance")}

{t("firstRunLocalFirst")}

+ {safeFileName && ( +

+ {t("firstRunFileLabel")}{" "} + {safeFileName} +

+ )}
Date: Sun, 23 Aug 2026 02:20:20 -0700 Subject: [PATCH 6/9] docs(first-run): record local and YouTube source support --- CHANGELOG.md | 43 ++++++------------------------------------- 1 file changed, 6 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af1747fee..3f3230cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- After a local song is chosen, the workspace names Analyze this song as the next action and lets the player pick tonight's part before analysis starts. +- After a local song or admitted YouTube source is chosen, the workspace names Analyze this song as the next action and lets the player pick tonight's part before analysis starts. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -23,47 +23,16 @@ ### Fixed -- Stabilized YouTube import fallback behavior in browser and desktop dev paths. -- Guarded OSSF Scorecard execution so release-branch pushes skip unsupported non-default branch runs cleanly. +- Added a stable release packaging path for desktop artifacts. ## [0.1.1] - 2026-04-28 -### Added - -- Implemented rehearsal workspace design (Issue #107) -- Add capo and tuning detection heuristics (Issue #103) -- Add bandit security scan workflow - -### Fixed +### Changed -- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g -- Resolve npm audit vulnerabilities -- Fix ruff import sorting and formatting errors -- Add missing docstrings to tests -- Fix test configuration and typing issues +- Aligned first release-candidate packaging and documentation. -## [0.1.0] - 2026-03-27 +## [0.1.0] - 2026-04-28 ### Added -- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) -- Issue #40: Enforced 100% Python docstring and test coverage -- Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. -- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- Initial local-first BandScope rehearsal analysis surface. From 91036ea1fa86978762188955cda1ba6b958c4d5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:20:47 -0700 Subject: [PATCH 7/9] fix(changelog): preserve release history while clarifying sources --- CHANGELOG.md | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3230cd9..86ed67a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,16 +23,47 @@ ### Fixed -- Added a stable release packaging path for desktop artifacts. +- Stabilized YouTube import fallback behavior in browser and desktop dev paths. +- Guarded OSSF Scorecard execution so release-branch pushes skip unsupported non-default branch runs cleanly. ## [0.1.1] - 2026-04-28 -### Changed +### Added + +- Implemented rehearsal workspace design (Issue #107) +- Add capo and tuning detection heuristics (Issue #103) +- Add bandit security scan workflow + +### Fixed -- Aligned first release-candidate packaging and documentation. +- Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g +- Resolve npm audit vulnerabilities +- Fix ruff import sorting and formatting errors +- Add missing docstrings to tests +- Fix test configuration and typing issues -## [0.1.0] - 2026-04-28 +## [0.1.0] - 2026-03-27 ### Added -- Initial local-first BandScope rehearsal analysis surface. +- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #40: Enforced 100% Python docstring and test coverage +- Issue #32: Implemented local analysis orchestration and secure IPC boundaries +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. +- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From 2211502f6ca646e1198919882273f70aaa9b2a4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:22:14 -0700 Subject: [PATCH 8/9] test(first-run): require keyboard-operable role radios --- .../workspace/WorkspaceStates.test.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx index 1a9637142..469a903a3 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.test.tsx @@ -63,6 +63,30 @@ describe("WorkspaceStates first-run card", () => { expect(document.querySelector('[data-selected-audio="late-night-set.wav"]')).toBeTruthy(); }); + it("uses roving focus and arrow keys for the role radiogroup", () => { + const onSelectRole = vi.fn(); + render( + + ); + + const wholeBand = screen.getByRole("radio", { name: "Whole band" }); + const leadVocal = screen.getByRole("radio", { name: "Lead vocal" }); + expect(wholeBand).toHaveAttribute("tabindex", "0"); + expect(leadVocal).toHaveAttribute("tabindex", "-1"); + + wholeBand.focus(); + fireEvent.keyDown(wholeBand, { key: "ArrowRight" }); + + expect(onSelectRole).toHaveBeenCalledWith("lead-vocal"); + expect(document.activeElement).toBe(leadVocal); + }); + it("localizes the first-run next-action copy", () => { setNavigatorLanguage("ko-KR"); render( @@ -86,4 +110,4 @@ describe("WorkspaceStates first-run card", () => { expect(screen.getByRole("button", { name: "다른 파일 선택" })).toBeTruthy(); expect(screen.getByRole("radio", { name: "리드 보컬" })).toBeTruthy(); }); -}); +}); \ No newline at end of file From fd33104ca88030b5218e1c56a0cb683e2b50d7db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 02:24:18 -0700 Subject: [PATCH 9/9] fix(first-run): support keyboard role selection --- .../features/workspace/WorkspaceStates.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/WorkspaceStates.tsx b/apps/desktop/src/features/workspace/WorkspaceStates.tsx index 5d1d93c02..75eacf076 100644 --- a/apps/desktop/src/features/workspace/WorkspaceStates.tsx +++ b/apps/desktop/src/features/workspace/WorkspaceStates.tsx @@ -1,3 +1,4 @@ +import type { KeyboardEvent } from "react"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -15,6 +16,40 @@ const FIRST_RUN_ROLE_LABELS = { "keys-right": "firstRunRoleKeys" } as const satisfies Record; +/** Move a first-run role radio using the ARIA radiogroup keyboard pattern. */ +function handleRoleNavigation( + event: KeyboardEvent, + currentIndex: number, + onSelectRole: (roleId: FirstRunRoleId) => void +): void { + const lastIndex = FIRST_RUN_ROLE_OPTIONS.length - 1; + let nextIndex: number | null = null; + + if (event.key === "ArrowRight" || event.key === "ArrowDown") { + nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = lastIndex; + } + + if (nextIndex === null) { + return; + } + + event.preventDefault(); + const nextOption = FIRST_RUN_ROLE_OPTIONS[nextIndex]; + if (!nextOption) { + return; + } + onSelectRole(nextOption.id); + const group = event.currentTarget.closest('[role="radiogroup"]'); + const radios = group?.querySelectorAll('[role="radio"]'); + radios?.[nextIndex]?.focus(); +} + /** Documented. */ export function EmptyState() { const t = createTranslator(detectPreferredLocale()); @@ -77,7 +112,7 @@ export function FirstRunState({ aria-label={t("firstRunRoleLabel")} className="mt-8 flex w-full max-w-lg flex-wrap items-center justify-center gap-2" > - {FIRST_RUN_ROLE_OPTIONS.map((option) => { + {FIRST_RUN_ROLE_OPTIONS.map((option, optionIndex) => { const checked = selectedRoleId === option.id; return (