From 144edc47ff8b9a7f51c8e5ec211e30b0e2bc51f7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sun, 23 Aug 2026 12:40:19 +0000 Subject: [PATCH 01/25] feat(workspace): name tonight's first transition cue on the map Name the holding part when an active graph node corroborates it, the owned cue.kind === "transition" value, and the time so the room can catch the change together. Open scrolls the renderer-owned song-structure section. Do not invent a change from lyric, count, groove, setup, simplification, overlap, or form labels. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstTransitionCallout.particle.test.tsx | 52 ++++ ...tTransitionCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstTransitionCallout.test.tsx | 212 +++++++++++++ .../workspace/FirstTransitionCallout.tsx | 144 +++++++++ .../src/features/workspace/Workspace.test.tsx | 26 ++ .../src/features/workspace/Workspace.tsx | 11 +- ...firstTransition.inherited-metadata.test.ts | 93 ++++++ .../workspace/firstTransition.test.ts | 195 ++++++++++++ .../src/features/workspace/firstTransition.ts | 283 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 7 + apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- apps/desktop/vite.config.ts | 4 +- docs/design-system/component-contract.md | 1 + ...uced-motion-first-transition-navigation.md | 14 + 19 files changed, 1104 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstTransition.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTransition.test.ts create mode 100644 apps/desktop/src/features/workspace/firstTransition.ts create mode 100644 docs/doctoring/reduced-motion-first-transition-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..c9db46354 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 owned transition cue with the holding part when an active role is corroborated, the change, and the time so the next action is obvious. - 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..5fcf1fdd0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,6 +6,7 @@ Last updated: 2026-03-11 - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. +- The mounted workspace copy for tonight's first owned transition cue must name the holding part when corroborated, the change, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a transition from lyric, count, groove, setup, simplification, overlap, or form labels. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..f6fefafda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's first owned transition cue in the mounted rehearsal workspace so the room can catch the change together; the Open action moves to the matching rendered map section, while lyric, count, inherited, or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..b0fa6eb96 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 owned transition cue and opens the matching rendered map section. Do not invent a change from lyric, count, groove, or unlabeled form. `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/FirstTransitionCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx new file mode 100644 index 000000000..1ae094f83 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.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 { FirstTransitionCallout } from "./FirstTransitionCallout"; + +describe("FirstTransitionCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the transition action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles = [ + { + ...song.sections[0]!.roles[0]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + cue: { kind: "transition", value: "픽업 뒤에서 다운비트를 잡으세요." } + } + ]; + song.sections[0]!.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(); + + 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/FirstTransitionCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..87c200191 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.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 { FirstTransitionCallout } from "./FirstTransitionCallout"; + +describe("FirstTransitionCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx new file mode 100644 index 000000000..22e24644e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx @@ -0,0 +1,212 @@ +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 { FirstTransitionCallout } from "./FirstTransitionCallout"; + +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("FirstTransitionCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No transition cue yet. Stay on tonight's map until a change is named.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = createDemoRehearsalSong(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same transition signature", () => { + const firstSong = createDemoRehearsalSong(); + const nextSong = createDemoRehearsalSong(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar catches the change at 0:10.")).toBeTruthy(); + expect( + screen.queryByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeNull(); + + grid.remove(); + }); + + it("names the first transition as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Bass Guitar transition at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + expect(screen.getByText("Hold through the pickup before the downbeat.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim map navigation completed when the rendered section target is missing", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + + expect(screen.getByText("Bass Guitar catches the change at 0:10.")).toBeTruthy(); + expect( + screen.queryByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition 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 transition changes or returns later", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + + const nextSong = createDemoRehearsalSong(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 70, end: 90 }; + rerender(); + expect(screen.getByText("Bass Guitar catches the change at 1:10.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable transition guidance-only", () => { + const song = createDemoRehearsalSong(); + for (const role of song.sections[0]!.roles) { + role.cue = { kind: "lyric", value: "city lights" }; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No transition cue yet. Stay on tonight's map until a change is named.") + ).toBeTruthy(); + }); + + it("names a band-wide change when no part holds the transition", () => { + const song = createDemoRehearsalSong(); + for (const node of song.sections[0]!.partGraph) { + node.is_active = false; + } + render(); + expect(screen.getByRole("button", { name: "Open the first transition at 0:10" })).toBeTruthy(); + expect(screen.getByText("The band catches the change at 0:10.")).toBeTruthy(); + }); + + it("renders the owned cue as a text node instead of rescanning it as template syntax", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.cue = { + kind: "transition", + value: "Hold {role} through {at} before the hit." + }; + render(); + expect(screen.getByText("Hold {role} through {at} before the hit.")).toBeTruthy(); + expect(screen.queryByText("Hold Bass Guitar through 0:10 before the hit.")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx new file mode 100644 index 000000000..2a7c9c217 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import type { RehearsalSong } from "@bandscope/shared-types"; +import { Button } from "@/components/ui/button"; +import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { formatTransitionTime, resolveFirstTransition } from "./firstTransition"; + +/** Props for the first-transition rehearsal callout. */ +export interface FirstTransitionCalloutProps { + song: RehearsalSong; +} + +type TransitionCopyValues = Readonly>; + +type OpenedTransition = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; + cue: string; +}>; + +/** Interpolate transition placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatTransitionCopy(template: string, values: TransitionCopyValues): string { + return template.replace(/\{(role|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof TransitionCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredTransitionScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first owned transition cue and open the matching rendered map section. */ +export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const transition = resolveFirstTransition(song); + const transitionSectionIndex = + transition && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(transition.section) + : -1; + const [openedTransition, setOpenedTransition] = useState(null); + + useEffect(() => { + setOpenedTransition(null); + }, [ + songIdentity, + transitionSectionIndex, + transition?.section.id, + transition?.holdingRole?.id, + transition?.atSeconds, + transition?.cue + ]); + + if (!transition) { + return ( + + ); + } + + const opened = + openedTransition !== null && + openedTransition.songIdentity === songIdentity && + openedTransition.sectionId === transition.section.id && + openedTransition.sectionIndex === transitionSectionIndex && + openedTransition.holdingRoleId === (transition.holdingRole?.id ?? null) && + openedTransition.atSeconds === transition.atSeconds && + openedTransition.cue === transition.cue; + const at = formatTransitionTime(transition.atSeconds); + const copyValues: TransitionCopyValues = { + role: transition.holdingRole?.name ?? "", + at + }; + const hasRole = transition.holdingRole !== null; + const actionLabel = formatTransitionCopy( + t(hasRole ? "firstTransitionOpenAction" : "firstTransitionOpenActionBand"), + copyValues + ); + const body = formatTransitionCopy( + t(hasRole ? "firstTransitionBody" : "firstTransitionBodyBand"), + copyValues + ); + const armed = formatTransitionCopy( + t(hasRole ? "firstTransitionArmed" : "firstTransitionArmedBand"), + copyValues + ); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..c0fca41c6 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,30 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first transition as workspace navigation", () => { + 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 + }); + + const action = screen.getByRole("button", { + name: "Open Bass Guitar transition at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + expect( + screen.getAllByText("Hold through the pickup before the downbeat.").length + ).toBeGreaterThan(0); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..36410a8aa 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 { FirstTransitionCallout } from "./FirstTransitionCallout"; 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/firstTransition.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTransition.inherited-metadata.test.ts new file mode 100644 index 000000000..3bffe6730 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTransition.inherited-metadata.test.ts @@ -0,0 +1,93 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstTransition } from "./firstTransition"; + +function songWithTransition() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "change-own"; + section.timeRange = { start: 46, end: 54 }; + section.roles = [ + { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "high", + cue: { kind: "transition", value: "Hold through the pickup before the downbeat." } + } + ]; + section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstTransition inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithTransition(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstTransition(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithTransition(); + section.timeRange = Object.create({ start: 46, end: 54 }) as typeof section.timeRange; + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithTransition(); + Object.defineProperty(section.roles[0]!, "cue", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile cue getter"); + } + }); + + expect(() => resolveFirstTransition(song)).not.toThrow(); + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("does not treat own accessors as stable section identity authority", () => { + const { song, section } = songWithTransition(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "change-own"; + } + }); + + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("does not let inherited role, cue, or graph metadata establish the holding part", () => { + const { song, section } = songWithTransition(); + const role = section.roles[0]!; + const node = section.partGraph[0]!; + section.roles = [Object.create(role) as typeof role]; + section.partGraph = [Object.create(node) as typeof node]; + + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithTransition(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("rejects inherited cue records even when kind looks like a transition", () => { + const { song, section } = songWithTransition(); + section.roles[0]!.cue = Object.create({ + kind: "transition", + value: "Hold through the pickup before the downbeat." + }) as typeof section.roles[0]["cue"]; + expect(resolveFirstTransition(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTransition.test.ts b/apps/desktop/src/features/workspace/firstTransition.test.ts new file mode 100644 index 000000000..b605ff7e9 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTransition.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatTransitionTime, resolveFirstTransition } from "./firstTransition"; + +function withTransitionRole( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + cueKind?: "lyric" | "count" | "transition"; + cueValue?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + for (const role of verse.roles) { + role.cue = { kind: "lyric", value: "city lights" }; + } + const section = structuredClone(verse); + section.id = overrides.id ?? "change-1"; + section.label = "chorus"; + section.groove = ""; + section.timeRange = { start: overrides.start ?? 46, end: overrides.end ?? 62 }; + const roleId = overrides.roleId ?? "lead-vocal"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high", + cue: { + kind: overrides.cueKind ?? "transition", + value: overrides.cueValue ?? "Hold the last chord into the downbeat." + }, + simplification: "Stay on roots.", + setupNote: "Keep the patch dry.", + overlapWarnings: ["Density warning: competing with keys."] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, section]; + return song; +} + +describe("resolveFirstTransition", () => { + it("picks the demo song's owned bass transition without inventing one from lyric or count cues", () => { + const song = createDemoRehearsalSong(); + const transition = resolveFirstTransition(song); + + expect(song.sections[0]!.roles.find((role) => role.id === "lead-vocal")?.cue.kind).toBe("lyric"); + expect(song.sections[0]!.roles.find((role) => role.id === "keys-right")?.cue.kind).toBe("count"); + expect(transition?.holdingRole?.id).toBe("bass-guitar"); + expect(transition?.atSeconds).toBe(10); + expect(transition?.cue).toBe("Hold through the pickup before the downbeat."); + expect(formatTransitionTime(transition?.atSeconds ?? -1)).toBe("0:10"); + expect(formatTransitionTime(Number.NaN)).toBe("0:00"); + expect(formatTransitionTime(-4)).toBe("0:00"); + }); + + it("does not invent a transition from groove, setup notes, simplification, overlap, or form labels", () => { + const song = createDemoRehearsalSong(); + for (const role of song.sections[0]!.roles) { + role.cue = { kind: "lyric", value: "city lights" }; + } + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("does not treat a count or lyric cue as a transition", () => { + expect(resolveFirstTransition(withTransitionRole({ cueKind: "count" }))).toBeNull(); + expect(resolveFirstTransition(withTransitionRole({ cueKind: "lyric" }))).toBeNull(); + }); + + it("prefers the earlier of two labeled transition sections", () => { + const song = withTransitionRole({ id: "change-late", start: 80, end: 88, roleId: "keys-right", roleName: "Keys" }); + const earlier = structuredClone(song.sections[1]!); + earlier.id = "change-early"; + earlier.timeRange = { start: 46, end: 54 }; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium", + cue: { kind: "transition", value: "Leave space for the vocal lift." } + } + ]; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, song.sections[1]!, earlier]; + + const transition = resolveFirstTransition(song); + expect(transition?.section.id).toBe("change-early"); + expect(transition?.holdingRole?.id).toBe("bass-guitar"); + expect(transition?.atSeconds).toBe(46); + }); + + it("breaks same-time section ties with locale-independent id ordering", () => { + const song = withTransitionRole({ id: "ä-change", start: 46, end: 54 }); + const ascii = structuredClone(song.sections[1]!); + ascii.id = "z-change"; + song.sections = [song.sections[0]!, song.sections[1]!, ascii]; + + expect(resolveFirstTransition(song)?.section.id).toBe("z-change"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withTransitionRole({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[1]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + cue: { kind: "transition" as const, value: "Catch the hit together." } + }; + 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(resolveFirstTransition(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide change when no active ranked role holds it", () => { + const song = withTransitionRole({ isActive: false }); + const transition = resolveFirstTransition(song); + expect(transition?.section.id).toBe("change-1"); + expect(transition?.holdingRole).toBeNull(); + expect(transition?.cue).toBe("Hold the last chord into the downbeat."); + }); + + it("skips whitespace-only and overlong transition values", () => { + expect(resolveFirstTransition(withTransitionRole({ cueValue: " " }))).toBeNull(); + expect(resolveFirstTransition(withTransitionRole({ cueValue: "x".repeat(181) }))).toBeNull(); + }); + + it("skips a section whose rehearsal window is unbounded", () => { + expect(resolveFirstTransition(withTransitionRole({ start: Number.NaN, end: 62 }))).toBeNull(); + }); + + it("skips a section whose end precedes its start", () => { + expect(resolveFirstTransition(withTransitionRole({ start: 62, end: 46 }))).toBeNull(); + }); + + it("skips a zero-length window", () => { + expect(resolveFirstTransition(withTransitionRole({ start: 46, end: 46 }))).toBeNull(); + }); + + it("skips a section whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstTransition( + withTransitionRole({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstTransition(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withTransitionRole(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstTransition(song)).toBeNull(); + }); + + it("keeps the change band-wide when role identities are duplicated", () => { + const song = withTransitionRole(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const transition = resolveFirstTransition(song); + expect(transition?.section.id).toBe("change-1"); + expect(transition?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstTransition.ts b/apps/desktop/src/features/workspace/firstTransition.ts new file mode 100644 index 000000000..3b652b6d5 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTransition.ts @@ -0,0 +1,283 @@ +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_TRANSITION_CUE_CODE_POINTS = 180; + +/** Tonight's first owned transition cue: the earliest change and the part that holds it. */ +export type FirstTransition = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + cue: string; +}; + +/** Format a non-negative transition time as m:ss for rehearsal copy. */ +export function formatTransitionTime(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; +} + +/** 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 the owned transition cue text, or null when lyric/count/empty/overlong values cannot be trusted. */ +function ownedTransitionCue(role: RehearsalRole): string | null { + if (!hasOwnData(role, "cue")) { + return null; + } + const cue = role.cue as Partial | null; + if ( + !isRuntimeObject(cue) || + !hasOwnData(cue, "kind") || + cue.kind !== "transition" || + !hasOwnData(cue, "value") || + typeof cue.value !== "string" + ) { + return null; + } + const value = cue.value.trim(); + if (value.length === 0 || [...value].length > MAX_TRANSITION_CUE_CODE_POINTS) { + return null; + } + return value; +} + +/** 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 highest-priority ranked role, then a locale-independent stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + 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) + ); +} + +/** Collect owned transition-bearing roles in a section, regardless of graph activity. */ +function transitionRoles(section: RehearsalSection): RehearsalRole[] { + if (!hasOwnData(section, "roles") || !isDenseRuntimeArray(section.roles)) { + return []; + } + return section.roles.filter( + (role) => isRuntimeObject(role) && ownedTransitionCue(role) !== null + ); +} + +/** Resolve a transition after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null { + if ( + !isRuntimeObject(song) || + !hasOwnData(song, "sections") || + !isDenseRuntimeArray(song.sections) + ) { + return null; + } + + const transitionSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) && + transitionRoles(section).length > 0 + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = transitionSections[0]; + if (!section) { + return null; + } + + const holdingRole = pickHighestPriorityRole( + rankedActiveRoles(section).filter((role) => ownedTransitionCue(role) !== null) + ); + const cueRole = + holdingRole ?? + [...transitionRoles(section)].sort((left, right) => compareStableId(left.id, right.id))[0]; + const cue = cueRole ? ownedTransitionCue(cueRole) : null; + if (cue === null) { + return null; + } + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + cue + }; +} + +/** Return the first owned transition cue, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstTransition(song: RehearsalSong): FirstTransition | null { + try { + return resolveSafeFirstTransition(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..1fd3054a2 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -75,4 +75,11 @@ describe("i18n", () => { } }); }); + + it("keeps Korean first-transition next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstTransitionOpenAction")).toBe("{at} {role} 전환 위치 열기"); + expect(t("firstTransitionBody")).toBe("{at}에서 {role} 파트가 전환을 잡습니다."); + expect(t("firstTransitionArmed")).toBe("{at}에서 {role} 파트와 함께 전환을 잡으세요. 같이 넘기세요."); + }); }); diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..ba83c8301 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstTransitionLabel": "Tonight's first transition", + "firstTransitionOpenAction": "Open {role} transition at {at}", + "firstTransitionOpenActionBand": "Open the first transition at {at}", + "firstTransitionBody": "{role} catches the change at {at}.", + "firstTransitionBodyBand": "The band catches the change at {at}.", + "firstTransitionArmed": "Catch the change with {role} at {at}. Stay together through it.", + "firstTransitionArmedBand": "Catch the change at {at}. Stay together through it.", + "firstTransitionUnavailable": "No transition cue yet. Stay on tonight's map until a change is named." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..bd24b1d1b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstTransitionLabel": "오늘 첫 전환", + "firstTransitionOpenAction": "{at} {role} 전환 위치 열기", + "firstTransitionOpenActionBand": "{at} 첫 전환 위치 열기", + "firstTransitionBody": "{at}에서 {role} 파트가 전환을 잡습니다.", + "firstTransitionBodyBand": "밴드가 {at}에서 전환을 잡습니다.", + "firstTransitionArmed": "{at}에서 {role} 파트와 함께 전환을 잡으세요. 같이 넘기세요.", + "firstTransitionArmedBand": "{at}에서 전환을 잡으세요. 같이 넘기세요.", + "firstTransitionUnavailable": "아직 전환 큐가 없습니다. 전환이 표시될 때까지 오늘 지도에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..c543975c2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstTransition.ts", + "src/features/workspace/FirstTransitionCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..920cc4f0e 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 Transition Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstTransitionCallout.tsx` | Name the holding part when an active graph node corroborates it, the owned `cue.kind === "transition"` value, and the time. Do not invent a change from `lyric`, `count`, `groove`, `setupNote`, `simplification`, overlap warnings, or form labels. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-lyric, first-count-in, first-handoff, and first-stop work. | | 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-transition-navigation.md b/docs/doctoring/reduced-motion-first-transition-navigation.md new file mode 100644 index 000000000..5cfb5f820 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-transition-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-transition navigation + +Workspace map navigation for tonight's first owned transition cue follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstTransitionCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Transition resolution and analysis-id isolation stay unchanged. The owned cue text is rendered as a text node and is never rescanned as copy-template syntax. + +## Security Notes + +- Untrusted input: song, section, time-range, role, section-local graph, and `cue` metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: transition resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. Lyric, count, groove, setup, simplification, overlap, and form-label fields cannot establish a transition. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, cue values are bounded and trimmed, `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once on role/time placeholders only. +- Test points: inherited song/section/timing/role/cue/graph metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From b6780f4a5119bd4a1e162b1661335eecf2c639e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:18:20 -0700 Subject: [PATCH 02/25] test(workspace): preserve opened transition across immutable song updates --- .../FirstTransitionCallout.identity.test.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.identity.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.identity.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.identity.test.tsx new file mode 100644 index 000000000..815342e46 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.identity.test.tsx @@ -0,0 +1,38 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstTransitionCallout } from "./FirstTransitionCallout"; + +function appendTransitionTarget() { + const renderer = document.createElement("div"); + renderer.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + renderer.appendChild(target); + document.body.appendChild(renderer); + return renderer; +} + +describe("FirstTransitionCallout song identity", () => { + it("keeps opened guidance when an immutable update replaces the song object without changing the transition", () => { + const renderer = appendTransitionTarget(); + const song = createDemoRehearsalSong(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") + ).toBeTruthy(); + renderer.remove(); + }); +}); From 151d8ec7336889fecfaead0367b734ef1fbe38d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 06:19:00 -0700 Subject: [PATCH 03/25] fix(workspace): keep opened transition across immutable song updates --- .../workspace/FirstTransitionCallout.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index 2a7c9c217..2571193be 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -28,6 +28,27 @@ function formatTransitionCopy(template: string, values: TransitionCopyValues): s }); } +/** Resolve a stable song identity without invoking inherited or accessor-backed runtime state. */ +function stableSongIdentity(song: unknown): unknown { + if (song === null || typeof song !== "object") { + return song; + } + try { + const descriptor = Object.getOwnPropertyDescriptor(song, "id"); + if ( + descriptor && + Object.prototype.hasOwnProperty.call(descriptor, "value") && + typeof descriptor.value === "string" && + descriptor.value.trim().length > 0 + ) { + return descriptor.value; + } + } catch { + // Hostile Proxy descriptor traps cannot establish persistent song identity. + } + return song; +} + /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredTransitionScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -40,7 +61,7 @@ function preferredTransitionScrollBehavior(): ScrollBehavior { export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); - const songIdentity: unknown = song; + const songIdentity = stableSongIdentity(song); const runtimeSong = song as unknown as Partial | null; const transition = resolveFirstTransition(song); const transitionSectionIndex = From 5788dc489ed77947d28d389f5c00d4c585068e59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 07:44:47 -0700 Subject: [PATCH 04/25] test(workspace): lock transition time-range authority --- ...rstTransition.time-range-authority.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/desktop/src/features/workspace/firstTransition.time-range-authority.test.ts diff --git a/apps/desktop/src/features/workspace/firstTransition.time-range-authority.test.ts b/apps/desktop/src/features/workspace/firstTransition.time-range-authority.test.ts new file mode 100644 index 000000000..f2b73b883 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstTransition.time-range-authority.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstTransition } from "./firstTransition"; + +it("uses snapshotted own time-range values instead of Proxy get-trap substitutions", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const trustedTimeRange = section.timeRange; + + section.timeRange = new Proxy(trustedTimeRange, { + get(target, property, receiver) { + if (property === "start") { + return trustedTimeRange.start + 10; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstTransition(song)?.atSeconds).toBe(trustedTimeRange.start); +}); From 9318b9046ea89ccc4de8a617deb71452e8e4a265 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 07:46:00 -0700 Subject: [PATCH 05/25] fix(workspace): snapshot transition time-range authority --- .../src/features/workspace/firstTransition.ts | 101 +++++++++++------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTransition.ts b/apps/desktop/src/features/workspace/firstTransition.ts index 3b652b6d5..b8b7c714d 100644 --- a/apps/desktop/src/features/workspace/firstTransition.ts +++ b/apps/desktop/src/features/workspace/firstTransition.ts @@ -8,6 +8,11 @@ import { const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; const MAX_TRANSITION_CUE_CODE_POINTS = 180; +type BoundedTimeRange = Readonly<{ + start: number; + end: number; +}>; + /** Tonight's first owned transition cue: the earliest change and the part that holds it. */ export type FirstTransition = { section: RehearsalSection; @@ -42,10 +47,17 @@ function isRuntimeObject(value: unknown): value is object { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** Return an owned data-property value without invoking a getter or ordinary property read. */ +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; +} + /** 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 ownDataValue(value, key) !== undefined; } /** Return whether every numeric index is an own data element in a bounded runtime array. */ @@ -79,30 +91,29 @@ function hasRankedPriority(role: RehearsalRole): boolean { ); } -/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ -function hasBoundedTimeRange(section: RehearsalSection): boolean { - if (!hasOwnData(section, "timeRange")) { - return false; +/** Snapshot a bounded, positive-length integer rehearsal window from owned data properties. */ +function boundedTimeRange(section: RehearsalSection): BoundedTimeRange | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; } - const timeRange = section.timeRange as Partial | null; + + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); if ( - !isRuntimeObject(timeRange) || - !hasOwnData(timeRange, "start") || - !hasOwnData(timeRange, "end") + 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 false; + return null; } - const start = timeRange.start ?? -1; - const end = timeRange.end ?? -1; - return ( - Number.isInteger(start) && - start >= 0 && - start <= MAX_SECTION_TIME_SECONDS && - Number.isInteger(end) && - end > start && - end <= MAX_SECTION_TIME_SECONDS - ); + return { start, end }; } /** Return the owned transition cue text, or null when lyric/count/empty/overlong values cannot be trusted. */ @@ -232,27 +243,37 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null return null; } - const transitionSections = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasOwnData(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) && - transitionRoles(section).length > 0 - ) - .sort((left, right) => { - if (left.timeRange.start !== right.timeRange.start) { - return left.timeRange.start - right.timeRange.start; - } - return compareStableId(left.id, right.id); - }); + const transitionSections: Array<{ + section: RehearsalSection; + timeRange: BoundedTimeRange; + }> = []; + for (const section of song.sections) { + if ( + !isRuntimeObject(section) || + !hasOwnData(section, "id") || + typeof section.id !== "string" || + section.id.trim().length === 0 + ) { + continue; + } + const timeRange = boundedTimeRange(section); + if (timeRange === null || transitionRoles(section).length === 0) { + continue; + } + transitionSections.push({ section, timeRange }); + } + transitionSections.sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.section.id, right.section.id); + }); - const section = transitionSections[0]; - if (!section) { + const candidate = transitionSections[0]; + if (!candidate) { return null; } + const section = candidate.section; const holdingRole = pickHighestPriorityRole( rankedActiveRoles(section).filter((role) => ownedTransitionCue(role) !== null) @@ -268,7 +289,7 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null return { section, holdingRole, - atSeconds: section.timeRange.start, + atSeconds: candidate.timeRange.start, cue }; } From f0905a4bb73a99cdd8d9a46a31f2f3e023c0b582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:40:55 -0700 Subject: [PATCH 06/25] test(workspace): require localized transition section --- .../workspace/FirstTransitionCallout.particle.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx index 1ae094f83..3acc474a1 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.particle.test.tsx @@ -39,7 +39,7 @@ describe("FirstTransitionCallout Korean role copy", () => { render(); - expect(screen.getByText("0:10에서 피아노 파트가 전환을 잡습니다.")).toBeTruthy(); + expect(screen.getByText("0:10 벌스에서 피아노 파트가 전환을 잡습니다.")).toBeTruthy(); expect(screen.queryByText(/피아노이/)).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 전환 위치 열기" })); @@ -49,4 +49,4 @@ describe("FirstTransitionCallout Korean role copy", () => { grid.remove(); }); -}); +}); \ No newline at end of file From 67255bbd57d445b351400623d93cad8ec7e7f389 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:43:01 -0700 Subject: [PATCH 07/25] fix(workspace): snapshot transition section label --- .../src/features/workspace/firstTransition.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTransition.ts b/apps/desktop/src/features/workspace/firstTransition.ts index b8b7c714d..a257de3c7 100644 --- a/apps/desktop/src/features/workspace/firstTransition.ts +++ b/apps/desktop/src/features/workspace/firstTransition.ts @@ -16,6 +16,7 @@ type BoundedTimeRange = Readonly<{ /** Tonight's first owned transition cue: the earliest change and the part that holds it. */ export type FirstTransition = { section: RehearsalSection; + sectionLabel: string; holdingRole: RehearsalRole | null; atSeconds: number; cue: string; @@ -245,14 +246,20 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null const transitionSections: Array<{ section: RehearsalSection; + sectionLabel: string; timeRange: BoundedTimeRange; }> = []; for (const section of song.sections) { + if (!isRuntimeObject(section)) { + continue; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); if ( - !isRuntimeObject(section) || - !hasOwnData(section, "id") || - typeof section.id !== "string" || - section.id.trim().length === 0 + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + sectionLabel.trim().length === 0 ) { continue; } @@ -260,7 +267,7 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null if (timeRange === null || transitionRoles(section).length === 0) { continue; } - transitionSections.push({ section, timeRange }); + transitionSections.push({ section, sectionLabel, timeRange }); } transitionSections.sort((left, right) => { if (left.timeRange.start !== right.timeRange.start) { @@ -288,6 +295,7 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null return { section, + sectionLabel: candidate.sectionLabel, holdingRole, atSeconds: candidate.timeRange.start, cue From e9ab48bd8b9098536b5a50cbf6dc9f6c6132da7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:43:34 -0700 Subject: [PATCH 08/25] feat(i18n): localize rehearsal section forms --- apps/desktop/src/i18n/index.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..afc1aafe7 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return a localized section form when it is canonical; preserve unknown owned labels literally. */ +export function translateSectionFormLabel(locale: Locale, label: string): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label]! : label; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { From 774570b8c143a78496aa9f9e5570fb2369b9e720 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:44:01 -0700 Subject: [PATCH 09/25] test(i18n): cover transition section labels --- apps/desktop/src/i18n/index.test.ts | 46 +++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index 1fd3054a2..1c50d48c4 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", () => { @@ -76,10 +76,50 @@ describe("i18n", () => { }); }); + describe("translateSectionFormLabel", () => { + it("localizes every supported section form label for Korean rehearsal copy", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves supported English section form labels", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("preserves unknown owned section labels literally instead of inheriting object keys", () => { + expect(translateSectionFormLabel("ko", "toString")).toBe("toString"); + expect(translateSectionFormLabel("ko", "verse-legacy")).toBe("verse-legacy"); + }); + }); + it("keeps Korean first-transition next-action copy particle-safe", () => { const t = createTranslator("ko"); expect(t("firstTransitionOpenAction")).toBe("{at} {role} 전환 위치 열기"); - expect(t("firstTransitionBody")).toBe("{at}에서 {role} 파트가 전환을 잡습니다."); + expect(t("firstTransitionBody")).toBe("{at} {section}에서 {role} 파트가 전환을 잡습니다."); expect(t("firstTransitionArmed")).toBe("{at}에서 {role} 파트와 함께 전환을 잡으세요. 같이 넘기세요."); }); -}); +}); \ No newline at end of file From 9837d428349940400a616e6081437fa3a09ea262 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:47:05 -0700 Subject: [PATCH 10/25] feat(i18n): name transition section in English --- apps/desktop/src/locales/en/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index ba83c8301..b9a0bea8d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -152,8 +152,8 @@ "firstTransitionLabel": "Tonight's first transition", "firstTransitionOpenAction": "Open {role} transition at {at}", "firstTransitionOpenActionBand": "Open the first transition at {at}", - "firstTransitionBody": "{role} catches the change at {at}.", - "firstTransitionBodyBand": "The band catches the change at {at}.", + "firstTransitionBody": "{role} catches the change in the {section} at {at}.", + "firstTransitionBodyBand": "The band catches the change in the {section} at {at}.", "firstTransitionArmed": "Catch the change with {role} at {at}. Stay together through it.", "firstTransitionArmedBand": "Catch the change at {at}. Stay together through it.", "firstTransitionUnavailable": "No transition cue yet. Stay on tonight's map until a change is named." From cd0309e3ea5338cc468b7355dd3831819bdf8287 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:48:12 -0700 Subject: [PATCH 11/25] feat(i18n): name transition section in Korean --- apps/desktop/src/locales/ko/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index bd24b1d1b..36aed677b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -152,8 +152,8 @@ "firstTransitionLabel": "오늘 첫 전환", "firstTransitionOpenAction": "{at} {role} 전환 위치 열기", "firstTransitionOpenActionBand": "{at} 첫 전환 위치 열기", - "firstTransitionBody": "{at}에서 {role} 파트가 전환을 잡습니다.", - "firstTransitionBodyBand": "밴드가 {at}에서 전환을 잡습니다.", + "firstTransitionBody": "{at} {section}에서 {role} 파트가 전환을 잡습니다.", + "firstTransitionBodyBand": "{at} {section}에서 밴드가 전환을 잡습니다.", "firstTransitionArmed": "{at}에서 {role} 파트와 함께 전환을 잡으세요. 같이 넘기세요.", "firstTransitionArmedBand": "{at}에서 전환을 잡으세요. 같이 넘기세요.", "firstTransitionUnavailable": "아직 전환 큐가 없습니다. 전환이 표시될 때까지 오늘 지도에 머무르세요." From 6d46388e47eca7513938c6837c50fce3fe4ad194 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:49:14 -0700 Subject: [PATCH 12/25] feat(workspace): show localized transition section --- .../features/workspace/FirstTransitionCallout.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index 2571193be..b5964e079 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { + createTranslator, + detectPreferredLocale, + translateSectionFormLabel +} from "../../i18n"; import { formatTransitionTime, resolveFirstTransition } from "./firstTransition"; /** Props for the first-transition rehearsal callout. */ @@ -9,12 +13,13 @@ export interface FirstTransitionCalloutProps { song: RehearsalSong; } -type TransitionCopyValues = Readonly>; +type TransitionCopyValues = Readonly>; type OpenedTransition = Readonly<{ songIdentity: unknown; sectionId: string; sectionIndex: number; + sectionLabel: string; holdingRoleId: string | null; atSeconds: number; cue: string; @@ -22,7 +27,7 @@ type OpenedTransition = Readonly<{ /** Interpolate transition placeholders once so rehearsal data is never rescanned as template syntax. */ function formatTransitionCopy(template: string, values: TransitionCopyValues): string { - return template.replace(/\{(role|at)\}/g, (placeholder) => { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { const key = placeholder.slice(1, -1) as keyof TransitionCopyValues; return values[key] ?? placeholder; }); @@ -76,6 +81,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { songIdentity, transitionSectionIndex, transition?.section.id, + transition?.sectionLabel, transition?.holdingRole?.id, transition?.atSeconds, transition?.cue @@ -99,12 +105,14 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { openedTransition.songIdentity === songIdentity && openedTransition.sectionId === transition.section.id && openedTransition.sectionIndex === transitionSectionIndex && + openedTransition.sectionLabel === transition.sectionLabel && openedTransition.holdingRoleId === (transition.holdingRole?.id ?? null) && openedTransition.atSeconds === transition.atSeconds && openedTransition.cue === transition.cue; const at = formatTransitionTime(transition.atSeconds); const copyValues: TransitionCopyValues = { role: transition.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, transition.sectionLabel), at }; const hasRole = transition.holdingRole !== null; @@ -152,6 +160,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { songIdentity, sectionId: transition.section.id, sectionIndex: transitionSectionIndex, + sectionLabel: transition.sectionLabel, holdingRoleId: transition.holdingRole?.id ?? null, atSeconds: transition.atSeconds, cue: transition.cue From af90205a90832cd3ac9fe9535e7e7323a886d700 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:50:50 -0700 Subject: [PATCH 13/25] test(workspace): align transition section copy --- .../features/workspace/FirstTransitionCallout.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx index 22e24644e..46665112a 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.test.tsx @@ -71,7 +71,7 @@ describe("FirstTransitionCallout", () => { rerender(); - expect(screen.getByText("Bass Guitar catches the change at 0:10.")).toBeTruthy(); + expect(screen.getByText("Bass Guitar catches the change in the verse at 0:10.")).toBeTruthy(); expect( screen.queryByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") ).toBeNull(); @@ -118,7 +118,7 @@ describe("FirstTransitionCallout", () => { fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transition at 0:10" })); - expect(screen.getByText("Bass Guitar catches the change at 0:10.")).toBeTruthy(); + expect(screen.getByText("Bass Guitar catches the change in the verse at 0:10.")).toBeTruthy(); expect( screen.queryByText("Catch the change with Bass Guitar at 0:10. Stay together through it.") ).toBeNull(); @@ -172,7 +172,7 @@ describe("FirstTransitionCallout", () => { nextSong.id = "next-song"; nextSong.sections[0]!.timeRange = { start: 70, end: 90 }; rerender(); - expect(screen.getByText("Bass Guitar catches the change at 1:10.")).toBeTruthy(); + expect(screen.getByText("Bass Guitar catches the change in the verse at 1:10.")).toBeTruthy(); grid.remove(); }); @@ -196,7 +196,7 @@ describe("FirstTransitionCallout", () => { } render(); expect(screen.getByRole("button", { name: "Open the first transition at 0:10" })).toBeTruthy(); - expect(screen.getByText("The band catches the change at 0:10.")).toBeTruthy(); + expect(screen.getByText("The band catches the change in the verse at 0:10.")).toBeTruthy(); }); it("renders the owned cue as a text node instead of rescanning it as template syntax", () => { From ff4a5bef46a5fd70e36e7d97e209bb73cb9ad2e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:52:34 -0700 Subject: [PATCH 14/25] test(workspace): pin transition section identity snapshot --- .../features/workspace/firstTransition.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/desktop/src/features/workspace/firstTransition.test.ts b/apps/desktop/src/features/workspace/firstTransition.test.ts index b605ff7e9..e364c4cf6 100644 --- a/apps/desktop/src/features/workspace/firstTransition.test.ts +++ b/apps/desktop/src/features/workspace/firstTransition.test.ts @@ -114,6 +114,24 @@ describe("resolveFirstTransition", () => { expect(resolveFirstTransition(song)?.section.id).toBe("z-change"); }); + it("uses the owned section-id snapshot when a Proxy get trap substitutes a different tie-break id", () => { + const song = withTransitionRole({ id: "a-change", start: 46, end: 54 }); + const first = song.sections[1]!; + const proxiedFirst = new Proxy(first, { + get(target, property, receiver) { + if (property === "id") { + return "z-change"; + } + return Reflect.get(target, property, receiver); + } + }); + const second = structuredClone(first); + second.id = "m-change"; + song.sections = [song.sections[0]!, proxiedFirst, second]; + + expect(resolveFirstTransition(song)?.section).toBe(proxiedFirst); + }); + it("breaks equal-priority role ties with locale-independent id ordering", () => { const song = withTransitionRole({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); const section = song.sections[1]!; From 22f608d5e3410d12de4eea606caca21efe1eac52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:54:14 -0700 Subject: [PATCH 15/25] fix(workspace): preserve transition section id snapshot --- apps/desktop/src/features/workspace/firstTransition.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstTransition.ts b/apps/desktop/src/features/workspace/firstTransition.ts index a257de3c7..edc101356 100644 --- a/apps/desktop/src/features/workspace/firstTransition.ts +++ b/apps/desktop/src/features/workspace/firstTransition.ts @@ -16,6 +16,7 @@ type BoundedTimeRange = Readonly<{ /** Tonight's first owned transition cue: the earliest change and the part that holds it. */ export type FirstTransition = { section: RehearsalSection; + sectionId: string; sectionLabel: string; holdingRole: RehearsalRole | null; atSeconds: number; @@ -246,6 +247,7 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null const transitionSections: Array<{ section: RehearsalSection; + sectionId: string; sectionLabel: string; timeRange: BoundedTimeRange; }> = []; @@ -267,13 +269,13 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null if (timeRange === null || transitionRoles(section).length === 0) { continue; } - transitionSections.push({ section, sectionLabel, timeRange }); + transitionSections.push({ section, sectionId, sectionLabel, timeRange }); } transitionSections.sort((left, right) => { if (left.timeRange.start !== right.timeRange.start) { return left.timeRange.start - right.timeRange.start; } - return compareStableId(left.section.id, right.section.id); + return compareStableId(left.sectionId, right.sectionId); }); const candidate = transitionSections[0]; @@ -295,6 +297,7 @@ function resolveSafeFirstTransition(song: RehearsalSong): FirstTransition | null return { section, + sectionId: candidate.sectionId, sectionLabel: candidate.sectionLabel, holdingRole, atSeconds: candidate.timeRange.start, From afb17db6cb5ae075a7606116599b2bfb4c094940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:54:53 -0700 Subject: [PATCH 16/25] fix(workspace): consume transition section snapshot --- .../src/features/workspace/FirstTransitionCallout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index b5964e079..06e10c099 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -80,7 +80,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { }, [ songIdentity, transitionSectionIndex, - transition?.section.id, + transition?.sectionId, transition?.sectionLabel, transition?.holdingRole?.id, transition?.atSeconds, @@ -103,7 +103,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { const opened = openedTransition !== null && openedTransition.songIdentity === songIdentity && - openedTransition.sectionId === transition.section.id && + openedTransition.sectionId === transition.sectionId && openedTransition.sectionIndex === transitionSectionIndex && openedTransition.sectionLabel === transition.sectionLabel && openedTransition.holdingRoleId === (transition.holdingRole?.id ?? null) && @@ -158,7 +158,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { }); setOpenedTransition({ songIdentity, - sectionId: transition.section.id, + sectionId: transition.sectionId, sectionIndex: transitionSectionIndex, sectionLabel: transition.sectionLabel, holdingRoleId: transition.holdingRole?.id ?? null, From 2b3e32eaed07568b97eb69bfdde70ecf4f9ed237 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 09:10:54 -0700 Subject: [PATCH 17/25] perf(workspace): memoize transition translator --- .../src/features/workspace/FirstTransitionCallout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index 06e10c099..0ae403558 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -64,8 +64,8 @@ function preferredTransitionScrollBehavior(): ScrollBehavior { /** Name tonight's first owned transition cue and open the matching rendered map section. */ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { - const locale = detectPreferredLocale(); - const t = createTranslator(locale); + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); const songIdentity = stableSongIdentity(song); const runtimeSong = song as unknown as Partial | null; const transition = resolveFirstTransition(song); From 09c501f67c334aebb8db5ff8165bcd1cea429ad2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 10:14:32 -0700 Subject: [PATCH 18/25] test(workspace): pin transition resolver reuse --- ...tTransitionCallout.resolver-cache.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.resolver-cache.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.resolver-cache.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.resolver-cache.test.tsx new file mode 100644 index 000000000..2ec8cec45 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.resolver-cache.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 { FirstTransitionCallout } from "./FirstTransitionCallout"; + +describe("FirstTransitionCallout resolver reuse", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not rescan transition metadata when a parent rerenders the same song object", () => { + const song = createDemoRehearsalSong(); + const role = song.sections[0]!.roles[0]!; + 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); + }); +}); From cf56a0b0871c0656dc683f9181bf49cffbf6c4ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 10:15:18 -0700 Subject: [PATCH 19/25] perf(workspace): memoize transition resolution per song --- .../workspace/FirstTransitionCallout.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index 0ae403558..4209bae24 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -66,13 +66,18 @@ function preferredTransitionScrollBehavior(): ScrollBehavior { export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); - const songIdentity = stableSongIdentity(song); - const runtimeSong = song as unknown as Partial | null; - const transition = resolveFirstTransition(song); - const transitionSectionIndex = - transition && Array.isArray(runtimeSong?.sections) - ? runtimeSong.sections.indexOf(transition.section) - : -1; + const resolution = useMemo(() => { + const songIdentity = stableSongIdentity(song); + const runtimeSong = song as unknown as Partial | null; + const transition = resolveFirstTransition(song); + const transitionSectionIndex = + transition && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(transition.section) + : -1; + + return { songIdentity, transition, transitionSectionIndex } as const; + }, [song]); + const { songIdentity, transition, transitionSectionIndex } = resolution; const [openedTransition, setOpenedTransition] = useState(null); useEffect(() => { From f15031383109cdfffc3a3eb407c5d88b31d5e3d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 10:16:05 -0700 Subject: [PATCH 20/25] test(workspace): require unique transition landmark ids --- ...irstTransitionCallout.landmark-id.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstTransitionCallout.landmark-id.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.landmark-id.test.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.landmark-id.test.tsx new file mode 100644 index 000000000..ca921ad25 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.landmark-id.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { FirstTransitionCallout } from "./FirstTransitionCallout"; + +it("gives co-mounted transition callouts distinct DOM identities", () => { + const song = createDemoRehearsalSong(); + const { container } = render( + <> + + + + ); + + const calloutIds = Array.from( + container.querySelectorAll('[id^="workspace-surface-transition-"]'), + (element) => element.id + ); + + expect(calloutIds).toHaveLength(2); + expect(new Set(calloutIds).size).toBe(calloutIds.length); +}); From 95ede906c38325df897795ee387fb112039e5a63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 10:16:42 -0700 Subject: [PATCH 21/25] fix(workspace): make transition landmarks unique --- .../src/features/workspace/FirstTransitionCallout.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx index 4209bae24..a1be01f65 100644 --- a/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstTransitionCallout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useId, useMemo, useState } from "react"; import type { RehearsalSong } from "@bandscope/shared-types"; import { Button } from "@/components/ui/button"; import { @@ -66,6 +66,7 @@ function preferredTransitionScrollBehavior(): ScrollBehavior { export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + const landmarkId = useId(); const resolution = useMemo(() => { const songIdentity = stableSongIdentity(song); const runtimeSong = song as unknown as Partial | null; @@ -95,7 +96,7 @@ export function FirstTransitionCallout({ song }: FirstTransitionCalloutProps) { if (!transition) { return (