From abfb399a574d1625fd2b4de9ce88cd3a274ca6df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 00:28:43 +0000 Subject: [PATCH 01/26] feat(workspace): name the selected part's first entrance cue After a named part is selected, the ready rehearsal map names that part's first trusted lyric, count, or transition cue and tells the player to catch it before entering. Untrusted or missing cues still name the next action instead of hiding it. --- AGENTS.md | 2 +- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../workspace/Workspace.entrance-cue.test.tsx | 78 +++++++ .../src/features/workspace/Workspace.tsx | 36 ++- .../workspace/firstEntranceCue.test.ts | 209 ++++++++++++++++++ .../features/workspace/firstEntranceCue.ts | 171 ++++++++++++++ apps/desktop/src/locales/en/common.json | 5 + apps/desktop/src/locales/ko/common.json | 5 + docs/design-system/component-contract.md | 1 + docs/doctoring/selected-part-entrance-cue.md | 68 ++++++ 12 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstEntranceCue.test.ts create mode 100644 apps/desktop/src/features/workspace/firstEntranceCue.ts create mode 100644 docs/doctoring/selected-part-entrance-cue.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..e9e50574b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project overview -- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. +- BandScope is a local-first desktop app for rehearsal prep: a practical song view with likely harmony by section and by instrument or vocal role, form and groove cues, stems, playable ranges, simplification guidance, transposition or setup cues, part-overlap cues, visible confidence, and rehearsal priorities. After a named part is selected, the ready workspace names that part's first trusted lyric, count, or transition cue as the next entrance. - Authoritative delivery rules live in `ARCHITECTURE.md`, `docs/plans/`, and the root verification scripts. - Brand, tone, UX copy, and prioritization rules live in `docs/brand-story.md` and must be applied to PRDs, TRDs, UI copy, onboarding, empty states, and error messages. - App security rules live in `docs/security/app-security.md` and must be applied to file handling, URL intake, subprocesses, IPC, WebView usage, model loading, updates, logging, cache handling, and export behavior. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..0f837fa4a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,6 +83,7 @@ Last updated: 2026-03-11 - section roadmap with entries, dropouts, pickups, stops, tags, and handoffs - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check + - the selected part's first trusted lyric, count, or transition cue as the next entrance - simplification, transposition, capo, tuning, or setup cues where applicable - role-specific rehearsal priorities and confidence flags - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..9f092604b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- After a named part is selected, name that part's first trusted lyric, count, or transition cue and tell the player to catch it before the entrance. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..ac7ab6ba3 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). The ready workspace names tonight's first playable range and the next instrument check. `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). The ready workspace names tonight's first playable range and the next instrument check. After a named part is selected, it also names that part's first trusted lyric, count, or transition cue as the next entrance. `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/workspace/Workspace.entrance-cue.test.tsx b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx new file mode 100644 index 000000000..5ca3dcd1e --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace selected-part entrance cue", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("hides the entrance cue until a named part is selected", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull(); + }); + + it("names the selected bass part's transition as the next entrance", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-entrance-cue"); + expect(callout).toHaveTextContent("Tonight's entrance cue"); + expect(callout).toHaveTextContent( + "Catch this transition in verse before Bass Guitar enters: Hold through the pickup before the downbeat." + ); + }); + + it("names the selected vocal lyric as the next entrance", () => { + setNavigatorLanguage("en-US"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( + 'Listen for "city lights" in verse, then Lead Vocal enters.' + ); + }); + + it("keeps Korean copy particle-safe for a Latin role name", () => { + setNavigatorLanguage("ko-KR"); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + const callout = screen.getByTestId("selected-part-entrance-cue"); + expect(callout).toHaveTextContent("오늘 이 파트의 첫 입장 큐"); + expect(callout).toHaveTextContent("Bass Guitar 파트"); + expect(callout).not.toHaveTextContent("Bass Guitar으로"); + expect(callout).toHaveTextContent( + "verse에서 이 전환을 잡고 Bass Guitar 파트로 들어오세요: Hold through the pickup before the downbeat." + ); + }); + + it("tells the player to confirm a missing cue instead of hiding the next action", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + cue: { kind: "transition", value: "none" } + }; + + render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toHaveTextContent( + "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..ffe781a6a 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,7 +5,8 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { fillEntranceCueCopy, firstEntranceCue } from "./firstEntranceCue"; +import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; @@ -118,6 +119,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R ); }); +/** Documented. */ +function entranceCueCopyKey(kind: "lyric" | "count" | "transition"): TranslationKey { + if (kind === "lyric") { + return "workspaceSelectedEntranceCueLyric"; + } + if (kind === "count") { + return "workspaceSelectedEntranceCueCount"; + } + return "workspaceSelectedEntranceCueTransition"; +} + /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); @@ -163,6 +175,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const selectedEntranceCue = useMemo( + () => (activeRole ? firstEntranceCue(song, activeRole) : null), + [activeRole, song] + ); + const selectedEntranceCueCopy = + selectedEntranceCue?.status === "ready" + ? fillEntranceCueCopy(t(entranceCueCopyKey(selectedEntranceCue.kind)), { + roleName: selectedEntranceCue.roleName, + sectionLabel: selectedEntranceCue.sectionLabel, + value: selectedEntranceCue.value + }) + : t("workspaceSelectedEntranceCueUnavailable"); /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -372,6 +396,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

Stem Player

{activeRoleDetails?.name ?? activeRole}

+
+

+ {t("workspaceSelectedEntranceCueTitle")} +

+

{selectedEntranceCueCopy}

+
); -} +} \ No newline at end of file From e583873fbfca0732f49e6bb1942ae7ef879ca823 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:11:14 +0900 Subject: [PATCH 07/26] feat(i18n): consolidate selected-part first-pass copy --- apps/desktop/src/locales/en/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 5252b66f1..8d010e46b 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -158,6 +158,9 @@ "workspaceSelectedEntranceCueCount": "Count this in {sectionLabel} before {roleName} enters: {value}", "workspaceSelectedEntranceCueTransition": "Catch this transition in {sectionLabel} before {roleName} enters: {value}", "workspaceSelectedEntranceCueUnavailable": "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance.", + "workspaceSelectedFirstPassTitle": "Tonight's first-pass take", + "workspaceSelectedFirstPassReady": "First pass for {roleName} in {sectionLabel}: {value} Play that simpler take before adding the rest.", + "workspaceSelectedFirstPassUnavailable": "This part still needs a trusted first-pass take. Confirm the simpler version before the first run.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } From 424821b59ea8f667c59f88fb871bfd662f61e3f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:12:01 +0900 Subject: [PATCH 08/26] feat(i18n): consolidate Korean first-pass copy --- apps/desktop/src/locales/ko/common.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 4c7ff0e83..070074123 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -158,6 +158,9 @@ "workspaceSelectedEntranceCueCount": "{sectionLabel}에서 이 카운트를 센 다음 {roleName} 파트로 들어오세요: {value}", "workspaceSelectedEntranceCueTransition": "{sectionLabel}에서 이 전환을 잡고 {roleName} 파트로 들어오세요: {value}", "workspaceSelectedEntranceCueUnavailable": "이 파트의 입장 큐를 아직 믿을 수 없습니다. 가사·카운트·전환을 확인한 다음 들어오세요.", + "workspaceSelectedFirstPassTitle": "오늘 이 파트의 첫 간소화", + "workspaceSelectedFirstPassReady": "{sectionLabel}에서 {roleName} 파트의 첫 패스: {value} 나머지를 더하기 전에 그 간소화된 버전으로 연습하세요.", + "workspaceSelectedFirstPassUnavailable": "이 파트에는 아직 신뢰할 수 있는 첫 패스 간소화가 없습니다. 첫 연습 전에 더 단순한 버전을 확인하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } From 7037ce72db0d638b057acde6991a99e5615b55ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:14:50 +0900 Subject: [PATCH 09/26] feat(workspace): consolidate selected-part confirmed chord --- .../workspace/selectedPartConfirmedChord.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts new file mode 100644 index 000000000..17705cc70 --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts @@ -0,0 +1,173 @@ +import type { RehearsalSong } from "@bandscope/shared-types"; +import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; + +/** Room-confirmed chord a selected part should lock before the section. */ +export type SelectedPartConfirmedChord = { + sectionLabel: string; + roleName: string; + chord: string; +}; + +const CANONICAL_SECTION_LABELS = new Set([ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" +]); + +/** Return whether an untrusted runtime value is a plain object record. */ +function isRuntimeObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Read an own data property and contain throwing membership or getter traps. */ +function ownValue(record: object, key: string): unknown { + try { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return undefined; + } + return (record as Record)[key]; + } catch { + return undefined; + } +} + +/** Admit a dense array or fail closed on holes and non-arrays. */ +function denseArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) { + return null; + } + } + + return value; +} + +/** Pull the first trusted user harmony chord from own override records. */ +function ownHarmonyOverrideChord(roleValue: object): string | undefined { + const overrides = denseArray(ownValue(roleValue, "manualOverrides")); + if (!overrides) { + return undefined; + } + + for (const item of overrides) { + if (!isRuntimeObject(item)) { + continue; + } + if (ownValue(item, "field") !== "harmony") { + continue; + } + if (ownValue(item, "source") !== "user") { + continue; + } + + const overrideValue = ownValue(item, "value"); + if (!isRuntimeObject(overrideValue) || ownValue(overrideValue, "source") !== "user") { + continue; + } + + const chord = meaningfulRangeText(ownValue(overrideValue, "chord")); + if (chord) { + return chord; + } + } + + return undefined; +} + +/** + * Pick the selected part's first room-confirmed harmony chord. + * + * Hidden until a named part is selected. Only own user harmony overrides + * become buyer-visible chord authority. Conflicting role copies, inherited + * prototypes, sparse collections, and non-canonical section labels fail + * closed instead of inventing a rehearsal chord. + */ +export function selectedPartConfirmedChord( + song: RehearsalSong, + activeRole: string | null +): SelectedPartConfirmedChord | null { + const selectedRoleId = meaningfulRangeText(activeRole); + if (!selectedRoleId) { + return null; + } + + const runtimeSong: unknown = song; + if (!isRuntimeObject(runtimeSong)) { + return null; + } + + const sections = denseArray(ownValue(runtimeSong, "sections")); + if (!sections) { + return null; + } + + let found: SelectedPartConfirmedChord | null = null; + let seenName: string | undefined; + + for (const sectionValue of sections) { + if (!isRuntimeObject(sectionValue)) { + continue; + } + + const sectionLabel = meaningfulRangeText(ownValue(sectionValue, "label")); + if (!sectionLabel || !CANONICAL_SECTION_LABELS.has(sectionLabel)) { + continue; + } + + const roles = denseArray(ownValue(sectionValue, "roles")); + if (!roles) { + continue; + } + + for (const roleValue of roles) { + if (!isRuntimeObject(roleValue)) { + continue; + } + + const roleId = meaningfulRangeText(ownValue(roleValue, "id")); + const roleName = meaningfulRangeText(ownValue(roleValue, "name")); + if (!roleId || !roleName || roleId !== selectedRoleId) { + continue; + } + + if (seenName && seenName !== roleName) { + return null; + } + seenName = roleName; + + const chord = ownHarmonyOverrideChord(roleValue); + if (!chord) { + continue; + } + + if (found && found.chord !== chord) { + return null; + } + + if (!found) { + found = { sectionLabel, roleName, chord }; + } + } + } + + return found; +} + +/** Fill trusted `{token}` placeholders for confirmed-chord copy. */ +export function fillConfirmedChordCopy( + template: string, + values: Record +): string { + return fillRangeCopy(template, values); +} From 9375cf1d0c5e69c9625392fae91b147947928123 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:15:01 +0900 Subject: [PATCH 10/26] test(workspace): preserve confirmed-chord mount --- .../Workspace.confirmed-chord.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx diff --git a/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx new file mode 100644 index 000000000..7850a7a76 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace selected-part confirmed chord", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("stays hidden until a part with a room-confirmed chord is selected", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + expect(screen.queryByTestId("selected-part-confirmed-chord")).toBeNull(); + }); + + it("names the selected part's confirmed chord and the next lock-in action", () => { + setNavigatorLanguage("en-US"); + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + const callout = screen.getByTestId("selected-part-confirmed-chord"); + expect(callout).toHaveTextContent("Tonight's confirmed chord"); + expect(callout).toHaveTextContent( + "Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse." + ); + }); + + it("keeps Korean copy particle-safe for Latin role names", () => { + setNavigatorLanguage("ko-KR"); + render(); + + fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); + + expect(screen.getByTestId("selected-part-confirmed-chord")).toHaveTextContent( + "verse의 Lead Vocal 파트는 방이 확인한 C#m11를 씁니다. verse 전에 그 코드를 고정하세요." + ); + }); +}); From 7ad3924c5caff360151c7dbf72704ed18fdf6a1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:15:23 +0900 Subject: [PATCH 11/26] test(workspace): preserve confirmed-chord regressions --- .../selectedPartConfirmedChord.test.ts | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts new file mode 100644 index 000000000..c4f83c3c0 --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts @@ -0,0 +1,185 @@ +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { + fillConfirmedChordCopy, + selectedPartConfirmedChord +} from "./selectedPartConfirmedChord"; + +function withSelectedOverride( + song: RehearsalSong, + roleId: string, + chord: string | null, + extras: Partial = {} +): RehearsalSong { + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => { + if (role.id !== roleId) { + return role; + } + return { + ...role, + ...extras, + manualOverrides: + chord === null + ? [] + : [ + { + field: "harmony" as const, + value: { + chord, + functionLabel: "user confirmed", + source: "user" as const + }, + source: "user" as const + } + ] + }; + }) + })) + }; +} + +describe("selectedPartConfirmedChord", () => { + it("names the selected part's first own user harmony override", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), "lead-vocal")).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal", + chord: "C#m11" + }); + }); + + it("stays hidden until a named part is selected", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), null)).toBeNull(); + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), " ")).toBeNull(); + }); + + it("stays hidden when the selected part has no trusted override", () => { + expect(selectedPartConfirmedChord(createDemoRehearsalSong(), "bass-guitar")).toBeNull(); + expect( + selectedPartConfirmedChord(withSelectedOverride(createDemoRehearsalSong(), "bass-guitar", "none"), "bass-guitar") + ).toBeNull(); + }); + + it("skips inherited, model, and non-harmony overrides", () => { + const song = createDemoRehearsalSong(); + const bass = song.sections[0]!.roles[0]!; + const inherited = Object.create({ + manualOverrides: [ + { + field: "harmony", + value: { chord: "G", functionLabel: "inherited", source: "user" }, + source: "user" + } + ] + }) as typeof bass; + Object.assign(inherited, { ...bass, manualOverrides: undefined }); + delete (inherited as { manualOverrides?: unknown }).manualOverrides; + song.sections[0]!.roles[0] = inherited; + + expect(selectedPartConfirmedChord(song, "bass-guitar")).toBeNull(); + + const modelOnly = withSelectedOverride(createDemoRehearsalSong(), "bass-guitar", "E3"); + modelOnly.sections[0]!.roles[0] = { + ...modelOnly.sections[0]!.roles[0]!, + manualOverrides: [ + { + field: "harmony", + value: { + chord: "Gmaj7", + functionLabel: "model leftover", + source: "model" + }, + source: "model" + } + ] + }; + + expect(selectedPartConfirmedChord(modelOnly, "bass-guitar")).toBeNull(); + }); + + it("fails closed on conflicting role copies and sparse collections", () => { + const conflict = createDemoRehearsalSong(); + conflict.sections.push({ + ...conflict.sections[0]!, + id: "verse-2", + roles: conflict.sections[0]!.roles.map((role) => + role.id === "lead-vocal" ? { ...role, name: "Lead Vox" } : role + ) + }); + expect(selectedPartConfirmedChord(conflict, "lead-vocal")).toBeNull(); + + const chordConflict = createDemoRehearsalSong(); + chordConflict.sections.push({ + ...chordConflict.sections[0]!, + id: "chorus-1", + label: "chorus", + roles: chordConflict.sections[0]!.roles.map((role) => + role.id === "lead-vocal" + ? { + ...role, + manualOverrides: [ + { + field: "harmony" as const, + value: { + chord: "Bmaj7", + functionLabel: "other copy", + source: "user" as const + }, + source: "user" as const + } + ] + } + : role + ) + }); + expect(selectedPartConfirmedChord(chordConflict, "lead-vocal")).toBeNull(); + + const sparse = createDemoRehearsalSong() as unknown as { sections: unknown[] }; + sparse.sections = []; + sparse.sections[1] = createDemoRehearsalSong().sections[0]; + expect(selectedPartConfirmedChord(sparse as unknown as RehearsalSong, "lead-vocal")).toBeNull(); + }); + + it("fails closed on malformed roots, traps, and non-canonical labels", () => { + expect(selectedPartConfirmedChord(null as unknown as RehearsalSong, "lead-vocal")).toBeNull(); + expect(selectedPartConfirmedChord({} as RehearsalSong, "lead-vocal")).toBeNull(); + + const trap = new Proxy(createDemoRehearsalSong(), { + has() { + throw new Error("has trap"); + }, + get(target, property, receiver) { + if (property === "sections") { + throw new Error("get trap"); + } + return Reflect.get(target, property, receiver); + } + }); + expect(selectedPartConfirmedChord(trap, "lead-vocal")).toBeNull(); + + const unknownLabel = createDemoRehearsalSong(); + unknownLabel.sections[0] = { ...unknownLabel.sections[0]!, label: "vibe-check" as typeof unknownLabel.sections[0]["label"] }; + expect(selectedPartConfirmedChord(unknownLabel, "lead-vocal")).toBeNull(); + }); +}); + +describe("fillConfirmedChordCopy", () => { + it("keeps placeholder-shaped chords literal", () => { + expect( + fillConfirmedChordCopy("{roleName} locks {chord} before {sectionLabel}.", { + roleName: "Lead Vocal", + chord: "C#m11 {sectionLabel}", + sectionLabel: "verse" + }) + ).toBe("Lead Vocal locks C#m11 {sectionLabel} before verse."); + }); + + it("does not satisfy tokens with inherited object members", () => { + expect(fillConfirmedChordCopy("Use {toString} in {missingToken}.", { chord: "C#m11" })).toBe( + "Use {toString} in {missingToken}." + ); + }); +}); From a5d07507b0d9c8da256dd250e91386648334d4a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:15:36 +0900 Subject: [PATCH 12/26] docs(workspace): preserve confirmed-chord evidence --- .../selected-part-confirmed-chord.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/doctoring/selected-part-confirmed-chord.md diff --git a/docs/doctoring/selected-part-confirmed-chord.md b/docs/doctoring/selected-part-confirmed-chord.md new file mode 100644 index 000000000..f994d7039 --- /dev/null +++ b/docs/doctoring/selected-part-confirmed-chord.md @@ -0,0 +1,37 @@ +# Selected-part confirmed chord + +## Product decision + +After a named part is selected, the ready rehearsal workspace names that part's first trusted user harmony override and tells the player to lock the room-confirmed chord before the section. The callout stays hidden until a part is selected and stays hidden when the part has no trusted override. + +This is selected-part confirmed-chord guidance only. It does not replace: + +- song-wide first confirmed chord ownership (`#1002`) +- selected-part entrance cue (`#1150`) +- selected-part first-pass simplification (`#1151`) +- setup-before-entrance (`#910`) +- Active Player (`#961`) +- MIR / known-stem ownership (`#828` / `#770`) + +## Buyer-visible next action + +- Lead Vocal: **Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse.** +- Bass Guitar / Keyboard: no callout, because those demo parts have no user harmony override. +- Korean copy keeps Latin role names particle-safe (`Lead Vocal 파트는`). + +## Trust boundary + +- Untrusted input: in-memory project `manualOverrides`, role identity, section labels, and chord strings. +- Own-property admission only. Inherited `manualOverrides`, throwing `has`/`get` traps, sparse arrays, and non-object members fail closed. +- Only `field: "harmony"` overrides with `source: "user"` and a non-blank, non-`none` chord become buyer copy. +- Only shared canonical section labels (`intro` through `handoff`) become localization authority. +- Duplicate selected-role ids with conflicting display names or conflicting override chords fail closed. +- `fillConfirmedChordCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source, and placeholder-shaped chords stay literal. + +## Security Notes + +- Attack surface: rehearsal workspace UI copy from in-memory analysis output. No new file, URL, subprocess, IPC, WebView, model, credential, or export path. +- Trust boundary: browser/React state → selector → translated callout. +- Safe failure: missing selection, missing override, malformed runtime evidence, and conflicting copies hide the callout instead of inventing a chord. +- Privacy: chord symbols and role names remain rehearsal display data already present in the project; nothing is logged or exported by this slice. +- Test points: demo Lead Vocal override, hidden-until-selected, missing/`none` overrides, inherited/model overrides, conflicting copies, sparse collections, getter traps, non-canonical labels, and literal placeholder-shaped chords. From 9a8b30eae26b8e042bbc34a9d530b5ff75c29fd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:17:51 +0900 Subject: [PATCH 13/26] feat(workspace): mount consolidated confirmed-chord guidance --- .../src/features/workspace/Workspace.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 4a889130a..4f726ed30 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -7,6 +7,7 @@ import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { fillEntranceCueCopy, firstEntranceCue } from "./firstEntranceCue"; import { fillFirstPassCopy, firstPassSimplification } from "./firstPassSimplification"; +import { fillConfirmedChordCopy, selectedPartConfirmedChord } from "./selectedPartConfirmedChord"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -176,6 +177,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp } ) : t("workspaceFirstRangeMissing"); + const confirmedChord = useMemo( + () => selectedPartConfirmedChord(song, activeRole), + [activeRole, song] + ); + const confirmedChordCopy = confirmedChord + ? fillConfirmedChordCopy(t("workspaceConfirmedChordLock"), { + roleName: confirmedChord.roleName, + chord: confirmedChord.chord, + sectionLabel: confirmedChord.sectionLabel + }) + : null; const selectedEntranceCue = useMemo( () => (activeRole ? firstEntranceCue(song, activeRole) : null), [activeRole, song] @@ -347,6 +359,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ {confirmedChordCopy ? ( +
+

{t("workspaceConfirmedChordTitle")}

+

{confirmedChordCopy}

+
+ ) : null} +

{t("workspaceSongTimelineLabel")}

From 958b096a32f19f6fa426d71d5c97921fe878530d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:18:27 +0900 Subject: [PATCH 14/26] feat(i18n): consolidate confirmed-chord copy --- apps/desktop/src/locales/en/common.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 8d010e46b..ea2101d7f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -153,6 +153,8 @@ "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", "workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.", + "workspaceConfirmedChordTitle": "Tonight's confirmed chord", + "workspaceConfirmedChordLock": "{roleName} uses the room's {chord} in {sectionLabel}. Lock that chord before the {sectionLabel}.", "workspaceSelectedEntranceCueTitle": "Tonight's entrance cue", "workspaceSelectedEntranceCueLyric": "Listen for \"{value}\" in {sectionLabel}, then {roleName} enters.", "workspaceSelectedEntranceCueCount": "Count this in {sectionLabel} before {roleName} enters: {value}", From d6621734b7b81c37db68c83b77e5dfa036d3d64f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:19:44 +0900 Subject: [PATCH 15/26] feat(i18n): consolidate Korean confirmed-chord copy --- apps/desktop/src/locales/ko/common.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 070074123..e8a2fd836 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -153,6 +153,8 @@ "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", + "workspaceConfirmedChordTitle": "오늘 방이 확인한 코드", + "workspaceConfirmedChordLock": "{sectionLabel}의 {roleName} 파트는 방이 확인한 {chord}를 씁니다. {sectionLabel} 전에 그 코드를 고정하세요.", "workspaceSelectedEntranceCueTitle": "오늘 이 파트의 첫 입장 큐", "workspaceSelectedEntranceCueLyric": "{sectionLabel}에서 \"{value}\"를 듣고 {roleName} 파트로 들어오세요.", "workspaceSelectedEntranceCueCount": "{sectionLabel}에서 이 카운트를 센 다음 {roleName} 파트로 들어오세요: {value}", From 8581afb03ab774cb55639e7a73c8dda9ec2c9e43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:20:11 +0900 Subject: [PATCH 16/26] refactor(workspace): share canonical section labels --- .../workspace/selectedPartConfirmedChord.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts index 17705cc70..82cf0518a 100644 --- a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts @@ -1,4 +1,4 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { SECTION_FORM_LABELS, type RehearsalSong } from "@bandscope/shared-types"; import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze"; /** Room-confirmed chord a selected part should lock before the section. */ @@ -8,18 +8,7 @@ export type SelectedPartConfirmedChord = { chord: string; }; -const CANONICAL_SECTION_LABELS = new Set([ - "intro", - "verse", - "pre-chorus", - "chorus", - "bridge", - "outro", - "tag", - "pickup", - "stop", - "handoff" -]); +const CANONICAL_SECTION_LABELS = new Set(SECTION_FORM_LABELS); /** Return whether an untrusted runtime value is a plain object record. */ function isRuntimeObject(value: unknown): value is Record { From 8821ae1bda6c4111a0f531865fca3e726628848e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:21:34 +0900 Subject: [PATCH 17/26] docs(ui): consolidate selected-part guidance contracts --- docs/design-system/component-contract.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 701bc0eea..0b4152f22 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -31,7 +31,9 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | -| Selected Part Entrance Cue | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-239 | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/firstEntranceCue.ts` | Feature-local selected-part callout. Show only after Role Switcher selection; copy must name the next entrance. | +| Selected Part Entrance Cue | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/firstEntranceCue.ts` | Feature-local selected-part callout. Show only after Role Switcher selection; copy must name the next entrance. | +| Selected Part First-Pass Take | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/firstPassSimplification.ts` | Feature-local selected-part callout. Show only after Role Switcher selection; copy must name the simpler take to play first. | +| Selected Part Confirmed Chord | Pending live Figma node verification; do not reuse `19-239` | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts` | Feature-local selected-part callout. Show only when the selected part has a trusted user harmony override; copy must name the room-confirmed chord and lock-in action. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | From 7d03dc9ce4ba68a05b6c17a2da96c743ec4d13cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:36:13 +0900 Subject: [PATCH 18/26] test(workspace): reject stale selected roles --- .../features/workspace/RoleSwitcher.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx index 575684c50..3a7dbe730 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.test.tsx @@ -72,4 +72,27 @@ describe("RoleSwitcher", () => { expect(tabValueToRoleId("role:unknown-role", roles)).toBeNull(); expect(tabValueToRoleId("raw-unknown-role", roles)).toBeNull(); }); + + it("clears an active role that is absent from the current song role allowlist", () => { + const onRoleChange = vi.fn(); + const { rerender } = render( + + ); + + expect(onRoleChange).not.toHaveBeenCalled(); + + rerender( + + ); + + expect(onRoleChange).toHaveBeenCalledWith(null); + }); }); From c2644582cc6c789da351477dd9426674c9ae7c7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:36:54 +0900 Subject: [PATCH 19/26] test(workspace): hide stale selected-part guidance --- .../workspace/Workspace.entrance-cue.test.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx index 5ca3dcd1e..f37ce1fa1 100644 --- a/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.entrance-cue.test.tsx @@ -75,4 +75,25 @@ describe("Workspace selected-part entrance cue", () => { "This part still needs a trusted entrance cue. Confirm the lyric, count, or transition before the first entrance." ); }); + + it("clears selected-part guidance when the next project no longer contains the selected role", () => { + setNavigatorLanguage("en-US"); + const firstSong = createDemoRehearsalSong(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); + + expect(screen.getByTestId("selected-part-entrance-cue")).toBeInTheDocument(); + + const nextSong = createDemoRehearsalSong(); + nextSong.id = "replacement-project"; + nextSong.sections = nextSong.sections.map((section) => ({ + ...section, + roles: section.roles.filter((role) => role.id !== "bass-guitar") + })); + + rerender(); + + expect(screen.queryByTestId("selected-part-entrance-cue")).toBeNull(); + expect(screen.queryByTestId("selected-part-first-pass")).toBeNull(); + }); }); From 10d9ed0cafcbfe03e4f5b6cafa98dc767cd22aea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:38:42 +0900 Subject: [PATCH 20/26] test(i18n): require particle-safe confirmed chord copy --- .../src/features/workspace/Workspace.confirmed-chord.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx index 7850a7a76..2ca8a0e5c 100644 --- a/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx @@ -40,14 +40,14 @@ describe("Workspace selected-part confirmed chord", () => { ); }); - it("keeps Korean copy particle-safe for Latin role names", () => { + it("keeps Korean copy particle-safe for arbitrary chord symbols", () => { setNavigatorLanguage("ko-KR"); render(); fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" })); expect(screen.getByTestId("selected-part-confirmed-chord")).toHaveTextContent( - "verse의 Lead Vocal 파트는 방이 확인한 C#m11를 씁니다. verse 전에 그 코드를 고정하세요." + "verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요." ); }); }); From 141d8e99df9b84b54effcce9ce5a46876bc4713c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:39:35 +0900 Subject: [PATCH 21/26] fix(workspace): fail closed on chord override conflicts --- .../workspace/selectedPartConfirmedChord.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts index 82cf0518a..45f71543a 100644 --- a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts @@ -42,13 +42,14 @@ function denseArray(value: unknown): unknown[] | null { return value; } -/** Pull the first trusted user harmony chord from own override records. */ -function ownHarmonyOverrideChord(roleValue: object): string | undefined { +/** Pull one unambiguous trusted user harmony chord from own override records. */ +function ownHarmonyOverrideChord(roleValue: object): string | null | undefined { const overrides = denseArray(ownValue(roleValue, "manualOverrides")); if (!overrides) { return undefined; } + let foundChord: string | undefined; for (const item of overrides) { if (!isRuntimeObject(item)) { continue; @@ -66,12 +67,16 @@ function ownHarmonyOverrideChord(roleValue: object): string | undefined { } const chord = meaningfulRangeText(ownValue(overrideValue, "chord")); - if (chord) { - return chord; + if (!chord) { + continue; + } + if (foundChord && foundChord !== chord) { + return null; } + foundChord = chord; } - return undefined; + return foundChord; } /** @@ -136,6 +141,9 @@ export function selectedPartConfirmedChord( seenName = roleName; const chord = ownHarmonyOverrideChord(roleValue); + if (chord === null) { + return null; + } if (!chord) { continue; } From c5ce688c1580ce735e6d89e003bdbc21f8aa1aff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:40:55 +0900 Subject: [PATCH 22/26] test(workspace): cover conflicting chord overrides --- ...electedPartConfirmedChord.conflict.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts diff --git a/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts new file mode 100644 index 000000000..3e3c1f21e --- /dev/null +++ b/apps/desktop/src/features/workspace/selectedPartConfirmedChord.conflict.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { selectedPartConfirmedChord } from "./selectedPartConfirmedChord"; + +describe("selectedPartConfirmedChord conflicting overrides", () => { + it("fails closed when one selected role has two different user-confirmed harmony chords", () => { + const song = createDemoRehearsalSong(); + const leadVocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + leadVocal.manualOverrides = [ + { + field: "harmony", + value: { chord: "C#m11", functionLabel: "room confirmation", source: "user" }, + source: "user" + }, + { + field: "harmony", + value: { chord: "Bmaj7", functionLabel: "conflicting confirmation", source: "user" }, + source: "user" + } + ]; + + expect(selectedPartConfirmedChord(song, "lead-vocal")).toBeNull(); + }); + + it("accepts repeated copies of the same user-confirmed chord", () => { + const song = createDemoRehearsalSong(); + const leadVocal = song.sections[0]!.roles.find((role) => role.id === "lead-vocal")!; + leadVocal.manualOverrides = [ + ...(leadVocal.manualOverrides ?? []), + ...(leadVocal.manualOverrides ?? []) + ]; + + expect(selectedPartConfirmedChord(song, "lead-vocal")).toEqual({ + sectionLabel: "verse", + roleName: "Lead Vocal", + chord: "C#m11" + }); + }); +}); From 81d022cc043deed42b30ccdfee35109932ed73c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:44:51 +0900 Subject: [PATCH 23/26] fix(i18n): make confirmed chord copy particle-safe --- apps/desktop/src/locales/ko/common.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index e8a2fd836..779507c51 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -154,7 +154,7 @@ "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", "workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.", "workspaceConfirmedChordTitle": "오늘 방이 확인한 코드", - "workspaceConfirmedChordLock": "{sectionLabel}의 {roleName} 파트는 방이 확인한 {chord}를 씁니다. {sectionLabel} 전에 그 코드를 고정하세요.", + "workspaceConfirmedChordLock": "{sectionLabel}의 {roleName} 파트는 방이 확인한 {chord} 코드로 맞춥니다. {sectionLabel} 전에 그 코드를 고정하세요.", "workspaceSelectedEntranceCueTitle": "오늘 이 파트의 첫 입장 큐", "workspaceSelectedEntranceCueLyric": "{sectionLabel}에서 \"{value}\"를 듣고 {roleName} 파트로 들어오세요.", "workspaceSelectedEntranceCueCount": "{sectionLabel}에서 이 카운트를 센 다음 {roleName} 파트로 들어오세요: {value}", From 44b1233b067a73bf3ccd84fd120ac2a96ad13e1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:45:30 +0900 Subject: [PATCH 24/26] fix(workspace): clear roles missing from replacement project --- apps/desktop/src/features/workspace/RoleSwitcher.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/features/workspace/RoleSwitcher.tsx b/apps/desktop/src/features/workspace/RoleSwitcher.tsx index f5275964d..f37292438 100644 --- a/apps/desktop/src/features/workspace/RoleSwitcher.tsx +++ b/apps/desktop/src/features/workspace/RoleSwitcher.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Users } from "lucide-react"; @@ -40,6 +41,12 @@ export function tabValueToRoleId(value: string, roles: RehearsalRoleOption[]): s export function RoleSwitcher({ roles, activeRole, onRoleChange }: RoleSwitcherProps) { const t = createTranslator(detectPreferredLocale()); + useEffect(() => { + if (activeRole !== null && !roles.some((role) => role.id === activeRole)) { + onRoleChange(null); + } + }, [activeRole, onRoleChange, roles]); + return (
From 84443ce735846b937730250464aaf67bcc1daa5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:47:00 +0900 Subject: [PATCH 25/26] docs(workspace): record selected-part ordering invariants --- docs/doctoring/selected-part-entrance-cue.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/doctoring/selected-part-entrance-cue.md b/docs/doctoring/selected-part-entrance-cue.md index 328454801..5d486f67d 100644 --- a/docs/doctoring/selected-part-entrance-cue.md +++ b/docs/doctoring/selected-part-entrance-cue.md @@ -12,6 +12,12 @@ When the selected part has no trusted cue, the callout still names the next acti This is not Active Player ownership (`#961`) and not MIR ownership (`#828` / `#770`). +## Ordering and selection invariants + +The project contract treats `sections` array order as timeline order. `firstEntranceCue` therefore scans in array order rather than independently sorting by `timeRange.start`; ingestion/migration code that reorders sections must preserve chronological array order. If that invariant changes, the domain contract and tests must change together rather than silently choosing a different entrance. + +The selected role is also project-scoped. When a replacement project no longer contains the previously selected role id, `RoleSwitcher` clears the selection through the current role allowlist so entrance, first-pass, and related selected-part guidance do not survive as stale UI state. + ## Own-property admission `cue.kind` and `cue.value` are untrusted project fields. Workspace copy may only name an entrance from own-property evidence: @@ -40,13 +46,15 @@ Admission is lexical and own-property only. The helper reads in-memory song obje - `meaningfulRangeText` rejects blank and `none` sentinel values. - Unknown kinds and non-canonical section labels never become buyer-visible localization authority. - Conflicting section copies of the same named part return `unavailable`; Workspace still tells the player to confirm the entrance instead of guessing. +- A malformed role anywhere in the admitted section list causes the selector to fail closed. Parsed production projects exclude malformed roles, so this is a defensive integrity boundary rather than a normal user-visible fallback. - Locale templates keep `{roleName}` / `{sectionLabel}` / `{value}` placeholders; `fillRangeCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source. - Korean copy uses `{roleName} 파트` so a Latin role label cannot produce `Bass Guitar으로`. ### Test points - Helper: lyric/count/transition; missing selection; blank/`none`/unknown kind; inherited cue and kind; duplicate ids; conflicting names; non-canonical labels; first untrusted canonical copy is not skipped. -- Workspace: hidden until a part is selected; bass transition copy; vocal lyric copy; Korean particle-safe Latin role; unavailable copy when the cue is `none`. +- Workspace: hidden until a part is selected; bass transition copy; vocal lyric copy; Korean particle-safe Latin role; unavailable copy when the cue is `none`; replacement project clears a selected role that is absent from the new project. +- Role switcher: a stale active role outside the current rendered role allowlist is cleared to the all-roles state. ### Realistic threats @@ -62,6 +70,7 @@ Run: ```bash npm --workspace @bandscope/desktop exec vitest run \ + src/features/workspace/RoleSwitcher.test.tsx \ src/features/workspace/firstEntranceCue.test.ts \ src/features/workspace/Workspace.entrance-cue.test.tsx \ src/features/workspace/Workspace.test.tsx From f8a6c2bac6f1a04cc103940f681139cf9fd1be21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 15:47:52 +0900 Subject: [PATCH 26/26] docs(workspace): record confirmed chord conflict policy --- docs/doctoring/selected-part-confirmed-chord.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/selected-part-confirmed-chord.md b/docs/doctoring/selected-part-confirmed-chord.md index f994d7039..ed7100c5d 100644 --- a/docs/doctoring/selected-part-confirmed-chord.md +++ b/docs/doctoring/selected-part-confirmed-chord.md @@ -7,8 +7,7 @@ After a named part is selected, the ready rehearsal workspace names that part's This is selected-part confirmed-chord guidance only. It does not replace: - song-wide first confirmed chord ownership (`#1002`) -- selected-part entrance cue (`#1150`) -- selected-part first-pass simplification (`#1151`) +- selected-part entrance/first-pass guidance owned by the canonical `#1150` vertical - setup-before-entrance (`#910`) - Active Player (`#961`) - MIR / known-stem ownership (`#828` / `#770`) @@ -17,21 +16,22 @@ This is selected-part confirmed-chord guidance only. It does not replace: - Lead Vocal: **Lead Vocal uses the room's C#m11 in verse. Lock that chord before the verse.** - Bass Guitar / Keyboard: no callout, because those demo parts have no user harmony override. -- Korean copy keeps Latin role names particle-safe (`Lead Vocal 파트는`). +- Korean copy avoids attaching a case particle directly to arbitrary chord notation: **verse의 Lead Vocal 파트는 방이 확인한 C#m11 코드로 맞춥니다. verse 전에 그 코드를 고정하세요.** ## Trust boundary - Untrusted input: in-memory project `manualOverrides`, role identity, section labels, and chord strings. - Own-property admission only. Inherited `manualOverrides`, throwing `has`/`get` traps, sparse arrays, and non-object members fail closed. - Only `field: "harmony"` overrides with `source: "user"` and a non-blank, non-`none` chord become buyer copy. -- Only shared canonical section labels (`intro` through `handoff`) become localization authority. -- Duplicate selected-role ids with conflicting display names or conflicting override chords fail closed. +- Every valid user harmony override on the selected role is inspected. Repeated copies of the same chord are harmless; two different admitted chords on the same role are ambiguous and fail closed instead of choosing by array order. +- Only shared `SECTION_FORM_LABELS` become localization authority. +- Duplicate selected-role ids with conflicting display names or conflicting override chords across section copies fail closed. - `fillConfirmedChordCopy` uses own-property token lookup so inherited members such as `toString` cannot render function source, and placeholder-shaped chords stay literal. ## Security Notes - Attack surface: rehearsal workspace UI copy from in-memory analysis output. No new file, URL, subprocess, IPC, WebView, model, credential, or export path. - Trust boundary: browser/React state → selector → translated callout. -- Safe failure: missing selection, missing override, malformed runtime evidence, and conflicting copies hide the callout instead of inventing a chord. +- Safe failure: missing selection, missing override, malformed runtime evidence, and conflicting same-role or cross-section copies hide the callout instead of inventing a chord. - Privacy: chord symbols and role names remain rehearsal display data already present in the project; nothing is logged or exported by this slice. -- Test points: demo Lead Vocal override, hidden-until-selected, missing/`none` overrides, inherited/model overrides, conflicting copies, sparse collections, getter traps, non-canonical labels, and literal placeholder-shaped chords. +- Test points: demo Lead Vocal override, hidden-until-selected, missing/`none` overrides, inherited/model overrides, conflicting copies, same-role conflicting user overrides, duplicate identical user overrides, sparse collections, getter traps, non-canonical labels, literal placeholder-shaped chords, and particle-safe Korean chord copy.