From 55646c2453cb559ce5a0ef3cee77c2e6746f9df1 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 23 Aug 2026 19:30:03 +0000 Subject: [PATCH 1/5] feat(workspace): name tonight's first capo plan on the map Name the earliest corroborated guitar capo plan so the room can lock the owned capo and tuning before rehearsal. Open scrolls the renderer-owned song-structure section. Distinct from first-setup-note (#1004) and first-transposition-plan (#1006). Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes. --- AGENTS.md | 1 + ARCHITECTURE.md | 3 +- CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstCapoPlanCallout.particle.test.tsx | 52 ++++ ...rstCapoPlanCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstCapoPlanCallout.test.tsx | 263 +++++++++++++++++ .../workspace/FirstCapoPlanCallout.tsx | 178 ++++++++++++ ...stCapoPlanCallout.workspace-scope.test.tsx | 54 ++++ .../src/features/workspace/Workspace.test.tsx | 30 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstCapoPlan.inherited-metadata.test.ts | 94 ++++++ .../features/workspace/firstCapoPlan.test.ts | 273 +++++++++++++++++ .../src/features/workspace/firstCapoPlan.ts | 274 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 +++- apps/desktop/src/i18n/index.ts | 36 ++- apps/desktop/src/locales/en/common.json | 7 +- apps/desktop/src/locales/ko/common.json | 7 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-capo-plan-navigation.md | 3 + packages/shared-types/src/index.ts | 40 ++- packages/shared-types/test/index.test.ts | 19 +- 22 files changed, 1429 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstCapoPlanCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCapoPlanCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCapoPlanCallout.workspace-scope.test.tsx create mode 100644 apps/desktop/src/features/workspace/firstCapoPlan.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCapoPlan.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCapoPlan.ts create mode 100644 docs/doctoring/reduced-motion-first-capo-plan-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..ea0a60919 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 capo plan with the owning guitar part when an active role is corroborated, the owned `capoPlan` copy, the labeled section, and the time so the next action is obvious. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..5709eb46d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,11 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-24 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first capo plan must name the owning guitar part when corroborated, the owned `capoPlan` text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note and first-transposition-plan. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..accfb9fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first capo plan in the mounted rehearsal workspace so a guitar part can lock the owned capo and tuning before rehearsal; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..fb9d7cafe 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 capo plan and opens the matching rendered map section. Do not invent that copy from groove, cue, simplification, overlap, range, chord labels, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes. Distinct from first-setup-note and first-transposition-plan. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.particle.test.tsx new file mode 100644 index 000000000..0efb5d820 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.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 { FirstCapoPlanCallout } from "./FirstCapoPlanCallout"; + +describe("FirstCapoPlanCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the capo action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + capoPlan: "Capo 2 in standard tuning so the verse fingers G shapes." + } + ]; + 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/FirstCapoPlanCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..085070c66 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.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 { FirstCapoPlanCallout } from "./FirstCapoPlanCallout"; + +describe("FirstCapoPlanCallout 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 Acoustic Guitar capo at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx new file mode 100644 index 000000000..002602d4e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstCapoPlanCallout } from "./FirstCapoPlanCallout"; + +const DEMO_CAPO_PLAN = + "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A."; + +function songWithCapoPlan() { + 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("FirstCapoPlanCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText( + "Nothing still has a capo plan. Stay on tonight's map until a guitar part owns rehearsal-facing capo copy." + ) + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithCapoPlan(); + 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 Acoustic Guitar capo at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same capo signature", () => { + const firstSong = songWithCapoPlan(); + const nextSong = songWithCapoPlan(); + 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 Acoustic Guitar capo at 0:10" })); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Acoustic Guitar still has a capo plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeNull(); + + grid.remove(); + }); + + it("preserves armed guidance across immutable edits of the same owned song", () => { + const song = songWithCapoPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + expect(screen.queryByText("Acoustic Guitar still has a capo plan in the verse at 0:10.")).toBeNull(); + + grid.remove(); + }); + + it("does not show another part's capo plan under the named holding part", () => { + const song = songWithCapoPlan(); + song.sections[0]!.roles[0]!.capoPlan = ""; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[1]!.capoPlan = "Capo 3 in standard tuning so the chorus fingers F shapes."; + song.sections[0]!.roles[2]!.capoPlan = "Capo 4, drop the top string if the chorus still bites."; + song.sections[0]!.roles[3]!.capoPlan = ""; + song.sections[0]!.roles[3]!.rehearsalPriority = "low"; + + render(); + + expect( + screen.getByText("Keyboard 1 Right Hand still has a capo plan in the verse at 0:10.") + ).toBeTruthy(); + expect(screen.getByText("Capo 3 in standard tuning so the chorus fingers F shapes.")).toBeTruthy(); + expect(screen.queryByText("Capo 4, drop the top string if the chorus still bites.")).toBeNull(); + expect(screen.queryByText(DEMO_CAPO_PLAN)).toBeNull(); + }); + + it("names the first capo plan as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText(DEMO_CAPO_PLAN)).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Acoustic Guitar capo at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + + expect(screen.getByText("Acoustic Guitar still has a capo plan in the verse at 0:10.")).toBeTruthy(); + expect( + screen.queryByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithCapoPlan(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the first capo plan changes or returns later", () => { + const initialSong = songWithCapoPlan(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Acoustic Guitar capo at 0:10" })); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + const nextSong = songWithCapoPlan(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Acoustic Guitar still has a capo plan in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable capo plan guidance-only", () => { + const song = songWithCapoPlan(); + for (const role of song.sections[0]!.roles) { + role.capoPlan = ""; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText( + "Nothing still has a capo plan. Stay on tonight's map until a guitar part owns rehearsal-facing capo copy." + ) + ).toBeTruthy(); + }); + + it("localizes the capo-plan form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithCapoPlan(); + song.sections[0]!.roles[3]!.name = "기타"; + + render(); + + expect(screen.getByText("0:10 벌스에서 기타 파트의 카포 계획이 있습니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned capo plan as a text node instead of template syntax", () => { + const song = songWithCapoPlan(); + song.sections[0]!.roles[1]!.capoPlan = ""; + song.sections[0]!.roles[2]!.capoPlan = ""; + song.sections[0]!.roles[3]!.capoPlan = ""; + song.sections[0]!.roles[0]!.capoPlan = "Check {role} at {at}"; + render(); + expect(screen.getByText("Check {role} at {at}")).toBeTruthy(); + expect(screen.queryByText("Check Acoustic Guitar at 0:10")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx new file mode 100644 index 000000000..3b20642be --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; +import { formatCapoPlanTime, resolveFirstCapoPlan } from "./firstCapoPlan"; + +/** Props for the first capo-plan rehearsal callout. */ +export interface FirstCapoPlanCalloutProps { + song: RehearsalSong; +} + +type CapoPlanCopyValues = Readonly>; + +type OpenedCapoPlan = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string; + capoPlan: string; + atSeconds: number; +}>; + +/** Read a stable owned song id, falling back to object identity for untrusted identity metadata. */ +function stableCapoPlanSongIdentity(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 capo-plan placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatCapoPlanCopy(template: string, values: CapoPlanCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof CapoPlanCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredCapoPlanScrollBehavior(): 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 resolveCapoPlanRenderer(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 capo plan and open the matching rendered map section. */ +export function FirstCapoPlanCallout({ song }: FirstCapoPlanCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity = stableCapoPlanSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const named = resolveFirstCapoPlan(song); + const namedSectionIndex = + named && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(named.section) + : -1; + const [openedCapoPlan, setOpenedCapoPlan] = useState(null); + + useEffect(() => { + setOpenedCapoPlan(null); + }, [ + songIdentity, + namedSectionIndex, + named?.section.id, + named?.holdingRole.id, + named?.capoPlan, + named?.atSeconds + ]); + + if (!named) { + return ( + + ); + } + + const opened = + openedCapoPlan !== null && + openedCapoPlan.songIdentity === songIdentity && + openedCapoPlan.sectionId === named.section.id && + openedCapoPlan.sectionIndex === namedSectionIndex && + openedCapoPlan.holdingRoleId === named.holdingRole.id && + openedCapoPlan.capoPlan === named.capoPlan && + openedCapoPlan.atSeconds === named.atSeconds; + const at = formatCapoPlanTime(named.atSeconds); + const copyValues: CapoPlanCopyValues = { + role: named.holdingRole.name, + section: translateSectionFormLabel(locale, named.section.label), + at + }; + const actionLabel = formatCapoPlanCopy(t("firstCapoPlanOpenAction"), copyValues); + const body = formatCapoPlanCopy(t("firstCapoPlanBody"), copyValues); + const armed = formatCapoPlanCopy(t("firstCapoPlanArmed"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..f5993e2df --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.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 { FirstCapoPlanCallout } from "./FirstCapoPlanCallout"; + +describe("FirstCapoPlanCallout 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 Acoustic Guitar capo 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..796b36fb1 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 capo 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( + "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Acoustic Guitar capo at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that capo on Acoustic Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..72680270d 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 { FirstCapoPlanCallout } from "./FirstCapoPlanCallout"; 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/firstCapoPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstCapoPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..15331b35e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCapoPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCapoPlan } from "./firstCapoPlan"; + +function songWithCapoPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "transpose-own"; + section.roles = [ + { + ...section.roles[0]!, + id: "acoustic-guitar", + name: "Acoustic Guitar", + rehearsalPriority: "high", + capoPlan: "Capo 2 in standard tuning so the verse fingers G shapes." + } + ]; + section.partGraph = [{ role_id: "acoustic-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstCapoPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithCapoPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstCapoPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithCapoPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithCapoPlan(); + Object.defineProperty(section.roles[0]!, "capoPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile capoPlan getter"); + } + }); + + expect(() => resolveFirstCapoPlan(song)).not.toThrow(); + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable capo-plan identity authority", () => { + const { song, section } = songWithCapoPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "transpose-own"; + } + }); + + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("does not let inherited capo plans establish the named copy", () => { + const { song, section } = songWithCapoPlan(); + const inheritedRole = Object.create({ + capoPlan: "Inherited capo plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "acoustic-guitar" }, + name: { configurable: true, enumerable: true, value: "Acoustic Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithCapoPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithCapoPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCapoPlan.test.ts b/apps/desktop/src/features/workspace/firstCapoPlan.test.ts new file mode 100644 index 000000000..233e40d1f --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCapoPlan.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatCapoPlanTime, resolveFirstCapoPlan } from "./firstCapoPlan"; + +const DEMO_CAPO_PLAN = + "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A."; + +function withCapoSection( + overrides: { + id?: string; + start?: number; + end?: number; + capoPlan?: 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-transpose"; + 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." + }, + capoPlan: + overrides.capoPlan ?? + "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A.", + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstCapoPlan", () => { + it("picks the demo song's earliest high-priority capo plan and the part that owns it", () => { + const resolved = resolveFirstCapoPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("acoustic-guitar"); + expect(resolved?.capoPlan).toBe(DEMO_CAPO_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatCapoPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatCapoPlanTime(Number.NaN)).toBe("0:00"); + expect(formatCapoPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a capo plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withCapoSection(); + delete song.sections[0]!.roles[0]!.capoPlan; + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered."; + song.sections[0]!.roles[0]!.setupNote = "Setup: Standard tuning, Capo 2"; + song.sections[0]!.roles[0]!.transpositionPlan = "If the singer drops to B minor, keep the shape a whole step lower."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" }; + song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" }; + song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."]; + song.sections[0]!.roles[0]!.harmony = { + chord: "C#m7", + functionLabel: "vi melodic pull", + source: "user" + }; + song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works."; + song.sections[0]!.roles[0]!.manualOverrides = [ + { + field: "harmony", + value: { + chord: "C#m11", + functionLabel: "vi suspended lift", + source: "user" + }, + source: "user" + } + ]; + song.sections[0]!.roles[0]!.confidence = { + level: "high", + source: "user", + notes: "Capo 2 in standard tuning so the verse fingers G shapes." + }; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("skips a blank capo plan", () => { + expect(resolveFirstCapoPlan(withCapoSection({ capoPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line capo plan", () => { + expect( + resolveFirstCapoPlan(withCapoSection({ capoPlan: "Drop a step.\nKeep the pickup." })) + ).toBeNull(); + }); + + it("prefers the earlier of two capo plans", () => { + const song = withCapoSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + capoPlan: "Late guitar capo." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + capoPlan: "Earlier guitar capo." + } + ]; + 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 = resolveFirstCapoPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.capoPlan).toBe("Earlier guitar capo."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time capo-plan ties with locale-independent id ordering", () => { + const song = withCapoSection({ id: "ä-transpose", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-transpose"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstCapoPlan(song)?.section.id).toBe("z-transpose"); + }); + + it("prefers a high-priority capo part over a low-priority part in the same section", () => { + const song = withCapoSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + capoPlan: "Low-priority guitar capo." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + capoPlan: "High-priority guitar capo." + }; + 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(resolveFirstCapoPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstCapoPlan(song)?.capoPlan).toBe("High-priority guitar capo."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withCapoSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + capoPlan: "ASCII guitar capo." + }; + 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(resolveFirstCapoPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstCapoPlan(song)?.capoPlan).toBe("ASCII guitar capo."); + }); + + it("skips a capo plan whose graph node is inactive", () => { + expect(resolveFirstCapoPlan(withCapoSection({ isActive: false }))).toBeNull(); + }); + + it("skips a capo plan whose rehearsal window is unbounded", () => { + expect(resolveFirstCapoPlan(withCapoSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a capo plan whose end precedes its start", () => { + expect(resolveFirstCapoPlan(withCapoSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length capo-plan window", () => { + expect(resolveFirstCapoPlan(withCapoSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a capo plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstCapoPlan( + withCapoSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstCapoPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withCapoSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("keeps the capo plan unnamed when role identities are duplicated", () => { + const song = withCapoSection(); + 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(resolveFirstCapoPlan(song)).toBeNull(); + }); + + it("bounds the capo plan to 180 Unicode code points", () => { + const song = withCapoSection({ capoPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstCapoPlan(song); + expect(resolved?.capoPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the capo-plan boundary", () => { + const song = withCapoSection({ capoPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstCapoPlan(song); + expect(Array.from(resolved?.capoPlan ?? "")).toHaveLength(180); + expect(resolved?.capoPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCapoPlan.ts b/apps/desktop/src/features/workspace/firstCapoPlan.ts new file mode 100644 index 000000000..d03d73972 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCapoPlan.ts @@ -0,0 +1,274 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_CAPO_PLAN_CHARACTERS = 180; + +/** Tonight's first capo plan: the earliest labeled section and the part that owns it. */ +export type FirstCapoPlan = { + section: RehearsalSection; + holdingRole: RehearsalRole; + capoPlan: string; + atSeconds: number; +}; + +/** Format a non-negative capo-plan time as m:ss for rehearsal copy. */ +export function formatCapoPlanTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded own capo plan, or null when it cannot be shown. */ +function ownedCapoPlan(role: unknown): string | null { + if (!isRuntimeObject(role) || !hasOwnData(role, "capoPlan")) { + return null; + } + const capoPlan = (role as { capoPlan?: unknown }).capoPlan; + if (typeof capoPlan !== "string") { + return null; + } + const trimmed = capoPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_CAPO_PLAN_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if (!isRuntimeObject(timeRange) || !hasOwnData(timeRange, "start") || !hasOwnData(timeRange, "end")) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Resolve a capo plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstCapoPlan(song: RehearsalSong): FirstCapoPlan | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .flatMap((section) => { + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedCapoPlan(role) !== null) + ); + if (!holdingRole) { + return []; + } + const capoPlan = ownedCapoPlan(holdingRole); + if (!capoPlan) { + return []; + } + return [ + { + section, + holdingRole, + capoPlan, + atSeconds: section.timeRange.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.section.id, right.section.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first named capo plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstCapoPlan(song: RehearsalSong): FirstCapoPlan | null { + try { + return resolveSafeFirstCapoPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..b183ee72d 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported Korean section form label", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-capo-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstCapoPlanOpenAction")).toBe("{at} {role} 카포 위치 열기"); + expect(t("firstCapoPlanBody")).toBe("{at} {section}에서 {role} 파트의 카포 계획이 있습니다."); + expect(t("firstCapoPlanArmed")).toBe("{at}에서 {role} 파트의 카포를 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 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 locale-aware translation lookup that falls back to English copy. */ 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..6f8102bd2 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", + "firstCapoPlanLabel": "Tonight's first capo plan", + "firstCapoPlanOpenAction": "Open {role} capo at {at}", + "firstCapoPlanBody": "{role} still has a capo plan in the {section} at {at}.", + "firstCapoPlanArmed": "Lock that capo on {role} at {at} before the room starts.", + "firstCapoPlanUnavailable": "Nothing still has a capo plan. Stay on tonight's map until a guitar part owns rehearsal-facing capo copy." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..23d714cae 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": "진척도 증가", + "firstCapoPlanLabel": "오늘 첫 카포 계획", + "firstCapoPlanOpenAction": "{at} {role} 카포 위치 열기", + "firstCapoPlanBody": "{at} {section}에서 {role} 파트의 카포 계획이 있습니다.", + "firstCapoPlanArmed": "{at}에서 {role} 파트의 카포를 맞춘 다음 합주를 시작하세요.", + "firstCapoPlanUnavailable": "카포를 맞춰야 하는 파트가 없습니다. 합주용 카포 카피가 있는 기타 파트가 생길 때까지 오늘 맵에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..1284e11fe 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | +| First Capo Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx` | Name the owning guitar part when an active graph node corroborates it, the owned `capoPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-setup-note (#1004) and first-transposition-plan (#1006). | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-capo-plan-navigation.md b/docs/doctoring/reduced-motion-first-capo-plan-navigation.md new file mode 100644 index 000000000..29565e5d3 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-capo-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first capo-plan navigation + +Open tonight's first capo 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..364f973dc 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + capoPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -552,12 +553,45 @@ const demoRehearsalSongSeed: RehearsalSong = { overlapWarnings: [ "Melodic overlap: competing with Keyboard 1 Right Hand." ] + }, + { + id: "acoustic-guitar", + name: "Acoustic Guitar", + roleType: "instrument", + harmony: { + chord: "A", + functionLabel: "IV open-shape floor", + source: "model" + }, + harmonicExplanation: "The guitar holds the verse in sounding A so capo 2 can keep the shapes in G while the rest of the room stays in concert pitch.", + cue: { + kind: "count", + value: "Strum on beat 1 after the pickup." + }, + range: { + lowestNote: "E2", + highestNote: "B4" + }, + confidence: { + level: "medium", + source: "model", + notes: "Confirm the capo fret before the first pass." + }, + rehearsalPriority: "high", + simplification: "Stay on open G shapes if the chorus still feels crowded.", + setupNote: "Keep the strum light so the verse still breathes.", + capoPlan: "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A.", + manualOverrides: [], + overlapWarnings: [ + "Density warning: competing with Keyboard 1 Right Hand in the midrange." + ] } ], partGraph: [ { role_id: "bass-guitar", is_active: true, handoff_to: ["lead-vocal"], handoff_from: [] }, { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, - { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] } + { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: ["bass-guitar"] }, + { role_id: "acoustic-guitar", is_active: true, handoff_to: [], handoff_from: [] } ] } ], @@ -1497,6 +1531,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "capoPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1587,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.capoPlan !== undefined && typeof value.capoPlan !== "string") { + return invalidField(`${path}.capoPlan`); + } 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..a92b7ee52 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -233,7 +233,7 @@ describe("shared type helpers", () => { expect(request).toEqual({ sourceKind: "demo", sourceLabel: "Late Night Set", - roleFocus: ["bass-guitar", "keys-right", "lead-vocal"] + roleFocus: ["bass-guitar", "keys-right", "lead-vocal", "acoustic-guitar"] }); expect(parseAnalysisJobRequest(request)).toEqual(request); expect(() => parseAnalysisJobRequest(null)).toThrow("root"); @@ -723,6 +723,12 @@ describe("shared type helpers", () => { kind: "lyric", value: "city lights" } + }, + { + id: "acoustic-guitar", + name: "Acoustic Guitar", + roleType: "instrument", + capoPlan: "Capo 2 in standard tuning so the verse fingers G shapes while the room still sounds in A." } ] } @@ -738,6 +744,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[3]?.capoPlan).toContain("Capo 2"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -812,8 +819,8 @@ describe("shared type helpers", () => { parsed.sections[0]?.roles.splice(0, 1); parsed.collaboration?.comments.splice(0, 1); - expect(parsed.sections[0]?.roles).toHaveLength(2); - expect(song.sections[0]?.roles).toHaveLength(3); + expect(parsed.sections[0]?.roles).toHaveLength(3); + expect(song.sections[0]?.roles).toHaveLength(4); expect(song.collaboration?.comments).toHaveLength(2); const legacySong = createDemoRehearsalSong() as unknown as { sections: Array>; @@ -1257,6 +1264,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].capoPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.capoPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => { From 493a6613992b0e074d91cdd78affe958d8a4cde2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:09:41 -0700 Subject: [PATCH 2/5] test(workspace): pin stable capo-plan region label --- .../src/features/workspace/FirstCapoPlanCallout.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx index 002602d4e..cef14b22b 100644 --- a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.test.tsx @@ -232,6 +232,9 @@ describe("FirstCapoPlanCallout", () => { } render(); expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByRole("complementary", { name: "Tonight's first capo plan" }) + ).toBeTruthy(); expect( screen.getByText( "Nothing still has a capo plan. Stay on tonight's map until a guitar part owns rehearsal-facing capo copy." From e1673b5968411cde72ce8945281c5db5779e145f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:10:10 -0700 Subject: [PATCH 3/5] fix(workspace): keep capo-plan region name stable --- apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx index 3b20642be..5fde6ba8f 100644 --- a/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstCapoPlanCallout.tsx @@ -104,7 +104,7 @@ export function FirstCapoPlanCallout({ song }: FirstCapoPlanCalloutProps) {