From c8268a9b1f594fc124ff9930e56524686b3594ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:30:33 +0000 Subject: [PATCH 01/15] feat(workspace): loop tonight's first section on the map Replace the dead Loop section coming-soon control with a rehearsal action that arms the first role or focus window and jumps to that Section Roadmap card. Timeline chips start the same loop. Isolation playback stays uninvented. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 + .../workspace/SectionRoadmap.test.tsx | 10 ++ .../src/features/workspace/SectionRoadmap.tsx | 13 +- .../src/features/workspace/Workspace.test.tsx | 43 ++++++ .../src/features/workspace/Workspace.tsx | 139 +++++++++++++++--- apps/desktop/src/locales/en/common.json | 7 + apps/desktop/src/locales/ko/common.json | 7 + docs/design-system/component-contract.md | 2 +- 11 files changed, 205 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..065bfad1c 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, the song-structure timeline and role strip must start tonight's first loop on the map. Do not leave `Loop section` / `Play stem` as "coming soon" dead ends, and do not invent Stem Lab isolation here. - 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..41851371d 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 timeline chips and the role-strip loop control must arm tonight's first section window and focus the matching Section Roadmap card. Isolation playback stays out of this lane. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..6e52fea32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The ready workspace can loop tonight's first section from the timeline or the role strip and jump to that Section Roadmap card, instead of leaving `Loop section` as coming soon. - 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..c8cbcc5b5 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, the song-structure timeline and role-strip loop control must start tonight's first map loop. Do not leave those buttons as "coming soon". + 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..250eba356 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -11,10 +11,11 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + loopedSectionId?: string | null; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionId = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); @@ -106,8 +107,14 @@ 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..0f09b9902 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -28,6 +29,7 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); it("updates practice progress immutably through onSongUpdate", () => { @@ -270,4 +272,45 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("loops tonight's first section from the timeline and focuses the roadmap card", () => { + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30" })); + + expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); + expect(document.activeElement?.id).toBe("workspace-section-verse-1"); + expect(scrollIntoView).toHaveBeenCalled(); + }); + + it("names the first loop from the selected role strip instead of coming soon", () => { + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const loopButton = screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30 on tonight's map" }); + expect(loopButton).toBeTruthy(); + expect((loopButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(loopButton); + + expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); + expect(document.activeElement?.id).toBe("workspace-section-verse-1"); + expect(screen.getByRole("button", { name: "Isolation is not ready. Loop tonight's section on the map." })).toBeTruthy(); + }); + + it("localizes the first map loop action", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByRole("button", { name: "verse을 0:10부터 0:30까지 루프" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..8a279ec7e 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -39,6 +39,54 @@ function downloadTextFile(contents: string, type: string, filename: string): voi URL.revokeObjectURL(url); } +/** Return the first section this player should loop tonight. */ +function firstLoopSection( + song: RehearsalSong, + activeRole: string | null +): RehearsalSong["sections"][number] | undefined { + if (activeRole) { + const forRole = song.sections.find((section) => + section.roles.some((role) => role.id === activeRole) + ); + if (forRole) { + return forRole; + } + } + + 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(); +} + +/** Fill loop copy with a section label and its start–end window. */ +function loopCopy( + template: string, + section: RehearsalSong["sections"][number] +): string { + return template + .replace("{label}", section.label) + .replace("{start}", formatTimelineTime(section.timeRange.start)) + .replace("{end}", formatTimelineTime(section.timeRange.end)); +} + type Translator = ReturnType; /** Documented. */ @@ -71,7 +119,17 @@ 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, + loopedSectionId, + onLoopSection +}: { + sections: RehearsalSong["sections"]; + t: Translator; + loopedSectionId: string | null; + onLoopSection: (sectionId: string) => void; +}) { return (
@@ -91,11 +149,24 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > {sections.map((section) => ( -
-

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

-

{section.groove}

+
+
))}
@@ -120,6 +191,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 [loopedSectionId, setLoopedSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +284,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const loopSection = firstLoopSection(song, activeRole); + const loopedSection = + song.sections.find((section) => section.id === loopedSectionId) ?? null; + + const armSectionLoop = (sectionId: string): void => { + setLoopedSectionId(sectionId); + focusWorkspaceSection(sectionId); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -331,7 +411,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- + + {loopedSection ? ( +

+ {loopCopy(t("workspaceLoopArmed"), loopedSection)} +

+ ) : null}
@@ -354,8 +444,8 @@ 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..c24701dd6 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,6 +50,13 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceRolesHarmonyLabel": "Roles & Harmony", + "workspaceLoopSectionAction": "Loop {label} · {start}–{end}", + "workspaceLoopSectionAria": "Loop {label} from {start} to {end} on tonight's map", + "workspaceLoopTimelineAria": "Loop {label} from {start} to {end}", + "workspaceLoopArmed": "Tonight's loop is {label} · {start}–{end}. Count in on that card.", + "workspaceLoopUnavailable": "No section is ready to loop yet.", + "workspacePlayStemUnavailable": "Isolation is not ready. Loop tonight's section on the map.", + "workspaceSoloUnavailable": "Solo stays off until isolation is honest. Loop the map section first.", "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..b42e394d9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,6 +50,13 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", + "workspaceLoopSectionAction": "{label} 루프 · {start}–{end}", + "workspaceLoopSectionAria": "오늘 지도에서 {label}을 {start}부터 {end}까지 루프", + "workspaceLoopTimelineAria": "{label}을 {start}부터 {end}까지 루프", + "workspaceLoopArmed": "오늘 루프는 {label} · {start}–{end}. 그 카드에서 카운트인하세요.", + "workspaceLoopUnavailable": "아직 루프할 구간이 없습니다.", + "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 지도에서 오늘 구간을 루프하세요.", + "workspaceSoloUnavailable": "솔로는 분리가 정직해질 때까지 끕니다. 먼저 지도 구간을 루프하세요.", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..a897b01c7 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, onLoopSection, loopedSectionId })` memo component; timeline chips arm tonight's loop. | | 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`. | From 3f193bf01b7f059454ba13f192865bccbf091aac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 21:38:32 +0900 Subject: [PATCH 02/15] fix(workspace): document section loop handler --- apps/desktop/src/features/workspace/Workspace.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 8a279ec7e..e750915cd 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -288,6 +288,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const loopedSection = song.sections.find((section) => section.id === loopedSectionId) ?? null; + /** Arm a rehearsal loop and move focus to the matching section roadmap card. */ const armSectionLoop = (sectionId: string): void => { setLoopedSectionId(sectionId); focusWorkspaceSection(sectionId); From c652b8823437ab20bf55753fcd21cdf4cdde1839 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:30:31 +0900 Subject: [PATCH 03/15] test(workspace): require natural Korean loop action copy --- apps/desktop/src/features/workspace/Workspace.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 0f09b9902..61d61455c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -305,12 +305,12 @@ describe("Workspace", () => { expect(screen.getByRole("button", { name: "Isolation is not ready. Loop tonight's section on the map." })).toBeTruthy(); }); - it("localizes the first map loop action", () => { + it("localizes the first map loop action without broken Korean particles", () => { setNavigatorLanguage("ko-KR"); const song = createDemoRehearsalSong(); render(); - expect(screen.getByRole("button", { name: "verse을 0:10부터 0:30까지 루프" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "verse 구간을 0:10부터 0:30까지 루프" })).toBeTruthy(); }); }); From e89b26eabfa68457787af8993e708524c5ecd612 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:31:12 +0900 Subject: [PATCH 04/15] fix(workspace): use particle-safe Korean loop copy --- apps/desktop/src/locales/ko/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index b42e394d9..f9df6fa1f 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -51,8 +51,8 @@ "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceRolesHarmonyLabel": "역할과 화성", "workspaceLoopSectionAction": "{label} 루프 · {start}–{end}", - "workspaceLoopSectionAria": "오늘 지도에서 {label}을 {start}부터 {end}까지 루프", - "workspaceLoopTimelineAria": "{label}을 {start}부터 {end}까지 루프", + "workspaceLoopSectionAria": "오늘 지도에서 {label} 구간을 {start}부터 {end}까지 루프", + "workspaceLoopTimelineAria": "{label} 구간을 {start}부터 {end}까지 루프", "workspaceLoopArmed": "오늘 루프는 {label} · {start}–{end}. 그 카드에서 카운트인하세요.", "workspaceLoopUnavailable": "아직 루프할 구간이 없습니다.", "workspacePlayStemUnavailable": "분리 재생은 아직 없습니다. 지도에서 오늘 구간을 루프하세요.", From 1497cd65cb03ea64374406f13cde2ad1820d9983 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:34:47 +0900 Subject: [PATCH 05/15] test(workspace): require 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 75b4ebee66511f9c9b618bf707b54b4dcb294c7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:36:40 +0900 Subject: [PATCH 06/15] fix(workspace): localize timeline region accessibility label --- 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 e750915cd..d34c5b216 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -141,7 +141,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 From da9cace0f4932a91c8731428f4c498814e75e363 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:37:15 +0900 Subject: [PATCH 07/15] fix(i18n): add timeline region accessibility copy --- 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 c24701dd6..8b4703fb2 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 ff9be414f00323c6fdb2ee8045b82a22c35ff318 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:37:54 +0900 Subject: [PATCH 08/15] fix(i18n): localize Korean 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 f9df6fa1f..c16d8b987 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 29d708ac6f2326949aea1daf9dce847c1978cffc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:41:24 +0900 Subject: [PATCH 09/15] test(workspace): require reduced-motion-safe loop focus --- .../Workspace.reduced-motion.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.reduced-motion.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/Workspace.reduced-motion.test.tsx new file mode 100644 index 000000000..dbd499f4c --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.reduced-motion.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; +const originalMatchMedia = window.matchMedia; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + +function setNavigatorLanguage(language: string): void { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace reduced-motion loop navigation", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: originalMatchMedia + }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView + }); + vi.restoreAllMocks(); + }); + + it("uses non-animated roadmap scrolling when reduced motion is requested", () => { + setNavigatorLanguage("en-US"); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest", + inline: "center" + }); + }); +}); From c03d84d8f5797684136b7b4b4ca1fed90efbc7bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:43:01 +0900 Subject: [PATCH 10/15] fix(workspace): honor reduced-motion loop navigation --- apps/desktop/src/features/workspace/Workspace.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d34c5b216..856fbff8c 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -72,7 +72,14 @@ function focusWorkspaceSection(sectionId: string): void { if (!(node instanceof HTMLElement)) { return; } - node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + const prefersReducedMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + node.scrollIntoView({ + behavior: prefersReducedMotion ? "auto" : "smooth", + block: "nearest", + inline: "center" + }); node.focus(); } From 90f7a9957c748602fd8c656358a9a542a89b5f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:05:52 -0700 Subject: [PATCH 11/15] test(workspace): require renderer-owned section card ids --- apps/desktop/src/features/workspace/SectionRoadmap.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 02dbd2a96..a32deb8e7 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -61,13 +61,15 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); - it("exposes a focusable id for each rehearsal section card", () => { + it("keeps focus target ids renderer-owned for arbitrary analysis section ids", () => { const song = createDemoRehearsalSong(); + song.sections[0].id = " verse 1 "; render(); - const card = document.getElementById("workspace-section-verse-1"); + const card = document.getElementById("workspace-section-card-0"); expect(card).toBeTruthy(); expect(card?.getAttribute("tabindex")).toBe("-1"); + expect(card?.id).not.toContain(song.sections[0].id); }); }); From a34594ed8be27a06c68211500713e9da3af75e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:07:27 -0700 Subject: [PATCH 12/15] fix(workspace): keep section focus ids renderer-owned --- .../src/features/workspace/SectionRoadmap.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 250eba356..97ca6664e 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -11,11 +11,11 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; - loopedSectionId?: string | null; + loopedSectionIndex?: number | null; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionId = null }: SectionRoadmapProps) { +export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); @@ -104,13 +104,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionId tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( Date: Mon, 17 Aug 2026 23:11:16 -0700 Subject: [PATCH 13/15] fix(workspace): bind loop focus to renderer positions --- .../src/features/workspace/Workspace.tsx | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 856fbff8c..f75a64526 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -39,36 +39,36 @@ function downloadTextFile(contents: string, type: string, filename: string): voi URL.revokeObjectURL(url); } -/** Return the first section this player should loop tonight. */ -function firstLoopSection( +/** Return the renderer-owned position of the first section this player should loop tonight. */ +function firstLoopSectionIndex( song: RehearsalSong, activeRole: string | null -): RehearsalSong["sections"][number] | undefined { +): number | undefined { if (activeRole) { - const forRole = song.sections.find((section) => + const forRoleIndex = song.sections.findIndex((section) => section.roles.some((role) => role.id === activeRole) ); - if (forRole) { - return forRole; + if (forRoleIndex !== -1) { + return forRoleIndex; } } const requested = song.exportSummary?.focusSections?.[0]?.trim(); if (requested) { - const match = song.sections.find( + const requestedIndex = song.sections.findIndex( (section) => section.label === requested || section.id === requested ); - if (match) { - return match; + if (requestedIndex !== -1) { + return requestedIndex; } } - return song.sections[0]; + return song.sections.length > 0 ? 0 : undefined; } -/** Scroll and focus the matching section card on the rehearsal roadmap. */ -function focusWorkspaceSection(sectionId: string): void { - const node = document.getElementById(`workspace-section-${sectionId}`); +/** Scroll and focus one renderer-owned section card on the rehearsal roadmap. */ +function focusWorkspaceSection(sectionIndex: number): void { + const node = document.getElementById(`workspace-section-card-${sectionIndex}`); if (!(node instanceof HTMLElement)) { return; } @@ -129,13 +129,13 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro const SongStructure = memo(function SongStructure({ sections, t, - loopedSectionId, + loopedSectionIndex, onLoopSection }: { sections: RehearsalSong["sections"]; t: Translator; - loopedSectionId: string | null; - onLoopSection: (sectionId: string) => void; + loopedSectionIndex: number | null; + onLoopSection: (sectionIndex: number) => void; }) { return (
@@ -155,19 +155,19 @@ const SongStructure = memo(function SongStructure({ data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( + {sections.map((section, sectionIndex) => (
); -} \ No newline at end of file +} From 7d143339be4a26d34ef5a636ebcad6f139722d5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:13:03 -0700 Subject: [PATCH 14/15] test(workspace): verify renderer-owned loop focus positions --- .../src/features/workspace/Workspace.test.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 61d61455c..b84f356a8 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -283,7 +283,23 @@ describe("Workspace", () => { fireEvent.click(screen.getByRole("button", { name: "Loop verse from 0:10 to 0:30" })); expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); - expect(document.activeElement?.id).toBe("workspace-section-verse-1"); + expect(document.activeElement?.id).toBe("workspace-section-card-0"); + expect(scrollIntoView).toHaveBeenCalled(); + }); + + it("focuses the selected renderer position even when analysis section ids are duplicated", () => { + const song = createDemoRehearsalSong(); + const firstSectionId = song.sections[0]!.id; + song.sections[1]!.id = firstSectionId; + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + + const loopButtons = screen.getAllByRole("button", { name: /Loop .* from .* to .*/ }); + fireEvent.click(loopButtons[1]!); + + expect(document.activeElement?.id).toBe("workspace-section-card-1"); expect(scrollIntoView).toHaveBeenCalled(); }); @@ -301,7 +317,7 @@ describe("Workspace", () => { fireEvent.click(loopButton); expect(screen.getByText("Tonight's loop is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); - expect(document.activeElement?.id).toBe("workspace-section-verse-1"); + expect(document.activeElement?.id).toBe("workspace-section-card-0"); expect(screen.getByRole("button", { name: "Isolation is not ready. Loop tonight's section on the map." })).toBeTruthy(); }); From 1f3e8c75c57232208d2b723aa37137e47ccf46de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 23:33:43 -0700 Subject: [PATCH 15/15] test(workspace): build duplicate-section focus fixture explicitly --- .../src/features/workspace/Workspace.test.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index b84f356a8..1384b7740 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -289,14 +289,26 @@ describe("Workspace", () => { it("focuses the selected renderer position even when analysis section ids are duplicated", () => { const song = createDemoRehearsalSong(); - const firstSectionId = song.sections[0]!.id; - song.sections[1]!.id = firstSectionId; + const firstSection = song.sections[0]!; + song.sections = [ + firstSection, + { + ...firstSection, + id: firstSection.id, + label: "chorus", + timeRange: { + start: 30, + end: 50 + } + } + ]; const scrollIntoView = vi.fn(); HTMLElement.prototype.scrollIntoView = scrollIntoView; render(); const loopButtons = screen.getAllByRole("button", { name: /Loop .* from .* to .*/ }); + expect(loopButtons).toHaveLength(2); fireEvent.click(loopButtons[1]!); expect(document.activeElement?.id).toBe("workspace-section-card-1");