From 851964d00510ad8e4f6c5e16d985dad841f421e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:09:08 +0000 Subject: [PATCH 01/38] feat(workspace): name tonight's first dropout on the map Surface the earliest part-graph handoff as a next action so the outgoing player can hear the last bar before the incoming part takes the section. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 44 ++++ apps/desktop/src/features/player/index.tsx | 22 +- .../workspace/FirstDropoutCallout.test.tsx | 60 ++++++ .../workspace/FirstDropoutCallout.tsx | 113 ++++++++++ .../src/features/workspace/Workspace.test.tsx | 16 ++ .../src/features/workspace/Workspace.tsx | 5 +- .../workspace/firstDropoutHandoff.test.ts | 204 ++++++++++++++++++ .../features/workspace/firstDropoutHandoff.ts | 106 +++++++++ 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, 584 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstDropoutCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts create mode 100644 apps/desktop/src/features/workspace/firstDropoutHandoff.ts diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..1bee3a423 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 dropout with the outgoing part, incoming part, section, and end 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..bcbed2c6e 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 dropout must name the outgoing part, incoming part, section, and end time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..733ced3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first dropout on the workspace and player so the outgoing part can get out of the way. - 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..aef6c090e 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 dropout so the outgoing part can get out of the way. `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..649ec535d --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,44 @@ +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"; + +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 dropout from this player.") + ).toBeTruthy(); + }); + + it("keeps the dropout hear action unavailable without a player playback callback", () => { + render(); + + expect( + screen.queryByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }) + ).toBeNull(); + expect(screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy(); + }); + + it("delegates the dropout hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }) + ); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(30); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..55fb9a15c 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 { FirstDropoutCallout } from "../workspace/FirstDropoutCallout"; +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 dropout 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("firstDropoutNeedsSong")}

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

{title}

+
diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx new file mode 100644 index 000000000..b88e5bbff --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstDropoutCallout } from "./FirstDropoutCallout"; + +describe("FirstDropoutCallout", () => { + it("names the first dropout and arms that action", () => { + render(); + + const action = screen.getByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); + }); + + it("shows fresh guidance when the first dropout changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }) + ); + expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); + + const replacementSong = createDemoRehearsalSong(); + replacementSong.id = "demo-song-replacement"; + replacementSong.sections[0]!.roles[0]!.name = "Upright Bass"; + rerender(); + expect(screen.getByText("Upright Bass hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy(); + + rerender(); + expect(screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy(); + }); + + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "{section}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Hear {section} drop out for Lead Vocal at 0:30" + }) + ).toBeTruthy(); + }); + + it("tells the room to stay on the map when no dropout exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect( + screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.") + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx new file mode 100644 index 000000000..958f5a170 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatDropoutTime, resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; + +/** Props for the first-dropout rehearsal callout. */ +export interface FirstDropoutCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearDropout?: (endSeconds: number) => void; +} + +type DropoutCopyValues = Readonly>; + +type HeardDropout = Readonly<{ + songId: string; + sectionId: string; + fromRoleId: string; + toRoleId: string; + endSeconds: number; +}>; + +/** Interpolate dropout placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatDropoutCopy(template: string, values: DropoutCopyValues): string { + return template.replace(/\{(from|to|section|end)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof DropoutCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Name tonight's first dropout and offer only an action that the current surface can execute. */ +export function FirstDropoutCallout({ + song, + actionMode = "workspace-scroll", + onHearDropout +}: FirstDropoutCalloutProps) { + const t = createTranslator(detectPreferredLocale()); + const handoff = resolveFirstDropoutHandoff(song); + const [heardDropout, setHeardDropout] = useState(null); + + useEffect(() => { + setHeardDropout(null); + }, [song.id, handoff?.section.id, handoff?.fromRole.id, handoff?.toRole.id, handoff?.endSeconds]); + + if (!handoff) { + return ( + + ); + } + + const heard = + heardDropout?.songId === song.id && + heardDropout.sectionId === handoff.section.id && + heardDropout.fromRoleId === handoff.fromRole.id && + heardDropout.toRoleId === handoff.toRole.id && + heardDropout.endSeconds === handoff.endSeconds; + const end = formatDropoutTime(handoff.endSeconds); + const copyValues: DropoutCopyValues = { + from: handoff.fromRole.name, + to: handoff.toRole.name, + section: handoff.section.label, + end + }; + const actionLabel = formatDropoutCopy(t("firstDropoutAction"), copyValues); + const body = formatDropoutCopy(t("firstDropoutBody"), copyValues); + const armed = formatDropoutCopy(t("firstDropoutArmed"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || onHearDropout !== undefined; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..c7f0a0e42 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,20 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first dropout so the outgoing part can get out of the way", () => { + render(); + + expect( + screen.getByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }) + ).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { + name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + }) + ); + expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..44fb3334c 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 { FirstDropoutCallout } from "./FirstDropoutCallout"; 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/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts new file mode 100644 index 000000000..232618df5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatDropoutTime, resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; + +describe("resolveFirstDropoutHandoff", () => { + it("picks the earliest explicit handoff, not a later entrance", () => { + const song = createDemoRehearsalSong(); + const handoff = resolveFirstDropoutHandoff(song); + + expect(handoff?.section.id).toBe("verse-1"); + expect(handoff?.fromRole.id).toBe("bass-guitar"); + expect(handoff?.toRole.id).toBe("lead-vocal"); + expect(handoff?.endSeconds).toBe(30); + expect(formatDropoutTime(handoff?.endSeconds ?? -1)).toBe("0:30"); + expect(formatDropoutTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no part hands off", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("skips an earlier section that only has inactive or empty handoff lists", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "chorus-1"; + later.label = "chorus"; + later.timeRange = { start: 40, end: 70 }; + later.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar-chorus", + name: "Bass Guitar" + }, + { + ...verse.roles[2]!, + id: "lead-vocal-chorus", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "bass-guitar-chorus", + is_active: true, + handoff_to: ["lead-vocal-chorus"], + handoff_from: [] + } + ]; + song.sections = [ + { + ...verse, + partGraph: verse.partGraph.map((node) => ({ + ...node, + handoff_to: [] + })) + }, + later + ]; + + const handoff = resolveFirstDropoutHandoff(song); + expect(handoff?.section.id).toBe("chorus-1"); + expect(handoff?.fromRole.id).toBe("bass-guitar-chorus"); + expect(handoff?.toRole.id).toBe("lead-vocal-chorus"); + expect(handoff?.endSeconds).toBe(70); + }); + + it("prefers the higher-priority outgoing part when two handoffs share a section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + song.sections[0] = { + ...verse, + roles: verse.roles.map((role) => + role.id === "keys-right" ? { ...role, rehearsalPriority: "low" as const } : role + ), + partGraph: [ + { + role_id: "keys-right", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + } + ] + }; + + expect(resolveFirstDropoutHandoff(song)?.fromRole.id).toBe("bass-guitar"); + }); + + it("prefers the earlier dropout when a later section also hands off", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const later = structuredClone(verse); + later.id = "bridge-1"; + later.label = "bridge"; + later.timeRange = { start: 80, end: 100 }; + later.roles = [ + { + ...verse.roles[1]!, + id: "keys-bridge", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high" + }, + { + ...verse.roles[2]!, + id: "vocal-bridge", + name: "Lead Vocal" + } + ]; + later.partGraph = [ + { + role_id: "keys-bridge", + is_active: true, + handoff_to: ["vocal-bridge"], + handoff_from: [] + } + ]; + song.sections = [verse, later]; + + const handoff = resolveFirstDropoutHandoff(song); + expect(handoff?.section.id).toBe("verse-1"); + expect(handoff?.fromRole.id).toBe("bass-guitar"); + expect(handoff?.endSeconds).toBe(30); + }); + + it("skips non-finite ends, unknown priorities, missing roles, and self-handoffs", () => { + const song = createDemoRehearsalSong(); + const invalidEnd = structuredClone(song.sections[0]!); + invalidEnd.id = "invalid-end"; + invalidEnd.timeRange = { start: 0, end: Number.NaN }; + invalidEnd.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + } + ]; + + const validSection = structuredClone(song.sections[0]!); + validSection.id = "valid-chorus"; + validSection.label = "chorus"; + validSection.timeRange = { start: 20, end: 50 }; + const invalidPriorityRole = { + ...validSection.roles[0]!, + id: "invalid-priority" + }; + (invalidPriorityRole as unknown as { rehearsalPriority: string }).rehearsalPriority = "urgent"; + validSection.roles = [ + invalidPriorityRole, + { + ...validSection.roles[2]!, + id: "safe-lead", + rehearsalPriority: "high" + }, + { + ...validSection.roles[0]!, + id: "safe-bass", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + validSection.partGraph = [ + { + role_id: "missing-role", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "invalid-priority", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + }, + { + role_id: "safe-bass", + is_active: true, + handoff_to: ["safe-bass", " ", "nobody"], + handoff_from: [] + }, + { + role_id: "safe-bass", + is_active: true, + handoff_to: ["safe-lead"], + handoff_from: [] + } + ]; + + song.sections = [invalidEnd, validSection]; + + const handoff = resolveFirstDropoutHandoff(song); + expect(handoff?.section.id).toBe("valid-chorus"); + expect(handoff?.fromRole.id).toBe("safe-bass"); + expect(handoff?.toRole.id).toBe("safe-lead"); + expect(handoff?.endSeconds).toBe(50); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts new file mode 100644 index 000000000..1b2563bc3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -0,0 +1,106 @@ +import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first dropout: earliest section handoff, then the highest-priority outgoing part. */ +export type FirstDropoutHandoff = { + section: RehearsalSection; + fromRole: RehearsalRole; + toRole: RehearsalRole; + endSeconds: number; +}; + +/** Format a non-negative dropout time as m:ss for rehearsal copy. */ +export function formatDropoutTime(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 carries a ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); +} + +/** Find the first role with this id anywhere in the song. */ +function findRoleById(song: RehearsalSong, roleId: string): RehearsalRole | null { + for (const section of song.sections) { + const role = section.roles.find((candidate) => candidate.id === roleId); + if (role) { + return role; + } + } + return null; +} + +/** Return the first validated dropout the room should hear, or null when no safe candidate remains. */ +export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHandoff | null { + const sections = song.sections + .filter( + (section) => + Number.isFinite(section.timeRange.start) && + section.timeRange.start >= 0 && + Number.isFinite(section.timeRange.end) && + section.timeRange.end >= section.timeRange.start + ) + .sort((left, right) => left.timeRange.start - right.timeRange.start); + + const candidates: FirstDropoutHandoff[] = []; + + for (const section of sections) { + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); + + for (const node of section.partGraph) { + if (!Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { + continue; + } + + const fromRole = rolesInSection.get(node.role_id); + if (!fromRole || !hasRankedPriority(fromRole)) { + continue; + } + + const targets = node.handoff_to + .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) + .map((roleId) => findRoleById(song, roleId)) + .filter( + (role): role is RehearsalRole => + role !== null && hasRankedPriority(role) && role.id !== fromRole.id + ); + + if (targets.length === 0) { + continue; + } + + const toRole = [...targets].sort( + (left, right) => PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority] + )[0]; + if (!toRole) { + continue; + } + + candidates.push({ + section, + fromRole, + toRole, + endSeconds: section.timeRange.end + }); + } + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((left, right) => { + if (left.endSeconds !== right.endSeconds) { + return left.endSeconds - right.endSeconds; + } + return PRIORITY_RANK[left.fromRole.rehearsalPriority] - PRIORITY_RANK[right.fromRole.rehearsalPriority]; + }); + + return candidates[0] ?? null; +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..254be2dbf 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", + "firstDropoutLabel": "Tonight's first dropout", + "firstDropoutAction": "Hear {from} drop out for {to} at {end}", + "firstDropoutBody": "{from} hands off to {to} at the end of the {section} ({end}).", + "firstDropoutArmed": "Start the last bar of {from} before {to} takes the {section} ({end}).", + "firstDropoutUnavailable": "No dropout yet. Stay on tonight's map until a part hands off.", + "firstDropoutNeedsSong": "Analyze tonight's song first, then hear the first dropout from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..c97a9d5c4 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": "진척도 증가", + "firstDropoutLabel": "오늘 첫 드롭아웃", + "firstDropoutAction": "{end}에 {to}에게 넘기고 빠지는 {from} 듣기", + "firstDropoutBody": "{from}이 {end} {section} 끝에서 {to}에게 넘깁니다.", + "firstDropoutArmed": "{section}에서 {to}가 받기 전에 {from}의 마지막 마디를 시작하세요 ({end}).", + "firstDropoutUnavailable": "아직 드롭아웃이 없습니다. 파트가 넘기는 순간이 생길 때까지 오늘 지도에 머무르세요.", + "firstDropoutNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 드롭아웃을 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..63f93c745 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 Dropout Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstDropoutCallout.tsx` | Name the outgoing part, incoming part, section, and end time; show the Hear button only when a handoff exists, and keep the unavailable state guidance-only without a Hear button. | | 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 7df6d90e9928bdb6e865d5446fb900b6521bcd46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:18:09 -0700 Subject: [PATCH 02/38] test(workspace): distinguish dropout navigation from playback --- .../workspace/FirstDropoutCallout.test.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx index b88e5bbff..d663f22ba 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx @@ -1,18 +1,30 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { FirstDropoutCallout } from "./FirstDropoutCallout"; describe("FirstDropoutCallout", () => { - it("names the first dropout and arms that action", () => { + it("names the first dropout 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); + render(); const action = screen.getByRole("button", { - name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + name: "Open Bass Guitar dropout for Lead Vocal at 0:30" }); expect(action).toBeTruthy(); fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); + + target.remove(); }); it("shows fresh guidance when the first dropout changes or returns later", () => { @@ -21,7 +33,7 @@ describe("FirstDropoutCallout", () => { fireEvent.click( screen.getByRole("button", { - name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" + name: "Open Bass Guitar dropout for Lead Vocal at 0:30" }) ); expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); @@ -44,7 +56,7 @@ describe("FirstDropoutCallout", () => { expect( screen.getByRole("button", { - name: "Hear {section} drop out for Lead Vocal at 0:30" + name: "Open {section} dropout for Lead Vocal at 0:30" }) ).toBeTruthy(); }); @@ -57,4 +69,4 @@ describe("FirstDropoutCallout", () => { screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.") ).toBeTruthy(); }); -}); +}); \ No newline at end of file From 2e7baaa6781674f14e1f9c72ac4332d2272e73b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:18:39 -0700 Subject: [PATCH 03/38] fix(workspace): label dropout map navigation honestly --- .../desktop/src/features/workspace/FirstDropoutCallout.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx index 958f5a170..a269d1eed 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx @@ -69,7 +69,10 @@ export function FirstDropoutCallout({ section: handoff.section.label, end }; - const actionLabel = formatDropoutCopy(t("firstDropoutAction"), copyValues); + const actionLabel = formatDropoutCopy( + t(actionMode === "callback-only" ? "firstDropoutAction" : "firstDropoutOpenAction"), + copyValues + ); const body = formatDropoutCopy(t("firstDropoutBody"), copyValues); const armed = formatDropoutCopy(t("firstDropoutArmed"), copyValues); const canExecuteAction = actionMode === "workspace-scroll" || onHearDropout !== undefined; @@ -110,4 +113,4 @@ export function FirstDropoutCallout({ ) : null} ); -} +} \ No newline at end of file From 26ac05e7fb65486dd8c40fa81823b23961919162 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:19:33 -0700 Subject: [PATCH 04/38] feat(i18n): distinguish dropout 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 254be2dbf..cfd546706 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -151,8 +151,9 @@ "increasePracticeProgressLabel": "Increase progress", "firstDropoutLabel": "Tonight's first dropout", "firstDropoutAction": "Hear {from} drop out for {to} at {end}", + "firstDropoutOpenAction": "Open {from} dropout for {to} at {end}", "firstDropoutBody": "{from} hands off to {to} at the end of the {section} ({end}).", "firstDropoutArmed": "Start the last bar of {from} before {to} takes the {section} ({end}).", "firstDropoutUnavailable": "No dropout yet. Stay on tonight's map until a part hands off.", "firstDropoutNeedsSong": "Analyze tonight's song first, then hear the first dropout from this player." -} +} \ No newline at end of file From 6a852b7995d5918026958c3bb7a9e6cba99673d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:20:03 -0700 Subject: [PATCH 05/38] feat(i18n): localize dropout 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 c97a9d5c4..cf6ec0f42 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -151,8 +151,9 @@ "increasePracticeProgressLabel": "진척도 증가", "firstDropoutLabel": "오늘 첫 드롭아웃", "firstDropoutAction": "{end}에 {to}에게 넘기고 빠지는 {from} 듣기", + "firstDropoutOpenAction": "{end} {section}의 {from}→{to} 드롭아웃 위치 열기", "firstDropoutBody": "{from}이 {end} {section} 끝에서 {to}에게 넘깁니다.", "firstDropoutArmed": "{section}에서 {to}가 받기 전에 {from}의 마지막 마디를 시작하세요 ({end}).", "firstDropoutUnavailable": "아직 드롭아웃이 없습니다. 파트가 넘기는 순간이 생길 때까지 오늘 지도에 머무르세요.", "firstDropoutNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 드롭아웃을 들으세요." -} +} \ No newline at end of file From 3decb437d50c13d665afadb15acea46473b8c9eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:20:36 -0700 Subject: [PATCH 06/38] docs(workspace): distinguish dropout navigation from playback --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733ced3ef..93201bc3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first dropout on the workspace and player so the outgoing part can get out of the way. +- Name tonight's first dropout on the workspace and player so the outgoing part can get out of the way; the workspace action arms the handoff 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -66,4 +66,4 @@ - `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`). +- 신규 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 9ddd273232ef46250bb4448442589566ccc838c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:21:01 -0700 Subject: [PATCH 07/38] docs(workspace): align dropout action architecture copy --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aef6c090e..67872fac2 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 dropout so the outgoing part can get out of the way. `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 dropout so the outgoing part can get out of the way; the workspace action arms the handoff and opens its 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 bd7e3b1182de723e86c6a04e2e9e8c0f6430000c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:37:24 -0700 Subject: [PATCH 08/38] test(workspace): decouple dropout navigation from analysis ids --- .../workspace/FirstDropoutCallout.test.tsx | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx index d663f22ba..7f3ff87e9 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx @@ -3,16 +3,23 @@ import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it, vi } from "vitest"; import { FirstDropoutCallout } from "./FirstDropoutCallout"; +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("FirstDropoutCallout", () => { - it("names the first dropout 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 dropout as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); render(); @@ -24,7 +31,24 @@ describe("FirstDropoutCallout", () => { expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).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 dropout for Lead Vocal at 0:30" + }) + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); }); it("shows fresh guidance when the first dropout changes or returns later", () => { @@ -69,4 +93,4 @@ describe("FirstDropoutCallout", () => { screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.") ).toBeTruthy(); }); -}); \ No newline at end of file +}); From b60e0f404074d33bc96d7c00d2fc8d6e8907c43c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:37:48 -0700 Subject: [PATCH 09/38] fix(workspace): use renderer-owned dropout navigation targets --- .../src/features/workspace/FirstDropoutCallout.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx index a269d1eed..eb5b05bd2 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx @@ -16,6 +16,7 @@ type DropoutCopyValues = Readonly(null); useEffect(() => { setHeardDropout(null); - }, [song.id, handoff?.section.id, handoff?.fromRole.id, handoff?.toRole.id, handoff?.endSeconds]); + }, [song.id, handoffSectionIndex, handoff?.section.id, handoff?.fromRole.id, handoff?.toRole.id, handoff?.endSeconds]); if (!handoff) { return ( @@ -59,6 +61,7 @@ export function FirstDropoutCallout({ const heard = heardDropout?.songId === song.id && heardDropout.sectionId === handoff.section.id && + heardDropout.sectionIndex === handoffSectionIndex && heardDropout.fromRoleId === handoff.fromRole.id && heardDropout.toRoleId === handoff.toRole.id && heardDropout.endSeconds === handoff.endSeconds; @@ -93,6 +96,7 @@ export function FirstDropoutCallout({ setHeardDropout({ songId: song.id, sectionId: handoff.section.id, + sectionIndex: handoffSectionIndex, fromRoleId: handoff.fromRole.id, toRoleId: handoff.toRole.id, endSeconds: handoff.endSeconds @@ -101,7 +105,8 @@ export function FirstDropoutCallout({ onHearDropout(handoff.endSeconds); return; } - const target = document.getElementById(`song-structure-section-${handoff.section.id}`); + const grid = document.querySelector('[data-testid="song-structure-grid"]'); + const target = handoffSectionIndex >= 0 ? grid?.children.item(handoffSectionIndex) : null; target?.scrollIntoView?.({ block: "nearest", behavior: "smooth" @@ -113,4 +118,4 @@ export function FirstDropoutCallout({ ) : null} ); -} \ No newline at end of file +} From f56bed7757800ab54842e32785de6b13a2233917 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:39:26 -0700 Subject: [PATCH 10/38] fix(workspace): keep dropout 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 44fb3334c..53a648c19 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 d93ab2d3f8ee29797b5497f41318d5e9d87ce573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 04:40:08 -0700 Subject: [PATCH 11/38] test(workspace): align dropout map contracts with current behavior --- .../src/features/workspace/Workspace.test.tsx | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index c7f0a0e42..5cf0c03a9 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,11 +58,21 @@ 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(); @@ -81,7 +82,6 @@ describe("Workspace", () => { }; render(); - expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); @@ -271,19 +271,14 @@ describe("Workspace", () => { expect(screen.getByText("역할과 화성")).toBeTruthy(); }); - it("names tonight's first dropout so the outgoing part can get out of the way", () => { + it("names tonight's first dropout as workspace navigation", () => { render(); - expect( - screen.getByRole("button", { - name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" - }) - ).toBeTruthy(); - fireEvent.click( - screen.getByRole("button", { - name: "Hear Bass Guitar drop out for Lead Vocal at 0:30" - }) - ); + const action = screen.getByRole("button", { + name: "Open Bass Guitar dropout for Lead Vocal at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); expect(screen.getByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/)).toBeTruthy(); }); }); From 15f68b78524ae9b4dd0492161a3c3d208bf5d89c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:00:05 -0700 Subject: [PATCH 12/38] test(workspace): reject cross-section dropout targets --- .../workspace/firstDropoutHandoff.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index 232618df5..83265e4dc 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -66,6 +66,50 @@ describe("resolveFirstDropoutHandoff", () => { expect(handoff?.endSeconds).toBe(70); }); + it("does not resolve a section handoff against a role that exists only in another section", () => { + const song = createDemoRehearsalSong(); + const verse = structuredClone(song.sections[0]!); + verse.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + } + ]; + + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 70 }; + chorus.roles = [ + { + ...chorus.roles[0]!, + id: "chorus-bass", + rehearsalPriority: "medium" + }, + { + ...chorus.roles[2]!, + id: "future-lead", + rehearsalPriority: "high" + } + ]; + chorus.partGraph = [ + { + role_id: "chorus-bass", + is_active: true, + handoff_to: ["future-lead"], + handoff_from: [] + } + ]; + song.sections = [verse, chorus]; + + const handoff = resolveFirstDropoutHandoff(song); + expect(handoff?.section.id).toBe("chorus-1"); + expect(handoff?.fromRole.id).toBe("chorus-bass"); + expect(handoff?.toRole.id).toBe("future-lead"); + }); + it("prefers the higher-priority outgoing part when two handoffs share a section", () => { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; From 6f3a8fc311a6544253becdeebcee74e3633d8e1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:00:37 -0700 Subject: [PATCH 13/38] fix(workspace): keep dropout handoffs section-local --- .../src/features/workspace/firstDropoutHandoff.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 1b2563bc3..3ae1b4820 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -25,18 +25,7 @@ function hasRankedPriority(role: RehearsalRole): boolean { return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); } -/** Find the first role with this id anywhere in the song. */ -function findRoleById(song: RehearsalSong, roleId: string): RehearsalRole | null { - for (const section of song.sections) { - const role = section.roles.find((candidate) => candidate.id === roleId); - if (role) { - return role; - } - } - return null; -} - -/** Return the first validated dropout the room should hear, or null when no safe candidate remains. */ +/** Return the first validated section-local dropout, or null when no safe candidate remains. */ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHandoff | null { const sections = song.sections .filter( @@ -65,7 +54,7 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const targets = node.handoff_to .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) - .map((roleId) => findRoleById(song, roleId)) + .map((roleId) => rolesInSection.get(roleId) ?? null) .filter( (role): role is RehearsalRole => role !== null && hasRankedPriority(role) && role.id !== fromRole.id From f2dab08055bb00d371358ba181ee1774b7e5e002 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:02:08 -0700 Subject: [PATCH 14/38] test(workspace): ignore inactive dropout nodes --- .../workspace/firstDropoutHandoff.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index 83265e4dc..25b6063ed 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -51,10 +51,20 @@ describe("resolveFirstDropoutHandoff", () => { song.sections = [ { ...verse, - partGraph: verse.partGraph.map((node) => ({ - ...node, - handoff_to: [] - })) + partGraph: [ + { + role_id: "bass-guitar", + is_active: false, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "keys-right", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ] }, later ]; From 6f02d69b24355ad8d059e9c8ce888e0c6c5aa77a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:02:35 -0700 Subject: [PATCH 15/38] fix(workspace): ignore inactive dropout nodes --- apps/desktop/src/features/workspace/firstDropoutHandoff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 3ae1b4820..2c701ec2e 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -43,7 +43,7 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { - if (!Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { + if (!node.is_active || !Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { continue; } From f58f67e99c420bfa9e067014791518371e3b9a65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:04:23 -0700 Subject: [PATCH 16/38] test(workspace): reject inconsistent dropout handoffs --- .../src/features/workspace/firstDropoutHandoff.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index 25b6063ed..ea229401a 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -120,6 +120,16 @@ describe("resolveFirstDropoutHandoff", () => { expect(handoff?.toRole.id).toBe("future-lead"); }); + it("rejects an outgoing handoff that the target node does not corroborate", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, handoff_from: [] } : node + ); + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + it("prefers the higher-priority outgoing part when two handoffs share a section", () => { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; From 91ae7d47032ff399825f38477848e23d78060bae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:14:00 -0700 Subject: [PATCH 17/38] test(workspace): keep dropout action mode authoritative --- .../workspace/FirstDropoutCallout.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx index 7f3ff87e9..9e22e3424 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx @@ -34,6 +34,29 @@ describe("FirstDropoutCallout", () => { grid.remove(); }); + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearDropout = vi.fn(); + + render( + + ); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar dropout for Lead Vocal at 0:30" + }) + ); + expect(onHearDropout).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 = createDemoRehearsalSong(); song.sections[0]!.id = "analysis section / duplicate"; From ed8c0de6710d56ec38515de8f938c0374cb097c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:15:13 -0700 Subject: [PATCH 18/38] test(workspace): model reciprocal dropout graph evidence --- .../workspace/firstDropoutHandoff.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index ea229401a..04ede91aa 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -46,6 +46,12 @@ describe("resolveFirstDropoutHandoff", () => { is_active: true, handoff_to: ["lead-vocal-chorus"], handoff_from: [] + }, + { + role_id: "lead-vocal-chorus", + is_active: false, + handoff_to: [], + handoff_from: ["bass-guitar-chorus"] } ]; song.sections = [ @@ -110,6 +116,12 @@ describe("resolveFirstDropoutHandoff", () => { is_active: true, handoff_to: ["future-lead"], handoff_from: [] + }, + { + role_id: "future-lead", + is_active: false, + handoff_to: [], + handoff_from: ["chorus-bass"] } ]; song.sections = [verse, chorus]; @@ -130,6 +142,18 @@ describe("resolveFirstDropoutHandoff", () => { expect(resolveFirstDropoutHandoff(song)).toBeNull(); }); + it("accepts a reciprocal receiver that is inactive until the next section", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = verse.partGraph.map((node) => + node.role_id === "lead-vocal" ? { ...node, is_active: false } : node + ); + + const handoff = resolveFirstDropoutHandoff(song); + expect(handoff?.fromRole.id).toBe("bass-guitar"); + expect(handoff?.toRole.id).toBe("lead-vocal"); + }); + it("prefers the higher-priority outgoing part when two handoffs share a section", () => { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; @@ -150,6 +174,12 @@ describe("resolveFirstDropoutHandoff", () => { is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: ["keys-right", "bass-guitar"] } ] }; @@ -183,6 +213,12 @@ describe("resolveFirstDropoutHandoff", () => { is_active: true, handoff_to: ["vocal-bridge"], handoff_from: [] + }, + { + role_id: "vocal-bridge", + is_active: false, + handoff_to: [], + handoff_from: ["keys-bridge"] } ]; song.sections = [verse, later]; @@ -254,6 +290,12 @@ describe("resolveFirstDropoutHandoff", () => { is_active: true, handoff_to: ["safe-lead"], handoff_from: [] + }, + { + role_id: "safe-lead", + is_active: false, + handoff_to: [], + handoff_from: ["safe-bass"] } ]; From 3d02d98b1eb3bda7c637ef3968ec17058db2d94c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:16:11 -0700 Subject: [PATCH 19/38] fix(workspace): require reciprocal dropout handoffs --- .../src/features/workspace/firstDropoutHandoff.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 2c701ec2e..488bb6c86 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -25,6 +25,16 @@ function hasRankedPriority(role: RehearsalRole): boolean { return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); } +/** Require the receiving graph node to corroborate the outgoing edge. */ +function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { + return section.partGraph.some( + (candidate) => + candidate.role_id === toRoleId && + Array.isArray(candidate.handoff_from) && + candidate.handoff_from.includes(fromRoleId) + ); +} + /** Return the first validated section-local dropout, or null when no safe candidate remains. */ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHandoff | null { const sections = song.sections @@ -57,7 +67,10 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan .map((roleId) => rolesInSection.get(roleId) ?? null) .filter( (role): role is RehearsalRole => - role !== null && hasRankedPriority(role) && role.id !== fromRole.id + role !== null && + hasRankedPriority(role) && + role.id !== fromRole.id && + hasReciprocalHandoff(section, fromRole.id, role.id) ); if (targets.length === 0) { From c3cfbf70f2276be1e244b977b2d7f1cd25de24d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:16:48 -0700 Subject: [PATCH 20/38] fix(workspace): keep dropout action mode authoritative --- apps/desktop/src/features/workspace/FirstDropoutCallout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx index eb5b05bd2..b1f11193d 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx @@ -101,8 +101,8 @@ export function FirstDropoutCallout({ toRoleId: handoff.toRole.id, endSeconds: handoff.endSeconds }); - if (onHearDropout) { - onHearDropout(handoff.endSeconds); + if (actionMode === "callback-only") { + onHearDropout?.(handoff.endSeconds); return; } const grid = document.querySelector('[data-testid="song-structure-grid"]'); From f2a7b73ba7a00457e1b421c4b24fc08353d5de73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:18:22 -0700 Subject: [PATCH 21/38] docs(workspace): define dropout action authority --- docs/design-system/component-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 63f93c745..a11cabcc9 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,7 +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 Dropout Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstDropoutCallout.tsx` | Name the outgoing part, incoming part, section, and end time; show the Hear button only when a handoff exists, and keep the unavailable state guidance-only without a Hear button. | +| First Dropout Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstDropoutCallout.tsx` | Name the outgoing part, incoming part, section, and end 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 `onHearDropout` exists and delegates the exact handoff 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()`. | From c28ca04ac46cdf1013af5d6b43defae6639834b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:16:10 -0700 Subject: [PATCH 22/38] test: reject non-boolean dropout activity flags --- .../firstDropoutHandoff.activity-type.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts new file mode 100644 index 000000000..1ceef295d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.activity-type.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstDropoutHandoff activity-type authority", () => { + it("does not treat a string false flag as an active dropout source", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + section.partGraph = section.partGraph.map((node) => + node.role_id === "bass-guitar" ? { ...node, is_active: runtimeStringFalse } : node + ); + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); +}); From 99c8bd4d83538e3e77d221dedea21dc7feb95cf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:16:39 -0700 Subject: [PATCH 23/38] fix: require boolean dropout activity evidence --- apps/desktop/src/features/workspace/firstDropoutHandoff.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 488bb6c86..1dce494e7 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -53,7 +53,11 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { - if (!node.is_active || !Array.isArray(node.handoff_to) || node.handoff_to.length === 0) { + if ( + node.is_active !== true || + !Array.isArray(node.handoff_to) || + node.handoff_to.length === 0 + ) { continue; } From a6ae3511844312895f1260dd783adf62fbd5399e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:36:17 -0700 Subject: [PATCH 24/38] test: reject malformed dropout role ids --- ...irstDropoutHandoff.invalid-role-id.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts new file mode 100644 index 000000000..bef2dbd1e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.invalid-role-id.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; + +describe("resolveFirstDropoutHandoff runtime role identity", () => { + it("ignores a handoff source whose runtime role id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + + const malformedSource = { + ...section.roles[0]!, + id: 42 as unknown as string, + name: "Malformed Runtime Source", + rehearsalPriority: "high" as const + }; + const safeSource = { + ...section.roles[1]!, + id: "safe-keys", + name: "Safe Keys", + rehearsalPriority: "medium" as const + }; + const receiver = { + ...section.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const + }; + + section.roles = [malformedSource, safeSource, receiver]; + section.partGraph = [ + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "safe-keys", + is_active: true, + handoff_to: ["lead-vocal"], + handoff_from: [] + }, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: [42 as unknown as string, "safe-keys"] + } + ]; + song.sections = [section]; + + expect(resolveFirstDropoutHandoff(song)?.fromRole.id).toBe("safe-keys"); + }); +}); From 55ac6da64ee83e42d1207f1dc1a15c81e4caa614 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 10:36:41 -0700 Subject: [PATCH 25/38] fix: reject malformed dropout role ids --- .../desktop/src/features/workspace/firstDropoutHandoff.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 1dce494e7..7ae5968c7 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -20,9 +20,13 @@ export function formatDropoutTime(totalSeconds: number): string { return `${minutes}:${seconds}`; } -/** Return true when the role carries a ranked rehearsal priority. */ +/** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { - return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); } /** Require the receiving graph node to corroborate the outgoing edge. */ From 4227399ed216cdf4e77cf2b8b28fc0bcb2604c9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:04:06 -0700 Subject: [PATCH 26/38] test(workspace): reject ambiguous dropout identities --- .../workspace/firstDropoutHandoff.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index 04ede91aa..941915741 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -142,6 +142,34 @@ describe("resolveFirstDropoutHandoff", () => { expect(resolveFirstDropoutHandoff(song)).toBeNull(); }); + it("fails closed when a section contains duplicate role identities", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const duplicateLead = structuredClone( + verse.roles.find((role) => role.id === "lead-vocal")! + ); + duplicateLead.name = "Shadow Lead"; + verse.roles = [...verse.roles, duplicateLead]; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("fails closed when a section contains duplicate graph-node identities", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + verse.partGraph = [ + ...verse.partGraph, + { + role_id: "lead-vocal", + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + it("accepts a reciprocal receiver that is inactive until the next section", () => { const song = createDemoRehearsalSong(); const verse = song.sections[0]!; @@ -307,4 +335,4 @@ describe("resolveFirstDropoutHandoff", () => { expect(handoff?.toRole.id).toBe("safe-lead"); expect(handoff?.endSeconds).toBe(50); }); -}); +}); \ No newline at end of file From 5ded223469e518ee1554d802da3bbe6248d45a55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:07:25 -0700 Subject: [PATCH 27/38] fix(workspace): fail closed on ambiguous dropout identities --- .../src/features/workspace/firstDropoutHandoff.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 7ae5968c7..7b36b56a4 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -20,6 +20,11 @@ export function formatDropoutTime(totalSeconds: number): string { return `${minutes}:${seconds}`; } +/** Return true when every section-local identity is unambiguous. */ +function hasUniqueIdentities(ids: readonly string[]): boolean { + return new Set(ids).size === ids.length; +} + /** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { return ( @@ -54,6 +59,13 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const candidates: FirstDropoutHandoff[] = []; for (const section of sections) { + if ( + !hasUniqueIdentities(section.roles.map((role) => role.id)) || + !hasUniqueIdentities(section.partGraph.map((node) => node.role_id)) + ) { + continue; + } + const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { @@ -113,4 +125,4 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan }); return candidates[0] ?? null; -} +} \ No newline at end of file From c711dbd70af0afbe6fdb1c9e7f3ef09256689258 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 11:32:24 -0700 Subject: [PATCH 28/38] test(workspace): keep dropout fixture identities unique --- .../src/features/workspace/firstDropoutHandoff.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts index 941915741..c6d1c7616 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.test.ts @@ -310,13 +310,7 @@ describe("resolveFirstDropoutHandoff", () => { { role_id: "safe-bass", is_active: true, - handoff_to: ["safe-bass", " ", "nobody"], - handoff_from: [] - }, - { - role_id: "safe-bass", - is_active: true, - handoff_to: ["safe-lead"], + handoff_to: ["safe-bass", " ", "nobody", "safe-lead"], handoff_from: [] }, { From 7c14a93dbeb6e8af1f1f9e2dbd1e986bd41e179f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:53:20 -0700 Subject: [PATCH 29/38] test(workspace): reject malformed dropout evidence --- ...rstDropoutHandoff.runtime-boundary.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts new file mode 100644 index 000000000..d1c7c3907 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.runtime-boundary.test.ts @@ -0,0 +1,123 @@ +import { + MAX_SECTION_TIME_SECONDS, + createDemoRehearsalSong, + type RehearsalSong +} from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; + +function runtimeSong(value: unknown): RehearsalSong { + return value as RehearsalSong; +} + +function songWithLaterValidDropout() { + const song = createDemoRehearsalSong(); + const valid = structuredClone(song.sections[0]!); + valid.id = "later-valid-dropout"; + valid.label = "chorus"; + valid.timeRange = { start: 40, end: 70 }; + return { song, valid }; +} + +describe("first dropout runtime boundary", () => { + it.each([null, 42])("fails closed when the runtime song root is %s", (value) => { + const song = runtimeSong(value); + + expect(() => resolveFirstDropoutHandoff(song)).not.toThrow(); + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("fails closed when the runtime section collection is not an array", () => { + const song = createDemoRehearsalSong(); + (song as unknown as { sections: unknown }).sections = null; + + expect(() => resolveFirstDropoutHandoff(song)).not.toThrow(); + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("rejects sparse section evidence instead of skipping the missing entry", () => { + const song = createDemoRehearsalSong(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("ignores malformed section elements and preserves a later valid dropout", () => { + const { song, valid } = songWithLaterValidDropout(); + song.sections = [null, valid] as unknown as typeof song.sections; + + expect(resolveFirstDropoutHandoff(song)?.section.id).toBe("later-valid-dropout"); + }); + + it.each([ + { start: 10, end: 10 }, + { start: 10.5, end: 11.5 }, + null + ])("rejects an invalid runtime window %j and preserves a later valid dropout", (timeRange) => { + const { song, valid } = songWithLaterValidDropout(); + const invalid = structuredClone(valid); + invalid.id = "invalid-window"; + invalid.timeRange = timeRange as typeof invalid.timeRange; + song.sections = [invalid, valid]; + + expect(resolveFirstDropoutHandoff(song)?.section.id).toBe("later-valid-dropout"); + }); + + it("rejects a dropout window above the shared section-time ceiling", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("fails a section closed when role or graph collections are sparse", () => { + const roleSparseSong = createDemoRehearsalSong(); + const roleSection = roleSparseSong.sections[0]!; + const sparseRoles: typeof roleSection.roles = new Array(roleSection.roles.length + 1); + roleSection.roles.forEach((role, index) => { + sparseRoles[index + 1] = role; + }); + roleSection.roles = sparseRoles; + + const graphSparseSong = createDemoRehearsalSong(); + const graphSection = graphSparseSong.sections[0]!; + const sparseGraph: typeof graphSection.partGraph = new Array(graphSection.partGraph.length + 1); + graphSection.partGraph.forEach((node, index) => { + sparseGraph[index + 1] = node; + }); + graphSection.partGraph = sparseGraph; + + expect(() => resolveFirstDropoutHandoff(roleSparseSong)).not.toThrow(); + expect(resolveFirstDropoutHandoff(roleSparseSong)).toBeNull(); + expect(() => resolveFirstDropoutHandoff(graphSparseSong)).not.toThrow(); + expect(resolveFirstDropoutHandoff(graphSparseSong)).toBeNull(); + }); + + it("rejects sparse handoff edge collections as incomplete evidence", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const outgoing = section.partGraph.find((node) => node.role_id === "bass-guitar")!; + const incoming = section.partGraph.find((node) => node.role_id === "lead-vocal")!; + const sparseTo: string[] = new Array(2); + sparseTo[1] = "lead-vocal"; + const sparseFrom: string[] = new Array(2); + sparseFrom[1] = "bass-guitar"; + outgoing.handoff_to = sparseTo; + incoming.handoff_from = sparseFrom; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); + + it("rejects a buyer-visible holder whose runtime name is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const bass = song.sections[0]!.roles.find((role) => role.id === "bass-guitar")!; + (bass as unknown as { name: unknown }).name = { secret: "not-copy" }; + + expect(resolveFirstDropoutHandoff(song)).toBeNull(); + }); +}); From 9ddb70a3ad67dda80fc21ae1a76b9723ed6a5807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:55:15 -0700 Subject: [PATCH 30/38] fix(workspace): contain malformed dropout evidence --- .../features/workspace/firstDropoutHandoff.ts | 128 +++++++++++++++--- 1 file changed, 106 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 7b36b56a4..7dd96f5c4 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -1,7 +1,16 @@ -import type { RehearsalRole, RehearsalSection, RehearsalSong } from "@bandscope/shared-types"; +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +type PartGraphNode = RehearsalSection["partGraph"][number]; + /** Tonight's first dropout: earliest section handoff, then the highest-priority outgoing part. */ export type FirstDropoutHandoff = { section: RehearsalSection; @@ -20,17 +29,97 @@ export function formatDropoutTime(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 whether every numeric index is present in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!(index in value)) { + return false; + } + } + return true; +} + /** Return true when every section-local identity is unambiguous. */ function hasUniqueIdentities(ids: readonly string[]): boolean { return new Set(ids).size === ids.length; } -/** Return true when the role has a safe runtime identity and ranked rehearsal priority. */ +/** Return whether one role has safe buyer-visible identity and copy. */ +function hasSafeRoleIdentity(role: unknown): role is RehearsalRole { + if (!isRuntimeObject(role)) { + return false; + } + const candidate = role as Partial; + return ( + typeof candidate.id === "string" && + candidate.id.trim().length > 0 && + typeof candidate.name === "string" && + candidate.name.trim().length > 0 + ); +} + +/** Return true when the safe role also has a ranked rehearsal priority. */ function hasRankedPriority(role: RehearsalRole): boolean { + return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); +} + +/** Return whether one graph edge collection is complete and carries only safe role ids. */ +function isRoleIdCollection(value: unknown): value is string[] { return ( - typeof role.id === "string" && - role.id.trim().length > 0 && - Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + isDenseRuntimeArray(value) && + value.every((roleId) => typeof roleId === "string" && roleId.trim().length > 0) + ); +} + +/** Return whether one graph node has safe section-local identity and complete edge evidence. */ +function isSafeGraphNode(value: unknown): value is PartGraphNode { + if (!isRuntimeObject(value)) { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.role_id === "string" && + candidate.role_id.trim().length > 0 && + isRoleIdCollection(candidate.handoff_to) && + isRoleIdCollection(candidate.handoff_from) + ); +} + +/** Return whether one section has safe identity, form, and a bounded positive integer window. */ +function hasBoundedSectionWindow(value: unknown): value is RehearsalSection { + if (!isRuntimeObject(value)) { + return false; + } + const section = value as Partial; + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + return false; + } + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) && + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS ); } @@ -38,28 +127,28 @@ function hasRankedPriority(role: RehearsalRole): boolean { function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { return section.partGraph.some( (candidate) => - candidate.role_id === toRoleId && - Array.isArray(candidate.handoff_from) && - candidate.handoff_from.includes(fromRoleId) + candidate.role_id === toRoleId && candidate.handoff_from.includes(fromRoleId) ); } /** Return the first validated section-local dropout, or null when no safe candidate remains. */ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHandoff | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + const sections = song.sections - .filter( - (section) => - Number.isFinite(section.timeRange.start) && - section.timeRange.start >= 0 && - Number.isFinite(section.timeRange.end) && - section.timeRange.end >= section.timeRange.start - ) + .filter(hasBoundedSectionWindow) .sort((left, right) => left.timeRange.start - right.timeRange.start); const candidates: FirstDropoutHandoff[] = []; for (const section of sections) { if ( + !isDenseRuntimeArray(section.roles) || + !section.roles.every(hasSafeRoleIdentity) || + !isDenseRuntimeArray(section.partGraph) || + !section.partGraph.every(isSafeGraphNode) || !hasUniqueIdentities(section.roles.map((role) => role.id)) || !hasUniqueIdentities(section.partGraph.map((node) => node.role_id)) ) { @@ -69,11 +158,7 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { - if ( - node.is_active !== true || - !Array.isArray(node.handoff_to) || - node.handoff_to.length === 0 - ) { + if (node.is_active !== true || node.handoff_to.length === 0) { continue; } @@ -83,7 +168,6 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan } const targets = node.handoff_to - .filter((roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0) .map((roleId) => rolesInSection.get(roleId) ?? null) .filter( (role): role is RehearsalRole => @@ -125,4 +209,4 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan }); return candidates[0] ?? null; -} \ No newline at end of file +} From 85e4dd523e3b5ee7dd82d74e06800df94f157287 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:57:21 -0700 Subject: [PATCH 31/38] refactor(workspace): preserve valid dropout edges --- .../features/workspace/firstDropoutHandoff.ts | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts index 7dd96f5c4..012a11ff6 100644 --- a/apps/desktop/src/features/workspace/firstDropoutHandoff.ts +++ b/apps/desktop/src/features/workspace/firstDropoutHandoff.ts @@ -75,15 +75,17 @@ function hasRankedPriority(role: RehearsalRole): boolean { return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority); } -/** Return whether one graph edge collection is complete and carries only safe role ids. */ -function isRoleIdCollection(value: unknown): value is string[] { - return ( - isDenseRuntimeArray(value) && - value.every((roleId) => typeof roleId === "string" && roleId.trim().length > 0) +/** Return the usable role ids in a complete edge array, or null for sparse evidence. */ +function safeRoleIds(value: unknown): string[] | null { + if (!isDenseRuntimeArray(value)) { + return null; + } + return value.filter( + (roleId): roleId is string => typeof roleId === "string" && roleId.trim().length > 0 ); } -/** Return whether one graph node has safe section-local identity and complete edge evidence. */ +/** Return whether one graph node has safe section-local identity and complete edge arrays. */ function isSafeGraphNode(value: unknown): value is PartGraphNode { if (!isRuntimeObject(value)) { return false; @@ -92,8 +94,8 @@ function isSafeGraphNode(value: unknown): value is PartGraphNode { return ( typeof candidate.role_id === "string" && candidate.role_id.trim().length > 0 && - isRoleIdCollection(candidate.handoff_to) && - isRoleIdCollection(candidate.handoff_from) + isDenseRuntimeArray(candidate.handoff_to) && + isDenseRuntimeArray(candidate.handoff_from) ); } @@ -125,10 +127,14 @@ function hasBoundedSectionWindow(value: unknown): value is RehearsalSection { /** Require the receiving graph node to corroborate the outgoing edge. */ function hasReciprocalHandoff(section: RehearsalSection, fromRoleId: string, toRoleId: string): boolean { - return section.partGraph.some( - (candidate) => - candidate.role_id === toRoleId && candidate.handoff_from.includes(fromRoleId) - ); + return section.partGraph.some((candidate) => { + const incomingRoleIds = safeRoleIds(candidate.handoff_from); + return ( + candidate.role_id === toRoleId && + incomingRoleIds !== null && + incomingRoleIds.includes(fromRoleId) + ); + }); } /** Return the first validated section-local dropout, or null when no safe candidate remains. */ @@ -158,7 +164,8 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan const rolesInSection = new Map(section.roles.map((role) => [role.id, role])); for (const node of section.partGraph) { - if (node.is_active !== true || node.handoff_to.length === 0) { + const handoffTargets = safeRoleIds(node.handoff_to); + if (node.is_active !== true || handoffTargets === null || handoffTargets.length === 0) { continue; } @@ -167,7 +174,7 @@ export function resolveFirstDropoutHandoff(song: RehearsalSong): FirstDropoutHan continue; } - const targets = node.handoff_to + const targets = handoffTargets .map((roleId) => rolesInSection.get(roleId) ?? null) .filter( (role): role is RehearsalRole => From b6b7ae7a12b9b3066ee8dcdb9b78ef7b2d02ab80 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:02:32 -0700 Subject: [PATCH 32/38] test(workspace): require executed dropout actions --- .../workspace/FirstDropoutCallout.test.tsx | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx index 9e22e3424..7658d49c1 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.test.tsx @@ -1,6 +1,9 @@ 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 { FirstDropoutCallout } from "./FirstDropoutCallout"; function appendSongStructureTarget() { @@ -18,6 +21,10 @@ function appendSongStructureTarget() { } describe("FirstDropoutCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("names the first dropout as map navigation, scrolls to its rendered section, and arms that action", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); @@ -34,6 +41,23 @@ describe("FirstDropoutCallout", () => { grid.remove(); }); + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar dropout for Lead Vocal at 0:30" + }) + ); + + expect( + screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).") + ).toBeTruthy(); + expect( + screen.queryByText(/Start the last bar of Bass Guitar before Lead Vocal takes the verse \(0:30\)/) + ).toBeNull(); + }); + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { const { grid, scrollIntoView } = appendSongStructureTarget(); const onHearDropout = vi.fn(); @@ -76,6 +100,7 @@ describe("FirstDropoutCallout", () => { it("shows fresh guidance when the first dropout changes or returns later", () => { const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); const { rerender } = render(); fireEvent.click( @@ -93,6 +118,8 @@ describe("FirstDropoutCallout", () => { rerender(); expect(screen.getByText("Bass Guitar hands off to Lead Vocal at the end of the verse (0:30).")).toBeTruthy(); + + grid.remove(); }); it("keeps placeholder-looking rehearsal data literal", () => { @@ -116,4 +143,24 @@ describe("FirstDropoutCallout", () => { screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.") ).toBeTruthy(); }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No dropout yet. Stay on tonight's map until a part hands off.") + ).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 = "베이스 기타"; + song.sections[0]!.roles[2]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("베이스 기타가 0:30 벌스 끝에서 리드 보컬에게 넘깁니다.")).toBeTruthy(); + expect(screen.queryByText(/verse 끝에서/)).toBeNull(); + }); }); From 6c602f87088499e462a87f76051ef73ade9524ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:03:06 -0700 Subject: [PATCH 33/38] feat(i18n): localize section form labels --- 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 745008c7d44d3d253ef4dac42d7f5eda63a50a75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 19:04:16 -0700 Subject: [PATCH 34/38] fix(workspace): arm dropout only after action --- .../workspace/FirstDropoutCallout.tsx | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx b/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx index b1f11193d..b8cd40fe5 100644 --- a/apps/desktop/src/features/workspace/FirstDropoutCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstDropoutCallout.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 { formatDropoutTime, resolveFirstDropoutHandoff } from "./firstDropoutHandoff"; /** Props for the first-dropout rehearsal callout. */ @@ -36,14 +40,27 @@ export function FirstDropoutCallout({ actionMode = "workspace-scroll", onHearDropout }: FirstDropoutCalloutProps) { - 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 handoff = resolveFirstDropoutHandoff(song); - const handoffSectionIndex = handoff ? song.sections.indexOf(handoff.section) : -1; + const handoffSectionIndex = + handoff && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(handoff.section) + : -1; const [heardDropout, setHeardDropout] = useState(null); useEffect(() => { setHeardDropout(null); - }, [song.id, handoffSectionIndex, handoff?.section.id, handoff?.fromRole.id, handoff?.toRole.id, handoff?.endSeconds]); + }, [ + songId, + handoffSectionIndex, + handoff?.section.id, + handoff?.fromRole.id, + handoff?.toRole.id, + handoff?.endSeconds + ]); if (!handoff) { return ( @@ -59,7 +76,7 @@ export function FirstDropoutCallout({ } const heard = - heardDropout?.songId === song.id && + heardDropout?.songId === songId && heardDropout.sectionId === handoff.section.id && heardDropout.sectionIndex === handoffSectionIndex && heardDropout.fromRoleId === handoff.fromRole.id && @@ -69,7 +86,7 @@ export function FirstDropoutCallout({ const copyValues: DropoutCopyValues = { from: handoff.fromRole.name, to: handoff.toRole.name, - section: handoff.section.label, + section: translateSectionFormLabel(locale, handoff.section.label), end }; const actionLabel = formatDropoutCopy( @@ -78,7 +95,20 @@ export function FirstDropoutCallout({ ); const body = formatDropoutCopy(t("firstDropoutBody"), copyValues); const armed = formatDropoutCopy(t("firstDropoutArmed"), copyValues); - const canExecuteAction = actionMode === "workspace-scroll" || onHearDropout !== undefined; + const canExecuteAction = + actionMode === "workspace-scroll" || typeof onHearDropout === "function"; + + /** Record completion only after the owning surface executes the selected dropout action. */ + const markDropoutActionComplete = () => { + setHeardDropout({ + songId, + sectionId: handoff.section.id, + sectionIndex: handoffSectionIndex, + fromRoleId: handoff.fromRole.id, + toRoleId: handoff.toRole.id, + endSeconds: handoff.endSeconds + }); + }; return (