From 200ab768801cc1546885c04bd154078a08c183a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:10:30 +0000 Subject: [PATCH 01/32] feat(workspace): guide tonight's first stop on map and player Name the earliest labeled cut so the room can hold it. Workspace opens the matching map section; the player exposes Hear only when the owning surface supplies a seek callback. Fail closed on malformed role ids and non-boolean activity flags. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 57 ++++++++ apps/desktop/src/features/player/index.tsx | 24 +++- .../FirstStopCallout.reduced-motion.test.tsx | 62 ++++++++ .../workspace/FirstStopCallout.test.tsx | 135 ++++++++++++++++++ .../features/workspace/FirstStopCallout.tsx | 134 +++++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 35 +++++ .../src/features/workspace/Workspace.tsx | 3 + .../firstStopHandoff.activity-type.test.ts | 46 ++++++ .../firstStopHandoff.inactive-labeled.test.ts | 34 +++++ .../firstStopHandoff.invalid-role-id.test.ts | 46 ++++++ .../workspace/firstStopHandoff.test.ts | 107 ++++++++++++++ .../features/workspace/firstStopHandoff.ts | 95 ++++++++++++ apps/desktop/src/i18n/index.test.ts | 9 ++ apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-stop-navigation.md | 14 ++ 21 files changed, 823 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstStopCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstStopCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.test.ts create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.ts create mode 100644 docs/doctoring/reduced-motion-first-stop-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..d0e7befee 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 stop with the holding part when an active role is corroborated, the labeled cut, and the time so the next 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..46e67c9c9 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 stop must name the holding part when corroborated, the labeled cut, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..7326cbcd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first stop on the workspace and player so the room can hold the cut; the workspace action 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..f0d95165e 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 stop so the room can hold the cut; the workspace action opens the matching map section, while the player exposes a 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. 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..b1e581631 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +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 stop from this player.") + ).toBeTruthy(); + }); + + it("keeps the stop hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal cut at 0:18" })).toBeNull(); + expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy(); + }); + + it("delegates the stop hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal cut at 0:18" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(18); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..0d01e7160 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,14 +1,22 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstStopCallout } from "../workspace/FirstStopCallout"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Player surface that names tonight's first stop and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return (

{title}

-

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

+

{t("firstStopNeedsSong")}

); } @@ -16,12 +24,14 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul return (

{title}

+
@@ -31,16 +41,16 @@ export function PlayerFeature(props: { title: string; song?: RehearsalSong | nul
- {song.sections.map((section) => ( + {song.sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..a834ab939 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.reduced-motion.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +describe("FirstStopCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + const matchMedia = vi.fn().mockReturnValue({ matches: true }); + vi.stubGlobal("matchMedia", matchMedia); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + + expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx new file mode 100644 index 000000000..56969b7bd --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstStopCallout } from "./FirstStopCallout"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstStopCallout", () => { + it("names the first stop as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal stop at 0:18" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + + grid.remove(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearStop = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(onHearStop).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithStop(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first stop changes or returns later", () => { + const initialSong = songWithStop(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + + const nextSong = songWithStop(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 24, end: 25 }; + rerender(); + expect(screen.getByText("Lead Vocal cuts the stop at 0:24.")).toBeTruthy(); + }); + + it("keeps an unavailable stop guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No stop yet. Stay on tonight's map until a cut is marked.") + ).toBeTruthy(); + }); + + it("names a band-wide cut when no part holds the stop", () => { + const song = songWithStop(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first stop at 0:18" })).toBeTruthy(); + expect(screen.getByText("The band cuts the stop at 0:18.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearStop = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal cut at 0:18" })); + expect(onHearStop).toHaveBeenCalledWith(18); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.tsx new file mode 100644 index 000000000..9c9e61cd8 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstStopCallout.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff"; + +/** Props for the first-stop rehearsal callout. */ +export interface FirstStopCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearStop?: (atSeconds: number) => void; +} + +type StopCopyValues = Readonly>; + +type HeardStop = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate stop placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatStopCopy(template: string, values: StopCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof StopCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredStopScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first stop and offer only an action that the current surface can execute. */ +export function FirstStopCallout({ + song, + actionMode = "workspace-scroll", + onHearStop +}: FirstStopCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const stop = resolveFirstStopHandoff(song); + const stopSectionIndex = stop ? song.sections.indexOf(stop.section) : -1; + const [heardStop, setHeardStop] = useState(null); + + useEffect(() => { + setHeardStop(null); + }, [song.id, stopSectionIndex, stop?.section.id, stop?.holdingRole?.id, stop?.atSeconds]); + + if (!stop) { + return ( + + ); + } + + const heard = + heardStop?.songId === song.id && + heardStop.sectionId === stop.section.id && + heardStop.sectionIndex === stopSectionIndex && + heardStop.holdingRoleId === (stop.holdingRole?.id ?? null) && + heardStop.atSeconds === stop.atSeconds; + const at = formatStopTime(stop.atSeconds); + const copyValues: StopCopyValues = { + role: stop.holdingRole?.name ?? "", + section: stop.section.label, + at + }; + const hasRole = stop.holdingRole !== null; + const actionLabel = formatStopCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstStopAction" + : "firstStopActionBand" + : hasRole + ? "firstStopOpenAction" + : "firstStopOpenActionBand" + ), + copyValues + ); + const body = formatStopCopy(t(hasRole ? "firstStopBody" : "firstStopBodyBand"), copyValues); + const armed = formatStopCopy(t(hasRole ? "firstStopArmed" : "firstStopArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || onHearStop !== undefined; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..62233dde8 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,39 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first stop as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + stop.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal stop at 0:18" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(screen.getByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..f5a52f0f3 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 { FirstStopCallout } from "./FirstStopCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts new file mode 100644 index 000000000..d8f702ce4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.activity-type.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstStopHandoff activity-type authority", () => { + it("does not treat a string false flag as an active stop holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstStopHandoff(song)?.holdingRole?.id).toBe("active-bass"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts new file mode 100644 index 000000000..127cc4ca2 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.inactive-labeled.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff inactive labeled holder", () => { + it("does not name an inactive labeled role as the stop holder", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-vocal", + name: "Resting Vocal", + rehearsalPriority: "high" + } + ]; + section.partGraph = [ + { + role_id: "resting-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..9b931a017 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-role-id.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff runtime role identity", () => { + it("ignores an active stop role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "stop-1"; + section.label = "stop"; + section.timeRange = { start: 18, end: 19 }; + + const safeRole = { + ...section.roles[2]!, + id: "safe-vocal", + name: "Safe Vocal", + rehearsalPriority: "high" as const + }; + const malformedRole = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-vocal", + 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(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole?.id).toBe("safe-vocal"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts new file mode 100644 index 000000000..d09129610 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatStopTime, resolveFirstStopHandoff } from "./firstStopHandoff"; + +function withStopSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + stop.id = overrides.id ?? "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: overrides.start ?? 18, end: overrides.end ?? 19 }; + const roleId = overrides.roleId ?? "lead-vocal"; + stop.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + stop.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return song; +} + +describe("resolveFirstStopHandoff", () => { + it("returns null when the demo song has no labeled stop", () => { + expect(resolveFirstStopHandoff(createDemoRehearsalSong())).toBeNull(); + expect(formatStopTime(Number.NaN)).toBe("0:00"); + expect(formatStopTime(-4)).toBe("0:00"); + }); + + it("picks the earliest labeled stop and the part that holds the cut", () => { + const song = withStopSection({ start: 18, end: 19 }); + const stop = resolveFirstStopHandoff(song); + + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole?.id).toBe("lead-vocal"); + expect(stop?.atSeconds).toBe(18); + expect(formatStopTime(stop?.atSeconds ?? -1)).toBe("0:18"); + }); + + it("prefers the earlier of two labeled stops", () => { + const song = withStopSection({ id: "stop-late", start: 40, end: 41 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "stop-early"; + earlier.timeRange = { start: 12, end: 13 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[0]!, song.sections[1]!, earlier]; + + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-early"); + expect(stop?.holdingRole?.id).toBe("bass-guitar"); + expect(stop?.atSeconds).toBe(12); + }); + + it("keeps a band-wide cut when no active ranked role holds it", () => { + const song = withStopSection({ isActive: false }); + const stop = resolveFirstStopHandoff(song); + expect(stop?.section.id).toBe("stop-1"); + expect(stop?.holdingRole).toBeNull(); + expect(stop?.atSeconds).toBe(18); + }); + + it("skips a stop whose rehearsal window is unbounded", () => { + const song = withStopSection({ start: Number.NaN, end: 19 }); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); + + it("skips a stop whose end precedes its start", () => { + const song = withStopSection({ start: 20, end: 10 }); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts new file mode 100644 index 000000000..7671d3f15 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -0,0 +1,95 @@ +import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first stop: the earliest labeled cut and the part that holds it. */ +export type FirstStopHandoff = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative stop time as m:ss for rehearsal copy. */ +export function formatStopTime(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 true when the role has a safe runtime identity and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section has a bounded, non-negative rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + return ( + Number.isFinite(section.timeRange.start) && + section.timeRange.start >= 0 && + Number.isFinite(section.timeRange.end) && + section.timeRange.end >= section.timeRange.start + ); +} + +/** Prefer the highest-priority ranked role, then a stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const activeIds = new Set( + 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.filter((role) => { + if (!hasRankedPriority(role) || rolesInSection.get(role.id) !== role) { + return false; + } + return activeIds.has(role.id); + }); +} + +/** Return the first labeled stop, or null when no safe cut remains. */ +export function resolveFirstStopHandoff(song: RehearsalSong): FirstStopHandoff | null { + const stopSections = song.sections + .filter((section) => section.label === "stop" && hasBoundedTimeRange(section)) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return left.id.localeCompare(right.id); + }); + + const section = stopSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..ecd368e65 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -74,5 +74,14 @@ describe("i18n", () => { koDictionary.appSubtitle = originalSubtitle; } }); + + it("keeps first-stop keys in both baseline locales", () => { + const tEn = createTranslator("en"); + const tKo = createTranslator("ko"); + expect(tEn("firstStopLabel")).toBe("Tonight's first stop"); + expect(tKo("firstStopLabel")).toBe("오늘 첫 스톱"); + expect(tEn("firstStopOpenAction")).toContain("{role}"); + expect(tKo("firstStopOpenAction")).toContain("{role}"); + }); }); }); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..6e2f76573 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstStopLabel": "Tonight's first stop", + "firstStopAction": "Hear {role} cut at {at}", + "firstStopActionBand": "Hear the first stop at {at}", + "firstStopOpenAction": "Open {role} stop at {at}", + "firstStopOpenActionBand": "Open the first stop at {at}", + "firstStopBody": "{role} cuts the {section} at {at}.", + "firstStopBodyBand": "The band cuts the {section} at {at}.", + "firstStopArmed": "Hold {role}'s cut at {at}. Do not play through it.", + "firstStopArmedBand": "Hold the cut at {at}. Do not play through it.", + "firstStopUnavailable": "No stop yet. Stay on tonight's map until a cut is marked.", + "firstStopNeedsSong": "Analyze tonight's song first, then hear the first stop from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..5a639f100 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstStopLabel": "오늘 첫 스톱", + "firstStopAction": "{at}에 {role} 컷 듣기", + "firstStopActionBand": "{at} 첫 스톱 듣기", + "firstStopOpenAction": "{at} {role} 스톱 위치 열기", + "firstStopOpenActionBand": "{at} 첫 스톱 위치 열기", + "firstStopBody": "{role}이 {at} {section}에서 컷합니다.", + "firstStopBodyBand": "밴드가 {at} {section}에서 컷합니다.", + "firstStopArmed": "{at}에서 {role} 컷을 지키세요. 그대로 밀고 가지 마세요.", + "firstStopArmedBand": "{at}에서 컷을 지키세요. 그대로 밀고 가지 마세요.", + "firstStopUnavailable": "아직 스톱이 없습니다. 컷이 표시될 때까지 오늘 지도에 머무르세요.", + "firstStopNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 스톱을 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..2874beb8e 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 Stop Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstStopCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled stop, and the time. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearStop` exists and delegates the exact stop second to that callback. Keep the unavailable state guidance-only. | | 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()`. | diff --git a/docs/doctoring/reduced-motion-first-stop-navigation.md b/docs/doctoring/reduced-motion-first-stop-navigation.md new file mode 100644 index 000000000..6490c2c38 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-stop-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-stop navigation + +Workspace map navigation for tonight's first stop follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstStopCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Stop resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: rehearsal section and role identifiers used only as React keys and copy values. +- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority. +- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once. +- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`. From 9a9aff0b2eae4e789db3da93b679a9a790629e94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:01:44 -0700 Subject: [PATCH 02/32] test(workspace): fail closed on ambiguous stop holders --- ...stStopHandoff.duplicate-identities.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts new file mode 100644 index 000000000..dd1b37a7a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.duplicate-identities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const stop = structuredClone(verse); + const role = { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high" as const + }; + + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + stop.roles = [role]; + stop.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, stop]; + return { song, stop, role }; +} + +describe("resolveFirstStopHandoff ambiguous identities", () => { + it("keeps a band-wide cut when a stop repeats one role identity", () => { + const { song, stop, role } = songWithStop(); + stop.roles = [role, { ...role, name: "Duplicate Bass" }]; + + const result = resolveFirstStopHandoff(song); + + expect(result?.section).toBe(stop); + expect(result?.holdingRole).toBeNull(); + }); + + it("keeps a band-wide cut when a stop repeats one graph-node identity", () => { + const { song, stop, role } = songWithStop(); + stop.partGraph = [ + { + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: role.id, + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + + const result = resolveFirstStopHandoff(song); + + expect(result?.section).toBe(stop); + expect(result?.holdingRole).toBeNull(); + }); +}); From b7999cf7185fd867c61db01f81de12ada210d842 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:02:36 -0700 Subject: [PATCH 03/32] fix(workspace): reject ambiguous stop holder identities --- .../features/workspace/firstStopHandoff.ts | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index 7671d3f15..ef639bc58 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -38,6 +38,20 @@ function hasBoundedTimeRange(section: RehearsalSection): boolean { ); } +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + /** Prefer the highest-priority ranked role, then a stable id order. */ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { if (roles.length === 0) { @@ -54,21 +68,32 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { ); } -/** Return ranked roles whose graph node is explicitly active. */ +/** Return ranked roles whose unique graph node is explicitly active. */ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { - const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + const safeRoleIds = section.roles + .filter((role) => typeof role.id === "string" && role.id.trim().length > 0) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter((node) => typeof node.role_id === "string" && node.role_id.trim().length > 0) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); const activeIds = new Set( section.partGraph - .filter((node) => node.is_active === true && typeof node.role_id === "string" && node.role_id.trim().length > 0) + .filter( + (node) => + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) .map((node) => node.role_id) ); - return section.roles.filter((role) => { - if (!hasRankedPriority(role) || rolesInSection.get(role.id) !== role) { - return false; - } - return activeIds.has(role.id); - }); + return section.roles.filter( + (role) => + hasRankedPriority(role) && !repeatedRoleIds.has(role.id) && activeIds.has(role.id) + ); } /** Return the first labeled stop, or null when no safe cut remains. */ From 4d0159c06835d1f5fc78e355d41d78c399384050 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:04:51 -0700 Subject: [PATCH 04/32] test(workspace): reject malformed stop section identities --- ...irstStopHandoff.invalid-section-id.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts new file mode 100644 index 000000000..d4b700183 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-id.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function malformedStop(sectionId: unknown) { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = sectionId as string; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return song; +} + +describe("resolveFirstStopHandoff runtime section identity", () => { + it("rejects stop sections whose runtime id is not a non-empty string", () => { + for (const invalidId of [42, " "]) { + const song = malformedStop(invalidId); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + } + }); +}); From 5c982c5395dc1f8baccb95a24d9e7903b921b141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:05:11 -0700 Subject: [PATCH 05/32] fix(workspace): reject malformed stop section identities --- apps/desktop/src/features/workspace/firstStopHandoff.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index ef639bc58..b544c236f 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -99,7 +99,13 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled stop, or null when no safe cut remains. */ export function resolveFirstStopHandoff(song: RehearsalSong): FirstStopHandoff | null { const stopSections = song.sections - .filter((section) => section.label === "stop" && hasBoundedTimeRange(section)) + .filter( + (section) => + section.label === "stop" && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) .sort((left, right) => { if (left.timeRange.start !== right.timeRange.start) { return left.timeRange.start - right.timeRange.start; From e82458da9b7b3ab45ea87aff75686b4c86cd60c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:06:58 -0700 Subject: [PATCH 06/32] test(workspace): reject malformed stop time ranges --- .../firstStopHandoff.invalid-time-range.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts new file mode 100644 index 000000000..ef96fe93f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-time-range.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +describe("resolveFirstStopHandoff runtime time range", () => { + it("rejects a stop whose runtime timeRange is not an object", () => { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = null as unknown as typeof stop.timeRange; + song.sections = [stop]; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); +}); From 3ad3e215d290ed1227bb7233fbcf2d9a4fc44fff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:07:32 -0700 Subject: [PATCH 07/32] fix(workspace): bound malformed stop time ranges --- .../src/features/workspace/firstStopHandoff.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index b544c236f..2b07fe7b3 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -30,11 +30,14 @@ function hasRankedPriority(role: RehearsalRole): boolean { /** Return whether a section has a bounded, non-negative rehearsal window. */ function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; return ( - Number.isFinite(section.timeRange.start) && - section.timeRange.start >= 0 && - Number.isFinite(section.timeRange.end) && - section.timeRange.end >= section.timeRange.start + timeRange !== null && + typeof timeRange === "object" && + Number.isFinite(timeRange.start) && + (timeRange.start ?? -1) >= 0 && + Number.isFinite(timeRange.end) && + (timeRange.end ?? -1) >= (timeRange.start ?? 0) ); } From 958c4367ee07b4374d05606776cdaa43cee046cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:07:59 -0700 Subject: [PATCH 08/32] test(workspace): isolate malformed stop holder collections --- ...Handoff.invalid-holder-collections.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts new file mode 100644 index 000000000..29f5e3f39 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-collections.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return { song, stop }; +} + +describe("resolveFirstStopHandoff runtime holder collections", () => { + it("keeps the cut band-wide when runtime roles are not an array", () => { + const { song, stop } = songWithStop(); + stop.roles = null as unknown as typeof stop.roles; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); + + it("keeps the cut band-wide when runtime partGraph is not an array", () => { + const { song, stop } = songWithStop(); + stop.partGraph = null as unknown as typeof stop.partGraph; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); +}); From a93ebd8137fd0607b6b66125f838554252ac7566 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:08:29 -0700 Subject: [PATCH 09/32] fix(workspace): isolate malformed stop holder collections --- apps/desktop/src/features/workspace/firstStopHandoff.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index 2b07fe7b3..4e12a737c 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -73,6 +73,10 @@ function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { /** Return ranked roles whose unique graph node is explicitly active. */ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!Array.isArray(section.roles) || !Array.isArray(section.partGraph)) { + return []; + } + const safeRoleIds = section.roles .filter((role) => typeof role.id === "string" && role.id.trim().length > 0) .map((role) => role.id); From 7061a6089d4e31e20fe669cac35853c36e4b9723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:12:25 -0700 Subject: [PATCH 10/32] test(workspace): isolate malformed stop holder elements --- ...topHandoff.invalid-holder-elements.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts new file mode 100644 index 000000000..0ef1e0a14 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithStop() { + const song = createDemoRehearsalSong(); + const stop = structuredClone(song.sections[0]!); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + song.sections = [stop]; + return { song, stop }; +} + +describe("resolveFirstStopHandoff runtime holder elements", () => { + it("keeps the cut band-wide when runtime roles contain a non-object element", () => { + const { song, stop } = songWithStop(); + stop.roles = [null] as unknown as typeof stop.roles; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); + + it("keeps the cut band-wide when runtime partGraph contains a non-object element", () => { + const { song, stop } = songWithStop(); + stop.partGraph = [null] as unknown as typeof stop.partGraph; + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + }); +}); From 9071154f2fdfc0e5ebf1b34dbba23e4d0422722b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:12:53 -0700 Subject: [PATCH 11/32] test(workspace): cover primitive stop holder elements --- ...topHandoff.invalid-holder-elements.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts index 0ef1e0a14..be41e8be4 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-holder-elements.test.ts @@ -14,18 +14,22 @@ function songWithStop() { describe("resolveFirstStopHandoff runtime holder elements", () => { it("keeps the cut band-wide when runtime roles contain a non-object element", () => { - const { song, stop } = songWithStop(); - stop.roles = [null] as unknown as typeof stop.roles; + for (const malformedRole of [null, 42]) { + const { song, stop } = songWithStop(); + stop.roles = [malformedRole] as unknown as typeof stop.roles; - expect(() => resolveFirstStopHandoff(song)).not.toThrow(); - expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + } }); it("keeps the cut band-wide when runtime partGraph contains a non-object element", () => { - const { song, stop } = songWithStop(); - stop.partGraph = [null] as unknown as typeof stop.partGraph; + for (const malformedNode of [null, 42]) { + const { song, stop } = songWithStop(); + stop.partGraph = [malformedNode] as unknown as typeof stop.partGraph; - expect(() => resolveFirstStopHandoff(song)).not.toThrow(); - expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)?.holdingRole).toBeNull(); + } }); }); From c863b7c38daba0eaf17655f8d602d2ad44d183d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 12:13:17 -0700 Subject: [PATCH 12/32] fix(workspace): isolate malformed stop holder elements --- .../features/workspace/firstStopHandoff.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index 4e12a737c..a9a0a7a25 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -19,6 +19,11 @@ export function formatStopTime(totalSeconds: number): string { return `${minutes}:${seconds}`; } +/** Return whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + /** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { return ( @@ -78,10 +83,14 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { } const safeRoleIds = section.roles - .filter((role) => typeof role.id === "string" && role.id.trim().length > 0) + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) .map((role) => role.id); const safeGraphRoleIds = section.partGraph - .filter((node) => typeof node.role_id === "string" && node.role_id.trim().length > 0) + .filter( + (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0 + ) .map((node) => node.role_id); const repeatedRoleIds = repeatedIds(safeRoleIds); const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); @@ -89,6 +98,7 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { section.partGraph .filter( (node) => + isRuntimeObject(node) && node.is_active === true && typeof node.role_id === "string" && node.role_id.trim().length > 0 && @@ -99,7 +109,10 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { return section.roles.filter( (role) => - hasRankedPriority(role) && !repeatedRoleIds.has(role.id) && activeIds.has(role.id) + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) ); } From 77f92b98e45f008f28f86fa7cffe8916af5b3e46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:11:31 -0700 Subject: [PATCH 13/32] test(workspace): reject malformed stop section collections --- ...Handoff.invalid-section-collection.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts new file mode 100644 index 000000000..0e4ee8860 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstStopHandoff.invalid-section-collection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstStopHandoff } from "./firstStopHandoff"; + +function songWithRuntimeSections(sections: unknown): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = sections as RehearsalSong["sections"]; + return song; +} + +describe("resolveFirstStopHandoff runtime section collection", () => { + it("fails closed when the runtime section collection is not an array", () => { + const song = songWithRuntimeSections(null); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements instead of dereferencing them", () => { + const song = songWithRuntimeSections([null, 42]); + + expect(() => resolveFirstStopHandoff(song)).not.toThrow(); + expect(resolveFirstStopHandoff(song)).toBeNull(); + }); +}); From 2ca1ea880dcee1a4b8748301c1d8fc3f0f993f3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:12:57 -0700 Subject: [PATCH 14/32] fix(workspace): fail closed on malformed stop sections --- apps/desktop/src/features/workspace/firstStopHandoff.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstStopHandoff.ts b/apps/desktop/src/features/workspace/firstStopHandoff.ts index a9a0a7a25..20ea46517 100644 --- a/apps/desktop/src/features/workspace/firstStopHandoff.ts +++ b/apps/desktop/src/features/workspace/firstStopHandoff.ts @@ -118,9 +118,14 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled stop, or null when no safe cut remains. */ export function resolveFirstStopHandoff(song: RehearsalSong): FirstStopHandoff | null { + if (!Array.isArray(song.sections)) { + return null; + } + const stopSections = song.sections .filter( (section) => + isRuntimeObject(section) && section.label === "stop" && typeof section.id === "string" && section.id.trim().length > 0 && From 3a4d6d002f7e72b06579e61e7bee04a4c67989fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:15:23 -0700 Subject: [PATCH 15/32] test(workspace): keep failed stop navigation unarmed --- .../src/features/workspace/FirstStopCallout.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx index 56969b7bd..1b6b8e814 100644 --- a/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstStopCallout.test.tsx @@ -63,6 +63,15 @@ describe("FirstStopCallout", () => { grid.remove(); }); + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal stop at 0:18" })); + + expect(screen.getByText("Lead Vocal cuts the stop at 0:18.")).toBeTruthy(); + expect(screen.queryByText(/Hold Lead Vocal's cut at 0:18. Do not play through it./)).toBeNull(); + }); + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); const onHearStop = vi.fn(); From 35761f8df86f81dd9a14f9d1f6eaa1cafd1e1241 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 13:16:04 -0700 Subject: [PATCH 16/32] fix(workspace): arm stop action only after execution --- .../features/workspace/FirstStopCallout.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstStopCallout.tsx b/apps/desktop/src/features/workspace/FirstStopCallout.tsx index 9c9e61cd8..782cb82a0 100644 --- a/apps/desktop/src/features/workspace/FirstStopCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstStopCallout.tsx @@ -92,7 +92,16 @@ export function FirstStopCallout({ ); const body = formatStopCopy(t(hasRole ? "firstStopBody" : "firstStopBodyBand"), copyValues); const armed = formatStopCopy(t(hasRole ? "firstStopArmed" : "firstStopArmedBand"), copyValues); - const canExecuteAction = actionMode === "workspace-scroll" || onHearStop !== undefined; + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearStop === "function"; + const markStopActionComplete = () => { + setHeardStop({ + songId: song.id, + sectionId: stop.section.id, + sectionIndex: stopSectionIndex, + holdingRoleId: stop.holdingRole?.id ?? null, + atSeconds: stop.atSeconds + }); + }; return (