From 062ba1257296988d5e8ae51fa32dff11d9b1dc77 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:07:50 +0000 Subject: [PATCH 01/14] feat(workspace): cue tonight's first lock-in on the timeline Name the first lock-in on the song-structure timeline and cue that bar so a bandmate can count in from the mark without inventing playback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/workspace/Workspace.test.tsx | 79 ++++++++- .../src/features/workspace/Workspace.tsx | 156 ++++++++++++++++-- apps/desktop/src/locales/en/common.json | 5 + apps/desktop/src/locales/ko/common.json | 5 + docs/design-system/component-contract.md | 4 +- 9 files changed, 236 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..fedcafdfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- After analysis, the song-structure timeline must name tonight's first lock-in and cue that bar. Do not invent playback, isolation, or a parallel MIR product; #828 remains the known-stem owner. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..45b23213b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,6 +85,7 @@ Last updated: 2026-03-11 - playable ranges and density or overlap warnings - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags + - a song-structure timeline that cues tonight's first lock-in bar so the band can count in from that mark - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..307dc5e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The song-structure timeline now names tonight's first lock-in and cues that bar so the band can count in from the mark. - 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..dd15d44ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis -- ## Architecture -BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. +BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. The song-structure timeline must name and cue tonight's first lock-in without inventing playback. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. Three layers, decoupled through shared contracts: diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..3a1bcdf47 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; @@ -82,7 +82,7 @@ describe("Workspace", () => { render(); - expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); + expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); it("enables bass transcription from selected role metadata rather than role id text", () => { @@ -269,5 +269,80 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); + expect(screen.getByRole("button", { name: "곡 타임라인에서 verse 큐, 0:10부터 0:30까지" })).toBeTruthy(); + }); + + it("cues tonight's first lock-in on the song-structure timeline", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + const focus = vi.fn(); + const originalGetElementById = document.getElementById.bind(document); + vi.spyOn(document, "getElementById").mockImplementation((id: string) => { + const node = originalGetElementById(id); + if (node && id === "workspace-timeline-verse-1") { + Object.defineProperty(node, "scrollIntoView", { configurable: true, value: scrollIntoView }); + Object.defineProperty(node, "focus", { configurable: true, value: focus }); + } + return node; + }); + + render(); + + expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30.")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Cue verse on the song timeline from 0:10 to 0:30" })); + + expect(screen.getByText("Tonight's first lock-in is cued at verse · 0:10–0:30. Count in from that mark.")).toBeTruthy(); + expect(document.getElementById("workspace-timeline-verse-1")?.getAttribute("aria-current")).toBe("true"); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(focus).toHaveBeenCalledTimes(1); + }); + + it("falls back to the first mapped section when focus labels do not match", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.exportSummary = { + ...song.exportSummary, + focusSections: ["missing-bridge"] + }; + song.sections[0] = { + ...song.sections[0]!, + id: "intro-1", + label: "intro", + timeRange: { start: 0, end: 8 } + }; + + render(); + + const bar = document.getElementById("workspace-timeline-intro-1"); + const scrollIntoView = vi.fn(); + expect(bar).toBeTruthy(); + Object.defineProperty(bar!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + fireEvent.click(screen.getByRole("button", { name: "Cue intro on the song timeline from 0:00 to 0:08" })); + + expect(screen.getByText("Tonight's first lock-in is cued at intro · 0:00–0:08. Count in from that mark.")).toBeTruthy(); + expect(document.getElementById("workspace-timeline-intro-1")?.getAttribute("aria-current")).toBe("true"); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it("keeps the timeline cue closed when no sections are mapped", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const locked = screen.getByRole("button", { name: "No lock-in section yet" }); + expect(locked.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(locked); + expect(screen.queryByText(/Count in from that mark/i)).toBeNull(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..6140806fb 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -57,6 +57,39 @@ function nonBlankText(value: string | undefined): string | undefined { return trimmed ? trimmed : undefined; } +/** Fill rehearsal copy with named placeholders. */ +function fillCopy(template: string, values: Record): string { + return Object.entries(values).reduce( + (text, [key, value]) => text.replaceAll(`{${key}}`, value), + template + ); +} + +/** Return tonight's first lock-in section from export focus, then the first mapped section. */ +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 bar on the song-structure timeline. */ +function focusTimelineSection(sectionId: string): void { + const node = document.getElementById(`workspace-timeline-${sectionId}`); + if (!(node instanceof HTMLElement)) { + return; + } + node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + node.focus(); +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -71,12 +104,74 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro } /** Documented. */ -const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { +const SongStructure = memo(function SongStructure({ + sections, + t, + focusSection, + cuedSectionId, + onCueFocus +}: { + sections: RehearsalSong["sections"]; + t: Translator; + focusSection?: RehearsalSong["sections"][number]; + cuedSectionId: string | null; + onCueFocus: () => void; +}) { + const focusCopyValues = focusSection + ? { + label: focusSection.label, + start: formatTimelineTime(focusSection.timeRange.start), + end: formatTimelineTime(focusSection.timeRange.end) + } + : null; + const cueActionLabel = focusCopyValues + ? fillCopy(t("workspaceCueTimelineAction"), focusCopyValues) + : t("workspaceCueTimelineUnavailable"); + const cueAriaLabel = focusCopyValues + ? fillCopy(t("workspaceCueTimelineAria"), focusCopyValues) + : t("workspaceCueTimelineUnavailable"); + const cueSummary = focusCopyValues + ? fillCopy(t("workspaceCueTimelineSummary"), focusCopyValues) + : t("workspaceCueTimelineUnavailable"); + const cuedSection = sections.find((section) => section.id === cuedSectionId) ?? null; + const cueStatus = focusCopyValues + ? fillCopy(t("workspaceCueTimelineArmed"), focusCopyValues) + : t("workspaceCueTimelineUnavailable"); + return (
-
-

{t("workspaceSongStructureLabel")}

- {t("workspaceRehearsalTimelineLabel")} +
+
+
+

{t("workspaceSongStructureLabel")}

+ {t("workspaceRehearsalTimelineLabel")} +
+

{cueSummary}

+
+ {focusSection ? ( + + ) : ( + + )}
- {sections.map((section) => ( -
-

- {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)} -

-

{section.groove}

-
- ))} + {sections.map((section) => { + const isCued = section.id === cuedSectionId; + return ( +
+

+ {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)} +

+

{section.groove}

+
+ ); + })}
+ {cuedSection ? ( +

+ {cueStatus} +

+ ) : null}
); }); @@ -120,6 +233,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [cuedTimelineSectionId, setCuedTimelineSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +326,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const focusSection = firstFocusSection(song); + + /** Cue tonight's first lock-in on the song-structure timeline without inventing playback. */ + const cueTonightFocus = (): void => { + if (!focusSection) { + return; + } + setCuedTimelineSectionId(focusSection.id); + focusTimelineSection(focusSection.id); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -331,7 +455,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp - +
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..dc416ff8f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "Transpose / simplify", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", + "workspaceCueTimelineAction": "Cue {label} · {start}–{end}", + "workspaceCueTimelineAria": "Cue {label} on the song timeline from {start} to {end}", + "workspaceCueTimelineSummary": "Tonight's first lock-in is {label} · {start}–{end}.", + "workspaceCueTimelineArmed": "Tonight's first lock-in is cued at {label} · {start}–{end}. Count in from that mark.", + "workspaceCueTimelineUnavailable": "No lock-in section yet", "workspaceRolesHarmonyLabel": "Roles & Harmony", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..e0e3eaf27 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceCueTimelineAction": "{label} 큐 · {start}–{end}", + "workspaceCueTimelineAria": "곡 타임라인에서 {label} 큐, {start}부터 {end}까지", + "workspaceCueTimelineSummary": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다.", + "workspaceCueTimelineArmed": "오늘 먼저 잠글 구간이 {label} · {start}–{end}에 큐되었습니다. 그 표시에서 카운트인하세요.", + "workspaceCueTimelineUnavailable": "아직 잠글 구간이 없습니다", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..16c6d2f71 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,7 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | -| 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. | +| 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, focusSection, cuedSectionId, onCueFocus })`; `id="workspace-timeline-{section.id}"` is the cue target for tonight's first lock-in. Cueing highlights the bar and does not start playback. | | 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`. | @@ -92,7 +92,7 @@ These Figma patterns are valid visual guidance but are not yet extracted as stan | Navigation Item | `apps/desktop/src/App.tsx` shell navigation | Extract when navigation appears outside the app shell. | | Metric Card | `apps/desktop/src/App.tsx` `MetricCard` | Extract when metrics move into feature pages or dashboards. | | Status Pill | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when assignment/comment/approval status UI is reused. | -| Song Structure Timeline | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when timeline editing or playback controls are added. | +| Song Structure Timeline | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when timeline editing or audio playback controls are added beyond tonight's first-lock-in cue. | | Export Action Group | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when export controls are reused outside the workspace header. | ## PR Review Rules From 3f65ef9e394453f10241900d92b05c2668837be8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:38:08 +0900 Subject: [PATCH 02/14] test(workspace): scope timeline range assertion --- apps/desktop/src/App.test.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..db570672b 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,7 +313,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:10–0:30/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); expect(screen.queryByText(/Mock-board/i)).toBeNull(); const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); @@ -775,9 +775,6 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); - await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); - - fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { @@ -810,9 +807,6 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); - await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); - - fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { From d90b8572347575d462734921dda6f7984274db56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:41:19 +0900 Subject: [PATCH 03/14] test(workspace): restore polling regressions --- apps/desktop/src/App.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index db570672b..3eed386f8 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,7 +313,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); expect(screen.queryByText(/Mock-board/i)).toBeNull(); const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); @@ -775,6 +775,9 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { @@ -807,6 +810,9 @@ describe("App", () => { render(); fireEvent.click(screen.getByRole("button", { name: /choose local audio/i })); + await waitFor(() => expect(screen.getByText(/late-night-set\.wav/i)).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: /start analysis/i })); await waitFor(() => expect(tauriInvoke).toHaveBeenCalledTimes(3)); act(() => { From 744cdd747d24110030bd858b4d3b96ab02453125 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:43:22 +0900 Subject: [PATCH 04/14] fix(workspace): keep timeline cue state coherent --- apps/desktop/src/features/workspace/Workspace.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 6140806fb..59f85d7da 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -134,8 +134,15 @@ const SongStructure = memo(function SongStructure({ ? fillCopy(t("workspaceCueTimelineSummary"), focusCopyValues) : t("workspaceCueTimelineUnavailable"); const cuedSection = sections.find((section) => section.id === cuedSectionId) ?? null; - const cueStatus = focusCopyValues - ? fillCopy(t("workspaceCueTimelineArmed"), focusCopyValues) + const cuedCopyValues = cuedSection + ? { + label: cuedSection.label, + start: formatTimelineTime(cuedSection.timeRange.start), + end: formatTimelineTime(cuedSection.timeRange.end) + } + : null; + const cueStatus = cuedCopyValues + ? fillCopy(t("workspaceCueTimelineArmed"), cuedCopyValues) : t("workspaceCueTimelineUnavailable"); return ( @@ -191,7 +198,7 @@ const SongStructure = memo(function SongStructure({
(null); const [cuedTimelineSectionId, setCuedTimelineSectionId] = useState(null); From ab39f80a56da4ad86aaec497b7af5ae2adf43d94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:44:56 +0900 Subject: [PATCH 05/14] test(workspace): cover timeline cue focus and status --- ...Workspace.timeline-cue-regression.test.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx new file mode 100644 index 000000000..13e0ed403 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguageDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "language"); + +afterEach(() => { + vi.restoreAllMocks(); + if (originalLanguageDescriptor) { + Object.defineProperty(window.navigator, "language", originalLanguageDescriptor); + } +}); + +function useEnglishLocale(): void { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: "en-US" + }); +} + +function makeTwoSectionSong(): RehearsalSong { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = { + ...verse, + id: "chorus-1", + label: "chorus", + timeRange: { start: 30, end: 50 } + }; + return { + ...song, + sections: [verse, chorus], + exportSummary: { + ...song.exportSummary, + focusSections: ["verse"] + } + }; +} + +describe("Workspace timeline cue regressions", () => { + it("moves real keyboard focus to the timeline bar on every cue action", () => { + useEnglishLocale(); + const song = createDemoRehearsalSong(); + render(); + + const bar = document.getElementById("workspace-timeline-verse-1"); + expect(bar).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(bar!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const cueButton = screen.getByRole("button", { + name: "Cue verse on the song timeline from 0:10 to 0:30" + }); + fireEvent.click(cueButton); + + expect(bar).toHaveAttribute("aria-current", "true"); + expect(document.activeElement).toBe(bar); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + fireEvent.click(cueButton); + + expect(document.activeElement).toBe(bar); + expect(scrollIntoView).toHaveBeenCalledTimes(2); + }); + + it("keeps the live cue status aligned with the highlighted section after focus metadata changes", () => { + useEnglishLocale(); + const initialSong = makeTwoSectionSong(); + const { rerender } = render(); + + const verseBar = document.getElementById("workspace-timeline-verse-1"); + expect(verseBar).toBeTruthy(); + Object.defineProperty(verseBar!, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + + fireEvent.click( + screen.getByRole("button", { + name: "Cue verse on the song timeline from 0:10 to 0:30" + }) + ); + + expect(verseBar).toHaveAttribute("aria-current", "true"); + expect( + screen.getByText("Tonight's first lock-in is cued at verse · 0:10–0:30. Count in from that mark.") + ).toBeTruthy(); + + const updatedSong: RehearsalSong = { + ...initialSong, + exportSummary: { + ...initialSong.exportSummary, + focusSections: ["chorus"] + } + }; + rerender(); + + expect(document.getElementById("workspace-timeline-verse-1")).toHaveAttribute("aria-current", "true"); + expect( + screen.getByText("Tonight's first lock-in is cued at verse · 0:10–0:30. Count in from that mark.") + ).toBeTruthy(); + expect( + screen.queryByText("Tonight's first lock-in is cued at chorus · 0:30–0:50. Count in from that mark.") + ).toBeNull(); + }); +}); From 21390caacb19f43990334fafe95045a1e2c0454a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:46:40 +0900 Subject: [PATCH 06/14] docs(workspace): specify timeline cue fallback contract --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index fedcafdfa..6d6d58aee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project overview - BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. -- After analysis, the song-structure timeline must name tonight's first lock-in and cue that bar. Do not invent playback, isolation, or a parallel MIR product; #828 remains the known-stem owner. +- After analysis, when mapped sections exist, the song-structure timeline must name tonight's first lock-in and cue the section matching the first export focus label or id; if that focus is missing or unmatched, cue the first mapped section. With no mapped sections, keep the cue unavailable as “No lock-in section yet.” Do not invent playback, isolation, or a parallel MIR product; #828 remains the known-stem owner. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. From b90e19e0baf75048c764028d85030492a7535bcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:47:09 +0900 Subject: [PATCH 07/14] docs(workspace): define timeline cue fallback semantics --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 45b23213b..8b0bfd0a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,7 +85,7 @@ Last updated: 2026-03-11 - playable ranges and density or overlap warnings - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - - a song-structure timeline that cues tonight's first lock-in bar so the band can count in from that mark + - a song-structure timeline that, when sections are mapped, cues the first export-focus label/id match or falls back to the first mapped section; without mapped sections it keeps the cue unavailable as “No lock-in section yet.” - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form ## Confidence, edits, and provenance From 682d68c69368c20926ba24c2124db0724b0e0326 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:47:37 +0900 Subject: [PATCH 08/14] docs(workspace): state timeline cue fallback behavior --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd15d44ca..b7244f971 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ uv run --project services/analysis-engine pytest --cov=src/bandscope_analysis -- ## Architecture -BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. The song-structure timeline must name and cue tonight's first lock-in without inventing playback. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. +BandScope is a local-first desktop app for rehearsal prep: it turns a song into likely harmony by section and role, a section roadmap, groove cues, stems, playable ranges, simplification/transposition cues, confidence flags, and rehearsal priorities. When mapped sections exist, the song-structure timeline must name and cue the first export-focus label/id match, falling back to the first mapped section when the focus is missing or unmatched; with no mapped sections it keeps the cue unavailable as “No lock-in section yet.” It must not invent playback. `ARCHITECTURE.md` is the authoritative reference; the analysis target is a `song -> section -> role` hierarchy, never a single song-wide chord track. Three layers, decoupled through shared contracts: From 0e6352f4eea7fb6ecf8b923715cc615f3e66bfe5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:40:07 +0900 Subject: [PATCH 09/14] test(workspace): scope timeline range assertion --- apps/desktop/src/App.test.tsx | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..bf19c10f5 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,7 +313,7 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + expect(within(screen.getByTestId("song-structure-grid")).getByText(/verse · 0:10–0:30/i)).toBeTruthy(); expect(screen.getByText(/Rehearsal timeline/i)).toBeTruthy(); expect(screen.queryByText(/Mock-board/i)).toBeNull(); const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); @@ -361,8 +361,6 @@ describe("App", () => { it("short-circuits confidence evaluation when encountering a low confidence section", async () => { const loadedProject = succeededResult().result; // medium is first - // Add low and high sections. High shouldn't matter since low is lowest. - // And low will trigger the early break in the loop. loadedProject.sections.push( { ...loadedProject.sections[0], @@ -1191,9 +1189,6 @@ describe("App", () => { const input = screen.getByPlaceholderText(/YouTube URL.../i); fireEvent.change(input, { target: { value: " " } }); const button = screen.getByRole("button", { name: /Import YouTube/i }); - // Button is disabled if youtubeUrl is empty, but we simulate enabling it for coverage - // or we can test that the error is set when it somehow triggers, but actually it's disabled. - // Wait, the button is disabled if `!youtubeUrl`. `youtubeUrl` is " ", so button is NOT disabled! fireEvent.click(button); await waitFor(() => { @@ -1294,7 +1289,6 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: /open project/i })); - // Should not show error, should remain in empty state await waitFor(() => { expect(mockLoadProject).toHaveBeenCalledTimes(1); }); @@ -1359,7 +1353,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1367,7 +1360,6 @@ describe("App", () => { mockSaveProject.mockResolvedValueOnce(undefined); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1379,7 +1371,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1387,7 +1378,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce(new Error("Permission denied")); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1399,7 +1389,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1407,7 +1396,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce(new Error("User cancelled")); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1422,7 +1410,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1430,7 +1417,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce("Disk full"); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1468,7 +1454,6 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); @@ -1476,7 +1461,6 @@ describe("App", () => { mockSaveProject.mockRejectedValueOnce("User cancelled"); - // Now click save fireEvent.click(screen.getByRole("button", { name: /save project/i })); await waitFor(() => { @@ -1491,19 +1475,15 @@ describe("App", () => { mockLoadProject.mockResolvedValueOnce(succeededResult().result); render(); - // Load first to get jobResult populated fireEvent.click(screen.getByRole("button", { name: /open project/i })); await waitFor(() => { expect(screen.getByRole("heading", { name: /Late Night Set/i })).toBeTruthy(); }); - // Mock prompt to simulate user entering a new chord const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("Dbmaj7"); - // Click on the chord to edit it (assuming SectionRoadmap renders it and allows click to edit) fireEvent.click(screen.getAllByText("C#m7", { selector: 'button' })[0]); - // Wait for the UI to update with the new chord (which verifies handleSongUpdate was called and state updated) await waitFor(() => { expect(screen.getAllByText("Dbmaj7").length).toBeGreaterThan(0); }); @@ -1552,7 +1532,6 @@ describe("App", () => { }); }); - it("renders Settings and Help as focusable aria-disabled controls", () => { render(); const settingsButton = screen.getByRole("button", { name: "Settings coming soon" }); @@ -1589,8 +1568,6 @@ describe("App", () => { fireEvent.click(scoreButton); expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); - // Projects opened from a .bscope file have no live workspace, so score - // storage is gated behind the active-project notice. expect(screen.getByText(/Scores attach to the active analysis project/i)).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); @@ -1604,9 +1581,6 @@ describe("App", () => { expect(screen.getByText(/Song Timeline/i)).toBeTruthy(); }); - // The compact nav is a separate rendered bar (shown on small viewports) with - // its own set of buttons; exercise it directly so the mobile navigation path - // is covered, not just the sidebar one. const compactNav = screen.getByRole("navigation", { name: /compact rehearsal views/i }); const compactScoreButton = within(compactNav).getByRole("button", { name: /Score compact view/i }); expect(compactScoreButton).toBeEnabled(); @@ -1616,4 +1590,4 @@ describe("App", () => { expect(await screen.findByRole("heading", { name: /Score · Late Night Set/i })).toBeInTheDocument(); expect(screen.queryByText(/Song Timeline/i)).toBeNull(); }); -}); +}); \ No newline at end of file From 2193a547f07c40171ec2c98618aeb7dac6f81e2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:48:38 +0900 Subject: [PATCH 10/14] test(workspace): restore navigator locale state --- .../workspace/Workspace.timeline-cue-regression.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx index 13e0ed403..858a53c83 100644 --- a/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx @@ -9,6 +9,8 @@ afterEach(() => { vi.restoreAllMocks(); if (originalLanguageDescriptor) { Object.defineProperty(window.navigator, "language", originalLanguageDescriptor); + } else { + Reflect.deleteProperty(window.navigator, "language"); } }); From 576a483dbd23a754e9c6ef8af37ab7602f55a9eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:06:50 +0900 Subject: [PATCH 11/14] test(workspace): cover localized timeline region label --- .../Workspace.timeline-region-i18n.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.timeline-region-i18n.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.timeline-region-i18n.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-region-i18n.test.tsx new file mode 100644 index 000000000..af0af009b --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-region-i18n.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguageDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "language"); + +afterEach(() => { + if (originalLanguageDescriptor) { + Object.defineProperty(window.navigator, "language", originalLanguageDescriptor); + } else { + Reflect.deleteProperty(window.navigator, "language"); + } +}); + +function useKoreanLocale(): void { + Object.defineProperty(window.navigator, "language", { + configurable: true, + value: "ko-KR" + }); +} + +describe("Workspace timeline region localization", () => { + it("uses localized accessible copy for the scrollable song-structure timeline", () => { + useKoreanLocale(); + + render(); + + expect(screen.getByRole("region", { name: "스크롤 가능한 곡 구조 타임라인" })).toBeTruthy(); + }); +}); From bd0405561507befe2c26b399504384ce68638000 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:07:55 +0900 Subject: [PATCH 12/14] fix(i18n): localize song timeline region label --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index e0e3eaf27..3602ec00c 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -37,6 +37,7 @@ "workspaceTempoLabel": "템포", "workspaceSongStructureLabel": "곡 구조", "workspaceRehearsalTimelineLabel": "합주 타임라인", + "workspaceSongStructureTimelineRegionAria": "스크롤 가능한 곡 구조 타임라인", "workspaceSongTimelineLabel": "곡 타임라인", "workspaceCollaborationLabel": "협업", "workspaceCollaborationEmpty": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.", From 98ee60cfe2cecf61a139df7f98c795813759001f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:08:24 +0900 Subject: [PATCH 13/14] fix(i18n): add song timeline region label --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index dc416ff8f..63aa91631 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -37,6 +37,7 @@ "workspaceTempoLabel": "Tempo", "workspaceSongStructureLabel": "Song Structure", "workspaceRehearsalTimelineLabel": "Rehearsal timeline", + "workspaceSongStructureTimelineRegionAria": "Scrollable song structure timeline", "workspaceSongTimelineLabel": "Song Timeline", "workspaceCollaborationLabel": "Collaboration", "workspaceCollaborationEmpty": "Assignments, comments, and approvals will show up here as the room aligns.", From 12a17ff9127078f399c8b3ecd8e29bc78a28d5d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:09:25 +0900 Subject: [PATCH 14/14] fix(workspace): localize timeline region accessibility name --- apps/desktop/src/features/workspace/Workspace.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 59f85d7da..6ca4259b0 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -185,7 +185,7 @@ const SongStructure = memo(function SongStructure({ role="region" tabIndex={0} className="overflow-x-auto rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(8,18,35,0.96),rgba(2,6,23,0.98))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" - aria-label="Scrollable song structure timeline" + aria-label={t("workspaceSongStructureTimelineRegionAria")} >
); -} +} \ No newline at end of file