diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..84b66d156 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. +- Customer-facing workspace copy must name the next rehearsal action (open tonight's first lock-in, start a part, see first notes) instead of leaving priority or empty cards inert. - 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..6036a7060 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 workspace priorities card that opens tonight's first lock-in section on the Section Roadmap - 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..b65bdd0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- The rehearsal priorities card now opens tonight's first lock-in section on the Section Roadmap, with bilingual next-action copy. - 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..b22fd7aa8 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 workspace priorities card must name and open tonight's first lock-in on the Section Roadmap. `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/App.test.tsx b/apps/desktop/src/App.test.tsx index 3eed386f8..9948eeba7 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -313,10 +313,10 @@ describe("App", () => { await waitFor(() => { expect(screen.getByRole("heading", { name: /Song Structure/i })).toBeTruthy(); }); - expect(screen.getByText(/verse · 0:10–0:30/i)).toBeTruthy(); + const timelineRegion = screen.getByRole("region", { name: /scrollable song structure timeline/i }); + expect(within(timelineRegion).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 }); expect(timelineRegion.className).toContain("overflow-x-auto"); expect(timelineRegion.getAttribute("tabindex")).toBe("0"); expect(screen.queryByLabelText(/decorative waveform overview/i)).toBeNull(); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..603e099c3 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -60,4 +60,15 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + it("exposes focus targets for tonight's first lock-in section", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const card = document.getElementById("workspace-section-verse-1"); + expect(card).toBeTruthy(); + expect(card?.className).toContain("ring-amber-300/70"); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..030deda05 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; + focusedSectionId?: string | null; } -/** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +/** Render the rehearsal section roadmap and highlight the card identified by `focusedSectionId` when provided. */ +export function SectionRoadmap({ song, activeRole, onSongUpdate, focusedSectionId = 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..5c252a124 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("opens tonight's first lock-in section on the roadmap", () => { + 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-section-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: "Open verse on the section roadmap from 0:10 to 0:30" })); + + expect(screen.getByText("Tonight's first lock-in is verse · 0:10–0:30. Count in on that card.")).toBeTruthy(); + expect(document.getElementById("workspace-section-verse-1")).toBeTruthy(); + 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 card = document.getElementById("workspace-section-intro-1"); + const scrollIntoView = vi.fn(); + expect(card).toBeTruthy(); + Object.defineProperty(card!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + fireEvent.click(screen.getByRole("button", { name: "Open intro on the section roadmap from 0:00 to 0:08" })); + + expect(screen.getByText("Tonight's first lock-in is intro · 0:00–0:08. Count in on that card.")).toBeTruthy(); + expect(document.getElementById("workspace-section-intro-1")).toBeTruthy(); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + }); + + it("keeps the priorities action 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 on that card/i)).toBeNull(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..f2670853d 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 section card on the rehearsal roadmap. */ +function focusWorkspaceSection(sectionId: string): void { + const node = document.getElementById(`workspace-section-${sectionId}`); + if (!(node instanceof HTMLElement)) { + return; + } + node.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + node.focus(); +} + /** Documented. */ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): ProjectBootstrapSummary | null { if (!value) { @@ -120,6 +153,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 [openedFocusSectionId, setOpenedFocusSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +246,37 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const focusSection = firstFocusSection(song); + const openedFocusSection = + song.sections.find((section) => section.id === openedFocusSectionId) ?? null; + const focusCopyValues = focusSection + ? { + label: focusSection.label, + start: formatTimelineTime(focusSection.timeRange.start), + end: formatTimelineTime(focusSection.timeRange.end) + } + : null; + const focusActionLabel = focusCopyValues + ? fillCopy(t("workspaceOpenFocusAction"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusAriaLabel = focusCopyValues + ? fillCopy(t("workspaceOpenFocusAria"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusSummary = focusCopyValues + ? fillCopy(t("workspaceOpenFocusSummary"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + const focusStatus = focusCopyValues + ? fillCopy(t("workspaceOpenFocusArmed"), focusCopyValues) + : t("workspaceOpenFocusUnavailable"); + + /** Open tonight's first lock-in on the section roadmap without inventing playback. */ + const openTonightFocus = (): void => { + if (!focusSection) { + return; + } + setOpenedFocusSectionId(focusSection.id); + focusWorkspaceSection(focusSection.id); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -325,9 +390,36 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRehearsalPrioritiesLabel")}

-

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. -

+

{focusSummary}

+ {focusSection ? ( + + ) : ( + + )} + {openedFocusSection ? ( +

+ {focusStatus} +

+ ) : null}
@@ -484,6 +576,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp song={song} activeRole={activeRole} onSongUpdate={onSongUpdate} + focusedSectionId={openedFocusSectionId} /> diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..f31d7f284 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", + "workspaceOpenFocusAction": "Open {label} · {start}–{end}", + "workspaceOpenFocusAria": "Open {label} on the section roadmap from {start} to {end}", + "workspaceOpenFocusSummary": "Tonight's first lock-in is {label} · {start}–{end}.", + "workspaceOpenFocusArmed": "Tonight's first lock-in is {label} · {start}–{end}. Count in on that card.", + "workspaceOpenFocusUnavailable": "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..652c689d1 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceOpenFocusAction": "{label} 열기 · {start}–{end}", + "workspaceOpenFocusAria": "구간 흐름에서 {label} 열기, {start}부터 {end}까지", + "workspaceOpenFocusSummary": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다.", + "workspaceOpenFocusArmed": "오늘 먼저 잠글 구간은 {label} · {start}–{end}입니다. 그 카드에서 카운트인하세요.", + "workspaceOpenFocusUnavailable": "아직 잠글 구간이 없습니다", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", diff --git a/apps/desktop/src/setupTests.ts b/apps/desktop/src/setupTests.ts index 753877d90..2dfafdc95 100644 --- a/apps/desktop/src/setupTests.ts +++ b/apps/desktop/src/setupTests.ts @@ -17,3 +17,10 @@ if (typeof window !== "undefined" && !window.matchMedia) { dispatchEvent: vi.fn(), })); } + +// jsdom also omits the browser scrolling API. Keep the test environment at +// the same DOM capability boundary as supported desktop WebViews so focus +// interactions exercise application behavior instead of throwing in jsdom. +if (typeof HTMLElement !== "undefined" && !HTMLElement.prototype.scrollIntoView) { + HTMLElement.prototype.scrollIntoView = vi.fn(); +} diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c3479ab45 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -29,7 +29,7 @@ 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. | +| 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`, optional `onSongUpdate`, and optional `focusedSectionId`; `id="workspace-section-{section.id}"` is the focus target for tonight's first lock-in. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. |