From e124451fb5b3aadaed6e30c1e87b9169b25557b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 05:16:31 +0000 Subject: [PATCH 01/30] feat(workspace): name tonight's first vamp plan on the map Name the owning part, owned vampPlan copy, labeled section, and time so the next rehearsal action is Open Bass Guitar vamp at 0:10. Do not invent that copy from groove, cue, pad, solo, or other neighboring metadata. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstVampPlanCallout.identity.test.tsx | 21 ++ .../FirstVampPlanCallout.memoization.test.tsx | 25 ++ .../FirstVampPlanCallout.particle.test.tsx | 52 +++ ...rstVampPlanCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstVampPlanCallout.test.tsx | 202 +++++++++++ .../workspace/FirstVampPlanCallout.tsx | 172 +++++++++ ...tVampPlanCallout.unavailable-copy.test.tsx | 34 ++ ...stVampPlanCallout.workspace-scope.test.tsx | 54 +++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../workspace/coverageContract.test.ts | 13 + .../firstVampPlan.inherited-metadata.test.ts | 94 +++++ .../firstVampPlan.proxy-authority.test.ts | 83 +++++ .../firstVampPlan.section-label.test.ts | 14 + .../features/workspace/firstVampPlan.test.ts | 288 +++++++++++++++ .../src/features/workspace/firstVampPlan.ts | 328 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 50 ++- apps/desktop/src/i18n/index.ts | 36 +- apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- apps/desktop/vite.config.ts | 21 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-vamp-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 7 + packages/shared-types/test/index.test.ts | 7 + 29 files changed, 1594 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.memoization.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstVampPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/coverageContract.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVampPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVampPlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVampPlan.section-label.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVampPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstVampPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-vamp-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..7ade5c862 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 vamp plan with the owning part when an active role is corroborated, the owned `vampPlan` 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, hook plans, solo plans, pad 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..48fa60849 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-25 ## 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 vamp plan must name the owning part when corroborated, the owned `vampPlan` 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, hook plans, solo plans, pad plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-pad-plan, first-solo-plan, first-hook-plan, 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..d6a8cef80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first vamp plan in the mounted rehearsal workspace so a part can hold the groove until the next entrance; 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..80f33fb61 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 vamp 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, hook plans, solo plans, pad plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-pad-plan, first-solo-plan, first-hook-plan, 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/FirstVampPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.identity.test.tsx new file mode 100644 index 000000000..48bae40a2 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +it("gives co-mounted vamp-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first vamp 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/FirstVampPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..e29099c51 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +describe("FirstVampPlanCallout 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/FirstVampPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx new file mode 100644 index 000000000..8196540c6 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +describe("FirstVampPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the vamp 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", + vampPlan: "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights." + } + ]; + 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/FirstVampPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..2d035da98 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +describe("FirstVampPlanCallout 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 Bass Guitar vamp at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.test.tsx new file mode 100644 index 000000000..d6a797e97 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +const DEMO_VAMP_PLAN = + "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights."; + +function songWithVampPlan() { + 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("FirstVampPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No vamp 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 = songWithVampPlan(); + 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 Bass Guitar vamp at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same vamp signature", () => { + const firstSong = songWithVampPlan(); + const nextSong = songWithVampPlan(); + 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 Bass Guitar vamp at 0:10" })); + expect( + screen.getByText(/Lock that vamp on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar still has a vamp plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that vamp on Bass Guitar at 0:10 before the room starts./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithVampPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar vamp at 0:10" })); + expect( + screen.getByText(/Lock that vamp on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that vamp on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Bass Guitar still has a vamp plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's vamp plan under the named holding part", () => { + const song = songWithVampPlan(); + song.sections[0]!.roles[0]!.vampPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.vampPlan = "Leave the vocal on the last lyric while the vamp holds."; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.vampPlan = + "Keep the right-hand figure under the vocal so the vamp still reads."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has a vamp plan in the verse at 0:10.") + ).toBeTruthy(); + expect( + screen.getByText("Keep the right-hand figure under the vocal so the vamp still reads.") + ).toBeTruthy(); + expect(screen.queryByText("Leave the vocal on the last lyric while the vamp holds.")).toBeNull(); + expect(screen.queryByText(DEMO_VAMP_PLAN)).toBeNull(); + }); + + it("names the first vamp plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_VAMP_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar vamp at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that vamp on Bass Guitar 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 Bass Guitar vamp at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that vamp on Bass Guitar 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 Bass Guitar vamp at 0:10" })); + + expect(screen.getByText("Bass Guitar still has a vamp plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that vamp on Bass Guitar at 0:10 before the room starts./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithVampPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar vamp 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 Bass Guitar vamp at 0:10" })); + + expect(first.scrollIntoView).not.toHaveBeenCalled(); + expect(second.scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Bass Guitar still has a vamp plan in the verse at 0:10.")).toBeTruthy(); + + first.grid.remove(); + second.grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx new file mode 100644 index 000000000..9844d88d0 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx @@ -0,0 +1,172 @@ +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 { formatVampPlanTime, resolveFirstVampPlan } from "./firstVampPlan"; + +/** Props for the first vamp-plan rehearsal callout. */ +export interface FirstVampPlanCalloutProps { + song: RehearsalSong; +} + +type VampPlanCopyValues = Readonly>; + +type OpenedVampPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + vampPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableVampPlanSongIdentity(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 vamp-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatVampPlanCopy(template: string, values: VampPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof VampPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredVampPlanScrollBehavior(): 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 resolveVampPlanRenderer(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 vamp plan and open the matching rendered map section. */ +export function FirstVampPlanCallout({ song }: FirstVampPlanCalloutProps) { + const calloutId = `workspace-surface-vamp-plan-${useId()}`; + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableVampPlanSongIdentity(song); + const named = useMemo(() => resolveFirstVampPlan(song), [song]); + const [openedVampPlan, setOpenedVampPlan] = useState(null); + + useEffect(() => { + setOpenedVampPlan(null); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.holdingRoleId, + named?.vampPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedVampPlan !== null && + openedVampPlan.songIdentity === songIdentity && + openedVampPlan.sectionId === named.sectionId && + openedVampPlan.sectionIndex === named.sectionIndex && + openedVampPlan.holdingRoleId === named.holdingRoleId && + openedVampPlan.vampPlan === named.vampPlan && + openedVampPlan.atSeconds === named.atSeconds; + const at = formatVampPlanTime(named.atSeconds); + const copyValues: VampPlanCopyValues = { + role: named.holdingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatVampPlanCopy(t("firstVampPlanOpenAction"), copyValues); + const body = formatVampPlanCopy(t("firstVampPlanBody"), copyValues); + const armed = formatVampPlanCopy(t("firstVampPlanArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..449b8812d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +function songWithoutVampPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.vampPlan = ""; + } + } + return song; +} + +describe("FirstVampPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English vamp plan is unavailable", () => { + render(); + + expect(screen.getByText("No vamp plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean vamp plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 뱀프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..541ab6351 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; + +describe("FirstVampPlanCallout 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 Bass Guitar vamp 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..d121f8dbe 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 vamp 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( + "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar vamp at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that vamp on Bass Guitar 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..e6131f95c 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 { FirstVampPlanCallout } from "./FirstVampPlanCallout"; 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..92ad20ea7 --- /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 vamp-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstVampPlan.ts", + "src/features/workspace/FirstVampPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVampPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstVampPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..5666cc198 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVampPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVampPlan } from "./firstVampPlan"; + +function songWithVampPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "vamp-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + vampPlan: "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstVampPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithVampPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstVampPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithVampPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithVampPlan(); + Object.defineProperty(section.roles[0]!, "vampPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile vampPlan getter"); + } + }); + + expect(() => resolveFirstVampPlan(song)).not.toThrow(); + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable vamp-plan identity authority", () => { + const { song, section } = songWithVampPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "vamp-own"; + } + }); + + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("does not let inherited vamp plans establish the named copy", () => { + const { song, section } = songWithVampPlan(); + const inheritedRole = Object.create({ + vampPlan: "Inherited vamp 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(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithVampPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithVampPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVampPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstVampPlan.proxy-authority.test.ts new file mode 100644 index 000000000..e343417ae --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVampPlan.proxy-authority.test.ts @@ -0,0 +1,83 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVampPlan } from "./firstVampPlan"; + +const DEMO_VAMP_PLAN = + "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights."; + +describe("resolveFirstVampPlan own-data authority", () => { + it("uses the snapshotted own-data vamp 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 === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo vamp-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "vampPlan") { + return "Injected proxy vamp."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstVampPlan(song)?.vampPlan).toBe(DEMO_VAMP_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 vamp-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(resolveFirstVampPlan(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 === "bass-guitar") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo vamp-plan fixture is missing the expected Bass Guitar 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 = resolveFirstVampPlan(song) as + | (ReturnType & { + holdingRoleId?: string; + holdingRoleName?: string; + }) + | null; + expect(resolved?.vampPlan).toBe(DEMO_VAMP_PLAN); + expect(resolved?.holdingRoleId).toBe(expectedId); + expect(resolved?.holdingRoleName).toBe(expectedName); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVampPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstVampPlan.section-label.test.ts new file mode 100644 index 000000000..d8e085cef --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVampPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVampPlan } from "./firstVampPlan"; + +describe("resolveFirstVampPlan 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(resolveFirstVampPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVampPlan.test.ts b/apps/desktop/src/features/workspace/firstVampPlan.test.ts new file mode 100644 index 000000000..49bfa6b58 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVampPlan.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatVampPlanTime, resolveFirstVampPlan } from "./firstVampPlan"; + +const DEMO_VAMP_PLAN = + "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights."; + +function withVampSection( + overrides: { + id?: string; + start?: number; + end?: number; + vampPlan?: 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-vamp"; + 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." + }, + vampPlan: + overrides.vampPlan ?? + DEMO_VAMP_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstVampPlan", () => { + it("picks the demo song's earliest vamp plan and the part that owns it", () => { + const resolved = resolveFirstVampPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.vampPlan).toBe(DEMO_VAMP_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatVampPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatVampPlanTime(Number.NaN)).toBe("0:00"); + expect(formatVampPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a vamp plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, solo plans, pad plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withVampSection(); + delete song.sections[0]!.roles[0]!.vampPlan; + 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_VAMP_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] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + (song.sections[0]!.roles[0] as { soloPlan?: string }).soloPlan = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + (song.sections[0]!.roles[0] as { padPlan?: string }).padPlan = + "Drop to a two-bar pad so the Keyboard 1 Right Hand run can land."; + 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_VAMP_PLAN + }; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("skips a blank vamp plan", () => { + expect(resolveFirstVampPlan(withVampSection({ vampPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line vamp plan", () => { + expect( + resolveFirstVampPlan(withVampSection({ vampPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two vamp plans", () => { + const song = withVampSection({ + id: "verse-late-vamp", + start: 40, + end: 56, + roleId: "keys-right", + vampPlan: "Late vamp." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + vampPlan: "Earlier vamp." + } + ]; + 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 = resolveFirstVampPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.vampPlan).toBe("Earlier vamp."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time vamp-plan ties with locale-independent id ordering", () => { + const song = withVampSection({ id: "ä-vamp", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-vamp"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstVampPlan(song)?.section.id).toBe("z-vamp"); + }); + + it("prefers a high-priority vamp part over a low-priority part in the same section", () => { + const song = withVampSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + vampPlan: "Low-priority vamp." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + vampPlan: "High-priority vamp." + }; + 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(resolveFirstVampPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstVampPlan(song)?.vampPlan).toBe("High-priority vamp."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withVampSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + vampPlan: "ASCII vamp." + }; + 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(resolveFirstVampPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstVampPlan(song)?.vampPlan).toBe("ASCII vamp."); + }); + + it("skips a vamp plan whose graph node is inactive", () => { + expect(resolveFirstVampPlan(withVampSection({ isActive: false }))).toBeNull(); + }); + + it("skips a vamp plan whose rehearsal window is unbounded", () => { + expect(resolveFirstVampPlan(withVampSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a vamp plan whose end precedes its start", () => { + expect(resolveFirstVampPlan(withVampSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length vamp-plan window", () => { + expect(resolveFirstVampPlan(withVampSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a vamp plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstVampPlan( + withVampSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstVampPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withVampSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("keeps the vamp plan unnamed when role identities are duplicated", () => { + const song = withVampSection(); + 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(resolveFirstVampPlan(song)).toBeNull(); + }); + + it("bounds the vamp plan to 180 Unicode code points", () => { + const song = withVampSection({ vampPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstVampPlan(song); + expect(resolved?.vampPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the vamp-plan boundary", () => { + const song = withVampSection({ vampPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstVampPlan(song); + expect(Array.from(resolved?.vampPlan ?? "")).toHaveLength(180); + expect(resolved?.vampPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVampPlan.ts b/apps/desktop/src/features/workspace/firstVampPlan.ts new file mode 100644 index 000000000..2995681e4 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVampPlan.ts @@ -0,0 +1,328 @@ +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_VAMP_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 vamp plan: the earliest labeled section and the part that owns it. */ +export type FirstVampPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; + vampPlan: string; + atSeconds: number; +}; + +/** Format a non-negative vamp-plan time as m:ss for rehearsal copy. */ +export function formatVampPlanTime(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"); +} + +/** 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 null; + } + 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 null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** 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 vamp plan, or null when it cannot be shown. */ +function ownedVampPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const vampPlan = ownDataValue(role, "vampPlan"); + if (typeof vampPlan !== "string") { + return null; + } + const trimmed = vampPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_VAMP_PLAN_CHARACTERS); +} + +/** 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 + }; +} + +/** 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 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; + } + return { start, end }; +} + +/** 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: RankedRoleMetadata[]): RankedRoleMetadata | 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): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { + return []; + } + + 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( + 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 roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Resolve a vamp plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstVampPlan(song: RehearsalSong): FirstVampPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + 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 as RehearsalSection).filter( + (metadata) => ownedVampPlan(metadata.role) !== null + ) + ); + if (!holdingRole) { + return []; + } + const vampPlan = ownedVampPlan(holdingRole.role); + if (!vampPlan) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, + vampPlan, + atSeconds: timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named vamp plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstVampPlan(song: RehearsalSong): FirstVampPlan | null { + try { + return resolveSafeFirstVampPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..5f83480d0 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-vamp-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstVampPlanOpenAction")).toBe("{at} {role} 뱀프 열기"); + expect(t("firstVampPlanBody")).toBe("{at} {section}에서 {role} 파트의 뱀프 계획이 있습니다."); + expect(t("firstVampPlanArmed")).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..87ac4a378 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,10 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstVampPlanLabel": "Tonight's first vamp plan", + "firstVampPlanOpenAction": "Open {role} vamp at {at}", + "firstVampPlanBody": "{role} still has a vamp plan in the {section} at {at}.", + "firstVampPlanArmed": "Lock that vamp on {role} at {at} before the room starts.", + "firstVampPlanUnavailable": "No vamp 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..c31ccc10d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,10 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstVampPlanLabel": "오늘 첫 뱀프 계획", + "firstVampPlanOpenAction": "{at} {role} 뱀프 열기", + "firstVampPlanBody": "{at} {section}에서 {role} 파트의 뱀프 계획이 있습니다.", + "firstVampPlanArmed": "{at}에서 {role} 파트의 뱀프를 맞춘 다음 합주를 시작하세요.", + "firstVampPlanUnavailable": "사용 가능한 뱀프 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..5462d894b 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/firstVampPlan.ts", + "src/features/workspace/FirstVampPlanCallout.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..91a83beee 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 Vamp Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `vampPlan` 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`, `hookPlan`, `soloPlan`, `padPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-pad-plan, first-solo-plan, first-hook-plan, 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-vamp-plan-navigation.md b/docs/doctoring/reduced-motion-first-vamp-plan-navigation.md new file mode 100644 index 000000000..68f4d3a7f --- /dev/null +++ b/docs/doctoring/reduced-motion-first-vamp-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first vamp-plan navigation + +Open tonight's first vamp 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..56c5d104c 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,8 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + /** Rehearsal-facing vamp guidance owned by this role when runtime graph evidence corroborates it. */ + vampPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +476,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Stay on roots if the chorus entrance gets muddy.", setupNote: "Keep the attack short so the verse breathes.", transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.", + vampPlan: "Hold the two-bar verse groove until the vocal pickup; don't move until you hear city lights.", manualOverrides: [], overlapWarnings: [ "Density warning: competing with Keyboard Left Hand in low register." @@ -1497,6 +1500,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "vampPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1556,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.vampPlan !== undefined && typeof value.vampPlan !== "string") { + return invalidField(`${path}.vampPlan`); + } 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..b9b5512ef 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[0]?.vampPlan).toContain("two-bar verse groove"); 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[0].vampPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.vampPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From c701b82e2a0e3988fc4b2860dfd185c3d8333007 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:21:55 -0700 Subject: [PATCH 02/30] test: require real-audio vamp plan emission --- services/analysis-engine/tests/test_roles.py | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index 45a2ddada..3622beb0a 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -133,3 +133,40 @@ def test_role_extractor_falls_back_when_activity_detection_fails() -> None: assert result["topologies"][0]["section_id"] == "verse-1" assert result["topologies"][0]["part_graph"][0]["role_id"] == "bass-guitar" + + +def test_role_extractor_emits_activity_corroborated_vamp_plan() -> None: + """Emit a vamp plan only from real stem activity plus an upcoming entrance.""" + extractor = RoleExtractor() + sections = [{"id": "verse-1"}, {"id": "chorus-1"}] + audio_features = { + "stems": {"bass": np.ones(200, dtype=np.float32)}, + "sr": 10, + "boundaries": [(0.0, 10.0), (10.0, 20.0)], + } + + with ( + patch( + "bandscope_analysis.roles.extractor.detect_stem_activity", + return_value=[ + {"bass": True, "vocals": False, "other": False}, + {"bass": True, "vocals": True, "other": False}, + ], + ), + patch.object( + RoleExtractor, + "_extract_features", + return_value=( + {"lowestNote": "", "highestNote": ""}, + "", + {"lowestNote": "E1", "highestNote": "E3"}, + "Em", + ), + ), + ): + result = extractor.extract(sections, audio_features) + + verse_roles = {role["id"]: role for role in result["topologies"][0]["active_roles"]} + assert verse_roles["bass-guitar"]["vampPlan"] == ( + "Keep this part going until Lead Vocal enters in the next section." + ) From e665ae089d01b2cc9e1d67ad91a5bc0f7330aac8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:22:25 -0700 Subject: [PATCH 03/30] feat: type activity-derived vamp plan --- .../analysis-engine/src/bandscope_analysis/roles/model.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index ea6fc1449..c0449e559 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal, TypedDict +from typing import Any, Literal, NotRequired, TypedDict class RoleType(str, Enum): @@ -81,6 +81,7 @@ class RehearsalRole(TypedDict): rehearsalPriority: RehearsalPriority simplification: str setupNote: str + vampPlan: NotRequired[str] manualOverrides: list[ManualOverride] overlapWarnings: list[str] @@ -106,4 +107,4 @@ class RoleExtractionResult(TypedDict): """Result returned by the role extraction pipeline.""" topologies: list[SectionRoleTopology] - extraction_notes: str + extraction_notes: str \ No newline at end of file From a6fbd9c30a81a0c3bc417fc8b4296ba536c921a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:24:01 -0700 Subject: [PATCH 04/30] feat: derive vamp plan from real stem entrances --- .../src/bandscope_analysis/roles/extractor.py | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index a0f092213..7f0db5d71 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -330,6 +330,44 @@ def _build_roles( "acoustic_guitar": acoustic_guitar_role, } + @staticmethod + def _activity_vamp_plan( + role_id: str, + roles: dict[str, RehearsalRole], + role_activity: dict[str, bool], + next_role_activity: dict[str, bool] | None, + ) -> str | None: + """Return bounded vamp guidance only for an unambiguous upcoming entrance. + + A vamp plan is emitted only when real stem activity shows that this role + stays active across the next structural boundary and exactly one other + role becomes active there. Ambiguous multi-role entrances and heuristic + fallback topology intentionally produce no plan. + """ + if ( + next_role_activity is None + or not role_activity.get(role_id, False) + or not next_role_activity.get(role_id, False) + ): + return None + + activating_role_ids = [ + candidate_id + for candidate_id, is_active in next_role_activity.items() + if is_active and not role_activity.get(candidate_id, False) + ] + if len(activating_role_ids) != 1: + return None + + target_role_id = activating_role_ids[0] + target_role_name = next( + (role["name"] for role in roles.values() if role["id"] == target_role_id), + None, + ) + if target_role_name is None: + return None + return f"Keep this part going until {target_role_name} enters in the next section." + def _build_activity_topology( self, section_id: str, @@ -357,7 +395,17 @@ def _build_activity_topology( handoff_to, handoff_from = handoffs.get(role_id, ([], [])) if is_active: - active_roles.append(roles[role_key]) + role = roles[role_key] + vamp_plan = self._activity_vamp_plan( + role_id, + roles, + role_activity, + next_role_activity, + ) + if vamp_plan is not None: + role = role.copy() + role["vampPlan"] = vamp_plan + active_roles.append(role) part_graph.append( { @@ -465,4 +513,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) + return max(counts, key=lambda c: counts[c]) \ No newline at end of file From dbf692a498accc82e1e50ca1db51a2dd164bd32a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:30:18 -0700 Subject: [PATCH 05/30] docs: record real-audio vamp guidance boundary --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a8cef80..a8eea2942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Name tonight's first vamp plan in the mounted rehearsal workspace so a part can hold the groove until the next entrance; 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. +- Name tonight's first vamp plan in the mounted rehearsal workspace so a part can hold the groove until the next entrance; real analyzed songs now receive this guidance only when section-level stem activity shows the same part staying active across the boundary and exactly one other role entering next, while ambiguous or heuristic-only topology remains unavailable. Open moves to the matching rendered map section, and 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -40,7 +40,6 @@ - Resolve npm audit vulnerabilities - Fix ruff import sorting and formatting errors - Add missing docstrings to tests -- Fix test configuration and typing issues ## [0.1.0] - 2026-03-27 @@ -66,4 +65,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file From d3497353d47a98cabb58fdc157c77aee44529238 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:15:42 -0700 Subject: [PATCH 06/30] style(analysis): normalize vamp role model formatting --- services/analysis-engine/src/bandscope_analysis/roles/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index c0449e559..3683019c5 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -107,4 +107,4 @@ class RoleExtractionResult(TypedDict): """Result returned by the role extraction pipeline.""" topologies: list[SectionRoleTopology] - extraction_notes: str \ No newline at end of file + extraction_notes: str From 6ef3dd6ebe4aad8f9f65fac569dd77a222cd6db7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:16:40 -0700 Subject: [PATCH 07/30] style(analysis): apply locked Ruff formatting to vamp plan --- .../src/bandscope_analysis/roles/extractor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 7f0db5d71..d24d23f77 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -366,7 +366,9 @@ def _activity_vamp_plan( ) if target_role_name is None: return None - return f"Keep this part going until {target_role_name} enters in the next section." + return ( + f"Keep this part going until {target_role_name} enters in the next section." + ) def _build_activity_topology( self, @@ -513,4 +515,4 @@ def _most_common_chord(chords: list[str]) -> str: counts: dict[str, int] = {} for chord in chords: counts[chord] = counts.get(chord, 0) + 1 - return max(counts, key=lambda c: counts[c]) \ No newline at end of file + return max(counts, key=lambda c: counts[c]) From 2dfb27a7c3b9523469a0859989f954a3055f4517 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:25:54 -0700 Subject: [PATCH 08/30] fix(changelog): preserve released 0.1.1 history --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8eea2942..86a79c145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Resolve npm audit vulnerabilities - Fix ruff import sorting and formatting errors - Add missing docstrings to tests +- Fix test configuration and typing issues ## [0.1.0] - 2026-03-27 @@ -65,4 +66,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 100% 테스트 커버리지를 보장하는 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). From cb9c6ff56f3148bb3af9ba037a462f81b4d229f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:39:31 -0700 Subject: [PATCH 09/30] test(workspace): require localized generated vamp guidance --- .../FirstVampPlanCallout.particle.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx index 8196540c6..09f806029 100644 --- a/apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.particle.test.tsx @@ -49,4 +49,29 @@ describe("FirstVampPlanCallout Korean role copy", () => { grid.remove(); }); + + it("localizes the analysis-engine vamp template instead of exposing English guidance", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + vampPlan: "Keep this part going until Lead Vocal enters in the next section." + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + render(); + + expect( + screen.getByText("다음 섹션에서 Lead Vocal 파트가 들어올 때까지 이 파트를 유지하세요.") + ).toBeTruthy(); + expect( + screen.queryByText("Keep this part going until Lead Vocal enters in the next section.") + ).toBeNull(); + }); }); From 94c091cb2f72b0660edba6df9174b697f47ca947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 02:40:09 -0700 Subject: [PATCH 10/30] fix(workspace): localize generated vamp guidance --- .../features/workspace/FirstVampPlanCallout.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx index 9844d88d0..c63fc1327 100644 --- a/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstVampPlanCallout.tsx @@ -24,6 +24,9 @@ type OpenedVampPlan = Readonly<{ atSeconds: number; }>; +const GENERATED_ACTIVITY_VAMP_PLAN = + /^Keep this part going until (.+) enters in the next section\.$/u; + /** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ function stableVampPlanSongIdentity(song: RehearsalSong): unknown { if (song === null || typeof song !== "object" || Array.isArray(song)) { @@ -51,6 +54,16 @@ function formatVampPlanCopy(template: string, values: VampPlanCopyValues): strin }); } +/** Localize the analysis-engine-owned vamp sentence while preserving custom role-owned guidance verbatim. */ +function localizedVampPlan(vampPlan: string, generatedTemplate: string): string { + const match = GENERATED_ACTIVITY_VAMP_PLAN.exec(vampPlan); + const targetRole = match?.[1]?.trim() ?? ""; + if (targetRole.length === 0) { + return vampPlan; + } + return generatedTemplate.replace("{target}", () => targetRole); +} + /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredVampPlanScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -127,6 +140,7 @@ export function FirstVampPlanCallout({ song }: FirstVampPlanCalloutProps) { const actionLabel = formatVampPlanCopy(t("firstVampPlanOpenAction"), copyValues); const body = formatVampPlanCopy(t("firstVampPlanBody"), copyValues); const armed = formatVampPlanCopy(t("firstVampPlanArmed"), copyValues); + const vampPlan = localizedVampPlan(named.vampPlan, t("firstVampPlanGeneratedGuidance")); return (