From d2641d87a67b55e1984808c70ece8514e4238b5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 10:07:17 +0000 Subject: [PATCH 1/3] feat(workspace): name tonight's export and priority actions After analysis, export, stems, and rehearsal-priority cards now start the next rehearsal step instead of describing the gap and stopping. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + .../workspace/SectionRoadmap.test.tsx | 10 ++ .../src/features/workspace/SectionRoadmap.tsx | 4 +- .../src/features/workspace/Workspace.test.tsx | 103 +++++++++++++++++- .../src/features/workspace/Workspace.tsx | 74 +++++++++++-- apps/desktop/src/locales/en/common.json | 9 ++ apps/desktop/src/locales/ko/common.json | 9 ++ docs/design-system/component-contract.md | 2 +- 11 files changed, 204 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..fea440846 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 analysis, export, stems, and rehearsal-priority copy must enable the next action (download tonight's cue sheet, save a chart, share a handoff, or open the first lock-in section). Do not leave those ready-state cards as dead-end descriptions. - 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..85a072cc8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. +- Ready-workspace export, stems, and rehearsal-priority cards must start tonight's next action: download the cue sheet, save a compact chart, share a handoff, or open the first lock-in section. Stem Lab remains a separate lane. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..42217b2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Ready-workspace export, stems, and rehearsal-priority cards now name the next rehearsal action: download tonight's cue sheet, save a compact chart, share a handoff, or open the first lock-in 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 82c2c704a..fb0956d14 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 analysis, export, stems, and rehearsal-priority cards must name a next action. Do not leave those ready-state surfaces as dead-end descriptions. + 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/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..02dbd2a96 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -60,4 +60,14 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("exposes a focusable id for each rehearsal section card", () => { + const song = createDemoRehearsalSong(); + + render(); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(card?.getAttribute("tabindex")).toBe("-1"); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..c87418249 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -106,7 +106,9 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..f22cacd7b 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -193,7 +193,7 @@ describe("Workspace", () => { }); render(); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: "Share a handoff file" })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); @@ -222,7 +222,7 @@ describe("Workspace", () => { }); render(); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: "Share a handoff file" })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); @@ -270,4 +270,103 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names rehearsal-first export and priority actions after analysis", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText("Print the cue sheet or send the handoff before you leave.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Share a handoff file" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Save a compact chart" })).toBeTruthy(); + expect(screen.getByText("Start with verse — that is tonight's first lock-in.")).toBeTruthy(); + expect(screen.getByText("Stems are not ready yet. Start with tonight's cue sheet.")).toBeTruthy(); + expect(screen.getAllByRole("button", { name: "Download tonight's cue sheet" }).length).toBe(2); + }); + + it("localizes ready-workspace export and priority actions", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText("나가기 전에 큐시트를 출력하거나 핸드오프를 보내세요.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "다음 연습용 핸드오프 보내기" })).toBeTruthy(); + expect(screen.getByText("오늘은 verse부터 잠그세요.")).toBeTruthy(); + expect(screen.getByRole("button", { name: "이 구간 열기" })).toBeTruthy(); + expect(screen.getByText("스템은 아직 준비되지 않았습니다. 오늘 큐시트로 먼저 시작하세요.")).toBeTruthy(); + }); + + it("opens the first rehearsal-priority section from the priorities card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open this section" })); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalled(); + expect(document.activeElement).toBe(card); + }); + + it("lets the stems card download tonight's cue sheet when stems are not ready", async () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const createObjectUrl = vi.fn(() => "blob:cuesheet"); + const revokeObjectUrl = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectUrl + }); + + render(); + const stemsCard = document.getElementById("workspace-stems-card"); + expect(stemsCard).toBeTruthy(); + fireEvent.click(screen.getAllByRole("button", { name: "Download tonight's cue sheet" })[1]!); + + const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; + expect(blob.type).toContain("text/csv"); + expect(click).toHaveBeenCalledTimes(1); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:cuesheet"); + }); + + it("keeps the priority action disabled when no section can be opened", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + expect(screen.getByText("Start with first pass — that is tonight's first lock-in.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Open this section" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("matches a rehearsal-priority section by id when the label is not the stored key", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.exportSummary = { + ...song.exportSummary, + focusSections: ["verse-1"] + }; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + expect(screen.getByText("Start with verse — that is tonight's first lock-in.")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Open this section" })); + expect(document.activeElement).toBe(document.getElementById("workspace-section-verse-1")); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..8d457a5d1 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -70,6 +70,30 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro } } +/** Return the first rehearsal-priority section the player should open. */ +function firstFocusSection(song: RehearsalSong): RehearsalSong["sections"][number] | undefined { + const requested = song.exportSummary?.focusSections?.[0]?.trim(); + if (requested) { + const match = song.sections.find( + (section) => section.label === requested || section.id === requested + ); + if (match) { + return match; + } + } + return song.sections[0]; +} + +/** Scroll and focus the matching section card on the rehearsal roadmap. */ +function focusWorkspaceSection(sectionId: string): void { + const node = document.getElementById(`workspace-section-${sectionId}`); + if (!(node instanceof HTMLElement)) { + return; + } + node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + node.focus(); +} + /** Documented. */ const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { return ( @@ -212,6 +236,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const focusSection = firstFocusSection(song); + const focusLabel = focusSection?.label ?? t("workspaceFocusFallback"); /** Documented. */ const handleExportCueSheet = () => { @@ -255,34 +281,40 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp {song.exportSummary?.headline || t("workspaceRehearsalFallback")} -
+
+

{t("workspaceExportNextHint")}

+
+
@@ -318,16 +350,42 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp )} -
+

{t("workspaceStemsLabel")}

-

Stem lanes will appear when separation results are available.

+

{t("workspaceStemsNextHint")}

+
-
+

{t("workspaceRehearsalPrioritiesLabel")}

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. + {t("workspacePrioritiesNextHint").replace("{section}", focusLabel)}

+
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..38e48ea2d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,6 +50,15 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", + "workspaceExportCueSheet": "Download tonight's cue sheet", + "workspaceExportChart": "Save a compact chart", + "workspaceExportHandoff": "Share a handoff file", + "workspaceExportNextHint": "Print the cue sheet or send the handoff before you leave.", + "workspacePrioritiesNextHint": "Start with {section} — that is tonight's first lock-in.", + "workspacePrioritiesOpenSection": "Open this section", + "workspaceStemsNextHint": "Stems are not ready yet. Start with tonight's cue sheet.", + "workspaceStemsDownloadCueSheet": "Download tonight's cue sheet", + "workspaceFocusFallback": "first pass", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", "sectionGrooveLabel": "Groove", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..685ad0ab5 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,6 +50,15 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", + "workspaceExportCueSheet": "오늘 쓸 큐시트 받기", + "workspaceExportChart": "한눈에 보는 차트 저장", + "workspaceExportHandoff": "다음 연습용 핸드오프 보내기", + "workspaceExportNextHint": "나가기 전에 큐시트를 출력하거나 핸드오프를 보내세요.", + "workspacePrioritiesNextHint": "오늘은 {section}부터 잠그세요.", + "workspacePrioritiesOpenSection": "이 구간 열기", + "workspaceStemsNextHint": "스템은 아직 준비되지 않았습니다. 오늘 큐시트로 먼저 시작하세요.", + "workspaceStemsDownloadCueSheet": "오늘 쓸 큐시트 받기", + "workspaceFocusFallback": "첫 패스", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..934e56dde 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -33,7 +33,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | -| Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | +| Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. Labels name the next rehearsal use, not the file format. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | ## Prop And State Mapping From 450af9158eee1ede747ed4923c60f96d144b808b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:21:34 +0900 Subject: [PATCH 2/3] test(workspace): track buyer-facing ready action names --- apps/desktop/src/App.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..7210b53e0 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -299,7 +299,7 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); - expect(screen.getByText(/Stems/i)).toBeTruthy(); + expect(screen.getByText(/^Stems$/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); }); @@ -923,7 +923,7 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); await waitFor(() => expect(screen.getByText(/next-song\.wav/i)).toBeTruthy()); - fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); + fireEvent.click(screen.getByRole("button", { name: /share a handoff file/i })); const blob = createObjectUrl.mock.calls[0]?.[0] as Blob; const payload = JSON.parse(await blob.text()); From 0c09689fc981d72c2a6d7505c0ab0c41a97d0ebe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:30:44 +0900 Subject: [PATCH 3/3] test(workspace): align command-board assertion with buyer copy --- apps/desktop/src/App.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 7210b53e0..41bb766c5 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -301,7 +301,7 @@ describe("App", () => { expect(screen.getByText(/Roles & Harmony/i)).toBeTruthy(); expect(screen.getByText(/^Stems$/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal Priorities/i)).toBeTruthy(); - expect(screen.getByText(/Export Cue Sheet/i)).toBeTruthy(); + expect(screen.getAllByRole("button", { name: /download tonight's cue sheet/i }).length).toBeGreaterThan(0); }); it("renders a rehearsal song structure timeline from real section ranges", async () => {