From ec1bd8248ba04962f99dc7f24c0072d9f7eeeadf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:13:38 +0000 Subject: [PATCH] feat(workspace): name the selected part's confirmed chord After a named part is selected, the ready rehearsal map names that part's first trusted user harmony override and tells the player to lock the room-confirmed chord before the section. Missing or untrusted overrides stay hidden instead of inventing a chord. --- AGENTS.md | 2 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../Workspace.confirmed-chord.test.tsx | 53 +++++ .../src/features/workspace/Workspace.tsx | 23 +++ .../selectedPartConfirmedChord.test.ts | 185 ++++++++++++++++++ .../workspace/selectedPartConfirmedChord.ts | 173 ++++++++++++++++ apps/desktop/src/locales/en/common.json | 2 + apps/desktop/src/locales/ko/common.json | 2 + docs/design-system/component-contract.md | 1 + .../selected-part-confirmed-chord.md | 37 ++++ 12 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/features/workspace/Workspace.confirmed-chord.test.tsx create mode 100644 apps/desktop/src/features/workspace/selectedPartConfirmedChord.test.ts create mode 100644 apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts create mode 100644 docs/doctoring/selected-part-confirmed-chord.md diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..4b7e0d06a 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. The ready workspace names tonight's first playable range, and a selected part with a room-confirmed harmony override names that chord as the next lock-in. - 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..49c64e2ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,7 +79,7 @@ Last updated: 2026-03-11 ## Rehearsal outputs - Core rehearsal artifacts should include: - - likely harmony by section and by role + - likely harmony by section and by role, with a selected part naming a room-confirmed override chord as the next lock-in - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..f0db24b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - 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. +- Name a selected part's room-confirmed harmony override and tell the player to lock that chord 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..8a3e44add 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; a selected part with a room-confirmed harmony override also names that chord as the next lock-in. `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.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 전에 그 코드를 고정하세요." + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..485a78e60 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; +import { fillConfirmedChordCopy, selectedPartConfirmedChord } from "./selectedPartConfirmedChord"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -163,6 +164,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; /** Handle the practice progress change internally by immutably updating the song state. */ const handlePracticeProgressChange = (newProgress: number) => { @@ -310,6 +322,17 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{firstRangeCopy}

+ {confirmedChordCopy ? ( +
+

{t("workspaceConfirmedChordTitle")}

+

{confirmedChordCopy}

+
+ ) : null} +

{t("workspaceSongTimelineLabel")}

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}." + ); + }); +}); 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); +} diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..c319594b6 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}.", "sectionRangeLabel": "Range", "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..6dca8842d 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} 전에 그 코드를 고정하세요.", "sectionRangeLabel": "음역", "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..f0f89d94d 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -35,6 +35,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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()`. | +| Selected-part confirmed chord | Ready workspace callout; Figma page `31 Component Contract Catalog` / Status Pill `19-283` for tone | `apps/desktop/src/features/workspace/Workspace.tsx`, `apps/desktop/src/features/workspace/selectedPartConfirmedChord.ts` | Hidden until a named part is selected. Names the first trusted user harmony override and the next lock-in. | ## Prop And State Mapping 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.