diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..efb4d8b76 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, playable ranges, 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 ca0df5ac4..612afa739 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 0b6f7e784..b05286432 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. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - 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 b5a34c1fa..59981e74e 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 5b32019d2..51d914ec0 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -106,4 +106,16 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).not.toHaveBeenCalled(); }); + + 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-card-0"); + expect(card).toBeTruthy(); + expect(card?.getAttribute("tabindex")).toBe("-1"); + expect(card?.id).not.toContain(song.sections[0].id); + }); }); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..61ec249c8 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -12,10 +12,11 @@ interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles onSongUpdate?: (song: RehearsalSong) => void; + loopedSectionIndex?: number | null; } /** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +export function SectionRoadmap({ song, activeRole, onSongUpdate, loopedSectionIndex = null }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); @@ -104,11 +105,17 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma tabIndex={0} aria-labelledby={sectionRoadmapTitleId} > - {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( 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" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..c01e06d5e 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", () => { @@ -326,4 +328,73 @@ 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-card-0"); + expect(scrollIntoView).toHaveBeenCalled(); + }); + + it("focuses the selected renderer position even when analysis section ids are duplicated", () => { + const song = createDemoRehearsalSong(); + 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"); + 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-card-0"); + expect(screen.getByRole("button", { name: "Isolation is not ready. Loop tonight's section on the map." })).toBeTruthy(); + }); + + 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(); + }); }); 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 d44e20777..293051132 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -40,6 +40,61 @@ function downloadTextFile(contents: string, type: string, filename: string): voi URL.revokeObjectURL(url); } +/** Return the renderer-owned position of the first section this player should loop tonight. */ +function firstLoopSectionIndex( + song: RehearsalSong, + activeRole: string | null +): number | undefined { + if (activeRole) { + const forRoleIndex = song.sections.findIndex((section) => + section.roles.some((role) => role.id === activeRole) + ); + if (forRoleIndex !== -1) { + return forRoleIndex; + } + } + + const requested = song.exportSummary?.focusSections?.[0]?.trim(); + if (requested) { + const requestedIndex = song.sections.findIndex( + (section) => section.label === requested || section.id === requested + ); + if (requestedIndex !== -1) { + return requestedIndex; + } + } + + return song.sections.length > 0 ? 0 : undefined; +} + +/** 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; + } + 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(); +} + +/** 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. */ @@ -72,7 +127,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, + loopedSectionIndex, + onLoopSection +}: { + sections: RehearsalSong["sections"]; + t: Translator; + loopedSectionIndex: number | null; + onLoopSection: (sectionIndex: number) => void; +}) { return (
@@ -84,19 +149,32 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R 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")} >
- {sections.map((section) => ( -
-

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

-

{section.groove}

+ {sections.map((section, sectionIndex) => ( +
+
))}
@@ -121,6 +199,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 [loopedSectionIndex, setLoopedSectionIndex] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -225,6 +304,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const loopSectionIndex = firstLoopSectionIndex(song, activeRole); + const loopSection = loopSectionIndex === undefined ? undefined : song.sections[loopSectionIndex]; + const loopedSection = loopedSectionIndex === null ? null : (song.sections[loopedSectionIndex] ?? null); + + /** Arm a rehearsal loop and move focus to the matching renderer-owned roadmap card. */ + const armSectionLoop = (sectionIndex: number): void => { + if (!Number.isSafeInteger(sectionIndex) || sectionIndex < 0 || sectionIndex >= song.sections.length) { + return; + } + setLoopedSectionIndex(sectionIndex); + focusWorkspaceSection(sectionIndex); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -353,7 +444,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- + + {loopedSection ? ( +

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

+ ) : null}
@@ -376,8 +477,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 d803a765e..ccabee7d5 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.", @@ -50,6 +51,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 0f6c6c66d..ba8cf1d28 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": "담당, 코멘트, 승인 내역이 정리되면 이곳에 표시됩니다.", @@ -50,6 +51,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`. |