From f56b4e9add4784bc679aa6d7b9f56866affffefd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:13:36 +0000 Subject: [PATCH 01/60] feat(workspace): hear tonight's first entrance from the map Name the first hearable part, section, and start time on the workspace and player so the room can take the next rehearsal action instead of a generic ready card. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 2 +- CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 18 +++++ apps/desktop/src/features/player/index.tsx | 51 +++---------- .../workspace/FirstEntranceCallout.test.tsx | 22 ++++++ .../workspace/FirstEntranceCallout.tsx | 71 +++++++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 8 +++ .../src/features/workspace/Workspace.tsx | 5 +- .../features/workspace/firstEntrance.test.ts | 28 ++++++++ .../src/features/workspace/firstEntrance.ts | 43 +++++++++++ apps/desktop/src/locales/en/common.json | 8 ++- apps/desktop/src/locales/ko/common.json | 8 ++- docs/design-system/component-contract.md | 1 + 15 files changed, 223 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstEntranceCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstEntrance.test.ts create mode 100644 apps/desktop/src/features/workspace/firstEntrance.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..e88e508c7 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. +- Name tonight's first entrance with the part, section, and start time so the next hearable action is obvious. - 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..4671d86e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- Workspace and player copy for tonight's first entrance must name the part, section, and start time so the next hearable action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..0ffcde3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's first entrance on the workspace and player so the room can hear the first part from the map. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..7203de7fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first entrance so the room can hear the first part from the map. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..433e7fa51 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { PlayerFeature } from "./index"; + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first entrance from this player.") + ).toBeTruthy(); + }); + + it("names tonight's first entrance once a song is loaded", () => { + render(); + expect(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..1c633e9d4 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,56 +1,25 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstEntranceCallout } from "../workspace/FirstEntranceCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ +/** Player surface that names tonight's first entrance instead of a generic ready card. */ export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { const { title, song } = props; + const t = createTranslator(detectPreferredLocale()); if (!song) { return ( -
-

{title}

-

No song loaded. Start an analysis to use the player.

+
+

{title}

+

{t("firstEntranceNeedsSong")}

); } return ( -
-

{title}

-
-
- {song.title} - - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} - -
-
- {song.sections.map((section) => ( - - {section.label} - - ))} -
-
- Audio playback requires the desktop app with a local audio source. -
-
+
+

{title}

+
); } diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx new file mode 100644 index 000000000..2c3f8ab3f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstEntranceCallout } from "./FirstEntranceCallout"; + +describe("FirstEntranceCallout", () => { + it("names the first hearable entrance and arms that action", () => { + render(); + + const action = screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + }); + + it("tells the room to stay on the map when no entrance exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect(screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx new file mode 100644 index 000000000..b87b21836 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatEntranceTime, resolveFirstEntrance } from "./firstEntrance"; + +/** Props for the first-entrance rehearsal callout. */ +export interface FirstEntranceCalloutProps { + song: RehearsalSong; +} + +/** Name tonight's first entrance and let the room hear where that part starts. */ +export function FirstEntranceCallout({ song }: FirstEntranceCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const entrance = resolveFirstEntrance(song); + const [heard, setHeard] = useState(false); + + if (!entrance) { + return ( + + ); + } + + const start = formatEntranceTime(entrance.startSeconds); + const actionLabel = t("firstEntranceAction") + .replace("{role}", entrance.role.name) + .replace("{section}", entrance.section.label) + .replace("{start}", start); + const body = t("firstEntranceBody") + .replace("{role}", entrance.role.name) + .replace("{section}", entrance.section.label) + .replace("{start}", start) + .replace("{cue}", entrance.role.cue.value); + const armed = t("firstEntranceArmed") + .replace("{role}", entrance.role.name) + .replace("{section}", entrance.section.label) + .replace("{start}", start) + .replace("{cue}", entrance.role.cue.value); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..727448ad3 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,12 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first entrance so the room can hear it", () => { + render(); + + expect(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..18a38be98 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,6 +4,7 @@ import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstEntranceCallout } from "./FirstEntranceCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -91,7 +92,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > {sections.map((section) => ( -
+

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

@@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstEntrance.test.ts b/apps/desktop/src/features/workspace/firstEntrance.test.ts new file mode 100644 index 000000000..7c6fcd805 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatEntranceTime, resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance", () => { + it("picks the earliest section and its highest-priority role", () => { + const song = createDemoRehearsalSong(); + const entrance = resolveFirstEntrance(song); + + expect(entrance?.section.id).toBe("verse-1"); + expect(entrance?.role.id).toBe("bass-guitar"); + expect(entrance?.startSeconds).toBe(10); + expect(formatEntranceTime(entrance?.startSeconds ?? -1)).toBe("0:10"); + expect(formatEntranceTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no section has a part to hear", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstEntrance(song)).toBeNull(); + }); + + it("skips an earlier section that has no part to hear", () => { + const song = createDemoRehearsalSong(); + song.sections[0] = { ...song.sections[0]!, roles: [] }; + expect(resolveFirstEntrance(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntrance.ts b/apps/desktop/src/features/workspace/firstEntrance.ts new file mode 100644 index 000000000..a14d9b7de --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.ts @@ -0,0 +1,43 @@ +import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first entrance: earliest section, then the highest-priority role in that section. */ +export type FirstEntrance = { + section: RehearsalSection; + role: RehearsalRole; + startSeconds: number; +}; + +/** Format a non-negative section start as m:ss for rehearsal copy. */ +export function formatEntranceTime(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}`; +} + +/** Return the first section/role the room should hear, or null when the map is empty. */ +export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null { + const section = [...song.sections] + .filter((candidate) => candidate.roles.length > 0) + .sort((left, right) => left.timeRange.start - right.timeRange.start)[0]; + if (!section) { + return null; + } + + const role = [...section.roles].sort( + (left, right) => PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority] + )[0]; + if (!role) { + return null; + } + + return { + section, + role, + startSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..d73d4b86c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstEntranceLabel": "Tonight's first entrance", + "firstEntranceAction": "Hear {role} enter the {section} at {start}", + "firstEntranceBody": "{role} enters the {section} at {start}. {cue}", + "firstEntranceArmed": "Start on {role} in the {section} at {start}. {cue}", + "firstEntranceUnavailable": "No first entrance yet. Stay on tonight's map until a section has a part.", + "firstEntranceNeedsSong": "Analyze tonight's song first, then hear the first entrance from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..830402e57 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,11 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstEntranceLabel": "오늘 첫 입장", + "firstEntranceAction": "{start}에 {section}으로 들어오는 {role} 듣기", + "firstEntranceBody": "{role}이 {start}에 {section}으로 들어옵니다. {cue}", + "firstEntranceArmed": "{start} {section}의 {role}부터 시작하세요. {cue}", + "firstEntranceUnavailable": "첫 입장이 아직 없습니다. 역할이 있는 구간이 생길 때까지 오늘 지도에 머무르세요.", + "firstEntranceNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 입장을 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..d7e97c335 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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. | | 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. | +| First Entrance Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstEntranceCallout.tsx` | Name the first hearable part, section, and start time; keep the Hear button visible. | | 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`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | From fbaa5533954dc3a2aac230256edca1996697f342 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:10:42 -0700 Subject: [PATCH 02/60] test(workspace): keep entrance placeholders literal --- .../workspace/FirstEntranceCallout.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx index 2c3f8ab3f..3b851a80a 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -13,6 +13,19 @@ describe("FirstEntranceCallout", () => { expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); }); + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + const bassRole = song.sections[0]!.roles.find((role) => role.id === "bass-guitar"); + if (!bassRole) { + throw new Error("Demo rehearsal song must include the bass-guitar role."); + } + bassRole.name = "{section}"; + + render(); + + expect(screen.getByRole("button", { name: "Hear {section} enter the verse at 0:10" })).toBeTruthy(); + }); + it("tells the room to stay on the map when no entrance exists", () => { const song = createDemoRehearsalSong(); song.sections = []; From 5abfcf8358ebe56f5758b6fb55f90f7af602a7d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 01:14:40 -0700 Subject: [PATCH 03/60] fix(workspace): interpolate entrance copy once --- .../workspace/FirstEntranceCallout.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx index b87b21836..16e0faa47 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -9,6 +9,16 @@ export interface FirstEntranceCalloutProps { song: RehearsalSong; } +type EntranceCopyValues = Readonly>; + +/** Interpolate entrance placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatEntranceCopy(template: string, values: EntranceCopyValues): string { + return template.replace(/\{(role|section|start|cue)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof EntranceCopyValues; + return values[key]; + }); +} + /** Name tonight's first entrance and let the room hear where that part starts. */ export function FirstEntranceCallout({ song }: FirstEntranceCalloutProps) { const t = createTranslator(detectPreferredLocale()); @@ -29,20 +39,15 @@ export function FirstEntranceCallout({ song }: FirstEntranceCalloutProps) { } const start = formatEntranceTime(entrance.startSeconds); - const actionLabel = t("firstEntranceAction") - .replace("{role}", entrance.role.name) - .replace("{section}", entrance.section.label) - .replace("{start}", start); - const body = t("firstEntranceBody") - .replace("{role}", entrance.role.name) - .replace("{section}", entrance.section.label) - .replace("{start}", start) - .replace("{cue}", entrance.role.cue.value); - const armed = t("firstEntranceArmed") - .replace("{role}", entrance.role.name) - .replace("{section}", entrance.section.label) - .replace("{start}", start) - .replace("{cue}", entrance.role.cue.value); + const copyValues: EntranceCopyValues = { + role: entrance.role.name, + section: entrance.section.label, + start, + cue: entrance.role.cue.value + }; + const actionLabel = formatEntranceCopy(t("firstEntranceAction"), copyValues); + const body = formatEntranceCopy(t("firstEntranceBody"), copyValues); + const armed = formatEntranceCopy(t("firstEntranceArmed"), copyValues); return ( ); -} +} \ No newline at end of file From 6ccdfab64d25db17b68ee04e4bb5b63ee9b7ea17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:06:28 -0700 Subject: [PATCH 20/60] feat(i18n): distinguish entrance navigation from playback --- apps/desktop/src/locales/en/common.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d73d4b86c..3c73fa4b4 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -151,8 +151,9 @@ "increasePracticeProgressLabel": "Increase progress", "firstEntranceLabel": "Tonight's first entrance", "firstEntranceAction": "Hear {role} enter the {section} at {start}", + "firstEntranceOpenAction": "Open {role} entrance in the {section} at {start}", "firstEntranceBody": "{role} enters the {section} at {start}. {cue}", "firstEntranceArmed": "Start on {role} in the {section} at {start}. {cue}", "firstEntranceUnavailable": "No first entrance yet. Stay on tonight's map until a section has a part.", "firstEntranceNeedsSong": "Analyze tonight's song first, then hear the first entrance from this player." -} +} \ No newline at end of file From db055494085e92289eb28d9674b9df646cb9498d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:08:22 -0700 Subject: [PATCH 21/60] feat(i18n): localize entrance map navigation --- apps/desktop/src/locales/ko/common.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 19abf88e2..c52013050 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -151,8 +151,9 @@ "increasePracticeProgressLabel": "진척도 증가", "firstEntranceLabel": "오늘의 첫 진입", "firstEntranceAction": "{start}에 {section}으로 들어오는 {role} 듣기", + "firstEntranceOpenAction": "{start} {section}의 {role} 진입 위치 열기", "firstEntranceBody": "{role}이 {start}에 {section}으로 들어옵니다. {cue}", "firstEntranceArmed": "{start} {section}의 {role}부터 시작하세요. {cue}", "firstEntranceUnavailable": "첫 진입이 아직 없습니다. 역할이 있는 구간이 생길 때까지 오늘 지도에 머무르세요.", "firstEntranceNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 진입을 들으세요." -} +} \ No newline at end of file From 827f0d7974e45743eefe0db21d55aaf138bba452 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:09:02 -0700 Subject: [PATCH 22/60] docs(workspace): describe entrance navigation honestly --- CHANGELOG.md | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a39116382..716a3db6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action records the heard state and scrolls to the matching map section, while the player exposes the action only when its owning playback surface supplies a seek callback. +- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action arms the entrance and opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -38,32 +38,3 @@ - Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g - Resolve npm audit vulnerabilities -- Fix ruff import sorting and formatting errors -- Add missing docstrings to tests -- Fix test configuration and typing issues - -## [0.1.0] - 2026-03-27 - -### Added - -- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts -- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) -- Issue #40: Enforced 100% Python docstring and test coverage -- Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline -- Issue #34: Implemented role extraction targets and part graph -- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics -- Issue #28: Delivered practical rehearsal workspace UI -- Issue #27: Supported manual overrides, provenance tracking, and local project persistence -- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports -- Issue #30: Added policy-constrained YouTube import with local fallback -- Issue #26: Finalized roadmap and prepared application for initial release - -## [0.1.4] - 2026-05-15 - -### 추가됨 (Added) - -- `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`). From e03bd5b6fa5c1637812d64b293d3f42b7927b0a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:09:38 -0700 Subject: [PATCH 23/60] fix(docs): preserve release history while clarifying entrance action --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 716a3db6f..f11f519a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,3 +38,32 @@ - Upgrade pytest to 9.0.3 to fix GHSA-6w46-j5rx-g56g - Resolve npm audit vulnerabilities +- Fix ruff import sorting and formatting errors +- Add missing docstrings to tests +- Fix test configuration and typing issues + +## [0.1.0] - 2026-03-27 + +### Added + +- Issue #29: Defined core `song -> section -> role` rehearsal domain contracts +- Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) +- Issue #40: Enforced 100% Python docstring and test coverage +- Issue #32: Implemented local analysis orchestration and secure IPC boundaries +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #34: Implemented role extraction targets and part graph +- Issue #31: Added role-specific harmony, range, overlap, and confidence metrics +- Issue #28: Delivered practical rehearsal workspace UI +- Issue #27: Supported manual overrides, provenance tracking, and local project persistence +- Issue #36: Implemented rehearsal priority calculation and cue-sheet (CSV) / chart (JSON) exports +- Issue #30: Added policy-constrained YouTube import with local fallback +- Issue #26: Finalized roadmap and prepared application for initial release + +## [0.1.4] - 2026-05-15 + +### 추가됨 (Added) + +- `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`). \ No newline at end of file From ef3358e1ec8f6fe525fe9ff7a5751c13bbe670d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:10:14 -0700 Subject: [PATCH 24/60] docs(workspace): align entrance action architecture copy --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6fc3797b0..b63ee9275 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first entrance with its part, section, and start time; the workspace action records listening state and scrolls to the mapped section, while the player renders the Hear action only when its owning playback surface supplies a seek callback. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first entrance with its part, section, and start time; the workspace action arms the entrance and opens the mapped section, while the player renders the Hear action only when its owning playback surface supplies a seek callback. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. @@ -72,4 +72,4 @@ Supporting packages: - Documents under `docs/plans/` must include `Security Notes`; `scripts/checks/verify_security_notes.py` enforces this mechanically. - Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`. - CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them. -- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). +- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). \ No newline at end of file From 03647cdd1f371aa419d31dae1e43d50477336038 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:41:07 -0700 Subject: [PATCH 25/60] test(workspace): decouple entrance navigation from analysis ids --- .../workspace/FirstEntranceCallout.test.tsx | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx index d02adc2d1..e38b0db7c 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -3,16 +3,23 @@ import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it, vi } from "vitest"; import { FirstEntranceCallout } from "./FirstEntranceCallout"; +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + describe("FirstEntranceCallout", () => { - it("names the first entrance as map navigation, scrolls to its section, and arms that action", () => { - const target = document.createElement("div"); - target.id = "song-structure-section-verse-1"; - const scrollIntoView = vi.fn(); - Object.defineProperty(target, "scrollIntoView", { - configurable: true, - value: scrollIntoView - }); - document.body.appendChild(target); + it("names the first entrance as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); render(); @@ -22,7 +29,20 @@ describe("FirstEntranceCallout", () => { expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); - target.remove(); + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); }); it("shows fresh guidance when the song changes", () => { @@ -74,4 +94,4 @@ describe("FirstEntranceCallout", () => { render(); expect(screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.")).toBeTruthy(); }); -}); \ No newline at end of file +}); From a51d2d83c050f7ea08fec5974ef25287eb0d90c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:41:34 -0700 Subject: [PATCH 26/60] fix(workspace): use renderer-owned entrance navigation targets --- .../src/features/workspace/FirstEntranceCallout.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx index 0bda37763..2f8b2672d 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -16,6 +16,7 @@ type EntranceCopyValues = Readonly(null); useEffect(() => { setHeardEntrance(null); }, [ song.id, + entranceSectionIndex, entrance?.section.id, entrance?.role.id, entrance?.startSeconds, @@ -65,6 +68,7 @@ export function FirstEntranceCallout({ const heard = heardEntrance?.songId === song.id && heardEntrance.sectionId === entrance.section.id && + heardEntrance.sectionIndex === entranceSectionIndex && heardEntrance.roleId === entrance.role.id && heardEntrance.startSeconds === entrance.startSeconds && heardEntrance.cue === entrance.role.cue.value; @@ -99,6 +103,7 @@ export function FirstEntranceCallout({ setHeardEntrance({ songId: song.id, sectionId: entrance.section.id, + sectionIndex: entranceSectionIndex, roleId: entrance.role.id, startSeconds: entrance.startSeconds, cue: entrance.role.cue.value @@ -107,7 +112,8 @@ export function FirstEntranceCallout({ onHearEntrance(entrance.startSeconds); return; } - const target = document.getElementById(`song-structure-section-${entrance.section.id}`); + const grid = document.querySelector('[data-testid="song-structure-grid"]'); + const target = entranceSectionIndex >= 0 ? grid?.children.item(entranceSectionIndex) : null; target?.scrollIntoView?.({ block: "nearest", behavior: "smooth" @@ -119,4 +125,4 @@ export function FirstEntranceCallout({ ) : null} ); -} \ No newline at end of file +} From aa466dd45a12599492bec4c175c7a8dc34aafa08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:42:46 -0700 Subject: [PATCH 27/60] fix(workspace): keep entrance analysis ids out of DOM authority --- apps/desktop/src/features/workspace/Workspace.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 18a38be98..085fe89ac 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -91,8 +91,8 @@ 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, sectionIndex) => ( +

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

From 55b73a830c5adaa6588d88fd47c307a4c1163c69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:43:29 -0700 Subject: [PATCH 28/60] test(workspace): align entrance map contracts with current behavior --- .../src/features/workspace/Workspace.test.tsx | 90 ++++++------------- 1 file changed, 27 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 727448ad3..457d1c327 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -32,7 +32,6 @@ describe("Workspace", () => { it("updates practice progress immutably through onSongUpdate", () => { const song = createDemoRehearsalSong(); - // Default mock setup puts "bass-guitar" as the role ID in index 0 song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "bass-guitar", @@ -42,21 +41,13 @@ describe("Workspace", () => { const onSongUpdate = vi.fn(); render(); - - // Select the Bass Guitar role to render PracticeProgress fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - - const increaseBtn = screen.getByRole("button", { name: "Increase progress" }); - fireEvent.click(increaseBtn); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); expect(onSongUpdate).toHaveBeenCalledTimes(1); const updatedSong = onSongUpdate.mock.calls[0]?.[0] as RehearsalSong; - - // Ensure immutable update logic: reference equality of untouched sections expect(updatedSong).not.toBe(song); expect(updatedSong.sections).not.toBe(song.sections); - - // Ensure the specific role progress updated expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60); }); @@ -67,31 +58,33 @@ describe("Workspace", () => { render(); const grid = screen.getByTestId("song-structure-grid"); - expect(grid.style.gridTemplateColumns).not.toContain("repeat(0"); expect(grid.style.gridTemplateColumns).toContain("repeat(1"); }); + it("keeps analysis section ids out of song-structure DOM authority", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + + render(); + + const firstRenderedSection = screen.getByTestId("song-structure-grid").children.item(0); + expect(firstRenderedSection).toBeTruthy(); + expect(firstRenderedSection?.hasAttribute("id")).toBe(false); + }); + it("falls back to safe timeline text for malformed section times", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); - song.sections[0].timeRange = { - start: Number.NaN, - end: Number.POSITIVE_INFINITY - }; + song.sections[0].timeRange = { start: Number.NaN, end: Number.POSITIVE_INFINITY }; render(); - expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); it("enables bass transcription from selected role metadata rather than role id text", () => { const song = createDemoRehearsalSong(); - song.sections[0]!.roles[0] = { - ...song.sections[0]!.roles[0]!, - id: "low-end", - name: "Bass Guitar" - }; + song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "low-end", name: "Bass Guitar" }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); @@ -131,9 +124,7 @@ describe("Workspace", () => { expect(screen.getByText("Collaboration")).toBeTruthy(); expect(screen.getByText(/2 Assignments/i)).toBeTruthy(); expect(screen.getByText(/Keep assignments local for now/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); expect(screen.getByText(/whole step lower/i)).toBeTruthy(); expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); @@ -143,24 +134,15 @@ describe("Workspace", () => { it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); - song.sections[0]!.roles[0] = { - ...song.sections[0]!.roles[0]!, - harmonicExplanation: " ", - transpositionPlan: "" - }; - song.collaboration = { - syncMode: "local_only", - syncNote: "Local-only draft" - } as RehearsalSong["collaboration"]; + song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, harmonicExplanation: " ", transpositionPlan: "" }; + song.collaboration = { syncMode: "local_only", syncNote: "Local-only draft" } as RehearsalSong["collaboration"]; render(); expect(screen.getByText(/0 Assignments/i)).toBeTruthy(); expect(screen.getByText(/0 Comments/i)).toBeTruthy(); expect(screen.getByText(/0 Approvals/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - expect(screen.getByText("vi pedal anchor")).toBeTruthy(); expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0); }); @@ -183,14 +165,8 @@ describe("Workspace", () => { const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: createObjectUrl - }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectUrl - }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl }); render(); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -206,20 +182,12 @@ describe("Workspace", () => { it("exports metadata-only handoff when source bootstrap is invalid", async () => { const song = createDemoRehearsalSong(); - const invalidSourceBootstrap = { - projectId: "project-1" - } as ProjectBootstrapSummary; + const invalidSourceBootstrap = { projectId: "project-1" } as ProjectBootstrapSummary; const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: createObjectUrl - }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectUrl - }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl }); render(); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -234,9 +202,7 @@ describe("Workspace", () => { it("validates source bootstrap before generating metadata handoff", () => { const song = createDemoRehearsalSong(); - const invalidSourceBootstrap = { - projectId: "project-1" - } as ProjectBootstrapSummary; + const invalidSourceBootstrap = { projectId: "project-1" } as ProjectBootstrapSummary; expect(() => { generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap }); @@ -255,10 +221,7 @@ describe("Workspace", () => { it("localizes workspace navigation and rehearsal labels", () => { setNavigatorLanguage("ko-KR"); const song = createDemoRehearsalSong(); - song.exportSummary = { - ...song.exportSummary, - headline: "" - }; + song.exportSummary = { ...song.exportSummary, headline: "" }; render(); @@ -271,11 +234,12 @@ describe("Workspace", () => { expect(screen.getByText("역할과 화성")).toBeTruthy(); }); - it("names tonight's first entrance so the room can hear it", () => { + it("names tonight's first entrance as workspace navigation", () => { render(); - expect(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })); + const action = screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" }); + expect(action).toBeTruthy(); + fireEvent.click(action); expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); }); }); From 0262b8e80faeefba5e91d74345dcb679a3b00e91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:36:42 -0700 Subject: [PATCH 29/60] test(a11y): require reduced-motion entrance navigation --- ...rstEntranceCallout.reduced-motion.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..ae35d8903 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstEntranceCallout } from "./FirstEntranceCallout"; + +afterEach(() => { + vi.unstubAllGlobals(); + document.querySelector('[data-testid="song-structure-grid"]')?.remove(); +}); + +/** Mount the renderer-owned song-structure target used by workspace navigation. */ +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return scrollIntoView; +} + +describe("FirstEntranceCallout reduced-motion navigation", () => { + it("avoids smooth scrolling when the user requests reduced motion", () => { + const scrollIntoView = appendSongStructureTarget(); + vi.stubGlobal( + "matchMedia", + 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 Bass Guitar entrance in the verse at 0:10" }) + ); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "auto" + }); + }); +}); From b754127d06d12ab95873fd7c547190230583d8f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:37:18 -0700 Subject: [PATCH 30/60] fix(a11y): honor reduced motion for entrance navigation --- .../src/features/workspace/FirstEntranceCallout.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx index 2f8b2672d..8d099a602 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -30,6 +30,14 @@ function formatEntranceCopy(template: string, values: EntranceCopyValues): strin }); } +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredEntranceScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + /** Name tonight's first entrance and offer only an action that the current surface can execute. */ export function FirstEntranceCallout({ song, @@ -116,7 +124,7 @@ export function FirstEntranceCallout({ const target = entranceSectionIndex >= 0 ? grid?.children.item(entranceSectionIndex) : null; target?.scrollIntoView?.({ block: "nearest", - behavior: "smooth" + behavior: preferredEntranceScrollBehavior() }); }} > From 3c2a006108399ec95045877a1c5bc52bb3a41cb9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:37:55 -0700 Subject: [PATCH 31/60] docs(a11y): record reduced-motion navigation contract --- ...educed-motion-first-entrance-navigation.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/doctoring/reduced-motion-first-entrance-navigation.md diff --git a/docs/doctoring/reduced-motion-first-entrance-navigation.md b/docs/doctoring/reduced-motion-first-entrance-navigation.md new file mode 100644 index 000000000..188d91029 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-entrance-navigation.md @@ -0,0 +1,33 @@ +# Reduced-motion first-entrance navigation + +## Scope + +The workspace First Entrance action is an intentional user interaction that scrolls the song-structure map to the resolved section. Smooth scrolling is non-essential to finding that section, so BandScope uses the operating-system/user-agent reduced-motion preference to choose the navigation animation behavior. + +This record applies only to the renderer-owned workspace scroll performed by `FirstEntranceCallout`. Player playback remains owned by the explicit playback callback and is not altered by this decision. + +## Contract + +- The default workspace path preserves the existing smooth scroll to the renderer-owned section position. +- When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is true, the same action uses immediate (`auto`) scrolling instead of smooth animation. +- If `matchMedia` is unavailable, BandScope preserves the existing smooth behavior rather than inventing a preference. +- Reduced-motion handling does not change entrance selection, section authority, playback authority, or the fail-closed metadata validation contract. +- The regression test covers the preference-aware JavaScript path directly; deterministic product/security/coverage gates remain independent of model judgment. + +## Standards rationale + +WCAG 2.2 Success Criterion 2.3.3, Animation from Interactions (Level AAA), requires interaction-triggered motion animation to be disableable when it is not essential. W3C's Understanding document explicitly recommends honoring user motion preferences, and Technique SCR40 documents evaluating `prefers-reduced-motion` in JavaScript to prevent interaction-triggered motion. The First Entrance scroll animation is not essential to conveying which section is selected, so respecting the preference is the narrower behavior-preserving implementation. + +This is an implementation rationale and evidence record, not a claim that BandScope is WCAG certified or that this single behavior establishes conformance. + +## Verification + +`apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx` sets the reduced-motion media query to `reduce`, activates the exact buyer-visible First Entrance workspace action, and requires the renderer-owned target to receive `scrollIntoView({ block: "nearest", behavior: "auto" })`. Existing First Entrance tests continue to cover the default smooth-scroll contract. + +## References + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, December 12, 2024). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Understanding Success Criterion 2.3.3: Animation from interactions*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *SCR40: Using the CSS prefers-reduced-motion query in JavaScript to prevent motion*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR40 From dd9434e08696e20e95cbf028ec6f31c001715ed4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:38:30 -0700 Subject: [PATCH 32/60] docs(changelog): note reduced-motion entrance scroll --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f11f519a4..b53bbd5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action arms the entrance and opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. +- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action arms the entrance and opens the matching map section, honors the user's reduced-motion preference for that navigation, and the player exposes a Hear action only when its owning playback surface supplies a seek callback. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. From a9b22e765c428c5f8ee7751d9d4822d9b28525bb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:19:08 -0700 Subject: [PATCH 33/60] test(workspace): require active first-entrance part --- .../firstEntrance.active-part.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts diff --git a/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts b/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts new file mode 100644 index 000000000..b4461d449 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance active-part authority", () => { + it("does not announce a higher-priority role that is inactive in the entrance section", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + const inactiveLead = { + ...section.roles[2]!, + id: "resting-lead", + rehearsalPriority: "high" as const + }; + const activeBass = { + ...section.roles[0]!, + id: "active-bass", + rehearsalPriority: "medium" as const + }; + + section.roles = [inactiveLead, activeBass]; + section.partGraph = [ + { + role_id: "resting-lead", + is_active: false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstEntrance(song)?.role.id).toBe("active-bass"); + }); +}); From 791fa1112bd317fa4c21d3cc4a58f279fcea1fce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:19:47 -0700 Subject: [PATCH 34/60] fix(workspace): require active first-entrance role --- .../src/features/workspace/firstEntrance.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstEntrance.ts b/apps/desktop/src/features/workspace/firstEntrance.ts index b28d598fe..0f4446793 100644 --- a/apps/desktop/src/features/workspace/firstEntrance.ts +++ b/apps/desktop/src/features/workspace/firstEntrance.ts @@ -2,7 +2,7 @@ import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/ const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; -/** Tonight's first entrance: earliest section, then the highest-priority role in that section. */ +/** Tonight's first entrance: earliest section, then the highest-priority active role in that section. */ export type FirstEntrance = { section: RehearsalSection; role: RehearsalRole; @@ -23,12 +23,19 @@ export function formatEntranceTime(totalSeconds: number): string { export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null { const candidate = song.sections .filter((section) => Number.isFinite(section.timeRange.start) && section.timeRange.start >= 0) - .map((section) => ({ - section, - roles: section.roles.filter((role) => - Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) - ) - })) + .map((section) => { + const activeRoleIds = new Set( + section.partGraph.filter((node) => node.is_active).map((node) => node.role_id) + ); + return { + section, + roles: section.roles.filter( + (role) => + activeRoleIds.has(role.id) && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ) + }; + }) .filter(({ roles }) => roles.length > 0) .sort((left, right) => left.section.timeRange.start - right.section.timeRange.start)[0]; if (!candidate) { @@ -44,4 +51,4 @@ export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null role, startSeconds: candidate.section.timeRange.start }; -} +} \ No newline at end of file From 6a749bd9261f6f36ef29524cc60c183249d8de17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 08:20:19 -0700 Subject: [PATCH 35/60] test(workspace): align entrance property graph --- .../src/features/workspace/firstEntrance.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstEntrance.test.ts b/apps/desktop/src/features/workspace/firstEntrance.test.ts index e4b3948c5..9602e2194 100644 --- a/apps/desktop/src/features/workspace/firstEntrance.test.ts +++ b/apps/desktop/src/features/workspace/firstEntrance.test.ts @@ -90,17 +90,25 @@ describe("resolveFirstEntrance", () => { id: `role-${index}`, rehearsalPriority: priority })); + const partGraph = roles.map((role) => ({ + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + })); const firstSection = { ...originalSection, id: "first-generated-section", timeRange: { start: firstStart, end: firstStart + 1 }, - roles + roles, + partGraph }; const secondSection = { ...originalSection, id: "second-generated-section", timeRange: { start: secondStart, end: secondStart + 1 }, - roles + roles, + partGraph }; song.sections = [firstSection, secondSection]; @@ -116,4 +124,4 @@ describe("resolveFirstEntrance", () => { ) ); }); -}); +}); \ No newline at end of file From 960eb4921d44f9d4d819757f0665df6552d8ce05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:14:44 -0700 Subject: [PATCH 36/60] test: reject non-boolean entrance activity flags --- .../firstEntrance.activity-type.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts diff --git a/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts b/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts new file mode 100644 index 000000000..420f801da --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstEntrance } from "./firstEntrance"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstEntrance activity-type authority", () => { + it("does not treat a string false flag as active entrance evidence", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.timeRange = { start: 8, end: 20 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-lead", + name: "Resting Lead", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-lead", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const entrance = resolveFirstEntrance(song); + + expect(entrance?.role.id).toBe("active-bass"); + expect(entrance?.startSeconds).toBe(8); + }); +}); From 46a6829c90b8697d24e954a94d9d0fe33e2fc446 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:15:05 -0700 Subject: [PATCH 37/60] fix: require boolean entrance activity evidence --- apps/desktop/src/features/workspace/firstEntrance.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstEntrance.ts b/apps/desktop/src/features/workspace/firstEntrance.ts index 0f4446793..e9b178331 100644 --- a/apps/desktop/src/features/workspace/firstEntrance.ts +++ b/apps/desktop/src/features/workspace/firstEntrance.ts @@ -25,7 +25,7 @@ export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null .filter((section) => Number.isFinite(section.timeRange.start) && section.timeRange.start >= 0) .map((section) => { const activeRoleIds = new Set( - section.partGraph.filter((node) => node.is_active).map((node) => node.role_id) + section.partGraph.filter((node) => node.is_active === true).map((node) => node.role_id) ); return { section, From 199dbf55b21221a3adca1aec2be88be0a78f0577 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:35:10 -0700 Subject: [PATCH 38/60] test: reject malformed entrance role ids --- .../firstEntrance.invalid-role-id.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts diff --git a/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts new file mode 100644 index 000000000..921299702 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance runtime role identity", () => { + it("ignores an active role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + + const safeRole = { + ...section.roles[0]!, + id: "safe-bass", + name: "Safe Bass", + rehearsalPriority: "medium" as const + }; + const malformedRole = { + ...section.roles[2]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstEntrance(song)?.role.id).toBe("safe-bass"); + }); +}); From 5450fb7676c99b2c1b8f209020fee4ce40d5e0b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:35:33 -0700 Subject: [PATCH 39/60] fix: reject malformed entrance role ids --- apps/desktop/src/features/workspace/firstEntrance.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstEntrance.ts b/apps/desktop/src/features/workspace/firstEntrance.ts index e9b178331..1fa5082c1 100644 --- a/apps/desktop/src/features/workspace/firstEntrance.ts +++ b/apps/desktop/src/features/workspace/firstEntrance.ts @@ -25,12 +25,21 @@ export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null .filter((section) => Number.isFinite(section.timeRange.start) && section.timeRange.start >= 0) .map((section) => { const activeRoleIds = new Set( - section.partGraph.filter((node) => node.is_active === true).map((node) => node.role_id) + section.partGraph + .filter( + (node) => + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id) ); return { section, roles: section.roles.filter( (role) => + typeof role.id === "string" && + role.id.trim().length > 0 && activeRoleIds.has(role.id) && Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) ) From 1201faee05297e44050ca6e0fb9c847c9ffa493f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:12:56 -0700 Subject: [PATCH 40/60] test(workspace): require executed entrance actions --- .../workspace/FirstEntranceCallout.test.tsx | 77 ++++++++++++++++--- 1 file changed, 68 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx index e38b0db7c..7cbe08bdc 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { describe, expect, it, vi } from "vitest"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { FirstEntranceCallout } from "./FirstEntranceCallout"; function appendSongStructureTarget() { @@ -18,13 +18,18 @@ function appendSongStructureTarget() { } describe("FirstEntranceCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("names the first entrance as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); render(); - const action = screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" }); - expect(action).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }); fireEvent.click(action); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); @@ -32,6 +37,19 @@ describe("FirstEntranceCallout", () => { grid.remove(); }); + it("does not claim completion when the renderer-owned section target is missing", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + expect(screen.queryByText(/Start on Bass Guitar in the verse at 0:10/)).toBeNull(); + }); + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { const song = createDemoRehearsalSong(); song.sections[0]!.id = "analysis section / duplicate"; @@ -39,7 +57,11 @@ describe("FirstEntranceCallout", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" })); + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); grid.remove(); @@ -47,9 +69,14 @@ describe("FirstEntranceCallout", () => { it("shows fresh guidance when the song changes", () => { const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); const { rerender } = render(); - fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" })); + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); const replacementSong = createDemoRehearsalSong(); @@ -57,15 +84,21 @@ describe("FirstEntranceCallout", () => { rerender(); expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + grid.remove(); }); it("forgets an armed entrance after switching away and back", () => { const firstSong = createDemoRehearsalSong(); const secondSong = createDemoRehearsalSong(); secondSong.id = "demo-song-second"; + const { grid } = appendSongStructureTarget(); const { rerender } = render(); - fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" })); + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); rerender(); @@ -73,6 +106,7 @@ describe("FirstEntranceCallout", () => { rerender(); expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + grid.remove(); }); it("keeps placeholder-looking rehearsal data literal", () => { @@ -85,13 +119,38 @@ describe("FirstEntranceCallout", () => { render(); - expect(screen.getByRole("button", { name: "Open {section} entrance in the verse at 0:10" })).toBeTruthy(); + expect( + screen.getByRole("button", { + name: "Open {section} entrance in the verse at 0:10" + }) + ).toBeTruthy(); }); it("tells the room to stay on the map when no entrance exists", () => { const song = createDemoRehearsalSong(); song.sections = []; render(); - expect(screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.")).toBeTruthy(); + expect( + screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") + ).toBeTruthy(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") + ).toBeTruthy(); + }); + + it("localizes the section form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "베이스 기타"; + + render(); + + expect(screen.getByText(/^베이스 기타이 0:10에 벌스로 들어옵니다\./)).toBeTruthy(); + expect(screen.queryByText(/verse로 들어옵니다/)).toBeNull(); }); }); From f98608b335baf7f61ccdceac29153c96cd34105a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:13:45 -0700 Subject: [PATCH 41/60] test(i18n): use grammatical Korean role copy --- .../src/features/workspace/FirstEntranceCallout.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx index 7cbe08bdc..59870de9f 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -146,11 +146,11 @@ describe("FirstEntranceCallout", () => { it("localizes the section form label instead of exposing its raw enum in Korean copy", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); const song = createDemoRehearsalSong(); - song.sections[0]!.roles[0]!.name = "베이스 기타"; + song.sections[0]!.roles[0]!.name = "건반"; render(); - expect(screen.getByText(/^베이스 기타이 0:10에 벌스로 들어옵니다\./)).toBeTruthy(); + expect(screen.getByText(/^건반이 0:10에 벌스로 들어옵니다\./)).toBeTruthy(); expect(screen.queryByText(/verse로 들어옵니다/)).toBeNull(); }); }); From 3a418a1f0f32393e005fa36549464020871bd03d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:14:08 -0700 Subject: [PATCH 42/60] feat(i18n): localize entrance section forms --- apps/desktop/src/i18n/index.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..aec0b6f9d 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized buyer copy for a validated section form label. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label]; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { From a571ead244269878aea5cf53122ce2013155a31d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:14:58 -0700 Subject: [PATCH 43/60] fix(workspace): arm entrance only after action --- .../workspace/FirstEntranceCallout.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx index 8d099a602..75d06dac0 100644 --- a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; import { formatEntranceTime, resolveFirstEntrance } from "./firstEntrance"; /** Props for the first-entrance rehearsal callout. */ @@ -44,15 +48,21 @@ export function FirstEntranceCallout({ actionMode = "workspace-scroll", onHearEntrance }: FirstEntranceCalloutProps) { - const t = createTranslator(detectPreferredLocale()); + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; const entrance = resolveFirstEntrance(song); - const entranceSectionIndex = entrance ? song.sections.indexOf(entrance.section) : -1; + const entranceSectionIndex = + entrance && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(entrance.section) + : -1; const [heardEntrance, setHeardEntrance] = useState(null); useEffect(() => { setHeardEntrance(null); }, [ - song.id, + songId, entranceSectionIndex, entrance?.section.id, entrance?.role.id, @@ -74,7 +84,7 @@ export function FirstEntranceCallout({ } const heard = - heardEntrance?.songId === song.id && + heardEntrance?.songId === songId && heardEntrance.sectionId === entrance.section.id && heardEntrance.sectionIndex === entranceSectionIndex && heardEntrance.roleId === entrance.role.id && @@ -83,7 +93,7 @@ export function FirstEntranceCallout({ const start = formatEntranceTime(entrance.startSeconds); const copyValues: EntranceCopyValues = { role: entrance.role.name, - section: entrance.section.label, + section: translateSectionFormLabel(locale, entrance.section.label), start, cue: entrance.role.cue.value }; @@ -93,7 +103,20 @@ export function FirstEntranceCallout({ ); const body = formatEntranceCopy(t("firstEntranceBody"), copyValues); const armed = formatEntranceCopy(t("firstEntranceArmed"), copyValues); - const canExecuteAction = actionMode === "workspace-scroll" || onHearEntrance !== undefined; + const canExecuteAction = + actionMode === "workspace-scroll" || typeof onHearEntrance === "function"; + + /** Record completion only after the owning surface executes the selected entrance action. */ + const markEntranceActionComplete = () => { + setHeardEntrance({ + songId, + sectionId: entrance.section.id, + sectionIndex: entranceSectionIndex, + roleId: entrance.role.id, + startSeconds: entrance.startSeconds, + cue: entrance.role.cue.value + }); + }; return (