diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..6d6d58aee 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, 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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..8b0bfd0a0 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, 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 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..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. `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: 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 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.timeline-cue-regression.test.tsx b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx new file mode 100644 index 000000000..858a53c83 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.timeline-cue-regression.test.tsx @@ -0,0 +1,112 @@ +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); + } else { + Reflect.deleteProperty(window.navigator, "language"); + } +}); + +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(); + }); +}); 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(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..6ca4259b0 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,33 +104,115 @@ 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 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 (
-
-

{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}
); }); -/** Documented. */ +/** Render the rehearsal workspace, including role planning and a focusable song-structure cue timeline. */ 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 +333,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 +462,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp - +
@@ -490,4 +627,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
); -} +} \ 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..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.", @@ -49,6 +50,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..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": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.", @@ -49,6 +50,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