From 6d92ed24f5695d6c130c1df9f6353f641c49a47b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 12:17:56 +0000 Subject: [PATCH 1/7] feat(workspace): name tonight's first hook plan on the map Name the owning part, owned hookPlan copy, labeled section, and time so the next rehearsal action is obvious. Open moves to the matching map section. Do not invent hook copy from other rehearsal metadata. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstHookPlanCallout.identity.test.tsx | 21 ++ .../FirstHookPlanCallout.memoization.test.tsx | 25 ++ .../FirstHookPlanCallout.particle.test.tsx | 52 ++++ ...rstHookPlanCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstHookPlanCallout.test.tsx | 202 ++++++++++++ .../workspace/FirstHookPlanCallout.tsx | 179 +++++++++++ ...tHookPlanCallout.unavailable-copy.test.tsx | 34 +++ ...stHookPlanCallout.workspace-scope.test.tsx | 54 ++++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../workspace/coverageContract.test.ts | 13 + .../firstHookPlan.inherited-metadata.test.ts | 94 ++++++ .../firstHookPlan.proxy-authority.test.ts | 31 ++ .../firstHookPlan.section-label.test.ts | 14 + .../features/workspace/firstHookPlan.test.ts | 282 +++++++++++++++++ .../src/features/workspace/firstHookPlan.ts | 287 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 50 ++- apps/desktop/src/i18n/index.ts | 36 ++- apps/desktop/src/locales/en/common.json | 8 +- apps/desktop/src/locales/ko/common.json | 8 +- apps/desktop/vite.config.ts | 21 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-hook-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 6 + packages/shared-types/test/index.test.ts | 7 + 29 files changed, 1503 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.memoization.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstHookPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/coverageContract.test.ts create mode 100644 apps/desktop/src/features/workspace/firstHookPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstHookPlan.section-label.test.ts create mode 100644 apps/desktop/src/features/workspace/firstHookPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstHookPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-hook-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..1c2c82ff1 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 hook plan with the owning part when an active role is corroborated, the owned `hookPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, confirmed overrides, harmonic explanations, or confidence notes. - 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..0b7422941 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-24 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first hook plan must name the owning part when corroborated, the owned `hookPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..87208e2fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first hook plan in the mounted rehearsal workspace so a part can lock the owned hook before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - 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..a0da4b96e 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). The mounted workspace names tonight's first hook plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. `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/FirstHookPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.identity.test.tsx new file mode 100644 index 000000000..538911354 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +it("gives co-mounted hook-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first hook plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..d9ea32923 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.memoization.test.tsx @@ -0,0 +1,25 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +describe("FirstHookPlanCallout resolver reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not rescan role metadata when a parent rerenders the same song object", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!; + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor"); + + const { rerender } = render(); + const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + expect(firstScanCount).toBeGreaterThan(0); + + rerender(); + const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + + expect(secondScanCount).toBe(firstScanCount); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.particle.test.tsx new file mode 100644 index 000000000..f3c5d095d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.particle.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +describe("FirstHookPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the hook action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + hookPlan: "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony." + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:10 벌스에서 피아노 파트의 훅 계획이 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + expect(screen.queryByText(/피아노가/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 훅 열기" })); + + expect(screen.getByText("0:10에서 피아노 파트의 훅을 맞춘 다음 합주를 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..75c9fc25b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.reduced-motion.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +describe("FirstHookPlanCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.test.tsx new file mode 100644 index 000000000..2bc3aec83 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.test.tsx @@ -0,0 +1,202 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +const DEMO_HOOK_PLAN = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + +function songWithHookPlan() { + return createDemoRehearsalSong(); +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstHookPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No hook plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithHookPlan(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same hook signature", () => { + const firstSong = songWithHookPlan(); + const nextSong = songWithHookPlan(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Lead Vocal still has a hook plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithHookPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Lead Vocal still has a hook plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's hook plan under the named holding part", () => { + const song = songWithHookPlan(); + song.sections[0]!.roles[0]!.hookPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.hookPlan = "Leave the bass on roots while the hook lands."; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.hookPlan = + "Keep the right-hand figure under the vocal so the hook still reads."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has a hook plan in the verse at 0:10.") + ).toBeTruthy(); + expect( + screen.getByText("Keep the right-hand figure under the vocal so the hook still reads.") + ).toBeTruthy(); + expect(screen.queryByText("Leave the bass on roots while the hook lands.")).toBeNull(); + expect(screen.queryByText(DEMO_HOOK_PLAN)).toBeNull(); + }); + + it("names the first hook plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_HOOK_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Lead Vocal hook at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + + expect(screen.getByText("Lead Vocal still has a hook plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithHookPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("fails closed when more than one song-structure renderer is mounted globally", () => { + const first = appendSongStructureTarget(); + const second = appendSongStructureTarget(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal hook at 0:10" })); + + expect(first.scrollIntoView).not.toHaveBeenCalled(); + expect(second.scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Lead Vocal still has a hook plan in the verse at 0:10.")).toBeTruthy(); + + first.grid.remove(); + second.grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx new file mode 100644 index 000000000..027e5ae9f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx @@ -0,0 +1,179 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatHookPlanTime, resolveFirstHookPlan } from "./firstHookPlan"; + +/** Props for the first hook-plan rehearsal callout. */ +export interface FirstHookPlanCalloutProps { + song: RehearsalSong; +} + +type HookPlanCopyValues = Readonly>; + +type OpenedHookPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + hookPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableHookPlanSongIdentity(song: RehearsalSong): unknown { + if (song === null || typeof song !== "object" || Array.isArray(song)) { + return song; + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(song, "id"); + } catch { + return song; + } + return descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ? descriptor.value + : song; +} + +/** Interpolate hook-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatHookPlanCopy(template: string, values: HookPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof HookPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredHookPlanScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ +function resolveHookPlanRenderer(origin: HTMLElement): HTMLElement | null { + const selector = '[data-testid="song-structure-grid"]'; + const localScope = origin.closest("aside")?.parentElement ?? null; + const localRenderers = localScope?.querySelectorAll(selector) ?? []; + if (localRenderers.length === 1) { + return localRenderers[0] ?? null; + } + if (localRenderers.length > 1) { + return null; + } + + const globalRenderers = document.querySelectorAll(selector); + return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; +} + +/** Name tonight's first hook plan and open the matching rendered map section. */ +export function FirstHookPlanCallout({ song }: FirstHookPlanCalloutProps) { + const calloutId = `workspace-surface-hook-plan-${useId()}`; + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableHookPlanSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const named = useMemo(() => resolveFirstHookPlan(song), [song]); + const namedSectionIndex = + named && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(named.section) + : -1; + const [openedHookPlan, setOpenedHookPlan] = useState(null); + + useEffect(() => { + setOpenedHookPlan(null); + }, [ + songIdentity, + namedSectionIndex, + named?.section.id, + named?.holdingRole.id, + named?.hookPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedHookPlan !== null && + openedHookPlan.songIdentity === songIdentity && + openedHookPlan.sectionId === named.section.id && + openedHookPlan.sectionIndex === namedSectionIndex && + openedHookPlan.holdingRoleId === named.holdingRole.id && + openedHookPlan.hookPlan === named.hookPlan && + openedHookPlan.atSeconds === named.atSeconds; + const at = formatHookPlanTime(named.atSeconds); + const copyValues: HookPlanCopyValues = { + role: named.holdingRole.name, + section: translateSectionFormLabel(locale, named.section.label), + at + }; + const actionLabel = formatHookPlanCopy(t("firstHookPlanOpenAction"), copyValues); + const body = formatHookPlanCopy(t("firstHookPlanBody"), copyValues); + const armed = formatHookPlanCopy(t("firstHookPlanArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..ad9f77cdd --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +function songWithoutHookPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.hookPlan = ""; + } + } + return song; +} + +describe("FirstHookPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English hook plan is unavailable", () => { + render(); + + expect(screen.getByText("No hook plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean hook plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 훅 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..f1ab439b5 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstHookPlanCallout } from "./FirstHookPlanCallout"; + +describe("FirstHookPlanCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Lead Vocal hook at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..389d8af71 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first hook plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Lead Vocal hook at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that hook on Lead Vocal at 0:10 before the room starts./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..fc55e6490 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 { FirstHookPlanCallout } from "./FirstHookPlanCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ 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)}

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts new file mode 100644 index 000000000..3c244565b --- /dev/null +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the first hook-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstHookPlan.ts", + "src/features/workspace/FirstHookPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstHookPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstHookPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..a73c09ff0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstHookPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstHookPlan } from "./firstHookPlan"; + +function songWithHookPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "hook-own"; + section.roles = [ + { + ...section.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high", + hookPlan: "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony." + } + ]; + section.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstHookPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithHookPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstHookPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithHookPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithHookPlan(); + Object.defineProperty(section.roles[0]!, "hookPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile hookPlan getter"); + } + }); + + expect(() => resolveFirstHookPlan(song)).not.toThrow(); + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable hook-plan identity authority", () => { + const { song, section } = songWithHookPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "hook-own"; + } + }); + + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("does not let inherited hook plans establish the named copy", () => { + const { song, section } = songWithHookPlan(); + const inheritedRole = Object.create({ + hookPlan: "Inherited hook plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "lead-vocal" }, + name: { configurable: true, enumerable: true, value: "Lead Vocal" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithHookPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithHookPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts new file mode 100644 index 000000000..2c9aa2076 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts @@ -0,0 +1,31 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstHookPlan } from "./firstHookPlan"; + +const DEMO_HOOK_PLAN = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + +describe("resolveFirstHookPlan own-data authority", () => { + it("uses the snapshotted own-data hook plan instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "lead-vocal") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo hook-plan fixture is missing the expected Lead Vocal role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "hookPlan") { + return "Injected proxy hook."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstHookPlan(song)?.hookPlan).toBe(DEMO_HOOK_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstHookPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstHookPlan.section-label.test.ts new file mode 100644 index 000000000..edb466bfc --- /dev/null +++ b/apps/desktop/src/features/workspace/firstHookPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstHookPlan } from "./firstHookPlan"; + +describe("resolveFirstHookPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstHookPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstHookPlan.test.ts b/apps/desktop/src/features/workspace/firstHookPlan.test.ts new file mode 100644 index 000000000..5ac1d54b7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstHookPlan.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatHookPlanTime, resolveFirstHookPlan } from "./firstHookPlan"; + +const DEMO_HOOK_PLAN = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + +function withHookSection( + overrides: { + id?: string; + start?: number; + end?: number; + hookPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + functionLabel?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-hook"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "lead-vocal"; + section.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "medium", + cue: { kind: "lyric", value: "city lights" }, + range: { lowestNote: "G#3", highestNote: "C#5" }, + setupNote: "Watch the breath before the last line of the verse.", + simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", + overlapWarnings: ["Melodic overlap: competing with Keyboard 1 Right Hand."], + harmony: { + chord: "C#m7", + functionLabel: overrides.functionLabel ?? "vi melodic pull", + source: "model" + }, + harmonicExplanation: + "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.", + confidence: { + level: "high", + source: "user", + notes: "Singer confirmed the pickup phrasing in rehearsal notes." + }, + hookPlan: + overrides.hookPlan ?? + DEMO_HOOK_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstHookPlan", () => { + it("picks the demo song's earliest hook plan and the part that owns it", () => { + const resolved = resolveFirstHookPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.hookPlan).toBe(DEMO_HOOK_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatHookPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatHookPlanTime(Number.NaN)).toBe("0:00"); + expect(formatHookPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a hook plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withHookSection(); + delete song.sections[0]!.roles[0]!.hookPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = DEMO_HOOK_PLAN; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[0]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the cutoff."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: DEMO_HOOK_PLAN + }; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("skips a blank hook plan", () => { + expect(resolveFirstHookPlan(withHookSection({ hookPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line hook plan", () => { + expect( + resolveFirstHookPlan(withHookSection({ hookPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two hook plans", () => { + const song = withHookSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + hookPlan: "Late hook." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + hookPlan: "Earlier hook." + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstHookPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.hookPlan).toBe("Earlier hook."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time hook-plan ties with locale-independent id ordering", () => { + const song = withHookSection({ id: "ä-hook", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-hook"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstHookPlan(song)?.section.id).toBe("z-hook"); + }); + + it("prefers a high-priority hook part over a low-priority part in the same section", () => { + const song = withHookSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + hookPlan: "Low-priority hook." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + hookPlan: "High-priority hook." + }; + section.roles = [section.roles[0]!, highRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstHookPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstHookPlan(song)?.hookPlan).toBe("High-priority hook."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withHookSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + hookPlan: "ASCII hook." + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstHookPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstHookPlan(song)?.hookPlan).toBe("ASCII hook."); + }); + + it("skips a hook plan whose graph node is inactive", () => { + expect(resolveFirstHookPlan(withHookSection({ isActive: false }))).toBeNull(); + }); + + it("skips a hook plan whose rehearsal window is unbounded", () => { + expect(resolveFirstHookPlan(withHookSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a hook plan whose end precedes its start", () => { + expect(resolveFirstHookPlan(withHookSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length hook-plan window", () => { + expect(resolveFirstHookPlan(withHookSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a hook plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstHookPlan( + withHookSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstHookPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withHookSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("keeps the hook plan unnamed when role identities are duplicated", () => { + const song = withHookSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + expect(resolveFirstHookPlan(song)).toBeNull(); + }); + + it("bounds the hook plan to 180 Unicode code points", () => { + const song = withHookSection({ hookPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstHookPlan(song); + expect(resolved?.hookPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the hook-plan boundary", () => { + const song = withHookSection({ hookPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstHookPlan(song); + expect(Array.from(resolved?.hookPlan ?? "")).toHaveLength(180); + expect(resolved?.hookPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstHookPlan.ts b/apps/desktop/src/features/workspace/firstHookPlan.ts new file mode 100644 index 000000000..77b2dc1a3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstHookPlan.ts @@ -0,0 +1,287 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_HOOK_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); + +/** Tonight's first hook plan: the earliest labeled section and the part that owns it. */ +export type FirstHookPlan = { + section: RehearsalSection; + holdingRole: RehearsalRole; + hookPlan: string; + atSeconds: number; +}; + +/** Format a non-negative hook-plan time as m:ss for rehearsal copy. */ +export function formatHookPlanTime(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}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element 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 (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded snapshotted own hook plan, or null when it cannot be shown. */ +function ownedHookPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(role, "hookPlan"); + if (descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value")) { + return null; + } + const hookPlan = descriptor.value; + if (typeof hookPlan !== "string") { + return null; + } + const trimmed = hookPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_HOOK_PLAN_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a canonical form label from the shared contract. */ +function hasSupportedSectionLabel(section: RehearsalSection): boolean { + return ( + hasOwnData(section, "label") && + typeof section.label === "string" && + SECTION_FORM_LABEL_SET.has(section.label) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Resolve a hook plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstHookPlan(song: RehearsalSong): FirstHookPlan | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasSupportedSectionLabel(section) && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .flatMap((section) => { + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedHookPlan(role) !== null) + ); + if (!holdingRole) { + return []; + } + const hookPlan = ownedHookPlan(holdingRole); + if (!hookPlan) { + return []; + } + return [ + { + section, + holdingRole, + hookPlan, + atSeconds: section.timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.section.id, right.section.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first named hook plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstHookPlan(song: RehearsalSong): FirstHookPlan | null { + try { + return resolveSafeFirstHookPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..d3a112b53 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,52 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-hook-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstHookPlanOpenAction")).toBe("{at} {role} 훅 열기"); + expect(t("firstHookPlanBody")).toBe("{at} {section}에서 {role} 파트의 훅 계획이 있습니다."); + expect(t("firstHookPlanArmed")).toBe("{at}에서 {role} 파트의 훅을 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f5656ce01 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,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +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: "핸드오프" + } +}; + +/** Create a translator for the requested locale, falling back to the English dictionary for missing entries. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..e143ac918 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", + "firstHookPlanLabel": "Tonight's first hook plan", + "firstHookPlanOpenAction": "Open {role} hook at {at}", + "firstHookPlanBody": "{role} still has a hook plan in the {section} at {at}.", + "firstHookPlanArmed": "Lock that hook on {role} at {at} before the room starts.", + "firstHookPlanUnavailable": "No hook plan is available. Stay on tonight's map for the next rehearsal cue." } + diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..0c0cb8a89 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": "진척도 증가", + "firstHookPlanLabel": "오늘 첫 훅 계획", + "firstHookPlanOpenAction": "{at} {role} 훅 열기", + "firstHookPlanBody": "{at} {section}에서 {role} 파트의 훅 계획이 있습니다.", + "firstHookPlanArmed": "{at}에서 {role} 파트의 훅을 맞춘 다음 합주를 시작하세요.", + "firstHookPlanUnavailable": "사용 가능한 훅 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." } + diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..cfde54a57 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,18 @@ import { fileURLToPath } from "node:url"; const configDirectory = path.dirname(fileURLToPath(import.meta.url)); +/** Production files whose V8 coverage is owned by the desktop test gate. */ +export const DESKTOP_OWNED_PRODUCTION_COVERAGE = [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstHookPlan.ts", + "src/features/workspace/FirstHookPlanCallout.tsx" +]; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +31,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..a68c0d75d 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | 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. | +| First Hook Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `hookPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. | | 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. | | 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. | diff --git a/docs/doctoring/reduced-motion-first-hook-plan-navigation.md b/docs/doctoring/reduced-motion-first-hook-plan-navigation.md new file mode 100644 index 000000000..4a0dc45ab --- /dev/null +++ b/docs/doctoring/reduced-motion-first-hook-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first hook-plan navigation + +Open tonight's first hook plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..ef2f514c6 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + hookPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -537,6 +538,7 @@ const demoRehearsalSongSeed: RehearsalSong = { rehearsalPriority: "medium", simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.", setupNote: "Watch the breath before the last line of the verse.", + hookPlan: "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony.", transpositionPlan: "If the room wants more ease, move the section down a whole step and keep the pickup breath mark in the same place.", manualOverrides: [ { @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "hookPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1555,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.hookPlan !== undefined && typeof value.hookPlan !== "string") { + return invalidField(`${path}.hookPlan`); + } if (!isDenseArray(value.manualOverrides)) { return invalidField(`${path}.manualOverrides`); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..5dd456413 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -738,6 +738,7 @@ describe("shared type helpers", () => { expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); + expect(song.sections[0]?.roles[2]?.hookPlan).toContain("chorus hook"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -1257,6 +1258,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[2].hookPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[2]!.hookPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From f899f46018dd137ba67109360767c154d8a9431e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:18:06 -0700 Subject: [PATCH 2/7] docs(shared-types): document hook plan ownership --- packages/shared-types/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index ef2f514c6..554e176f2 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + /** Rehearsal-facing hook guidance owned by this role when runtime graph evidence corroborates it. */ hookPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; @@ -1689,7 +1690,6 @@ function validateRehearsalSection(value: unknown, path: string): string | null { return roleError; } } - if (!isDenseArray(value.partGraph)) { return invalidField(`${path}.partGraph`); } From 07aac4701d30a03dbf319361b1e64b72afd46416 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:45:54 -0700 Subject: [PATCH 3/7] test(workspace): pin hook-plan runtime authority --- .../firstHookPlan.proxy-authority.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts index 2c9aa2076..0a693a5ba 100644 --- a/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts +++ b/apps/desktop/src/features/workspace/firstHookPlan.proxy-authority.test.ts @@ -28,4 +28,56 @@ describe("resolveFirstHookPlan own-data authority", () => { expect(resolveFirstHookPlan(song)?.hookPlan).toBe(DEMO_HOOK_PLAN); }); + + it("uses the snapshotted own-data time range instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + expect(section).toBeDefined(); + if (!section) { + throw new Error("Demo hook-plan fixture is missing the expected verse section."); + } + const expectedStart = section.timeRange.start; + section.timeRange = new Proxy(section.timeRange, { + get(target, property, receiver) { + if (property === "start") { + return expectedStart + 15; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstHookPlan(song)?.atSeconds).toBe(expectedStart); + }); + + it("returns snapshotted role identity and display copy instead of Proxy get values", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "lead-vocal") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo hook-plan fixture is missing the expected Lead Vocal role."); + } + const expectedId = role.id; + const expectedName = role.name; + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "name") { + return "Injected proxy role"; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstHookPlan(song) as + | (ReturnType & { + holdingRoleId?: string; + holdingRoleName?: string; + }) + | null; + expect(resolved?.hookPlan).toBe(DEMO_HOOK_PLAN); + expect(resolved?.holdingRoleId).toBe(expectedId); + expect(resolved?.holdingRoleName).toBe(expectedName); + }); }); From 975f56d5982f7acfb7113a292485cd5189ea9f25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:46:30 -0700 Subject: [PATCH 4/7] fix(workspace): snapshot hook-plan runtime authority --- .../src/features/workspace/firstHookPlan.ts | 273 ++++++++++-------- 1 file changed, 157 insertions(+), 116 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstHookPlan.ts b/apps/desktop/src/features/workspace/firstHookPlan.ts index 77b2dc1a3..8198619d8 100644 --- a/apps/desktop/src/features/workspace/firstHookPlan.ts +++ b/apps/desktop/src/features/workspace/firstHookPlan.ts @@ -10,10 +10,22 @@ const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_HOOK_PLAN_CHARACTERS = 180; const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + /** Tonight's first hook plan: the earliest labeled section and the part that owns it. */ export type FirstHookPlan = { section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; hookPlan: string; atSeconds: number; }; @@ -50,21 +62,36 @@ function hasOwnData(value: object, key: PropertyKey): boolean { return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); } -/** Return whether every numeric index is an own data element in a bounded runtime array. */ -function isDenseRuntimeArray(value: unknown): value is unknown[] { +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { if (!Array.isArray(value)) { - return false; + return null; } - const length = Number(value.length); - if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { - return false; + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; } + const items: unknown[] = []; for (let index = 0; index < length; index += 1) { if (!hasOwnData(value, index)) { - return false; + return null; } + items.push(ownDataValue(value, index)); } - return true; + return items; } /** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ @@ -86,11 +113,7 @@ function ownedHookPlan(role: unknown): string | null { if (!isRuntimeObject(role)) { return null; } - const descriptor = Object.getOwnPropertyDescriptor(role, "hookPlan"); - if (descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value")) { - return null; - } - const hookPlan = descriptor.value; + const hookPlan = ownDataValue(role, "hookPlan"); if (typeof hookPlan !== "string") { return null; } @@ -101,49 +124,55 @@ function ownedHookPlan(role: unknown): string | null { return truncateCodePoints(trimmed, MAX_HOOK_PLAN_CHARACTERS); } -/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ -function hasRankedPriority(role: RehearsalRole): boolean { - return ( - hasOwnData(role, "id") && - typeof role.id === "string" && - role.id.trim().length > 0 && - hasOwnData(role, "name") && - typeof role.name === "string" && - role.name.trim().length > 0 && - hasOwnData(role, "rehearsalPriority") && - Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) - ); -} - -/** Return whether a section owns a canonical form label from the shared contract. */ -function hasSupportedSectionLabel(section: RehearsalSection): boolean { - return ( - hasOwnData(section, "label") && - typeof section.label === "string" && - SECTION_FORM_LABEL_SET.has(section.label) - ); +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; } -/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ -function hasBoundedTimeRange(section: RehearsalSection): boolean { - if (!hasOwnData(section, "timeRange")) { - return false; +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; } - const timeRange = section.timeRange as Partial | null; - if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { - return false; + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; } - - const start = timeRange.start ?? -1; - const end = timeRange.end ?? -1; - return ( - Number.isInteger(start) && - start >= 0 && - start <= MAX_SECTION_TIME_SECONDS && - Number.isInteger(end) && - end > start && - end <= MAX_SECTION_TIME_SECONDS - ); + return { start, end }; } /** Return safe identities that appear more than once in one section-local collection. */ @@ -161,13 +190,14 @@ function repeatedIds(ids: string[]): Set { } /** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */ -function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { +function pickHoldingRole(roles: RankedRoleMetadata[]): RankedRoleMetadata | null { if (roles.length === 0) { return null; } return ( [...roles].sort((left, right) => { - const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; if (priorityDelta !== 0) { return priorityDelta; } @@ -177,93 +207,104 @@ function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { } /** Return ranked roles whose unique graph node is explicitly active. */ -function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { - if ( - !hasOwnData(section, "roles") || - !hasOwnData(section, "partGraph") || - !isDenseRuntimeArray(section.roles) || - !isDenseRuntimeArray(section.partGraph) - ) { +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { return []; } - const safeRoleIds = section.roles - .filter( - (role) => - isRuntimeObject(role) && - hasOwnData(role, "id") && - typeof role.id === "string" && - role.id.trim().length > 0 - ) - .map((role) => role.id); - const safeGraphRoleIds = section.partGraph - .filter( - (node) => - isRuntimeObject(node) && - hasOwnData(node, "role_id") && - typeof node.role_id === "string" && - node.role_id.trim().length > 0 - ) - .map((node) => node.role_id); + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); const repeatedRoleIds = repeatedIds(safeRoleIds); const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); const activeIds = new Set( - section.partGraph - .filter( - (node) => - isRuntimeObject(node) && - hasOwnData(node, "is_active") && - node.is_active === true && - hasOwnData(node, "role_id") && - typeof node.role_id === "string" && - node.role_id.trim().length > 0 && - !repeatedGraphRoleIds.has(node.role_id) - ) - .map((node) => node.role_id) + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) ); - return section.roles.filter( - (role) => - isRuntimeObject(role) && - hasRankedPriority(role) && - !repeatedRoleIds.has(role.id) && - activeIds.has(role.id) - ); + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); } /** Resolve a hook plan after the runtime root has passed its structural boundary checks. */ function resolveSafeFirstHookPlan(song: RehearsalSong): FirstHookPlan | null { - if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { return null; } - const candidates = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasSupportedSectionLabel(section) && - hasOwnData(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) - ) - .flatMap((section) => { + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + const holdingRole = pickHoldingRole( - rankedActiveRoles(section).filter((role) => ownedHookPlan(role) !== null) + rankedActiveRoles(section as RehearsalSection).filter( + (metadata) => ownedHookPlan(metadata.role) !== null + ) ); if (!holdingRole) { return []; } - const hookPlan = ownedHookPlan(holdingRole); + const hookPlan = ownedHookPlan(holdingRole.role); if (!hookPlan) { return []; } return [ { - section, - holdingRole, + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, hookPlan, - atSeconds: section.timeRange.start + atSeconds: timeRange.start } ]; }) @@ -271,7 +312,7 @@ function resolveSafeFirstHookPlan(song: RehearsalSong): FirstHookPlan | null { if (left.atSeconds !== right.atSeconds) { return left.atSeconds - right.atSeconds; } - return compareStableId(left.section.id, right.section.id); + return compareStableId(left.sectionId, right.sectionId); }); return candidates[0] ?? null; From dcbbd4c0ec9388b98df70fd8baa7070b7714f49b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:46:56 -0700 Subject: [PATCH 5/7] fix(workspace): consume hook-plan authority snapshots --- .../workspace/FirstHookPlanCallout.tsx | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx index 027e5ae9f..59b79ec9c 100644 --- a/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstHookPlanCallout.tsx @@ -81,21 +81,16 @@ export function FirstHookPlanCallout({ song }: FirstHookPlanCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); const songIdentity = stableHookPlanSongIdentity(song); - const runtimeSong = song as unknown as Partial | null; const named = useMemo(() => resolveFirstHookPlan(song), [song]); - const namedSectionIndex = - named && Array.isArray(runtimeSong?.sections) - ? runtimeSong.sections.indexOf(named.section) - : -1; const [openedHookPlan, setOpenedHookPlan] = useState(null); useEffect(() => { setOpenedHookPlan(null); }, [ songIdentity, - namedSectionIndex, - named?.section.id, - named?.holdingRole.id, + named?.sectionIndex, + named?.sectionId, + named?.holdingRoleId, named?.hookPlan, named?.atSeconds ]); @@ -118,15 +113,15 @@ export function FirstHookPlanCallout({ song }: FirstHookPlanCalloutProps) { const opened = openedHookPlan !== null && openedHookPlan.songIdentity === songIdentity && - openedHookPlan.sectionId === named.section.id && - openedHookPlan.sectionIndex === namedSectionIndex && - openedHookPlan.holdingRoleId === named.holdingRole.id && + openedHookPlan.sectionId === named.sectionId && + openedHookPlan.sectionIndex === named.sectionIndex && + openedHookPlan.holdingRoleId === named.holdingRoleId && openedHookPlan.hookPlan === named.hookPlan && openedHookPlan.atSeconds === named.atSeconds; const at = formatHookPlanTime(named.atSeconds); const copyValues: HookPlanCopyValues = { - role: named.holdingRole.name, - section: translateSectionFormLabel(locale, named.section.label), + role: named.holdingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), at }; const actionLabel = formatHookPlanCopy(t("firstHookPlanOpenAction"), copyValues); @@ -150,9 +145,9 @@ export function FirstHookPlanCallout({ song }: FirstHookPlanCalloutProps) { onClick={(event) => { const renderer = resolveHookPlanRenderer(event.currentTarget); const target = - namedSectionIndex >= 0 + named.sectionIndex >= 0 ? (renderer?.querySelector( - `[data-section-index="${namedSectionIndex}"]` + `[data-section-index="${named.sectionIndex}"]` ) ?? null) : null; if (typeof target?.scrollIntoView !== "function") { @@ -164,9 +159,9 @@ export function FirstHookPlanCallout({ song }: FirstHookPlanCalloutProps) { }); setOpenedHookPlan({ songIdentity, - sectionId: named.section.id, - sectionIndex: namedSectionIndex, - holdingRoleId: named.holdingRole.id, + sectionId: named.sectionId, + sectionIndex: named.sectionIndex, + holdingRoleId: named.holdingRoleId, hookPlan: named.hookPlan, atSeconds: named.atSeconds }); From fd86742298b8b517213882c0e3d4088eb69993d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:47:17 -0700 Subject: [PATCH 6/7] docs(changelog): record hook-plan authority snapshots --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87208e2fe..ccbdac0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first hook plan in the mounted rehearsal workspace so a part can lock the owned hook before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. +- Name tonight's first hook plan in the mounted rehearsal workspace so a part can lock the owned hook before rehearsal; the Open action moves to the matching rendered map section, while inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -50,8 +50,7 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Implemented secure local audio intake and project bootstrap -- Issue #35: Engineered section, form, and cue anchor extraction pipeline +- Issue #33: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI From c27d583c1847554053915b28aed7c1ceaffe252a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:03:32 -0700 Subject: [PATCH 7/7] fix(changelog): restore 0.1.0 issue history --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbdac0a3..e43d198ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,8 @@ - Issue #38: Added cross-architecture build support (Windows/macOS arm64+amd64) - Issue #40: Enforced 100% Python docstring and test coverage - Issue #32: Implemented local analysis orchestration and secure IPC boundaries -- Issue #33: Engineered section, form, and cue anchor extraction pipeline +- Issue #33: Implemented secure local audio intake and project bootstrap +- Issue #35: Engineered section, form, and cue anchor extraction pipeline - Issue #34: Implemented role extraction targets and part graph - Issue #31: Added role-specific harmony, range, overlap, and confidence metrics - Issue #28: Delivered practical rehearsal workspace UI