From 8c4f173230bc0323ed63afa72f761494f983a267 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:35:04 +0000 Subject: [PATCH 01/16] feat(workspace): name tonight's first tuning plan on the map Name the owning part, owned tuningPlan copy, labeled section, and time so a player can lock that tuning before rehearsal. Open scrolls the renderer-owned song-structure section. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstTuningPlanCallout.particle.test.tsx | 52 ++++ ...tTuningPlanCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstTuningPlanCallout.test.tsx | 263 +++++++++++++++++ .../workspace/FirstTuningPlanCallout.tsx | 178 ++++++++++++ ...TuningPlanCallout.workspace-scope.test.tsx | 54 ++++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- ...firstTuningPlan.inherited-metadata.test.ts | 94 ++++++ .../workspace/firstTuningPlan.test.ts | 273 +++++++++++++++++ .../src/features/workspace/firstTuningPlan.ts | 274 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 +++- apps/desktop/src/i18n/index.ts | 34 +++ apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- docs/design-system/component-contract.md | 1 + ...ced-motion-first-tuning-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 6 + packages/shared-types/test/index.test.ts | 7 + 22 files changed, 1386 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTuningPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTuningPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-tuning-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..a9f17584e 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 tuning plan with the owning part when an active role is corroborated, the owned `tuningPlan` 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, 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..396b4077f 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 tuning plan must name the owning part when corroborated, the owned `tuningPlan` 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note and first-transposition-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..1f6792aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first tuning plan in the mounted rehearsal workspace so a part can lock the owned tuning 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..24cdbb8ed 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 tuning 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, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note and first-transposition-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/FirstTuningPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.particle.test.tsx new file mode 100644 index 000000000..062780abe --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.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 { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; + +describe("FirstTuningPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the tuning action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + tuningPlan: "Tune the E string down to D so the verse riff sits on the open fifth." + } + ]; + 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/FirstTuningPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..735e87015 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.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 { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; + +describe("FirstTuningPlanCallout 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 tuning at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.test.tsx new file mode 100644 index 000000000..deb78a67d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.test.tsx @@ -0,0 +1,263 @@ +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 { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; + +const DEMO_TUNING_PLAN = + "Tune the E string down to D so the verse riff sits on the open fifth."; + +function songWithTuningPlan() { + 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("FirstTuningPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "Nothing still has a tuning plan. Stay on tonight's map until a part owns rehearsal-facing tuning copy." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithTuningPlan(); + 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 tuning at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same tuning signature", () => { + const firstSong = songWithTuningPlan(); + const nextSong = songWithTuningPlan(); + 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 tuning at 0:10" })); + expect( + screen.getByText(/Lock that tuning on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar still has a tuning plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that tuning 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 = songWithTuningPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar tuning at 0:10" })); + expect( + screen.getByText(/Lock that tuning on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that tuning on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Bass Guitar still has a tuning plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's tuning plan under the named holding part", () => { + const song = songWithTuningPlan(); + song.sections[0]!.roles[0]!.tuningPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.tuningPlan = "Tune the patch a half step down so the chorus still sits under the vocal."; + song.sections[0]!.roles[2]!.tuningPlan = "Keep concert pitch even if the band drops the last chorus."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has a tuning plan in the verse at 0:10.") + ).toBeTruthy(); + expect(screen.getByText("Tune the patch a half step down so the chorus still sits under the vocal.")).toBeTruthy(); + expect(screen.queryByText("Keep concert pitch even if the band drops the last chorus.")).toBeNull(); + expect(screen.queryByText(DEMO_TUNING_PLAN)).toBeNull(); + }); + + it("names the first tuning plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_TUNING_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar tuning at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that tuning 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 tuning at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that tuning 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 tuning at 0:10" })); + + expect(screen.getByText("Bass Guitar still has a tuning plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that tuning 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 = songWithTuningPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar tuning at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar tuning at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the first tuning plan changes or returns later", () => { + const initialSong = songWithTuningPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar tuning at 0:10" })); + expect( + screen.getByText(/Lock that tuning on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + const nextSong = songWithTuningPlan(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Bass Guitar still has a tuning plan in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable tuning plan guidance-only", () => { + const song = songWithTuningPlan(); + for (const role of song.sections[0]!.roles) { + role.tuningPlan = ""; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByRole("complementary", { name: "Tonight's first tuning plan" }) + ).toBeTruthy(); + expect( + screen.getByText( + "Nothing still has a tuning plan. Stay on tonight's map until a part owns rehearsal-facing tuning copy." + ) + ).toBeTruthy(); + }); + + it("localizes the tuning-plan form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithTuningPlan(); + song.sections[0]!.roles[0]!.name = "베이스"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 파트의 튜닝 계획이 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned tuning plan as a text node instead of template syntax", () => { + const song = songWithTuningPlan(); + song.sections[0]!.roles[1]!.tuningPlan = ""; + song.sections[0]!.roles[2]!.tuningPlan = ""; + song.sections[0]!.roles[0]!.tuningPlan = "Check {role} at {at}"; + render(); + expect(screen.getByText("Check {role} at {at}")).toBeTruthy(); + expect(screen.queryByText("Check Bass Guitar at 0:10")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx new file mode 100644 index 000000000..ea75a8c89 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatTuningPlanTime, resolveFirstTuningPlan } from "./firstTuningPlan"; + +/** Props for the first tuning-plan rehearsal callout. */ +export interface FirstTuningPlanCalloutProps { + song: RehearsalSong; +} + +type TuningPlanCopyValues = Readonly>; + +type OpenedTuningPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + tuningPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableTuningPlanSongIdentity(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 tuning-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatTuningPlanCopy(template: string, values: TuningPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof TuningPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredTuningPlanScrollBehavior(): 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 resolveTuningPlanRenderer(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 tuning plan and open the matching rendered map section. */ +export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableTuningPlanSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const named = resolveFirstTuningPlan(song); + const namedSectionIndex = + named && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(named.section) + : -1; + const [openedTuningPlan, setOpenedTuningPlan] = useState(null); + + useEffect(() => { + setOpenedTuningPlan(null); + }, [ + songIdentity, + namedSectionIndex, + named?.section.id, + named?.holdingRole.id, + named?.tuningPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedTuningPlan !== null && + openedTuningPlan.songIdentity === songIdentity && + openedTuningPlan.sectionId === named.section.id && + openedTuningPlan.sectionIndex === namedSectionIndex && + openedTuningPlan.holdingRoleId === named.holdingRole.id && + openedTuningPlan.tuningPlan === named.tuningPlan && + openedTuningPlan.atSeconds === named.atSeconds; + const at = formatTuningPlanTime(named.atSeconds); + const copyValues: TuningPlanCopyValues = { + role: named.holdingRole.name, + section: translateSectionFormLabel(locale, named.section.label), + at + }; + const actionLabel = formatTuningPlanCopy(t("firstTuningPlanOpenAction"), copyValues); + const body = formatTuningPlanCopy(t("firstTuningPlanBody"), copyValues); + const armed = formatTuningPlanCopy(t("firstTuningPlanArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..caab525dc --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.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 { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; + +describe("FirstTuningPlanCallout 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 tuning 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..519844ecd 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 tuning 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( + "Tune the E string down to D so the verse riff sits on the open fifth." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar tuning at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that tuning 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..2a998917f 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 { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; 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/firstTuningPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..e4e3eb9a6 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTuningPlan } from "./firstTuningPlan"; + +function songWithTuningPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "tuning-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + tuningPlan: "Tune the E string down to D so the verse riff sits on the open fifth." + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstTuningPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithTuningPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstTuningPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithTuningPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithTuningPlan(); + Object.defineProperty(section.roles[0]!, "tuningPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile tuningPlan getter"); + } + }); + + expect(() => resolveFirstTuningPlan(song)).not.toThrow(); + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable tuning-plan identity authority", () => { + const { song, section } = songWithTuningPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "tuning-own"; + } + }); + + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("does not let inherited tuning plans establish the named copy", () => { + const { song, section } = songWithTuningPlan(); + const inheritedRole = Object.create({ + tuningPlan: "Inherited tuning plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "bass-guitar" }, + name: { configurable: true, enumerable: true, value: "Bass Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithTuningPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithTuningPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.test.ts b/apps/desktop/src/features/workspace/firstTuningPlan.test.ts new file mode 100644 index 000000000..b3b854040 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTuningPlan.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatTuningPlanTime, resolveFirstTuningPlan } from "./firstTuningPlan"; + +const DEMO_TUNING_PLAN = + "Tune the E string down to D so the verse riff sits on the open fifth."; + +function withTuningSection( + overrides: { + id?: string; + start?: number; + end?: number; + tuningPlan?: 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-tuning"; + 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." + }, + tuningPlan: + overrides.tuningPlan ?? + "Tune the E string down to D so the verse riff sits on the open fifth.", + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstTuningPlan", () => { + it("picks the demo song's earliest high-priority tuning plan and the part that owns it", () => { + const resolved = resolveFirstTuningPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("bass-guitar"); + expect(resolved?.tuningPlan).toBe(DEMO_TUNING_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatTuningPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatTuningPlanTime(Number.NaN)).toBe("0:00"); + expect(formatTuningPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a tuning plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withTuningSection(); + delete song.sections[0]!.roles[0]!.tuningPlan; + 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 = "Setup: Drop D, tune the sixth string down"; + 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]!.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: "Tune the E string down to D so the verse riff sits on the open fifth." + }; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("skips a blank tuning plan", () => { + expect(resolveFirstTuningPlan(withTuningSection({ tuningPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line tuning plan", () => { + expect( + resolveFirstTuningPlan(withTuningSection({ tuningPlan: "Drop a step.\nKeep the pickup." })) + ).toBeNull(); + }); + + it("prefers the earlier of two tuning plans", () => { + const song = withTuningSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + tuningPlan: "Late bass tuning." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + tuningPlan: "Earlier bass tuning." + } + ]; + 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 = resolveFirstTuningPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.tuningPlan).toBe("Earlier bass tuning."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time tuning-plan ties with locale-independent id ordering", () => { + const song = withTuningSection({ id: "ä-tuning", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-tuning"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstTuningPlan(song)?.section.id).toBe("z-tuning"); + }); + + it("prefers a high-priority tuning part over a low-priority part in the same section", () => { + const song = withTuningSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + tuningPlan: "Low-priority bass tuning." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + tuningPlan: "High-priority bass tuning." + }; + 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(resolveFirstTuningPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstTuningPlan(song)?.tuningPlan).toBe("High-priority bass tuning."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withTuningSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + tuningPlan: "ASCII bass tuning." + }; + 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(resolveFirstTuningPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstTuningPlan(song)?.tuningPlan).toBe("ASCII bass tuning."); + }); + + it("skips a tuning plan whose graph node is inactive", () => { + expect(resolveFirstTuningPlan(withTuningSection({ isActive: false }))).toBeNull(); + }); + + it("skips a tuning plan whose rehearsal window is unbounded", () => { + expect(resolveFirstTuningPlan(withTuningSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a tuning plan whose end precedes its start", () => { + expect(resolveFirstTuningPlan(withTuningSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length tuning-plan window", () => { + expect(resolveFirstTuningPlan(withTuningSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a tuning plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstTuningPlan( + withTuningSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstTuningPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withTuningSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("keeps the tuning plan unnamed when role identities are duplicated", () => { + const song = withTuningSection(); + 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(resolveFirstTuningPlan(song)).toBeNull(); + }); + + it("bounds the tuning plan to 180 Unicode code points", () => { + const song = withTuningSection({ tuningPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstTuningPlan(song); + expect(resolved?.tuningPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the tuning-plan boundary", () => { + const song = withTuningSection({ tuningPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstTuningPlan(song); + expect(Array.from(resolved?.tuningPlan ?? "")).toHaveLength(180); + expect(resolved?.tuningPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.ts b/apps/desktop/src/features/workspace/firstTuningPlan.ts new file mode 100644 index 000000000..2f35ab9e7 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTuningPlan.ts @@ -0,0 +1,274 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_TUNING_PLAN_CHARACTERS = 180; + +/** Tonight's first tuning plan: the earliest labeled section and the part that owns it. */ +export type FirstTuningPlan = { + section: RehearsalSection; + holdingRole: RehearsalRole; + tuningPlan: string; + atSeconds: number; +}; + +/** Format a non-negative tuning-plan time as m:ss for rehearsal copy. */ +export function formatTuningPlanTime(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 own tuning plan, or null when it cannot be shown. */ +function ownedTuningPlan(role: unknown): string | null { + if (!isRuntimeObject(role) || !hasOwnData(role, "tuningPlan")) { + return null; + } + const tuningPlan = (role as { tuningPlan?: unknown }).tuningPlan; + if (typeof tuningPlan !== "string") { + return null; + } + const trimmed = tuningPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_TUNING_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 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 tuning plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstTuningPlan(song: RehearsalSong): FirstTuningPlan | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .flatMap((section) => { + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedTuningPlan(role) !== null) + ); + if (!holdingRole) { + return []; + } + const tuningPlan = ownedTuningPlan(holdingRole); + if (!tuningPlan) { + return []; + } + return [ + { + section, + holdingRole, + tuningPlan, + 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 tuning plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstTuningPlan(song: RehearsalSong): FirstTuningPlan | null { + try { + return resolveSafeFirstTuningPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..0c8617f3f 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,51 @@ 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", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-tuning-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstTuningPlanOpenAction")).toBe("{at} {role} 튜닝 열기"); + expect(t("firstTuningPlanBody")).toBe("{at} {section}에서 {role} 파트의 튜닝 계획이 있습니다."); + expect(t("firstTuningPlanArmed")).toBe("{at}에서 {role} 파트의 튜닝을 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..ff6e218d1 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** 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..1e96446cc 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", + "firstTuningPlanLabel": "Tonight's first tuning plan", + "firstTuningPlanOpenAction": "Open {role} tuning at {at}", + "firstTuningPlanBody": "{role} still has a tuning plan in the {section} at {at}.", + "firstTuningPlanArmed": "Lock that tuning on {role} at {at} before the room starts.", + "firstTuningPlanUnavailable": "Nothing still has a tuning plan. Stay on tonight's map until a part owns rehearsal-facing tuning copy." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..ec49d9810 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": "진척도 증가", + "firstTuningPlanLabel": "오늘 첫 튜닝 계획", + "firstTuningPlanOpenAction": "{at} {role} 튜닝 열기", + "firstTuningPlanBody": "{at} {section}에서 {role} 파트의 튜닝 계획이 있습니다.", + "firstTuningPlanArmed": "{at}에서 {role} 파트의 튜닝을 맞춘 다음 합주를 시작하세요.", + "firstTuningPlanUnavailable": "튜닝을 맞춰야 하는 파트가 없습니다. 합주용 튜닝 카피가 있는 파트가 생길 때까지 오늘 맵에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..963e6e94a 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Tuning Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `tuningPlan` 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`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-setup-note and first-transposition-plan. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-tuning-plan-navigation.md b/docs/doctoring/reduced-motion-first-tuning-plan-navigation.md new file mode 100644 index 000000000..f39c64068 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-tuning-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first tuning-plan navigation + +Open tonight's first tuning 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..3f002ce97 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; + tuningPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -474,6 +475,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.", + tuningPlan: "Tune the E string down to D so the verse riff sits on the open fifth.", manualOverrides: [], overlapWarnings: [ "Density warning: competing with Keyboard Left Hand in low register." @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "tuningPlan", "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.tuningPlan !== undefined && typeof value.tuningPlan !== "string") { + return invalidField(`${path}.tuningPlan`); + } 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..7c9e59581 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]?.tuningPlan).toContain("Tune the E string down to D"); 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].tuningPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.tuningPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From 242115e04d9424858ee9c04cc827b4ec2183e9c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:09:54 -0700 Subject: [PATCH 02/16] test(workspace): require tuning-plan resolver memoization --- ...irstTuningPlanCallout.memoization.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstTuningPlanCallout.memoization.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..206620c6b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.memoization.test.tsx @@ -0,0 +1,31 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it, vi } from "vitest"; + +const { resolveFirstTuningPlanSpy } = vi.hoisted(() => ({ + resolveFirstTuningPlanSpy: vi.fn() +})); + +vi.mock("./firstTuningPlan", async () => { + const actual = await vi.importActual("./firstTuningPlan"); + return { + ...actual, + resolveFirstTuningPlan: (...args: Parameters) => { + resolveFirstTuningPlanSpy(...args); + return actual.resolveFirstTuningPlan(...args); + } + }; +}); + +import { FirstTuningPlanCallout } from "./FirstTuningPlanCallout"; + +it("does not rescan an unchanged rehearsal song on parent rerender", () => { + const song = createDemoRehearsalSong(); + const { rerender } = render(); + + expect(resolveFirstTuningPlanSpy).toHaveBeenCalledTimes(1); + + rerender(); + + expect(resolveFirstTuningPlanSpy).toHaveBeenCalledTimes(1); +}); From 5b07db76282e89bf5c7aefb2cd680343c2d63f05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 14:10:26 -0700 Subject: [PATCH 03/16] fix(workspace): memoize first tuning-plan resolution --- .../src/features/workspace/FirstTuningPlanCallout.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx index ea75a8c89..e0f78ed5d 100644 --- a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -77,11 +77,11 @@ function resolveTuningPlanRenderer(origin: HTMLElement): HTMLElement | null { /** Name tonight's first tuning plan and open the matching rendered map section. */ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { - const locale = detectPreferredLocale(); - const t = createTranslator(locale); + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); const songIdentity = stableTuningPlanSongIdentity(song); const runtimeSong = song as unknown as Partial | null; - const named = resolveFirstTuningPlan(song); + const named = useMemo(() => resolveFirstTuningPlan(song), [song]); const namedSectionIndex = named && Array.isArray(runtimeSong?.sections) ? runtimeSong.sections.indexOf(named.section) @@ -175,4 +175,4 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { ); -} +} \ No newline at end of file From fb7c0d446f21c867652cdfe6055855646bb8b7b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:28:54 -0700 Subject: [PATCH 04/16] test(workspace): lock tuning-plan own-data authority --- .../firstTuningPlan.proxy-authority.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts new file mode 100644 index 000000000..a1e37d24d --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts @@ -0,0 +1,31 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTuningPlan } from "./firstTuningPlan"; + +const DEMO_TUNING_PLAN = + "Tune the E string down to D so the verse riff sits on the open fifth."; + +describe("resolveFirstTuningPlan own-data authority", () => { + it("uses the snapshotted own-data tuning 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 tuning-plan fixture is missing the expected Bass Guitar role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "tuningPlan") { + return "Injected proxy tuning."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstTuningPlan(song)?.tuningPlan).toBe(DEMO_TUNING_PLAN); + }); +}); From e8071ee6f406c1be21da780fea4ecd77a2689e08 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:29:25 -0700 Subject: [PATCH 05/16] fix(workspace): snapshot owned tuning-plan data --- apps/desktop/src/features/workspace/firstTuningPlan.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.ts b/apps/desktop/src/features/workspace/firstTuningPlan.ts index 2f35ab9e7..3bb56c7f9 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.ts @@ -79,12 +79,16 @@ function truncateCodePoints(value: string, maximum: number): string { return endIndex === value.length ? value : value.slice(0, endIndex); } -/** Return a bounded own tuning plan, or null when it cannot be shown. */ +/** Return a bounded snapshotted own tuning plan, or null when it cannot be shown. */ function ownedTuningPlan(role: unknown): string | null { - if (!isRuntimeObject(role) || !hasOwnData(role, "tuningPlan")) { + if (!isRuntimeObject(role)) { return null; } - const tuningPlan = (role as { tuningPlan?: unknown }).tuningPlan; + const descriptor = Object.getOwnPropertyDescriptor(role, "tuningPlan"); + if (descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value")) { + return null; + } + const tuningPlan = descriptor.value; if (typeof tuningPlan !== "string") { return null; } From 44d62fdc8e1749b352a9e55be20c771985e02408 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:43:41 -0700 Subject: [PATCH 06/16] test(workspace): reject unknown tuning section labels --- .../firstTuningPlan.inherited-metadata.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts index e4e3eb9a6..31b12b81e 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.inherited-metadata.test.ts @@ -85,6 +85,17 @@ describe("resolveFirstTuningPlan inherited metadata", () => { expect(resolveFirstTuningPlan(song)).toBeNull(); }); + it("rejects section labels outside the shared form vocabulary", () => { + const { song, section } = songWithTuningPlan(); + Object.defineProperty(section, "label", { + configurable: true, + enumerable: true, + value: "verse-legacy" + }); + + expect(resolveFirstTuningPlan(song)).toBeNull(); + }); + it("rejects arrays masquerading as section records", () => { const { song, section } = songWithTuningPlan(); const arraySection = Object.assign([], section) as unknown as typeof section; From 05eba321e644e4537bad42a65f028528140d077f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:44:19 -0700 Subject: [PATCH 07/16] fix(workspace): reject unknown tuning section labels --- apps/desktop/src/features/workspace/firstTuningPlan.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.ts b/apps/desktop/src/features/workspace/firstTuningPlan.ts index 3bb56c7f9..e4952298c 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.ts @@ -1,5 +1,6 @@ import { MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, type RehearsalRole, type RehearsalSection, type RehearsalSong @@ -7,6 +8,7 @@ import { const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_TUNING_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); /** Tonight's first tuning plan: the earliest labeled section and the part that owns it. */ export type FirstTuningPlan = { @@ -232,7 +234,7 @@ function resolveSafeFirstTuningPlan(song: RehearsalSong): FirstTuningPlan | null isRuntimeObject(section) && hasOwnData(section, "label") && typeof section.label === "string" && - section.label.trim().length > 0 && + SECTION_FORM_LABEL_SET.has(section.label) && hasOwnData(section, "id") && typeof section.id === "string" && section.id.trim().length > 0 && From 0dfae6091ebb0fbbb54a2125b2fc49e0982bb65b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:47:59 -0700 Subject: [PATCH 08/16] test(workspace): require tuning plan coverage ownership --- .../workspace/tuningCoverageContract.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/desktop/src/features/workspace/tuningCoverageContract.test.ts diff --git a/apps/desktop/src/features/workspace/tuningCoverageContract.test.ts b/apps/desktop/src/features/workspace/tuningCoverageContract.test.ts new file mode 100644 index 000000000..96d214c0a --- /dev/null +++ b/apps/desktop/src/features/workspace/tuningCoverageContract.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 tuning-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstTuningPlan.ts", + "src/features/workspace/FirstTuningPlanCallout.tsx" + ]) + ); + }); +}); From c38b10e8ec074978edd3c8ac077047572949d136 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 04:48:13 -0700 Subject: [PATCH 09/16] fix(coverage): include tuning production paths --- apps/desktop/vite.config.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..1785a9435 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/firstTuningPlan.ts", + "src/features/workspace/FirstTuningPlanCallout.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, From b2f8064ee3bb9bd6fe0f848260ec280564704f47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:38:52 -0700 Subject: [PATCH 10/16] test(workspace): pin tuning-plan proxy authority --- .../firstTuningPlan.proxy-authority.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts index a1e37d24d..0d3f1987e 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.proxy-authority.test.ts @@ -28,4 +28,55 @@ describe("resolveFirstTuningPlan own-data authority", () => { expect(resolveFirstTuningPlan(song)?.tuningPlan).toBe(DEMO_TUNING_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 tuning-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(resolveFirstTuningPlan(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 tuning-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 = resolveFirstTuningPlan(song) as + | (ReturnType & { + holdingRoleId?: string; + holdingRoleName?: string; + }) + | null; + expect(resolved?.holdingRoleId).toBe(expectedId); + expect(resolved?.holdingRoleName).toBe(expectedName); + }); }); From 9627edc78f2c8ad0d28c118bf0f87d3c7ccdc951 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:39:46 -0700 Subject: [PATCH 11/16] fix(workspace): snapshot tuning-plan runtime authority --- .../src/features/workspace/firstTuningPlan.ts | 263 ++++++++++-------- 1 file changed, 153 insertions(+), 110 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.ts b/apps/desktop/src/features/workspace/firstTuningPlan.ts index e4952298c..4e0f24a27 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.ts @@ -10,10 +10,22 @@ const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_TUNING_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 tuning plan: the earliest labeled section and the part that owns it. */ export type FirstTuningPlan = { section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; tuningPlan: string; atSeconds: number; }; @@ -50,21 +62,31 @@ 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 (!Number.isSafeInteger(length) || (length as number) < 0 || (length as number) > 0xffffffff) { + return null; } - for (let index = 0; index < length; index += 1) { + const items: unknown[] = []; + for (let index = 0; index < (length as number); 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 +108,7 @@ function ownedTuningPlan(role: unknown): string | null { if (!isRuntimeObject(role)) { return null; } - const descriptor = Object.getOwnPropertyDescriptor(role, "tuningPlan"); - if (descriptor === undefined || !Object.prototype.hasOwnProperty.call(descriptor, "value")) { - return null; - } - const tuningPlan = descriptor.value; + const tuningPlan = ownDataValue(role, "tuningPlan"); if (typeof tuningPlan !== "string") { return null; } @@ -101,40 +119,55 @@ function ownedTuningPlan(role: unknown): string | null { return truncateCodePoints(trimmed, MAX_TUNING_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) - ); +/** 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. */ @@ -152,13 +185,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; } @@ -168,95 +202,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 tuning plan after the runtime root has passed its structural boundary checks. */ function resolveSafeFirstTuningPlan(song: RehearsalSong): FirstTuningPlan | 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 = 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 candidates = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasOwnData(section, "label") && - typeof section.label === "string" && - SECTION_FORM_LABEL_SET.has(section.label) && - hasOwnData(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) - ) - .flatMap((section) => { const holdingRole = pickHoldingRole( - rankedActiveRoles(section).filter((role) => ownedTuningPlan(role) !== null) + rankedActiveRoles(section as RehearsalSection).filter( + (metadata) => ownedTuningPlan(metadata.role) !== null + ) ); if (!holdingRole) { return []; } - const tuningPlan = ownedTuningPlan(holdingRole); + const tuningPlan = ownedTuningPlan(holdingRole.role); if (!tuningPlan) { return []; } return [ { - section, - holdingRole, + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, tuningPlan, - atSeconds: section.timeRange.start + atSeconds: timeRange.start } ]; }) @@ -264,7 +307,7 @@ function resolveSafeFirstTuningPlan(song: RehearsalSong): FirstTuningPlan | 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 0663415f4393bcc5a8ad9a6b6e9a36c67cc7be01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:40:09 -0700 Subject: [PATCH 12/16] fix(workspace): consume tuning-plan authority snapshots --- .../workspace/FirstTuningPlanCallout.tsx | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx index e0f78ed5d..6af63ab18 100644 --- a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx @@ -80,21 +80,16 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); const songIdentity = stableTuningPlanSongIdentity(song); - const runtimeSong = song as unknown as Partial | null; const named = useMemo(() => resolveFirstTuningPlan(song), [song]); - const namedSectionIndex = - named && Array.isArray(runtimeSong?.sections) - ? runtimeSong.sections.indexOf(named.section) - : -1; const [openedTuningPlan, setOpenedTuningPlan] = useState(null); useEffect(() => { setOpenedTuningPlan(null); }, [ songIdentity, - namedSectionIndex, - named?.section.id, - named?.holdingRole.id, + named?.sectionIndex, + named?.sectionId, + named?.holdingRoleId, named?.tuningPlan, named?.atSeconds ]); @@ -117,15 +112,15 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { const opened = openedTuningPlan !== null && openedTuningPlan.songIdentity === songIdentity && - openedTuningPlan.sectionId === named.section.id && - openedTuningPlan.sectionIndex === namedSectionIndex && - openedTuningPlan.holdingRoleId === named.holdingRole.id && + openedTuningPlan.sectionId === named.sectionId && + openedTuningPlan.sectionIndex === named.sectionIndex && + openedTuningPlan.holdingRoleId === named.holdingRoleId && openedTuningPlan.tuningPlan === named.tuningPlan && openedTuningPlan.atSeconds === named.atSeconds; const at = formatTuningPlanTime(named.atSeconds); const copyValues: TuningPlanCopyValues = { - role: named.holdingRole.name, - section: translateSectionFormLabel(locale, named.section.label), + role: named.holdingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), at }; const actionLabel = formatTuningPlanCopy(t("firstTuningPlanOpenAction"), copyValues); @@ -149,9 +144,9 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { onClick={(event) => { const renderer = resolveTuningPlanRenderer(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") { @@ -163,9 +158,9 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { }); setOpenedTuningPlan({ songIdentity, - sectionId: named.section.id, - sectionIndex: namedSectionIndex, - holdingRoleId: named.holdingRole.id, + sectionId: named.sectionId, + sectionIndex: named.sectionIndex, + holdingRoleId: named.holdingRoleId, tuningPlan: named.tuningPlan, atSeconds: named.atSeconds }); @@ -175,4 +170,4 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { ); -} \ No newline at end of file +} From 16fb11a74ede935298849c40c4858036c9f1a0a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 15:40:49 -0700 Subject: [PATCH 13/16] fix(workspace): narrow dense tuning-plan arrays --- apps/desktop/src/features/workspace/firstTuningPlan.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTuningPlan.ts b/apps/desktop/src/features/workspace/firstTuningPlan.ts index 4e0f24a27..5999639fd 100644 --- a/apps/desktop/src/features/workspace/firstTuningPlan.ts +++ b/apps/desktop/src/features/workspace/firstTuningPlan.ts @@ -76,11 +76,16 @@ function ownedDenseRuntimeArray(value: unknown): unknown[] | null { return null; } const length = ownDataValue(value, "length"); - if (!Number.isSafeInteger(length) || (length as number) < 0 || (length as number) > 0xffffffff) { + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { return null; } const items: unknown[] = []; - for (let index = 0; index < (length as number); index += 1) { + for (let index = 0; index < length; index += 1) { if (!hasOwnData(value, index)) { return null; } From 5d30df84451fd4cbe405d8612b60d7d817941bbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:26:29 -0700 Subject: [PATCH 14/16] test(workspace): require unique tuning callout ids --- ...TuningPlanCallout.workspace-scope.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx index caab525dc..21c2ebdf8 100644 --- a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.workspace-scope.test.tsx @@ -51,4 +51,23 @@ describe("FirstTuningPlanCallout workspace scope", () => { behavior: "smooth" }); }); + + it("publishes a unique landmark id for each mounted tuning-plan callout", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + render( + <> + + + + ); + + const regions = screen.getAllByRole("complementary", { name: "Tonight's first tuning plan" }); + expect(regions).toHaveLength(2); + const ids = regions.map((region) => region.id); + expect(ids.every((id) => id.startsWith("workspace-surface-tuning-plan-"))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); }); From 9ec453d13c5ed7eea8818a9b1b4bb2ca3c6c7a63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:27:03 -0700 Subject: [PATCH 15/16] fix(workspace): make tuning callout ids mount-local --- .../src/features/workspace/FirstTuningPlanCallout.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx index 6af63ab18..b9dc58c84 100644 --- a/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTuningPlanCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -77,6 +77,7 @@ function resolveTuningPlanRenderer(origin: HTMLElement): HTMLElement | null { /** Name tonight's first tuning plan and open the matching rendered map section. */ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { + const calloutId = `workspace-surface-tuning-plan-${useId()}`; const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); const songIdentity = stableTuningPlanSongIdentity(song); @@ -97,7 +98,7 @@ export function FirstTuningPlanCallout({ song }: FirstTuningPlanCalloutProps) { if (!named) { return (