diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..92cee4ff4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- After a part is selected, Loop must name tonight's practice window (section + time, tempo when known) and open that section. Do not leave Loop as coming soon while Play stem stays unavailable. - 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..0d467f6c2 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. +- After a part is selected, the stem-player Loop control must name tonight's practice window and open that section on the roadmap. Play stem stays unavailable until Stem Lab exists. ## Analysis target model diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..5c4c8f399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After a part is selected, Loop names tonight's practice window (section, time range, tempo when known) and opens that section. Timeline cells and section cards do the same instead of sitting as dead descriptions; section navigation also honors the system reduced-motion preference instead of forcing smooth scrolling. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -65,4 +66,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..71fbacbe6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `AGENTS.md` is the canonical agent operating guide — read and follow it before making changes. It defines the security workflow (`Security Notes`), supply-chain workflow, cross-platform build rules, GitHub bootstrap rules, code style, and safety guardrails. This file complements it with commands and architecture; when in doubt, `AGENTS.md` and the docs it references win. +After a part is selected, Loop must name tonight's practice window and open that section. Do not leave that control as a coming-soon dead end. + 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..fd11dc332 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -60,4 +60,16 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("names a focusable practice window on each section card", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(card?.getAttribute("tabindex")).toBe("-1"); + expect(screen.getByRole("button", { name: "Practice verse 0:10–0:30" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..6e45710fb 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucide-react"; +import { Button } from "@/components/ui/button"; interface SectionRoadmapProps { song: RehearsalSong; @@ -13,6 +14,16 @@ interface SectionRoadmapProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** Documented. */ +function formatTimelineTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + /** Documented. */ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); @@ -103,10 +114,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( + {song.sections.map((section) => { + const window = `${formatTimelineTime(section.timeRange.start)}–${formatTimelineTime(section.timeRange.end)}`; + const practiceLabel = t("workspacePracticeWindowAction") + .replaceAll("{section}", section.label) + .replaceAll("{window}", window); + return ( @@ -119,6 +137,22 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionGrooveLabel")} {section.groove} +

{window}

+ @@ -212,7 +246,8 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma ))}
- ))} + ); + })} ); 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..ef987c3d4 --- /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 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 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: "Open verse 0:10–0:30" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + block: "nearest", + inline: "center" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..ff2bd1e5f 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,63 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's practice window after a part is selected", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByText(/Open verse 0:10–0:30 at 120 BPM and lock the entrance first/i)).toBeTruthy(); + const loopButton = screen.getByRole("button", { name: "Loop verse 0:10–0:30 · 120 BPM" }); + expect((loopButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(loopButton); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalled(); + expect(document.activeElement).toBe(card); + }); + + it("opens the named practice window from the song-structure timeline", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open verse 0:10–0:30" })); + + expect(document.activeElement).toBe(document.getElementById("workspace-section-verse-1")); + }); + + it("disables the loop action when no section can be opened", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + expect(screen.queryByRole("button", { name: /Loop /i })).toBeNull(); + expect(screen.getByTestId("song-structure-grid")).toBeTruthy(); + }); + + it("localizes the practice-window next action", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByRole("button", { name: "verse 0:10–0:30 · 120 BPM 반복" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "verse 0:10–0:30 연습" })).toBeTruthy(); + 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..36057061a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -8,7 +8,7 @@ import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; -import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4 } from "lucide-react"; +import { Download, CheckCheck, ClipboardList, MessageSquareMore, CloudOff, Music4, Repeat } from "lucide-react"; interface WorkspaceProps { song: RehearsalSong; @@ -70,8 +70,70 @@ 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]; +} + +/** Format a section's practice window as mm:ss–mm:ss. */ +function formatPracticeWindow(section: RehearsalSong["sections"][number]): string { + return `${formatTimelineTime(section.timeRange.start)}–${formatTimelineTime(section.timeRange.end)}`; +} + +/** Fill rehearsal copy that names a section and its practice window. */ +function fillPracticeWindowCopy( + template: string, + sectionLabel: string, + window: string, + tempo?: number +): string { + let text = template.replaceAll("{section}", sectionLabel).replaceAll("{window}", window); + if (tempo !== undefined) { + text = text.replaceAll("{tempo}", String(tempo)); + } + return text; +} + +/** 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; + } + const reduceMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (typeof node.scrollIntoView === "function") { + node.scrollIntoView({ + behavior: reduceMotion ? "auto" : "smooth", + block: "nearest", + inline: "center" + }); + } + if (typeof node.focus === "function") { + node.focus(); + } +} + /** Documented. */ -const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { +const SongStructure = memo(function SongStructure({ + sections, + t, + onOpenSection +}: { + sections: RehearsalSong["sections"]; + t: Translator; + onOpenSection: (sectionId: string) => void; +}) { return (
@@ -90,14 +152,23 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section) => { + const window = formatPracticeWindow(section); + return ( +
- ))} + + ); + })}
- +
@@ -350,6 +448,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

Stem Player

{activeRoleDetails?.name ?? activeRole}

+

{loopHint}

); -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..5c055a0cc 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 {section} {window}", + "workspaceLoopSectionActionWithTempo": "Loop {section} {window} · {tempo} BPM", + "workspaceLoopSectionHint": "Stems are not ready. Open {section} {window} and lock the entrance first.", + "workspaceLoopSectionHintWithTempo": "Stems are not ready. Open {section} {window} at {tempo} BPM and lock the entrance first.", + "workspaceLoopSectionDisabled": "No section to loop yet", + "workspacePracticeWindowAction": "Practice {section} {window}", + "workspaceTimelineOpenSection": "Open {section} {window}", "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..7d03db98b 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": "{section} {window} 반복", + "workspaceLoopSectionActionWithTempo": "{section} {window} · {tempo} BPM 반복", + "workspaceLoopSectionHint": "스템은 아직 없습니다. 먼저 {section} {window}를 열고 입구를 잠그세요.", + "workspaceLoopSectionHintWithTempo": "스템은 아직 없습니다. 먼저 {section} {window}를 {tempo} BPM으로 열고 입구를 잠그세요.", + "workspaceLoopSectionDisabled": "반복할 구간이 아직 없습니다", + "workspacePracticeWindowAction": "{section} {window} 연습", + "workspaceTimelineOpenSection": "{section} {window} 열기", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", "sectionGrooveLabel": "그루브", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..9f800d854 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -29,8 +29,8 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Confidence Badge | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-239 | `apps/desktop/src/features/workspace/ConfidenceBadge.tsx` | Use `level: ConfidenceLevel`; no `score` or `label` prop exists in current code. | | 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. | +| 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`; each card is `workspace-section-{id}` and names a Practice {section} {window} action. | +| 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, onOpenSection })` memo component; cells open the matching practice window. | | 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`. |