From 632011666686c3106f72ed4c7a88bc35f58d2f4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 01:25:18 +0000 Subject: [PATCH 01/26] feat(workspace): name tonight's first solo plan on the map Name the corroborated Keyboard 1 Right Hand verse solo at 0:10 so Open moves to the matching rendered map section. Fail closed on inherited, accessor, or Proxy-substituted runtime metadata. Do not invent solo copy from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition, fill, tuning, dynamics, articulation, hook, confirmed overrides, harmonic explanations, or confidence notes. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstSoloPlanCallout.identity.test.tsx | 21 ++ .../FirstSoloPlanCallout.memoization.test.tsx | 25 ++ .../FirstSoloPlanCallout.particle.test.tsx | 52 +++ ...rstSoloPlanCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstSoloPlanCallout.test.tsx | 202 +++++++++++ .../workspace/FirstSoloPlanCallout.tsx | 174 ++++++++++ ...tSoloPlanCallout.unavailable-copy.test.tsx | 34 ++ ...stSoloPlanCallout.workspace-scope.test.tsx | 54 +++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../workspace/coverageContract.test.ts | 13 + .../firstSoloPlan.inherited-metadata.test.ts | 94 +++++ .../firstSoloPlan.proxy-authority.test.ts | 83 +++++ .../firstSoloPlan.section-label.test.ts | 14 + .../features/workspace/firstSoloPlan.test.ts | 284 +++++++++++++++ .../src/features/workspace/firstSoloPlan.ts | 328 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 50 ++- apps/desktop/src/i18n/index.ts | 36 +- apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- apps/desktop/vite.config.ts | 22 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-solo-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 7 + packages/shared-types/test/index.test.ts | 7 + 29 files changed, 1593 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.identity.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.memoization.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/coverageContract.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSoloPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstSoloPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-solo-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..71cf343f9 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 solo plan with the owning part when an active role is corroborated, the owned `soloPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, 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..4394d84db 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-25 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first solo plan must name the owning part when corroborated, the owned `soloPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..e437d7638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first solo plan in the mounted rehearsal workspace so a part can lock the owned solo before the rest of the band comes back in; the Open action moves to the matching rendered map section, while inherited, accessor-backed, or Proxy-substituted runtime metadata remains guidance-only instead of becoming copy, identity, timing, or navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..4f8d0016d 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 solo plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.identity.test.tsx new file mode 100644 index 000000000..971a1ede4 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.identity.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +it("gives co-mounted solo-plan callouts distinct DOM identities", () => { + render( + <> + + + + ); + + const callouts = screen.getAllByRole("complementary", { + name: "Tonight's first solo plan" + }); + const ids = callouts.map((callout) => callout.id); + + expect(ids.every((id) => id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(callouts.length); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.memoization.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.memoization.test.tsx new file mode 100644 index 000000000..9742075aa --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.memoization.test.tsx @@ -0,0 +1,25 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +describe("FirstSoloPlanCallout resolver reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not rescan role metadata when a parent rerenders the same song object", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles.find((candidate) => candidate.id === "lead-vocal")!; + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor"); + + const { rerender } = render(); + const firstScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + expect(firstScanCount).toBeGreaterThan(0); + + rerender(); + const secondScanCount = descriptorSpy.mock.calls.filter(([target]) => target === role).length; + + expect(secondScanCount).toBe(firstScanCount); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx new file mode 100644 index 000000000..b9d3fc68e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.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 { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +describe("FirstSoloPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending role names particle-safe before and after the solo action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[2]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." + } + ]; + 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/FirstSoloPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..124a41a86 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.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 { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +describe("FirstSoloPlanCallout 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 Keyboard 1 Right Hand solo at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx new file mode 100644 index 000000000..ced151f95 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx @@ -0,0 +1,202 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +const DEMO_SOLO_PLAN = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + +function songWithSoloPlan() { + 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("FirstSoloPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithSoloPlan(); + 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 Keyboard 1 Right Hand solo at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same solo signature", () => { + const firstSong = songWithSoloPlan(); + const nextSong = songWithSoloPlan(); + 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 Keyboard 1 Right Hand solo at 0:10" })); + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithSoloPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's solo plan under the named holding part", () => { + const song = songWithSoloPlan(); + song.sections[0]!.roles[0]!.soloPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.soloPlan = "Leave the bass on roots while the solo lands."; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.soloPlan = + "Keep the right-hand figure under the vocal so the solo still reads."; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.") + ).toBeTruthy(); + expect( + screen.getByText("Keep the right-hand figure under the vocal so the solo still reads.") + ).toBeTruthy(); + expect(screen.queryByText("Leave the bass on roots while the solo lands.")).toBeNull(); + expect(screen.queryByText(DEMO_SOLO_PLAN)).toBeNull(); + }); + + it("names the first solo plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_SOLO_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand solo at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand 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 Keyboard 1 Right Hand solo at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand 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 Keyboard 1 Right Hand solo at 0:10" })); + + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithSoloPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("fails closed when more than one song-structure renderer is mounted globally", () => { + const first = appendSongStructureTarget(); + const second = appendSongStructureTarget(); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); + + expect(first.scrollIntoView).not.toHaveBeenCalled(); + expect(second.scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + + first.grid.remove(); + second.grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx new file mode 100644 index 000000000..0a6f6084b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx @@ -0,0 +1,174 @@ +import { useEffect, useId, useMemo, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatSoloPlanTime, resolveFirstSoloPlan } from "./firstSoloPlan"; + +/** Props for the first solo-plan rehearsal callout. */ +export interface FirstSoloPlanCalloutProps { + song: RehearsalSong; +} + +type SoloPlanCopyValues = Readonly>; + +type OpenedSoloPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + soloPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableSoloPlanSongIdentity(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 solo-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatSoloPlanCopy(template: string, values: SoloPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof SoloPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredSoloPlanScrollBehavior(): 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 resolveSoloPlanRenderer(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 solo plan and open the matching rendered map section. */ +export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { + const calloutId = `workspace-surface-solo-plan-${useId()}`; + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableSoloPlanSongIdentity(song); + const named = useMemo(() => resolveFirstSoloPlan(song), [song]); + const [openedSoloPlan, setOpenedSoloPlan] = useState(null); + + useEffect(() => { + setOpenedSoloPlan(null); + }, [ + songIdentity, + named?.sectionIndex, + named?.sectionId, + named?.holdingRoleId, + named?.soloPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedSoloPlan !== null && + openedSoloPlan.songIdentity === songIdentity && + openedSoloPlan.sectionId === named.sectionId && + openedSoloPlan.sectionIndex === named.sectionIndex && + openedSoloPlan.holdingRoleId === named.holdingRoleId && + openedSoloPlan.soloPlan === named.soloPlan && + openedSoloPlan.atSeconds === named.atSeconds; + const at = formatSoloPlanTime(named.atSeconds); + const copyValues: SoloPlanCopyValues = { + role: named.holdingRoleName, + section: translateSectionFormLabel(locale, named.sectionLabel), + at + }; + const actionLabel = formatSoloPlanCopy(t("firstSoloPlanOpenAction"), copyValues); + const body = formatSoloPlanCopy(t("firstSoloPlanBody"), copyValues); + const armed = formatSoloPlanCopy(t("firstSoloPlanArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..00bc40cfd --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +function songWithoutSoloPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.soloPlan = ""; + } + } + return song; +} + +describe("FirstSoloPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English solo plan is unavailable", () => { + render(); + + expect(screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean solo plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 솔로 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..c9596be06 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.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 { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +describe("FirstSoloPlanCallout 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 Keyboard 1 Right Hand solo 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..af76a29fb 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 solo plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand solo at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that solo on Keyboard 1 Right Hand 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..12316723d 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 { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts new file mode 100644 index 000000000..7814c032f --- /dev/null +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the first solo-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstSoloPlan.ts", + "src/features/workspace/FirstSoloPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..72992c4f0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +function songWithSoloPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "solo-own"; + section.roles = [ + { + ...section.roles[2]!, + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high", + soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." + } + ]; + section.partGraph = [{ role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstSoloPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithSoloPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstSoloPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithSoloPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithSoloPlan(); + Object.defineProperty(section.roles[0]!, "soloPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile soloPlan getter"); + } + }); + + expect(() => resolveFirstSoloPlan(song)).not.toThrow(); + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable solo-plan identity authority", () => { + const { song, section } = songWithSoloPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "solo-own"; + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not let inherited solo plans establish the named copy", () => { + const { song, section } = songWithSoloPlan(); + const inheritedRole = Object.create({ + soloPlan: "Inherited solo plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "lead-vocal" }, + name: { configurable: true, enumerable: true, value: "Lead Vocal" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithSoloPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithSoloPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts new file mode 100644 index 000000000..7c612e62f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts @@ -0,0 +1,83 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +const DEMO_SOLO_PLAN = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + +describe("resolveFirstSoloPlan own-data authority", () => { + it("uses the snapshotted own-data solo 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 === "keys-right") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "soloPlan") { + return "Injected proxy solo."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstSoloPlan(song)?.soloPlan).toBe(DEMO_SOLO_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 solo-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(resolveFirstSoloPlan(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 === "keys-right") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand 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 = resolveFirstSoloPlan(song) as + | (ReturnType & { + holdingRoleId?: string; + holdingRoleName?: string; + }) + | null; + expect(resolved?.soloPlan).toBe(DEMO_SOLO_PLAN); + expect(resolved?.holdingRoleId).toBe(expectedId); + expect(resolved?.holdingRoleName).toBe(expectedName); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts new file mode 100644 index 000000000..0f0485b93 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +describe("resolveFirstSoloPlan section-label authority", () => { + it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.test.ts new file mode 100644 index 000000000..f29336784 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatSoloPlanTime, resolveFirstSoloPlan } from "./firstSoloPlan"; + +const DEMO_SOLO_PLAN = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + +function withSoloSection( + overrides: { + id?: string; + start?: number; + end?: number; + soloPlan?: 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-solo"; + 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." + }, + soloPlan: + overrides.soloPlan ?? + DEMO_SOLO_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstSoloPlan", () => { + it("picks the demo song's earliest solo plan and the part that owns it", () => { + const resolved = resolveFirstSoloPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("keys-right"); + expect(resolved?.soloPlan).toBe(DEMO_SOLO_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatSoloPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatSoloPlanTime(Number.NaN)).toBe("0:00"); + expect(formatSoloPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a solo plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withSoloSection(); + delete song.sections[0]!.roles[0]!.soloPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = DEMO_SOLO_PLAN; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[0]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Shorten the last chorus vowel so the band can hear the cutoff."; + (song.sections[0]!.roles[0] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: DEMO_SOLO_PLAN + }; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("skips a blank solo plan", () => { + expect(resolveFirstSoloPlan(withSoloSection({ soloPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line solo plan", () => { + expect( + resolveFirstSoloPlan(withSoloSection({ soloPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two solo plans", () => { + const song = withSoloSection({ + id: "verse-late-solo", + start: 40, + end: 56, + roleId: "keys-right", + soloPlan: "Late solo." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + soloPlan: "Earlier solo." + } + ]; + 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 = resolveFirstSoloPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.soloPlan).toBe("Earlier solo."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time solo-plan ties with locale-independent id ordering", () => { + const song = withSoloSection({ id: "ä-solo", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-solo"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstSoloPlan(song)?.section.id).toBe("z-solo"); + }); + + it("prefers a high-priority solo part over a low-priority part in the same section", () => { + const song = withSoloSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + soloPlan: "Low-priority solo." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + soloPlan: "High-priority solo." + }; + 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(resolveFirstSoloPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstSoloPlan(song)?.soloPlan).toBe("High-priority solo."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withSoloSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + soloPlan: "ASCII solo." + }; + 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(resolveFirstSoloPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstSoloPlan(song)?.soloPlan).toBe("ASCII solo."); + }); + + it("skips a solo plan whose graph node is inactive", () => { + expect(resolveFirstSoloPlan(withSoloSection({ isActive: false }))).toBeNull(); + }); + + it("skips a solo plan whose rehearsal window is unbounded", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a solo plan whose end precedes its start", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length solo-plan window", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a solo plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstSoloPlan( + withSoloSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstSoloPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withSoloSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("keeps the solo plan unnamed when role identities are duplicated", () => { + const song = withSoloSection(); + 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(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("bounds the solo plan to 180 Unicode code points", () => { + const song = withSoloSection({ soloPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstSoloPlan(song); + expect(resolved?.soloPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the solo-plan boundary", () => { + const song = withSoloSection({ soloPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstSoloPlan(song); + expect(Array.from(resolved?.soloPlan ?? "")).toHaveLength(180); + expect(resolved?.soloPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts new file mode 100644 index 000000000..f95408852 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -0,0 +1,328 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_SOLO_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 solo plan: the earliest labeled section and the part that owns it. */ +export type FirstSoloPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + sectionIndex: number; + holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; + soloPlan: string; + atSeconds: number; +}; + +/** Format a non-negative solo-plan time as m:ss for rehearsal copy. */ +export function formatSoloPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + return items; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded snapshotted own solo plan, or null when it cannot be shown. */ +function ownedSoloPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const soloPlan = ownDataValue(role, "soloPlan"); + if (typeof soloPlan !== "string") { + return null; + } + const trimmed = soloPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_SOLO_PLAN_CHARACTERS); +} + +/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RankedRoleMetadata[]): RankedRoleMetadata | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = + PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { + return []; + } + + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); + + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Resolve a solo plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const holdingRole = pickHoldingRole( + rankedActiveRoles(section as RehearsalSection).filter( + (metadata) => ownedSoloPlan(metadata.role) !== null + ) + ); + if (!holdingRole) { + return []; + } + const soloPlan = ownedSoloPlan(holdingRole.role); + if (!soloPlan) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + sectionIndex, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, + soloPlan, + atSeconds: timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named solo plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { + try { + return resolveSafeFirstSoloPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..34ab47f4f 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,52 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-solo-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstSoloPlanOpenAction")).toBe("{at} {role} 솔로 열기"); + expect(t("firstSoloPlanBody")).toBe("{at} {section}에서 {role} 파트의 솔로 계획이 있습니다."); + expect(t("firstSoloPlanArmed")).toBe("{at}에서 {role} 파트의 솔로를 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f5656ce01 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a translator for the requested locale, falling back to the English dictionary for missing entries. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..1b0cfbe0c 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", + "firstSoloPlanLabel": "Tonight's first solo plan", + "firstSoloPlanOpenAction": "Open {role} solo at {at}", + "firstSoloPlanBody": "{role} still has a solo plan in the {section} at {at}.", + "firstSoloPlanArmed": "Lock that solo on {role} at {at} before the room starts.", + "firstSoloPlanUnavailable": "No solo plan is available. Stay on tonight's map for the next rehearsal cue." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..8d8d995d7 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": "진척도 증가", + "firstSoloPlanLabel": "오늘 첫 솔로 계획", + "firstSoloPlanOpenAction": "{at} {role} 솔로 열기", + "firstSoloPlanBody": "{at} {section}에서 {role} 파트의 솔로 계획이 있습니다.", + "firstSoloPlanArmed": "{at}에서 {role} 파트의 솔로를 맞춘 다음 합주를 시작하세요.", + "firstSoloPlanUnavailable": "사용 가능한 솔로 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..f991a56d4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,19 @@ 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/firstSoloPlan.ts", + "src/features/workspace/FirstSoloPlanCallout.tsx" +]; + + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +32,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..1443fbf3e 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | +| First Solo Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `soloPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, `hookPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-hook-plan, first-fill-plan, first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | diff --git a/docs/doctoring/reduced-motion-first-solo-plan-navigation.md b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md new file mode 100644 index 000000000..3816887a4 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first solo-plan navigation + +Open tonight's first solo 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..50591fb4e 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,8 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + /** Rehearsal-facing solo guidance owned by this role when runtime graph evidence corroborates it. */ + soloPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -506,6 +508,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Drop the top extension if the chorus turnaround still feels busy.", setupNote: "Keep the patch bright enough to stay over the guitars.", transpositionPlan: "If the band rehearses in D, keep the voicing in first inversion so the top line still sings.", + soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land.", manualOverrides: [], overlapWarnings: [ "Melodic overlap: top notes conflict with Lead Vocal range." @@ -1497,6 +1500,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "soloPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1556,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.soloPlan !== undefined && typeof value.soloPlan !== "string") { + return invalidField(`${path}.soloPlan`); + } 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..7e120d3a9 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[1]?.soloPlan).toContain("verse solo"); 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[1].soloPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[1]!.soloPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From f88542360b866a258263a73869d8e7d62055b0be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:12:19 -0700 Subject: [PATCH 02/26] test(workspace): cover proxy descriptor authority --- .../firstSoloPlan.proxy-authority.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts index 7c612e62f..bc96bf1c6 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts @@ -49,7 +49,7 @@ describe("resolveFirstSoloPlan own-data authority", () => { expect(resolveFirstSoloPlan(song)?.atSeconds).toBe(expectedStart); }); - it("returns snapshotted role identity and display copy instead of Proxy get values", () => { + it("uses 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 === "keys-right") ?? -1; @@ -63,6 +63,9 @@ describe("resolveFirstSoloPlan own-data authority", () => { const expectedName = role.name; section.roles[roleIndex] = new Proxy(role, { get(target, property, receiver) { + if (property === "id") { + return "injected-proxy-id"; + } if (property === "name") { return "Injected proxy role"; } @@ -80,4 +83,32 @@ describe("resolveFirstSoloPlan own-data authority", () => { expect(resolved?.holdingRoleId).toBe(expectedId); expect(resolved?.holdingRoleName).toBe(expectedName); }); + + it("fails closed when a Proxy descriptor trap fabricates solo-plan authority", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + section.roles[roleIndex] = new Proxy(role, { + getOwnPropertyDescriptor(target, property) { + if (property === "soloPlan") { + return { + configurable: true, + enumerable: true, + writable: true, + value: "Injected descriptor solo." + }; + } + return Reflect.getOwnPropertyDescriptor(target, property); + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); }); From 65a98cfba63bbff97c31ee9b49d4650cb28361b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:12:36 -0700 Subject: [PATCH 03/26] test(workspace): verify coverage config wiring --- .../desktop/src/features/workspace/coverageContract.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts index 7814c032f..e4d5141d9 100644 --- a/apps/desktop/src/features/workspace/coverageContract.test.ts +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; @@ -10,4 +11,9 @@ describe("desktop owned production coverage", () => { ]) ); }); + + it("wires the owned production list into Vitest coverage", () => { + const viteConfigSource = readFileSync(new URL("../../../vite.config.ts", import.meta.url), "utf8"); + expect(viteConfigSource).toMatch(/include:\s*DESKTOP_OWNED_PRODUCTION_COVERAGE\b/); + }); }); From 89d994ce81f08f54e533a7bd1eeccbae2c496387 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:14:45 -0700 Subject: [PATCH 04/26] test(workspace): cover stable solo navigation scope --- ...stSoloPlanCallout.workspace-scope.test.tsx | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx index c9596be06..a0c9df3dd 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx @@ -8,25 +8,27 @@ describe("FirstSoloPlanCallout workspace scope", () => { const firstSong = createDemoRehearsalSong(); const secondSong = createDemoRehearsalSong(); secondSong.id = "second-workspace-song"; + const firstSectionId = firstSong.sections[0]!.id; + const secondSectionId = secondSong.sections[0]!.id; const { container } = render( <>
-
+
-
+
); - const targets = container.querySelectorAll('[data-section-index="0"]'); + const targets = container.querySelectorAll("[data-section-id]"); expect(targets).toHaveLength(2); const firstScrollIntoView = vi.fn(); const secondScrollIntoView = vi.fn(); @@ -51,4 +53,78 @@ describe("FirstSoloPlanCallout workspace scope", () => { behavior: "smooth" }); }); + + it("navigates to the stable section identity even when rendered section order changes", () => { + const song = createDemoRehearsalSong(); + const expectedSectionId = song.sections[0]!.id; + const { container } = render( +
+ +
+
+
+
+
+ ); + + const targets = container.querySelectorAll("[data-section-id]"); + expect(targets).toHaveLength(2); + const wrongScrollIntoView = vi.fn(); + const expectedScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: wrongScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: expectedScrollIntoView + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(wrongScrollIntoView).not.toHaveBeenCalled(); + expect(expectedScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); + + it("fails closed when the current workspace contains multiple song-structure renderers", () => { + const song = createDemoRehearsalSong(); + const sectionId = song.sections[0]!.id; + const { container } = render( +
+ +
+
+
+
+
+
+
+ ); + + const targets = container.querySelectorAll("[data-section-id]"); + 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 + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + }); }); From e83705d9b2f0887d229e5ab5caf2f3a62db46012 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:15:18 -0700 Subject: [PATCH 05/26] test(workspace): fail closed on proxy-backed solo data --- .../firstSoloPlan.proxy-authority.test.ts | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts index bc96bf1c6..7674de49c 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts @@ -2,11 +2,8 @@ import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it } from "vitest"; import { resolveFirstSoloPlan } from "./firstSoloPlan"; -const DEMO_SOLO_PLAN = - "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; - -describe("resolveFirstSoloPlan own-data authority", () => { - it("uses the snapshotted own-data solo plan instead of a Proxy get trap", () => { +describe("resolveFirstSoloPlan runtime authority", () => { + it("fails closed when a Proxy get trap can substitute solo-plan copy", () => { const song = createDemoRehearsalSong(); const section = song.sections.find((candidate) => candidate.id === "verse-1"); const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; @@ -26,10 +23,10 @@ describe("resolveFirstSoloPlan own-data authority", () => { } }); - expect(resolveFirstSoloPlan(song)?.soloPlan).toBe(DEMO_SOLO_PLAN); + expect(resolveFirstSoloPlan(song)).toBeNull(); }); - it("uses the snapshotted own-data time range instead of a Proxy get trap", () => { + it("fails closed when a Proxy get trap can substitute the time range", () => { const song = createDemoRehearsalSong(); const section = song.sections.find((candidate) => candidate.id === "verse-1"); expect(section).toBeDefined(); @@ -46,10 +43,10 @@ describe("resolveFirstSoloPlan own-data authority", () => { } }); - expect(resolveFirstSoloPlan(song)?.atSeconds).toBe(expectedStart); + expect(resolveFirstSoloPlan(song)).toBeNull(); }); - it("uses snapshotted role identity and display copy instead of Proxy get values", () => { + it("fails closed when a Proxy get trap can substitute role identity or display copy", () => { const song = createDemoRehearsalSong(); const section = song.sections.find((candidate) => candidate.id === "verse-1"); const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; @@ -59,8 +56,6 @@ describe("resolveFirstSoloPlan own-data authority", () => { if (!section || !role || roleIndex < 0) { throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); } - const expectedId = role.id; - const expectedName = role.name; section.roles[roleIndex] = new Proxy(role, { get(target, property, receiver) { if (property === "id") { @@ -73,15 +68,7 @@ describe("resolveFirstSoloPlan own-data authority", () => { } }); - const resolved = resolveFirstSoloPlan(song) as - | (ReturnType & { - holdingRoleId?: string; - holdingRoleName?: string; - }) - | null; - expect(resolved?.soloPlan).toBe(DEMO_SOLO_PLAN); - expect(resolved?.holdingRoleId).toBe(expectedId); - expect(resolved?.holdingRoleName).toBe(expectedName); + expect(resolveFirstSoloPlan(song)).toBeNull(); }); it("fails closed when a Proxy descriptor trap fabricates solo-plan authority", () => { From e10c4b9feac940585f85b4ecf7d82951be31ea55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:16:35 -0700 Subject: [PATCH 06/26] fix(workspace): reject proxy-backed solo authority --- .../src/features/workspace/firstSoloPlan.ts | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts index f95408852..2bd9cfe93 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -56,6 +56,33 @@ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** + * Fail closed on Proxy or otherwise non-cloneable data boundaries. + * + * The HTML structured-serialization algorithm rejects Proxy exotic objects with + * DataCloneError. Ordinary accessor failures may still be tolerated because all + * authoritative reads below use own data-property descriptors and never invoke + * application getters. + */ +function rejectsStructuredDataBoundary(value: unknown): boolean { + if (value === null || typeof value !== "object") { + return false; + } + if (typeof structuredClone !== "function") { + return true; + } + try { + structuredClone(value); + return false; + } catch (error) { + return ( + typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "DataCloneError" + ); + } +} + /** 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); @@ -72,7 +99,7 @@ function ownDataValue(value: object, key: PropertyKey): unknown { /** Snapshot every numeric own data element from a bounded runtime array. */ function ownedDenseRuntimeArray(value: unknown): unknown[] | null { - if (!Array.isArray(value)) { + if (!Array.isArray(value) || rejectsStructuredDataBoundary(value)) { return null; } const length = ownDataValue(value, "length"); @@ -110,7 +137,7 @@ function truncateCodePoints(value: string, maximum: number): string { /** Return a bounded snapshotted own solo plan, or null when it cannot be shown. */ function ownedSoloPlan(role: unknown): string | null { - if (!isRuntimeObject(role)) { + if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(role)) { return null; } const soloPlan = ownDataValue(role, "soloPlan"); @@ -124,9 +151,9 @@ function ownedSoloPlan(role: unknown): string | null { return truncateCodePoints(trimmed, MAX_SOLO_PLAN_CHARACTERS); } -/** Snapshot trusted role identity, display name, and priority without Proxy get authority. */ +/** Snapshot trusted role identity, display name, and priority without accessor authority. */ function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { - if (!isRuntimeObject(role)) { + if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(role)) { return null; } const id = ownDataValue(role, "id"); @@ -155,7 +182,7 @@ function ownedBoundedTimeRange( section: RehearsalSection ): RehearsalSection["timeRange"] | null { const timeRange = ownDataValue(section, "timeRange"); - if (!isRuntimeObject(timeRange)) { + if (!isRuntimeObject(timeRange) || rejectsStructuredDataBoundary(timeRange)) { return null; } const start = ownDataValue(timeRange, "start"); @@ -215,14 +242,14 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { } const safeRoleIds = roles.flatMap((role) => { - if (!isRuntimeObject(role)) { + if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(role)) { return []; } const id = ownDataValue(role, "id"); return typeof id === "string" && id.trim().length > 0 ? [id] : []; }); const safeGraphRoleIds = partGraph.flatMap((node) => { - if (!isRuntimeObject(node)) { + if (!isRuntimeObject(node) || rejectsStructuredDataBoundary(node)) { return []; } const roleId = ownDataValue(node, "role_id"); @@ -232,7 +259,11 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); const activeIds = new Set( partGraph.flatMap((node) => { - if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + if ( + !isRuntimeObject(node) || + rejectsStructuredDataBoundary(node) || + ownDataValue(node, "is_active") !== true + ) { return []; } const roleId = ownDataValue(node, "role_id"); @@ -256,7 +287,7 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { /** Resolve a solo plan after the runtime root has passed its structural boundary checks. */ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { - if (!isRuntimeObject(song)) { + if (!isRuntimeObject(song) || rejectsStructuredDataBoundary(song)) { return null; } const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); @@ -266,7 +297,7 @@ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { const candidates = sections .flatMap((section, sectionIndex) => { - if (!isRuntimeObject(section)) { + if (!isRuntimeObject(section) || rejectsStructuredDataBoundary(section)) { return []; } const sectionId = ownDataValue(section, "id"); From 58f6a98b2e80dbc94f081004e52b5e8c33e2d118 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:17:06 -0700 Subject: [PATCH 07/26] fix(workspace): navigate solo plan by stable section id --- .../workspace/FirstSoloPlanCallout.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx index 0a6f6084b..74336af08 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx @@ -18,7 +18,6 @@ type SoloPlanCopyValues = Readonly>; type OpenedSoloPlan = Readonly<{ songIdentity: unknown; sectionId: string; - sectionIndex: number; holdingRoleId: string; soloPlan: string; atSeconds: number; @@ -75,6 +74,18 @@ function resolveSoloPlanRenderer(origin: HTMLElement): HTMLElement | null { return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; } +/** Resolve a rendered section by exact stable identity without selector interpolation. */ +function resolveSoloPlanSectionTarget(renderer: HTMLElement | null, sectionId: string): HTMLElement | null { + if (!renderer || sectionId.trim().length === 0) { + return null; + } + return ( + Array.from(renderer.querySelectorAll("[data-section-id]")).find( + (candidate) => candidate.dataset.sectionId === sectionId + ) ?? null + ); +} + /** Name tonight's first solo plan and open the matching rendered map section. */ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { const calloutId = `workspace-surface-solo-plan-${useId()}`; @@ -88,7 +99,6 @@ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { setOpenedSoloPlan(null); }, [ songIdentity, - named?.sectionIndex, named?.sectionId, named?.holdingRoleId, named?.soloPlan, @@ -114,7 +124,6 @@ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { openedSoloPlan !== null && openedSoloPlan.songIdentity === songIdentity && openedSoloPlan.sectionId === named.sectionId && - openedSoloPlan.sectionIndex === named.sectionIndex && openedSoloPlan.holdingRoleId === named.holdingRoleId && openedSoloPlan.soloPlan === named.soloPlan && openedSoloPlan.atSeconds === named.atSeconds; @@ -144,12 +153,7 @@ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { className="mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" onClick={(event) => { const renderer = resolveSoloPlanRenderer(event.currentTarget); - const target = - named.sectionIndex >= 0 - ? (renderer?.querySelector( - `[data-section-index="${named.sectionIndex}"]` - ) ?? null) - : null; + const target = resolveSoloPlanSectionTarget(renderer, named.sectionId); if (typeof target?.scrollIntoView !== "function") { return; } @@ -160,7 +164,6 @@ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { setOpenedSoloPlan({ songIdentity, sectionId: named.sectionId, - sectionIndex: named.sectionIndex, holdingRoleId: named.holdingRoleId, soloPlan: named.soloPlan, atSeconds: named.atSeconds From 46354e085c6fade5e5a9a6da17383ee7f513df66 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:19:05 -0700 Subject: [PATCH 08/26] fix(workspace): expose stable roadmap section identity --- apps/desktop/src/features/workspace/SectionRoadmap.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..1f5cabba9 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -106,6 +106,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( Date: Mon, 24 Aug 2026 19:19:43 -0700 Subject: [PATCH 09/26] fix(workspace): scope stable solo map navigation --- .../workspace/FirstSoloPlanCallout.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx index 74336af08..c81fdbf81 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx @@ -58,13 +58,13 @@ function preferredSoloPlanScrollBehavior(): ScrollBehavior { : "smooth"; } -/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */ -function resolveSoloPlanRenderer(origin: HTMLElement): HTMLElement | null { +/** Resolve this workspace's map scope, failing closed when its structure renderer is ambiguous. */ +function resolveSoloPlanWorkspaceScope(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; + return localScope; } if (localRenderers.length > 1) { return null; @@ -74,16 +74,15 @@ function resolveSoloPlanRenderer(origin: HTMLElement): HTMLElement | null { return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; } -/** Resolve a rendered section by exact stable identity without selector interpolation. */ -function resolveSoloPlanSectionTarget(renderer: HTMLElement | null, sectionId: string): HTMLElement | null { - if (!renderer || sectionId.trim().length === 0) { +/** Resolve exactly one rendered map section by stable identity without selector interpolation. */ +function resolveSoloPlanSectionTarget(scope: HTMLElement | null, sectionId: string): HTMLElement | null { + if (!scope || sectionId.trim().length === 0) { return null; } - return ( - Array.from(renderer.querySelectorAll("[data-section-id]")).find( - (candidate) => candidate.dataset.sectionId === sectionId - ) ?? null + const matches = Array.from(scope.querySelectorAll("[data-section-id]")).filter( + (candidate) => candidate.dataset.sectionId === sectionId ); + return matches.length === 1 ? (matches[0] ?? null) : null; } /** Name tonight's first solo plan and open the matching rendered map section. */ @@ -152,8 +151,8 @@ export function FirstSoloPlanCallout({ song }: FirstSoloPlanCalloutProps) { type="button" className="mt-3 min-h-11 bg-gradient-to-r from-cyan-300 to-emerald-300 font-black text-slate-950" onClick={(event) => { - const renderer = resolveSoloPlanRenderer(event.currentTarget); - const target = resolveSoloPlanSectionTarget(renderer, named.sectionId); + const scope = resolveSoloPlanWorkspaceScope(event.currentTarget); + const target = resolveSoloPlanSectionTarget(scope, named.sectionId); if (typeof target?.scrollIntoView !== "function") { return; } From 67a6d83f4d82f881f04eb3f8c324e27f7a5aee93 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:20:30 -0700 Subject: [PATCH 10/26] docs(storybook): add first solo plan states --- .../FirstSoloPlanCallout.stories.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstSoloPlanCallout.stories.tsx diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.stories.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.stories.tsx new file mode 100644 index 000000000..2e4f1b82d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; +import { SectionRoadmap } from "./SectionRoadmap"; + +function withoutSoloPlans(): RehearsalSong { + const song = createDemoRehearsalSong(); + return { + ...song, + sections: song.sections.map((section) => ({ + ...section, + roles: section.roles.map((role) => ({ ...role, soloPlan: "" })) + })) + }; +} + +function StorySurface({ song }: { song: RehearsalSong }) { + return ( +
+
+ + +
+ ); +} + +const meta = { + title: "Workspace/First Solo Plan Callout", + component: FirstSoloPlanCallout, + parameters: { layout: "fullscreen" }, + render: ({ song }) => +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Available: Story = { + args: { song: createDemoRehearsalSong() } +}; + +export const Unavailable: Story = { + args: { song: withoutSoloPlans() } +}; From c5d95a652484ee4969c4178e3a6bd1415e2f283e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:23:25 -0700 Subject: [PATCH 11/26] test(workspace): reject accessor-backed solo graphs --- .../workspace/FirstSoloPlanCallout.test.tsx | 92 +++++++++++-------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx index ced151f95..2df561e4f 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx @@ -10,14 +10,17 @@ function songWithSoloPlan() { return createDemoRehearsalSong(); } -function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { +function appendSongStructureTarget( + sectionId = "verse-1", + 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"; + target.dataset.sectionId = sectionId; const scrollIntoView = vi.fn(); Object.defineProperty(target, "scrollIntoView", { configurable: true, @@ -42,53 +45,62 @@ describe("FirstSoloPlanCallout", () => { ).toBeTruthy(); }); - it("contains a hostile song identity accessor instead of crashing the callout", () => { + it("fails closed on an accessor-backed runtime graph without invoking the getter", () => { const song = songWithSoloPlan(); + const readIdentity = vi.fn(() => { + throw new Error("hostile song id getter"); + }); Object.defineProperty(song, "id", { configurable: true, enumerable: true, - get() { - throw new Error("hostile song id getter"); - } + get: readIdentity }); expect(() => render()).not.toThrow(); - expect(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })).toBeTruthy(); + expect(readIdentity).not.toHaveBeenCalled(); + expect( + screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Open .* solo at/ })).toBeNull(); }); - it("resets armed guidance when accessor-id songs change with the same solo signature", () => { + it("keeps accessor-backed replacement songs unavailable without invoking either getter", () => { const firstSong = songWithSoloPlan(); const nextSong = songWithSoloPlan(); - 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(); + const firstRead = vi.fn(() => { + throw new Error("first hostile song id getter"); + }); + const nextRead = vi.fn(() => { + throw new Error("next hostile song id getter"); + }); + Object.defineProperty(firstSong, "id", { + configurable: true, + enumerable: true, + get: firstRead + }); + Object.defineProperty(nextSong, "id", { + configurable: true, + enumerable: true, + get: nextRead + }); - fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); + const { rerender } = render(); expect( - screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) + screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.") ).toBeTruthy(); rerender(); - expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + expect(firstRead).not.toHaveBeenCalled(); + expect(nextRead).not.toHaveBeenCalled(); expect( - screen.queryByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) - ).toBeNull(); - - grid.remove(); + screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.") + ).toBeTruthy(); }); it("preserves armed guidance across immutable edits of the same owned song", () => { const song = songWithSoloPlan(); - const { grid } = appendSongStructureTarget(); + const { grid } = appendSongStructureTarget(song.sections[0]!.id); const { rerender } = render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); @@ -128,9 +140,10 @@ describe("FirstSoloPlanCallout", () => { }); it("names the first solo plan as map navigation, scrolls to its rendered section, and arms that action", () => { - const { grid, scrollIntoView } = appendSongStructureTarget(); + const song = songWithSoloPlan(); + const { grid, scrollIntoView } = appendSongStructureTarget(song.sections[0]!.id); - render(); + render(); expect(screen.getByText(DEMO_SOLO_PLAN)).toBeTruthy(); const action = screen.getByRole("button", { @@ -147,9 +160,13 @@ describe("FirstSoloPlanCallout", () => { }); it("keeps map navigation stable when the renderer accessible name is localized", () => { - const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + const song = songWithSoloPlan(); + const { grid, scrollIntoView } = appendSongStructureTarget( + song.sections[0]!.id, + "스크롤 가능한 곡 구조 타임라인" + ); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); @@ -172,10 +189,10 @@ describe("FirstSoloPlanCallout", () => { ).toBeNull(); }); - it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + it("navigates by exact stable section identity without interpolating the id into a selector", () => { const song = songWithSoloPlan(); - song.sections[0]!.id = "analysis section / duplicate"; - const { grid, scrollIntoView } = appendSongStructureTarget(); + song.sections[0]!.id = 'analysis section / [data-test="hostile"]'; + const { grid, scrollIntoView } = appendSongStructureTarget(song.sections[0]!.id); render(); @@ -186,10 +203,11 @@ describe("FirstSoloPlanCallout", () => { }); it("fails closed when more than one song-structure renderer is mounted globally", () => { - const first = appendSongStructureTarget(); - const second = appendSongStructureTarget(); + const song = songWithSoloPlan(); + const first = appendSongStructureTarget(song.sections[0]!.id); + const second = appendSongStructureTarget(song.sections[0]!.id); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(first.scrollIntoView).not.toHaveBeenCalled(); From 636caf048a97214c6753aedcf69dd79fa6c4a4fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:24:26 -0700 Subject: [PATCH 12/26] fix(workspace): validate solo graph without invoking getters --- .../src/features/workspace/firstSoloPlan.ts | 97 +++++++++++++------ 1 file changed, 70 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts index 2bd9cfe93..9f81f83d9 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -8,6 +8,7 @@ import { const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_SOLO_PLAN_CHARACTERS = 180; +const MAX_RUNTIME_GRAPH_PROPERTIES = 100_000; const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); type RankedRoleMetadata = Readonly<{ @@ -17,6 +18,10 @@ type RankedRoleMetadata = Readonly<{ rehearsalPriority: keyof typeof PRIORITY_RANK; }>; +type RuntimeGraphBudget = { + properties: number; +}; + /** Tonight's first solo plan: the earliest labeled section and the part that owns it. */ export type FirstSoloPlan = { section: RehearsalSection; @@ -57,29 +62,71 @@ function isRuntimeObject(value: unknown): value is object { } /** - * Fail closed on Proxy or otherwise non-cloneable data boundaries. + * Verify that a structured-data graph contains only own data properties before cloning it. * - * The HTML structured-serialization algorithm rejects Proxy exotic objects with - * DataCloneError. Ordinary accessor failures may still be tolerated because all - * authoritative reads below use own data-property descriptors and never invoke - * application getters. + * Descriptor inspection avoids executing application getters. The subsequent HTML structured + * clone probe rejects Proxy exotic objects, so a Proxy cannot manufacture trusted descriptors + * for buyer-visible rehearsal guidance. The property budget also keeps this untrusted boundary + * finite before the resolver walks it. */ -function rejectsStructuredDataBoundary(value: unknown): boolean { - if (value === null || typeof value !== "object") { +function hasSafeStructuredDataDescriptors( + value: unknown, + seen: Set, + budget: RuntimeGraphBudget +): boolean { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "undefined" || + typeof value === "bigint" + ) { + return true; + } + if (typeof value !== "object") { return false; } - if (typeof structuredClone !== "function") { + if (seen.has(value)) { return true; } + seen.add(value); + + const keys = Reflect.ownKeys(value); + budget.properties += keys.length; + if (budget.properties > MAX_RUNTIME_GRAPH_PROPERTIES) { + return false; + } + + for (const key of keys) { + if (typeof key === "symbol") { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, "value") || + !hasSafeStructuredDataDescriptors(descriptor.value, seen, budget) + ) { + return false; + } + } + return true; +} + +/** Fail closed unless the entire runtime graph is descriptor-only and structured-clone compatible. */ +function hasSafeStructuredRuntimeGraph(value: unknown): boolean { + if (typeof structuredClone !== "function") { + return false; + } try { + if (!hasSafeStructuredDataDescriptors(value, new Set(), { properties: 0 })) { + return false; + } structuredClone(value); + return true; + } catch { return false; - } catch (error) { - return ( - typeof DOMException !== "undefined" && - error instanceof DOMException && - error.name === "DataCloneError" - ); } } @@ -99,7 +146,7 @@ function ownDataValue(value: object, key: PropertyKey): unknown { /** Snapshot every numeric own data element from a bounded runtime array. */ function ownedDenseRuntimeArray(value: unknown): unknown[] | null { - if (!Array.isArray(value) || rejectsStructuredDataBoundary(value)) { + if (!Array.isArray(value)) { return null; } const length = ownDataValue(value, "length"); @@ -137,7 +184,7 @@ function truncateCodePoints(value: string, maximum: number): string { /** Return a bounded snapshotted own solo plan, or null when it cannot be shown. */ function ownedSoloPlan(role: unknown): string | null { - if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(role)) { + if (!isRuntimeObject(role)) { return null; } const soloPlan = ownDataValue(role, "soloPlan"); @@ -153,7 +200,7 @@ function ownedSoloPlan(role: unknown): string | null { /** Snapshot trusted role identity, display name, and priority without accessor authority. */ function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { - if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(role)) { + if (!isRuntimeObject(role)) { return null; } const id = ownDataValue(role, "id"); @@ -182,7 +229,7 @@ function ownedBoundedTimeRange( section: RehearsalSection ): RehearsalSection["timeRange"] | null { const timeRange = ownDataValue(section, "timeRange"); - if (!isRuntimeObject(timeRange) || rejectsStructuredDataBoundary(timeRange)) { + if (!isRuntimeObject(timeRange)) { return null; } const start = ownDataValue(timeRange, "start"); @@ -242,14 +289,14 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { } const safeRoleIds = roles.flatMap((role) => { - if (!isRuntimeObject(role) || rejectsStructuredDataBoundary(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) || rejectsStructuredDataBoundary(node)) { + if (!isRuntimeObject(node)) { return []; } const roleId = ownDataValue(node, "role_id"); @@ -259,11 +306,7 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); const activeIds = new Set( partGraph.flatMap((node) => { - if ( - !isRuntimeObject(node) || - rejectsStructuredDataBoundary(node) || - ownDataValue(node, "is_active") !== true - ) { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { return []; } const roleId = ownDataValue(node, "role_id"); @@ -287,7 +330,7 @@ function rankedActiveRoles(section: RehearsalSection): RankedRoleMetadata[] { /** Resolve a solo plan after the runtime root has passed its structural boundary checks. */ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { - if (!isRuntimeObject(song) || rejectsStructuredDataBoundary(song)) { + if (!isRuntimeObject(song) || !hasSafeStructuredRuntimeGraph(song)) { return null; } const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); @@ -297,7 +340,7 @@ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { const candidates = sections .flatMap((section, sectionIndex) => { - if (!isRuntimeObject(section) || rejectsStructuredDataBoundary(section)) { + if (!isRuntimeObject(section)) { return []; } const sectionId = ownDataValue(section, "id"); From 7a682f64ebbc3095761be687a200c4f9021ab88a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 19:25:36 -0700 Subject: [PATCH 13/26] docs(workspace): record first solo trust and navigation contract --- ...duced-motion-first-solo-plan-navigation.md | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/reduced-motion-first-solo-plan-navigation.md b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md index 3816887a4..dc2f18cdc 100644 --- a/docs/doctoring/reduced-motion-first-solo-plan-navigation.md +++ b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md @@ -1,3 +1,39 @@ -# Reduced-motion first solo-plan navigation +# First solo-plan runtime and navigation contract -Open tonight's first solo plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. +## Buyer outcome + +The first-solo callout may name a rehearsal plan only when the current runtime song graph provides an owned, corroborated solo plan for an active role in a labeled section. If that authority cannot be established safely, the customer stays on the rehearsal map and receives the unavailable next-action guidance instead of invented or partially trusted copy. + +## Runtime authority boundary + +`resolveFirstSoloPlan` treats the incoming song as untrusted runtime data. Before any buyer-visible solo guidance is selected, the graph must satisfy all of these conditions: + +- the graph is finite under the repository's bounded property budget; +- every traversed own property is a data property, so application getters are never executed to establish rehearsal authority; +- symbol/function and accessor-backed authority is rejected; +- the graph is compatible with the HTML structured clone algorithm; and +- Proxy/exotic objects fail closed rather than supplying fabricated own-property descriptors. + +This last guard is standards-backed: the HTML Living Standard requires structured serialization to throw `DataCloneError` for unsupported exotic objects and explicitly gives a proxy object as an example. The resolver therefore performs descriptor-only preflight before the structured-clone probe: ordinary getters are not invoked, while a Proxy cannot become buyer-visible authority merely by trapping `getOwnPropertyDescriptor` (WHATWG, 2026). + +The resolver continues to require owned role identity, display name, rehearsal priority, bounded section time, unique active graph identity, and a bounded single-line `soloPlan`. Groove, cue, chord, simplification, overlap, setup, fill, tuning, dynamics, articulation, hook, transposition, override, harmonic explanation, and confidence text do not substitute for an owned solo plan. + +## Stable map navigation + +The Open action navigates by the exact stable `sectionId`, not by the section's current positional index. The action first requires one unambiguous song-structure renderer in the current workspace scope and then requires exactly one rendered element whose `data-section-id` equals the owned section identity. The id is compared as data rather than interpolated into a CSS selector, so punctuation or selector-like text cannot change selector semantics. Ambiguous or missing targets fail closed and do not switch the customer to the "locked" guidance state. + +The shared `SectionRoadmap` component exposes the stable section identity used by this navigation contract. Storybook's `Workspace/First Solo Plan Callout` stories exercise the reusable runtime callout and roadmap in both **Available** and **Unavailable** states rather than duplicating product markup. + +## Reduced motion + +When the operating-system preference `prefers-reduced-motion: reduce` matches, Open uses `behavior: "auto"` rather than smooth scrolling. W3C's current technique for interaction-triggered JavaScript motion recommends evaluating the reduced-motion media query so non-essential motion can be suppressed (World Wide Web Consortium [W3C], 2026). + +## Figma reconciliation status + +Repository runtime behavior and Storybook remain the executable source-backed contract. Fresh Figma metadata checked on 2026-08-25 for the repository-declared file `zthWmqfNKUgJBECvv002Qk` exposed only the `00 Cover` page (`16:2`), not the previously documented component-catalog page. This PR therefore does **not** claim a current Figma mapping for the first-solo callout. Figma/Storybook/source reconciliation remains tracked by BandScope issue #965 and must be corrected there before a Figma node is treated as implementation evidence. + +## References + +WHATWG. (2026). *HTML Living Standard: Safe passing of structured data*. https://html.spec.whatwg.org/multipage/structured-data.html + +World Wide Web Consortium. (2026). *SCR40: Using the CSS prefers-reduced-motion query in JavaScript to prevent motion*. https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR40 From 7d3d6de3e9dde2e10380fce7e7a6da8934685400 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 20:07:53 -0700 Subject: [PATCH 14/26] test(workspace): spy on solo-plan map target --- apps/desktop/src/features/workspace/Workspace.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index af76a29fb..39d358907 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -277,7 +277,10 @@ describe("Workspace", () => { render(); - const target = screen.getByTestId("song-structure-grid").children.item(0); + const firstSectionId = song.sections[0]!.id; + const target = Array.from(document.querySelectorAll("[data-section-id]")).find( + (candidate) => candidate.dataset.sectionId === firstSectionId + ); expect(target).toBeTruthy(); const scrollIntoView = vi.fn(); Object.defineProperty(target!, "scrollIntoView", { From 8bb9ba52dc9341939117c6a39b184d4f62eea9aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:06:55 -0700 Subject: [PATCH 15/26] test(workspace): reject exotic solo-plan metadata --- .../firstSoloPlan.proxy-authority.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts index 7674de49c..242c8ff74 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts @@ -98,4 +98,24 @@ describe("resolveFirstSoloPlan runtime authority", () => { expect(resolveFirstSoloPlan(song)).toBeNull(); }); + + it("fails closed when an exotic Map instance carries otherwise valid role metadata", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; + expect(section).toBeDefined(); + if (!section || roleIndex < 0) { + throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + const exoticRole = Object.assign(new Map(), { + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high" as const, + soloPlan: "Hold the owned solo before the room returns." + }); + section.roles[roleIndex] = exoticRole as unknown as (typeof section.roles)[number]; + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); }); From 4f9b3745af5c80e1227aac2661c85c83cc712bc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:07:37 -0700 Subject: [PATCH 16/26] fix(workspace): reject exotic solo-plan graph objects --- .../src/features/workspace/firstSoloPlan.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts index 9f81f83d9..670887aae 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -61,13 +61,22 @@ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** Accept only ordinary structured-data containers, never built-in or class-instance exotics. */ +function hasPlainStructuredPrototype(value: object): boolean { + const prototype = Object.getPrototypeOf(value); + if (Array.isArray(value)) { + return prototype === Array.prototype; + } + return prototype === Object.prototype || prototype === null; +} + /** - * Verify that a structured-data graph contains only own data properties before cloning it. + * Verify that a structured-data graph contains only plain containers and own data properties. * - * Descriptor inspection avoids executing application getters. The subsequent HTML structured - * clone probe rejects Proxy exotic objects, so a Proxy cannot manufacture trusted descriptors - * for buyer-visible rehearsal guidance. The property budget also keeps this untrusted boundary - * finite before the resolver walks it. + * Descriptor inspection avoids executing application getters. Prototype checks reject built-in + * and class-instance exotic objects before their attached properties can become buyer-visible + * authority. The subsequent HTML structured-clone probe rejects Proxy exotic objects, and the + * property budget keeps this untrusted boundary finite before the resolver walks it. */ function hasSafeStructuredDataDescriptors( value: unknown, @@ -84,7 +93,7 @@ function hasSafeStructuredDataDescriptors( ) { return true; } - if (typeof value !== "object") { + if (typeof value !== "object" || !hasPlainStructuredPrototype(value)) { return false; } if (seen.has(value)) { @@ -114,7 +123,7 @@ function hasSafeStructuredDataDescriptors( return true; } -/** Fail closed unless the entire runtime graph is descriptor-only and structured-clone compatible. */ +/** Fail closed unless the entire runtime graph is plain, descriptor-only, and clone compatible. */ function hasSafeStructuredRuntimeGraph(value: unknown): boolean { if (typeof structuredClone !== "function") { return false; From 0c2ee78e33c89941ee8af3a07cf05186178d276e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:08:06 -0700 Subject: [PATCH 17/26] docs(workspace): record solo-plan security test boundaries --- ...duced-motion-first-solo-plan-navigation.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/reduced-motion-first-solo-plan-navigation.md b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md index dc2f18cdc..ea5d7fc7a 100644 --- a/docs/doctoring/reduced-motion-first-solo-plan-navigation.md +++ b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md @@ -9,15 +9,33 @@ The first-solo callout may name a rehearsal plan only when the current runtime s `resolveFirstSoloPlan` treats the incoming song as untrusted runtime data. Before any buyer-visible solo guidance is selected, the graph must satisfy all of these conditions: - the graph is finite under the repository's bounded property budget; +- only ordinary arrays and plain objects are accepted as structured-data containers; built-in exotics and class instances are rejected; - every traversed own property is a data property, so application getters are never executed to establish rehearsal authority; - symbol/function and accessor-backed authority is rejected; - the graph is compatible with the HTML structured clone algorithm; and -- Proxy/exotic objects fail closed rather than supplying fabricated own-property descriptors. +- Proxy objects fail closed rather than supplying fabricated own-property descriptors. -This last guard is standards-backed: the HTML Living Standard requires structured serialization to throw `DataCloneError` for unsupported exotic objects and explicitly gives a proxy object as an example. The resolver therefore performs descriptor-only preflight before the structured-clone probe: ordinary getters are not invoked, while a Proxy cannot become buyer-visible authority merely by trapping `getOwnPropertyDescriptor` (WHATWG, 2026). +The plain-container check prevents `Map`, `Set`, `Date`, typed arrays, or class instances with attached rehearsal-looking properties from becoming product authority. The structured-clone probe remains a second boundary for Proxy/exotic behavior: the HTML Living Standard requires structured serialization to throw `DataCloneError` for unsupported exotic objects and explicitly gives a proxy object as an example. Descriptor-only preflight therefore avoids executing ordinary getters, while the clone probe prevents a Proxy from becoming buyer-visible authority merely by trapping `getOwnPropertyDescriptor` (WHATWG, 2026). The resolver continues to require owned role identity, display name, rehearsal priority, bounded section time, unique active graph identity, and a bounded single-line `soloPlan`. Groove, cue, chord, simplification, overlap, setup, fill, tuning, dynamics, articulation, hook, transposition, override, harmonic explanation, and confidence text do not substitute for an owned solo plan. +### Logging and privacy + +Rejected rehearsal metadata is not diagnostic payload. The resolver and navigation path do not log buyer-provided `soloPlan` text, role identifiers, section identifiers, or rejected graph contents. A safe failure returns guidance-only state and does not echo the rejected value, path, or object shape into logs. This keeps local rehearsal content on-device and prevents malformed runtime metadata from becoming an observability side channel. + +### Validation and test points + +The executable regression boundary covers: + +- own accessors and Proxy `get` / `getOwnPropertyDescriptor` substitution attempts; +- built-in exotic containers, including a `Map` carrying otherwise valid role metadata; +- malformed, sparse, duplicated, inherited, or unbounded runtime graph data; +- missing and duplicate rendered navigation targets, which must remain unarmed; +- stable `data-section-id` navigation when rendered order changes; and +- `prefers-reduced-motion: reduce`, which must use immediate (`auto`) scrolling. + +These checks belong to `resolveFirstSoloPlan`, `FirstSoloPlanCallout`, and the Workspace navigation regressions. New authority paths must extend those executable boundaries rather than relying on prose-only assurance. + ## Stable map navigation The Open action navigates by the exact stable `sectionId`, not by the section's current positional index. The action first requires one unambiguous song-structure renderer in the current workspace scope and then requires exactly one rendered element whose `data-section-id` equals the owned section identity. The id is compared as data rather than interpolated into a CSS selector, so punctuation or selector-like text cannot change selector semantics. Ambiguous or missing targets fail closed and do not switch the customer to the "locked" guidance state. From 66eaeacf89173489432a326858f170298f97eded Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:46:35 -0700 Subject: [PATCH 18/26] test(workspace): align solo particle navigation target --- .../features/workspace/FirstSoloPlanCallout.particle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx index b9d3fc68e..11277ff65 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx @@ -28,7 +28,7 @@ describe("FirstSoloPlanCallout Korean role copy", () => { grid.setAttribute("role", "region"); grid.setAttribute("aria-label", "Scrollable song structure timeline"); const target = document.createElement("div"); - target.dataset.sectionIndex = "0"; + target.dataset.sectionId = seed.id; Object.defineProperty(target, "scrollIntoView", { configurable: true, value: vi.fn() From ab886be01dd3e3ee613ffc933e535c05378cfe62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:47:58 -0700 Subject: [PATCH 19/26] test(workspace): align reduced-motion solo target --- .../workspace/FirstSoloPlanCallout.reduced-motion.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx index 124a41a86..238ed3aa8 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx @@ -20,12 +20,13 @@ describe("FirstSoloPlanCallout reduced motion", () => { dispatchEvent: vi.fn() })); + const song = createDemoRehearsalSong(); 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"; + target.dataset.sectionId = song.sections[0]!.id; const scrollIntoView = vi.fn(); Object.defineProperty(target, "scrollIntoView", { configurable: true, @@ -34,7 +35,7 @@ describe("FirstSoloPlanCallout reduced motion", () => { grid.appendChild(target); document.body.appendChild(grid); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); From 9292c9e32328871603021e3174eb63f0d7eb0a6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:48:23 -0700 Subject: [PATCH 20/26] test(workspace): verify solo coverage config directly --- .../src/features/workspace/coverageContract.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts index e4d5141d9..4ebda235e 100644 --- a/apps/desktop/src/features/workspace/coverageContract.test.ts +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -1,6 +1,5 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; +import desktopViteConfig, { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; describe("desktop owned production coverage", () => { it("keeps the first solo-plan resolver and callout inside the coverage gate", () => { @@ -13,7 +12,12 @@ describe("desktop owned production coverage", () => { }); it("wires the owned production list into Vitest coverage", () => { - const viteConfigSource = readFileSync(new URL("../../../vite.config.ts", import.meta.url), "utf8"); - expect(viteConfigSource).toMatch(/include:\s*DESKTOP_OWNED_PRODUCTION_COVERAGE\b/); + expect(desktopViteConfig).toMatchObject({ + test: { + coverage: { + include: DESKTOP_OWNED_PRODUCTION_COVERAGE + } + } + }); }); }); From 3c3ea647d9188c0e7bcdd2be6b88fb09c2956093 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:08:32 -0700 Subject: [PATCH 21/26] test(workspace): reject cross-workspace solo navigation --- ...stSoloPlanCallout.workspace-scope.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx index a0c9df3dd..f8450fdaf 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx @@ -127,4 +127,36 @@ describe("FirstSoloPlanCallout workspace scope", () => { expect(secondScrollIntoView).not.toHaveBeenCalled(); expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); }); + + it("does not navigate through a renderer owned by another workspace", () => { + const song = createDemoRehearsalSong(); + const sectionId = song.sections[0]!.id; + const { container } = render( + <> +
+ +
+
+
+
+
+
+ + ); + + const target = container.querySelector("[data-section-id]"); + expect(target).not.toBeNull(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + }); }); From 12ed1fb4bcc475bf1d44cc94f97bd4924cf34cca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:08:57 -0700 Subject: [PATCH 22/26] fix(workspace): keep solo navigation inside local scope --- .../src/features/workspace/FirstSoloPlanCallout.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx index c81fdbf81..e5600b322 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx @@ -58,20 +58,15 @@ function preferredSoloPlanScrollBehavior(): ScrollBehavior { : "smooth"; } -/** Resolve this workspace's map scope, failing closed when its structure renderer is ambiguous. */ +/** Resolve only this callout's local workspace scope; never borrow a renderer from another workspace. */ function resolveSoloPlanWorkspaceScope(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 localScope; - } - if (localRenderers.length > 1) { + if (!localScope) { return null; } - - const globalRenderers = document.querySelectorAll(selector); - return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null; + const localRenderers = localScope.querySelectorAll(selector); + return localRenderers.length === 1 ? localScope : null; } /** Resolve exactly one rendered map section by stable identity without selector interpolation. */ From bb6aa7034024da36fe3577f224ff97162f9bef57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 00:11:02 -0700 Subject: [PATCH 23/26] refactor(workspace): remove obsolete solo section index --- apps/desktop/src/features/workspace/firstSoloPlan.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts index 670887aae..f7b1ca3bf 100644 --- a/apps/desktop/src/features/workspace/firstSoloPlan.ts +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -27,7 +27,6 @@ export type FirstSoloPlan = { section: RehearsalSection; sectionId: string; sectionLabel: RehearsalSection["label"]; - sectionIndex: number; holdingRole: RehearsalRole; holdingRoleId: string; holdingRoleName: string; @@ -348,7 +347,7 @@ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { } const candidates = sections - .flatMap((section, sectionIndex) => { + .flatMap((section) => { if (!isRuntimeObject(section)) { return []; } @@ -382,7 +381,6 @@ function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { section: section as RehearsalSection, sectionId, sectionLabel: sectionLabel as RehearsalSection["label"], - sectionIndex, holdingRole: holdingRole.role, holdingRoleId: holdingRole.id, holdingRoleName: holdingRole.name, From 8a998b669394a713ac2ba6d4cec628d3ae2b8575 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 01:06:51 -0700 Subject: [PATCH 24/26] test(workspace): mount solo navigation in owned scope --- .../workspace/FirstSoloPlanCallout.test.tsx | 49 +++++++------------ 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx index 2df561e4f..5329e48d4 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.test.tsx @@ -11,6 +11,7 @@ function songWithSoloPlan() { } function appendSongStructureTarget( + parent: HTMLElement, sectionId = "verse-1", ariaLabel = "Scrollable song structure timeline" ) { @@ -19,6 +20,8 @@ function appendSongStructureTarget( timeline.setAttribute("aria-label", ariaLabel); const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; + timeline.appendChild(grid); + const target = document.createElement("div"); target.dataset.sectionId = sectionId; const scrollIntoView = vi.fn(); @@ -26,10 +29,9 @@ function appendSongStructureTarget( configurable: true, value: scrollIntoView }); - grid.appendChild(target); - timeline.appendChild(grid); - document.body.appendChild(timeline); - return { grid: timeline, scrollIntoView }; + + parent.append(timeline, target); + return { scrollIntoView }; } describe("FirstSoloPlanCallout", () => { @@ -100,8 +102,8 @@ describe("FirstSoloPlanCallout", () => { it("preserves armed guidance across immutable edits of the same owned song", () => { const song = songWithSoloPlan(); - const { grid } = appendSongStructureTarget(song.sections[0]!.id); - const { rerender } = render(); + const { container, rerender } = render(); + appendSongStructureTarget(container, song.sections[0]!.id); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect( @@ -114,8 +116,6 @@ describe("FirstSoloPlanCallout", () => { screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) ).toBeTruthy(); expect(screen.queryByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeNull(); - - grid.remove(); }); it("does not show another part's solo plan under the named holding part", () => { @@ -141,9 +141,8 @@ describe("FirstSoloPlanCallout", () => { it("names the first solo plan as map navigation, scrolls to its rendered section, and arms that action", () => { const song = songWithSoloPlan(); - const { grid, scrollIntoView } = appendSongStructureTarget(song.sections[0]!.id); - - render(); + const { container } = render(); + const { scrollIntoView } = appendSongStructureTarget(container, song.sections[0]!.id); expect(screen.getByText(DEMO_SOLO_PLAN)).toBeTruthy(); const action = screen.getByRole("button", { @@ -155,27 +154,23 @@ describe("FirstSoloPlanCallout", () => { expect( screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) ).toBeTruthy(); - - grid.remove(); }); it("keeps map navigation stable when the renderer accessible name is localized", () => { const song = songWithSoloPlan(); - const { grid, scrollIntoView } = appendSongStructureTarget( + const { container } = render(); + const { scrollIntoView } = appendSongStructureTarget( + container, song.sections[0]!.id, "스크롤 가능한 곡 구조 타임라인" ); - render(); - fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); expect( screen.getByText(/Lock that solo on Keyboard 1 Right Hand at 0:10 before the room starts./) ).toBeTruthy(); - - grid.remove(); }); it("does not claim map navigation completed when the rendered section target is missing", () => { @@ -192,29 +187,23 @@ describe("FirstSoloPlanCallout", () => { it("navigates by exact stable section identity without interpolating the id into a selector", () => { const song = songWithSoloPlan(); song.sections[0]!.id = 'analysis section / [data-test="hostile"]'; - const { grid, scrollIntoView } = appendSongStructureTarget(song.sections[0]!.id); - - render(); + const { container } = render(); + const { scrollIntoView } = appendSongStructureTarget(container, song.sections[0]!.id); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); - - grid.remove(); }); - it("fails closed when more than one song-structure renderer is mounted globally", () => { + it("fails closed when more than one song-structure renderer is mounted in the current workspace", () => { const song = songWithSoloPlan(); - const first = appendSongStructureTarget(song.sections[0]!.id); - const second = appendSongStructureTarget(song.sections[0]!.id); + const { container } = render(); + const first = appendSongStructureTarget(container, song.sections[0]!.id); + const second = appendSongStructureTarget(container, song.sections[0]!.id); - render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(first.scrollIntoView).not.toHaveBeenCalled(); expect(second.scrollIntoView).not.toHaveBeenCalled(); expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); - - first.grid.remove(); - second.grid.remove(); }); }); From 97e5d61ab6b04705a1e7a927746bea7dc46dae25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 01:07:18 -0700 Subject: [PATCH 25/26] test(workspace): scope Korean solo navigation fixture --- .../workspace/FirstSoloPlanCallout.particle.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx index 11277ff65..3969dee5e 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.particle.test.tsx @@ -23,6 +23,7 @@ describe("FirstSoloPlanCallout Korean role copy", () => { ]; seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + const { container } = render(); const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; grid.setAttribute("role", "region"); @@ -33,10 +34,7 @@ describe("FirstSoloPlanCallout Korean role copy", () => { configurable: true, value: vi.fn() }); - grid.appendChild(target); - document.body.appendChild(grid); - - render(); + container.append(grid, target); expect(screen.getByText("0:10 벌스에서 피아노 파트의 솔로 계획이 있습니다.")).toBeTruthy(); expect(screen.queryByText(/피아노이/)).toBeNull(); @@ -46,7 +44,5 @@ describe("FirstSoloPlanCallout Korean role copy", () => { expect(screen.getByText("0:10에서 피아노 파트의 솔로를 맞춘 다음 합주를 시작하세요.")).toBeTruthy(); expect(screen.queryByText(/피아노과/)).toBeNull(); - - grid.remove(); }); }); From 4be3ae300fdb1b75c42da9000c189014c599e5d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 01:07:41 -0700 Subject: [PATCH 26/26] test(workspace): scope reduced-motion solo fixture --- .../workspace/FirstSoloPlanCallout.reduced-motion.test.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx index 238ed3aa8..15517a253 100644 --- a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.reduced-motion.test.tsx @@ -21,6 +21,7 @@ describe("FirstSoloPlanCallout reduced motion", () => { })); const song = createDemoRehearsalSong(); + const { container } = render(); const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; grid.setAttribute("role", "region"); @@ -32,13 +33,9 @@ describe("FirstSoloPlanCallout reduced motion", () => { configurable: true, value: scrollIntoView }); - grid.appendChild(target); - document.body.appendChild(grid); + container.append(grid, target); - render(); fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" })); expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); - - grid.remove(); }); });