From c8e1b25bc68141058ac89eb6e01ae079ea24f4ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:16:42 +0000 Subject: [PATCH 1/7] feat(workspace): name tonight's first overlap on the map Name the earliest owned clash, the corroborated holding part, and the section time so the room can clear the overlap together. Open moves to the renderer-owned map section. Do not invent a clash from groove, cue, setup, simplification, or range copy. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstOverlapCallout.particle.test.tsx | 43 +++ ...irstOverlapCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstOverlapCallout.test.tsx | 202 ++++++++++++ .../workspace/FirstOverlapCallout.tsx | 135 ++++++++ .../src/features/workspace/Workspace.test.tsx | 25 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstOverlap.inherited-metadata.test.ts | 94 ++++++ .../features/workspace/firstOverlap.test.ts | 193 +++++++++++ .../src/features/workspace/firstOverlap.ts | 306 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 ++- apps/desktop/src/i18n/index.ts | 36 ++- apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- docs/design-system/component-contract.md | 1 + ...reduced-motion-first-overlap-navigation.md | 14 + 19 files changed, 1170 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstOverlap.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstOverlap.test.ts create mode 100644 apps/desktop/src/features/workspace/firstOverlap.ts create mode 100644 docs/doctoring/reduced-motion-first-overlap-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..e5b564f92 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 overlap with the warned part when an active role is corroborated, the owned clash text, the labeled section, 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..63a7f7513 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ Last updated: 2026-03-11 ## 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 overlap must name the warned part when corroborated, the owned clash text, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a clash from groove, cue, setup, simplification, or range copy. - 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..1dd93de21 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 overlap in the mounted rehearsal workspace so the room can clear the clash together; 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. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..456fe30ae 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 overlap and opens the matching rendered map section. Do not invent a clash from groove, cue, setup, simplification, or range copy. `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/FirstOverlapCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.particle.test.tsx new file mode 100644 index 000000000..a4041300a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.particle.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 { FirstOverlapCallout } from "./FirstOverlapCallout"; + +describe("FirstOverlapCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the overlap 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" }]; + 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(); + + 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/FirstOverlapCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..5ae8d55ff --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.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 { FirstOverlapCallout } from "./FirstOverlapCallout"; + +describe("FirstOverlapCallout 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 overlap at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx new file mode 100644 index 000000000..aec8636dd --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx @@ -0,0 +1,202 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstOverlapCallout } from "./FirstOverlapCallout"; + +function songWithOverlap() { + return createDemoRehearsalSong(); +} + +function appendSongStructureTarget() { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + const grid = document.createElement("div"); + 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("FirstOverlapCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No overlap yet. Stay on tonight's map until a part names a clash.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithOverlap(); + 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 overlap at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same overlap signature", () => { + const firstSong = songWithOverlap(); + const nextSong = songWithOverlap(); + 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 overlap at 0:10" })); + expect(screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Bass Guitar overlaps in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeNull(); + + grid.remove(); + }); + + it("names the first overlap as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Density warning: competing with Keyboard Left Hand in low register.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Bass Guitar overlap at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).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 overlap at 0:10" })); + + expect(screen.getByText("Bass Guitar overlaps in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithOverlap(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar overlap 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 overlap 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 overlap changes or returns later", () => { + const initialSong = songWithOverlap(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar overlap at 0:10" })); + expect(screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeTruthy(); + + const nextSong = songWithOverlap(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Bass Guitar overlaps in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable overlap guidance-only", () => { + const song = songWithOverlap(); + for (const role of song.sections[0]!.roles) { + role.overlapWarnings = [" "]; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No overlap yet. Stay on tonight's map until a part names a clash.") + ).toBeTruthy(); + }); + + it("names a band-wide overlap when no part carries it", () => { + const song = songWithOverlap(); + for (const node of song.sections[0]!.partGraph) { + node.is_active = false; + } + render(); + expect(screen.getByRole("button", { name: "Open the first overlap at 0:10" })).toBeTruthy(); + expect(screen.getByText("The band overlaps in the verse at 0:10.")).toBeTruthy(); + }); + + it("localizes the overlap form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithOverlap(); + song.sections[0]!.roles[0]!.name = "베이스 기타"; + song.sections[0]!.roles[1]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + + render(); + + expect(screen.getByText("0:10 벌스에서 베이스 기타 파트가 겹칩니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned overlap warning as a text node instead of template syntax", () => { + const song = songWithOverlap(); + song.sections[0]!.roles[0]!.overlapWarnings = ["Clash {role} at {at}"]; + song.sections[0]!.roles[1]!.overlapWarnings = []; + song.sections[0]!.roles[2]!.overlapWarnings = []; + render(); + expect(screen.getByText("Clash {role} at {at}")).toBeTruthy(); + expect(screen.queryByText("Clash Bass Guitar at 0:10")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx new file mode 100644 index 000000000..9a119b018 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx @@ -0,0 +1,135 @@ +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 { formatOverlapTime, resolveFirstOverlap } from "./firstOverlap"; + +/** Props for the first-overlap rehearsal callout. */ +export interface FirstOverlapCalloutProps { + song: RehearsalSong; +} + +type OverlapCopyValues = Readonly>; + +type OpenedOverlap = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate overlap placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatOverlapCopy(template: string, values: OverlapCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof OverlapCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredOverlapScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first overlap and open the matching rendered map section. */ +export function FirstOverlapCallout({ song }: FirstOverlapCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const overlap = resolveFirstOverlap(song); + const overlapSectionIndex = + overlap && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(overlap.section) + : -1; + const [openedOverlap, setOpenedOverlap] = useState(null); + + useEffect(() => { + setOpenedOverlap(null); + }, [songIdentity, overlapSectionIndex, overlap?.section.id, overlap?.holdingRole?.id, overlap?.atSeconds]); + + if (!overlap) { + return ( + + ); + } + + const opened = + openedOverlap !== null && + openedOverlap.songIdentity === songIdentity && + openedOverlap.sectionId === overlap.section.id && + openedOverlap.sectionIndex === overlapSectionIndex && + openedOverlap.holdingRoleId === (overlap.holdingRole?.id ?? null) && + openedOverlap.atSeconds === overlap.atSeconds; + const at = formatOverlapTime(overlap.atSeconds); + const copyValues: OverlapCopyValues = { + role: overlap.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, overlap.section.label), + at + }; + const hasRole = overlap.holdingRole !== null; + const actionLabel = formatOverlapCopy( + t(hasRole ? "firstOverlapOpenAction" : "firstOverlapOpenActionBand"), + copyValues + ); + const body = formatOverlapCopy(t(hasRole ? "firstOverlapBody" : "firstOverlapBodyBand"), copyValues); + const armed = formatOverlapCopy(t(hasRole ? "firstOverlapArmed" : "firstOverlapArmedBand"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..10cbd3fe3 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,29 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first overlap 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 + }); + + expect( + screen.getAllByText("Density warning: competing with Keyboard Left Hand in low register.").length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar overlap at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..37cbeaca7 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 { FirstOverlapCallout } from "./FirstOverlapCallout"; 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/firstOverlap.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstOverlap.inherited-metadata.test.ts new file mode 100644 index 000000000..33e295e69 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOverlap.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstOverlap } from "./firstOverlap"; + +function songWithOverlap() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "overlap-own"; + section.roles = [ + { + ...section.roles[0]!, + overlapWarnings: ["Density warning: competing with Keyboard Left Hand in low register."] + } + ]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstOverlap inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithOverlap(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstOverlap(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithOverlap(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithOverlap(); + Object.defineProperty(section.roles[0]!, "overlapWarnings", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile overlapWarnings getter"); + } + }); + + expect(() => resolveFirstOverlap(song)).not.toThrow(); + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("does not treat own accessors as stable overlap identity authority", () => { + const { song, section } = songWithOverlap(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "overlap-own"; + } + }); + + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("does not let inherited overlap warnings establish the clash", () => { + const { song, section } = songWithOverlap(); + const inheritedOverlap = Object.create({ + overlapWarnings: ["Inherited clash"] + }) as typeof section.roles[0]; + Object.defineProperties(inheritedOverlap, { + id: { configurable: true, enumerable: true, value: "bass-guitar" }, + name: { configurable: true, enumerable: true, value: "Bass Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedOverlap]; + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithOverlap(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstOverlap(song); + expect(resolved?.section.id).toBe("overlap-own"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Density warning: competing with Keyboard Left Hand in low register."); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithOverlap(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstOverlap(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstOverlap.test.ts b/apps/desktop/src/features/workspace/firstOverlap.test.ts new file mode 100644 index 000000000..c72fa0574 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOverlap.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatOverlapTime, resolveFirstOverlap } from "./firstOverlap"; + +function withOverlapSection( + overrides: { + id?: string; + start?: number; + end?: number; + warning?: string; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-overlap"; + 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 ?? "bass-guitar"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: overrides.priority ?? "high", + overlapWarnings: [overrides.warning ?? "Density warning: competing with Keyboard Left Hand in low register."] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstOverlap", () => { + it("picks the demo song's earliest named overlap and the part that carries it", () => { + const resolved = resolveFirstOverlap(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Density warning: competing with Keyboard Left Hand in low register."); + expect(formatOverlapTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatOverlapTime(Number.NaN)).toBe("0:00"); + expect(formatOverlapTime(-4)).toBe("0:00"); + }); + + it("does not invent a clash from groove, cue, setup, simplification, or range copy", () => { + const song = withOverlapSection({ warning: " " }); + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.setupNote = "Keep the attack short so the verse breathes."; + song.sections[0]!.roles[0]!.simplification = "Stay on roots if the chorus entrance gets muddy."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup." }; + song.sections[0]!.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace overlap warning as a named clash", () => { + expect(resolveFirstOverlap(withOverlapSection({ warning: "" }))).toBeNull(); + expect(resolveFirstOverlap(withOverlapSection({ warning: " \n\t " }))).toBeNull(); + }); + + it("prefers the earlier of two named overlaps", () => { + const song = withOverlapSection({ id: "verse-late", start: 40, end: 56, roleId: "keys-right" }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium", + overlapWarnings: ["Bass and keys share the floor."] + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstOverlap(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.hint).toBe("Bass and keys share the floor."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time overlap ties with locale-independent id ordering", () => { + const song = withOverlapSection({ id: "ä-overlap", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-overlap"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstOverlap(song)?.section.id).toBe("z-overlap"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withOverlapSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + overlapWarnings: ["ASCII clash"] + }; + 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(resolveFirstOverlap(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide overlap when no active ranked role carries it", () => { + const song = withOverlapSection({ isActive: false }); + const resolved = resolveFirstOverlap(song); + expect(resolved?.section.id).toBe("verse-overlap"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Density warning: competing with Keyboard Left Hand in low register."); + }); + + it("skips an overlap whose rehearsal window is unbounded", () => { + expect(resolveFirstOverlap(withOverlapSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips an overlap whose end precedes its start", () => { + expect(resolveFirstOverlap(withOverlapSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length overlap window", () => { + expect(resolveFirstOverlap(withOverlapSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips an overlap whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstOverlap( + withOverlapSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstOverlap(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withOverlapSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstOverlap(song)).toBeNull(); + }); + + it("keeps the overlap band-wide when role identities are duplicated", () => { + const song = withOverlapSection(); + 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: [] } + ]; + const resolved = resolveFirstOverlap(song); + expect(resolved?.section.id).toBe("verse-overlap"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("bounds the overlap hint to 180 Unicode code points", () => { + const song = withOverlapSection({ warning: `${"a".repeat(200)}` }); + const resolved = resolveFirstOverlap(song); + expect(resolved?.hint.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the hint boundary", () => { + const song = withOverlapSection({ warning: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstOverlap(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstOverlap.ts b/apps/desktop/src/features/workspace/firstOverlap.ts new file mode 100644 index 000000000..dff090d21 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOverlap.ts @@ -0,0 +1,306 @@ +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_OVERLAP_CHARACTERS = 180; + +/** Tonight's first named overlap: the earliest owned clash and the part that carries it. */ +export type FirstOverlap = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative overlap time as m:ss for rehearsal copy. */ +export function formatOverlapTime(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 owned overlap warning, or null when the field cannot be shown. */ +function firstOwnedWarning(role: RehearsalRole): string | null { + if (!hasOwnData(role, "overlapWarnings") || !isDenseRuntimeArray(role.overlapWarnings)) { + return null; + } + for (const item of role.overlapWarnings) { + if (typeof item !== "string") { + continue; + } + const hint = item.trim(); + if (hint.length === 0) { + continue; + } + return truncateCodePoints(hint, MAX_OVERLAP_CHARACTERS); + } + return null; +} + +/** 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 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) + ); +} + +/** Return the first owned clash string on a section, preferring the holding part. */ +function ownedOverlapHint(section: RehearsalSection, holdingRole: RehearsalRole | null): string | null { + if (holdingRole) { + const fromHolder = firstOwnedWarning(holdingRole); + if (fromHolder !== null) { + return fromHolder; + } + } + if (!hasOwnData(section, "roles") || !isDenseRuntimeArray(section.roles)) { + return null; + } + for (const role of section.roles) { + if (!isRuntimeObject(role)) { + continue; + } + const warning = firstOwnedWarning(role); + if (warning !== null) { + return warning; + } + } + return null; +} + +/** Resolve an overlap after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstOverlap(song: RehearsalSong): FirstOverlap | 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) && + ownedOverlapHint(section, null) !== null + ) + .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 = candidates[0]; + if (!section) { + return null; + } + + const holdingRole = pickHighestPriorityRole( + rankedActiveRoles(section).filter((role) => firstOwnedWarning(role) !== null) + ); + const hint = ownedOverlapHint(section, holdingRole); + if (hint === null) { + return null; + } + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + hint + }; +} + +/** Return the first named overlap, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstOverlap(song: RehearsalSong): FirstOverlap | null { + try { + return resolveSafeFirstOverlap(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..0a0fd7ac5 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 section form label for Korean rehearsal copy", () => { + 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-overlap next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstOverlapOpenAction")).toBe("{at} {role} 겹침 위치 열기"); + expect(t("firstOverlapBody")).toBe("{at} {section}에서 {role} 파트가 겹칩니다."); + expect(t("firstOverlapArmed")).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..abf78fc7e 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", + "firstOverlapLabel": "Tonight's first overlap", + "firstOverlapOpenAction": "Open {role} overlap at {at}", + "firstOverlapOpenActionBand": "Open the first overlap at {at}", + "firstOverlapBody": "{role} overlaps in the {section} at {at}.", + "firstOverlapBodyBand": "The band overlaps in the {section} at {at}.", + "firstOverlapArmed": "Clear the overlap with {role} at {at}. Make room for each other.", + "firstOverlapArmedBand": "Clear the overlap at {at}. Make room for each other.", + "firstOverlapUnavailable": "No overlap yet. Stay on tonight's map until a part names a clash." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..177854645 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": "진척도 증가", + "firstOverlapLabel": "오늘 첫 겹침", + "firstOverlapOpenAction": "{at} {role} 겹침 위치 열기", + "firstOverlapOpenActionBand": "{at} 첫 겹침 위치 열기", + "firstOverlapBody": "{at} {section}에서 {role} 파트가 겹칩니다.", + "firstOverlapBodyBand": "밴드가 {at} {section}에서 겹칩니다.", + "firstOverlapArmed": "{at}에서 {role} 파트 겹침을 풀으세요. 자리를 나눠 잡으세요.", + "firstOverlapArmedBand": "{at}에서 겹침을 풀으세요. 자리를 나눠 잡으세요.", + "firstOverlapUnavailable": "아직 겹침이 없습니다. 파트가 충돌을 표시할 때까지 오늘 지도에 머무르세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..458f0a947 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 Overlap Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstOverlapCallout.tsx` | Name the warned part when an active graph node corroborates it, the owned `overlapWarnings` text, the labeled section start, and the time. Do not invent a clash from `groove`, cue text, `setupNote`, `simplification`, range copy, or empty/whitespace warnings. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-groove, first-simplification, first-range, and first-form-label 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-overlap-navigation.md b/docs/doctoring/reduced-motion-first-overlap-navigation.md new file mode 100644 index 000000000..615111281 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-overlap-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-overlap navigation + +Workspace map navigation for tonight's first overlap follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstOverlapCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Overlap resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, overlap-warning text, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: overlap 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. The owned overlap-warning string is rendered as a text node and is never rescanned as template syntax. Groove, cue, setup, simplification, and range copy cannot invent a clash. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the overlap hint is bounded to 180 Unicode code points. +- Test points: inherited song/section/timing/role/graph/warning metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`. From 6f4a2fc4ceab9ff7cd4661c667e0fefc23de0d59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:07:57 -0700 Subject: [PATCH 2/7] test(workspace): cover localized overlap navigation target --- .../workspace/FirstOverlapCallout.test.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx index aec8636dd..8233d2c25 100644 --- a/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.test.tsx @@ -7,11 +7,12 @@ function songWithOverlap() { return createDemoRehearsalSong(); } -function appendSongStructureTarget() { +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { const timeline = document.createElement("div"); timeline.setAttribute("role", "region"); - timeline.setAttribute("aria-label", "Scrollable song structure timeline"); + 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(); @@ -95,6 +96,19 @@ describe("FirstOverlapCallout", () => { 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 overlap at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./)).toBeTruthy(); + + grid.remove(); + }); + it("does not claim map navigation completed when the rendered section target is missing", () => { render(); From 510347b801bf1d69a32b26b42519dbbb2827e4f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:09:00 -0700 Subject: [PATCH 3/7] fix(workspace): decouple overlap navigation from accessible copy --- apps/desktop/src/features/workspace/FirstOverlapCallout.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx index 9a119b018..2cccfbefb 100644 --- a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx @@ -103,9 +103,7 @@ export function FirstOverlapCallout({ song }: FirstOverlapCalloutProps) { type="button" className="mt-3 min-h-11 bg-gradient-to-r from-rose-300 to-amber-300 font-black text-slate-950" onClick={() => { - const renderer = document.querySelector( - '[role="region"][aria-label="Scrollable song structure timeline"]' - ); + const renderer = document.querySelector('[data-testid="song-structure-grid"]'); const target = overlapSectionIndex >= 0 ? (renderer?.querySelector( From 3f8f6ea139ec1877ce0a8d9b00f050842c6720b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:05:08 -0700 Subject: [PATCH 4/7] test(workspace): preserve overlap action across progress updates --- ...erlapCallout.progress-persistence.test.tsx | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.progress-persistence.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.progress-persistence.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.progress-persistence.test.tsx new file mode 100644 index 000000000..26e2e8413 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.progress-persistence.test.tsx @@ -0,0 +1,63 @@ +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 { FirstOverlapCallout } from "./FirstOverlapCallout"; + +let mountedTimeline: HTMLElement | null = null; + +function appendSongStructureTarget(): void { + const timeline = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + mountedTimeline = timeline; +} + +function withPracticeProgress(song: RehearsalSong, progress: number): RehearsalSong { + return { + ...song, + sections: song.sections.map((section, sectionIndex) => + sectionIndex === 0 + ? { + ...section, + roles: section.roles.map((role, roleIndex) => + roleIndex === 0 ? { ...role, practiceProgress: progress } : role + ) + } + : section + ) + }; +} + +describe("FirstOverlapCallout immutable song updates", () => { + afterEach(() => { + mountedTimeline?.remove(); + mountedTimeline = null; + }); + + it("keeps the opened overlap action armed after unrelated practice progress changes", () => { + const song = createDemoRehearsalSong(); + appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar overlap at 0:10" })); + expect( + screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./) + ).toBeTruthy(); + + rerender(); + + expect( + screen.getByText(/Clear the overlap with Bass Guitar at 0:10. Make room for each other./) + ).toBeTruthy(); + expect(screen.queryByText("Bass Guitar overlaps in the verse at 0:10.")).toBeNull(); + }); +}); From 59314ebd2e8b72d1783ea0c405b1a5d5a54879cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:05:44 -0700 Subject: [PATCH 5/7] fix(workspace): keep overlap action across progress updates --- .../features/workspace/FirstOverlapCallout.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx index 2cccfbefb..e5c3d2d70 100644 --- a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx @@ -31,6 +31,22 @@ function formatOverlapCopy(template: string, values: OverlapCopyValues): string }); } +/** Keep logical song identity stable across immutable edits without invoking an untrusted id accessor. */ +function overlapSongIdentity(song: unknown): unknown { + if ((typeof song !== "object" && typeof song !== "function") || song === null) { + return song; + } + + try { + const descriptor = Object.getOwnPropertyDescriptor(song, "id"); + return descriptor && "value" in descriptor && typeof descriptor.value === "string" + ? descriptor.value + : song; + } catch { + return song; + } +} + /** Use immediate scrolling when the operating system requests reduced motion. */ function preferredOverlapScrollBehavior(): ScrollBehavior { return typeof window.matchMedia === "function" && @@ -43,7 +59,7 @@ function preferredOverlapScrollBehavior(): ScrollBehavior { export function FirstOverlapCallout({ song }: FirstOverlapCalloutProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); - const songIdentity: unknown = song; + const songIdentity = overlapSongIdentity(song); const runtimeSong = song as unknown as Partial | null; const overlap = resolveFirstOverlap(song); const overlapSectionIndex = From 918b228276ad55be45fa16e55a8517cfaedcb577 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:09:31 -0700 Subject: [PATCH 6/7] test(workspace): keep overlap region name concise when empty --- .../FirstOverlapCallout.empty-a11y.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/desktop/src/features/workspace/FirstOverlapCallout.empty-a11y.test.tsx diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.empty-a11y.test.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.empty-a11y.test.tsx new file mode 100644 index 000000000..466f48d0b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.empty-a11y.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstOverlapCallout } from "./FirstOverlapCallout"; + +describe("FirstOverlapCallout empty accessible name", () => { + it("keeps the same concise region name while unavailable guidance remains visible", () => { + const song = createDemoRehearsalSong(); + for (const role of song.sections[0]!.roles) { + role.overlapWarnings = [" "]; + } + + render(); + + expect(screen.getByLabelText("Tonight's first overlap")).toBeTruthy(); + expect( + screen.getByText("No overlap yet. Stay on tonight's map until a part names a clash.") + ).toBeTruthy(); + expect( + screen.queryByLabelText("No overlap yet. Stay on tonight's map until a part names a clash.") + ).toBeNull(); + }); +}); From 691c0711c88eea067793ad0890fa1751348fecc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 03:10:07 -0700 Subject: [PATCH 7/7] fix(workspace): keep overlap region name consistent when empty --- apps/desktop/src/features/workspace/FirstOverlapCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx index e5c3d2d70..05b8d06d9 100644 --- a/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOverlapCallout.tsx @@ -77,7 +77,7 @@ export function FirstOverlapCallout({ song }: FirstOverlapCalloutProps) {