diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..bf04fa160 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 labeled intro with the holding part when an active role is corroborated, the labeled start, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, 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 ca0df5ac4..93556adb5 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. +- Workspace and player copy for tonight's first labeled intro must name the holding part when corroborated, the labeled start, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..a4be4bca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. +- Name tonight's first labeled intro on the workspace and player so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..0de0b2271 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). The ready workspace names tonight's first playable range and the next instrument check. `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). Workspace and player name tonight's first labeled intro so the room can count in together; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. The ready workspace also names tonight's first playable range and the next instrument check. `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/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..c80b86751 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first intro from this player.") + ).toBeTruthy(); + }); + + it("keeps the intro hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Drums start at 0:00" })).toBeNull(); + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + }); + + it("delegates the intro hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(0); + }); + + it("localizes the section count, labels, and playback hint instead of mixing English player copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + try { + render(); + expect(screen.getByText("2개 섹션")).toBeTruthy(); + expect(screen.queryByText("2 sections")).toBeNull(); + expect(screen.getByText("인트로")).toBeTruthy(); + expect(screen.queryByText("intro")).toBeNull(); + expect(screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.")).toBeTruthy(); + expect(screen.queryByText("Audio playback requires the desktop app with a local audio source.")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithIntro(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithIntro(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when sections is a throwing own accessor", () => { + const song = songWithIntro(); + Object.defineProperty(song, "sections", { + configurable: true, + enumerable: true, + get() { + throw new Error("sections getter must stay data"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when a song Proxy throws on sections access", () => { + const song = songWithIntro(); + const proxiedSong = new Proxy(song, { + get(target, key, receiver) { + if (key === "sections") { + throw new Error("sections get trap"); + } + return Reflect.get(target, key, receiver); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithIntro(); + song.sections = [null, song.sections[1]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithIntro(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..0698feadc 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,54 +1,131 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstIntroCallout } from "../workspace/FirstIntroCallout"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; -/** Documented. */ -export function PlayerFeature(props: { title: string; song?: RehearsalSong | null }) { - const { title, song } = props; +type PlayerFeatureProps = { + title: string; + song?: RehearsalSong | null; + onPlayFromSeconds?: (startSeconds: number) => void; +}; + +/** Read an own data property without invoking accessors or letting descriptor traps escape. */ +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + return undefined; + } + Reflect.get(value, key); + return descriptor.value; + } catch { + return undefined; + } +} + +/** Return whether one runtime section is safe to summarize in the player. */ +function isPlayerSummarySection(value: unknown): value is RehearsalSection { + if (value === null || typeof value !== "object") { + return false; + } + const section = value as Partial; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) + ); +} + +/** Return dense, individually valid sections without invoking an untrusted collection accessor. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = readOwnDataProperty(song, "sections"); + if (!Array.isArray(sections)) { + return []; + } + try { + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + const keys = Object.keys(sections); + if (keys.length !== length || !keys.every((key, index) => key === String(index))) { + return []; + } + return sections.filter(isPlayerSummarySection); + } catch { + return []; + } +} + +/** Player surface that names tonight's first labeled intro and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); if (!song) { return (

{title}

-

No song loaded. Start an analysis to use the player.

+

{t("firstIntroNeedsSong")}

); } + const sections = playerSummarySections(song); + const rawSongTitle = readOwnDataProperty(song, "title"); + const songTitle = typeof rawSongTitle === "string" ? rawSongTitle : ""; + const calloutSong = sections.length === 0 + ? ({ sections: [] } as unknown as RehearsalSong) + : song; + const sectionCountLabel = t( + sections.length === 1 + ? "metricConfidenceSectionCountSingular" + : "metricConfidenceSectionCountPlural" + ).replace("{count}", String(sections.length)); + return (

{title}

+
- {song.title} + {songTitle} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sectionCountLabel}
- {song.sections.map((section) => ( + {sections.map((section, sectionIndex) => ( - {section.label} + {translateSectionFormLabel(locale, section.label)} ))}
- Audio playback requires the desktop app with a local audio source. + {t("playerPlaybackRequiresDesktop")}
diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx new file mode 100644 index 000000000..6574110fc --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.particle.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstIntroCallout } from "./FirstIntroCallout"; + +describe("FirstIntroCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the intro action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const intro = structuredClone(seed); + intro.id = "intro-particle"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; + intro.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [intro]; + + const onHearIntro = vi.fn(); + render(); + + expect(screen.getByText("0:00 인트로에서 피아노 파트가 시작합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:00에 피아노 시작 듣기" })); + + expect(onHearIntro).toHaveBeenCalledWith(0); + expect(screen.getByText("0:00에서 피아노 파트와 함께 카운트인하세요. 같이 시작하세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..e4592bc9a --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.reduced-motion.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstIntroCallout } from "./FirstIntroCallout"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("FirstIntroCallout 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"; + const target = document.createElement("div"); + const second = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + grid.appendChild(second); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx new file mode 100644 index 000000000..1120823b9 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.test.tsx @@ -0,0 +1,204 @@ +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 { FirstIntroCallout } from "./FirstIntroCallout"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + const second = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + grid.appendChild(second); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstIntroCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("contains a throwing own sections accessor instead of crashing the callout", () => { + const song = songWithIntro(); + Object.defineProperty(song, "sections", { + configurable: true, + enumerable: true, + get() { + throw new Error("sections getter must stay data"); + } + }); + + expect(() => render()).not.toThrow(); + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("contains a song Proxy that throws on sections access instead of crashing the callout", () => { + const song = songWithIntro(); + const proxiedSong = new Proxy(song, { + get(target, key, receiver) { + if (key === "sections") { + throw new Error("sections get trap"); + } + return Reflect.get(target, key, receiver); + } + }); + + // Descriptor-based reads forward past get traps by design, so the intro still + // resolves safely instead of the render crashing on a direct sections read. + expect(() => render()).not.toThrow(); + expect(screen.getByLabelText("Tonight's first intro")).toBeTruthy(); + expect(screen.queryByText(/No intro yet/i)).toBeNull(); + }); + + it("names the first intro as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Drums intro at 0:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Count in with Drums at 0:00. Start together./)).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 Drums intro at 0:00" })); + + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + expect(screen.queryByText(/Count in with Drums at 0:00. Start together./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearIntro = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(onHearIntro).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithIntro(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first intro changes or returns later", () => { + const initialSong = songWithIntro(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums intro at 0:00" })); + expect(screen.getByText(/Count in with Drums at 0:00. Start together./)).toBeTruthy(); + + const nextSong = songWithIntro(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 4, end: 12 }; + rerender(); + expect(screen.getByText("Drums starts the intro at 0:04.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable intro guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No intro yet. Stay on tonight's map until the start is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide start when no part holds the intro", () => { + const song = songWithIntro(); + song.sections[0]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first intro at 0:00" })).toBeTruthy(); + expect(screen.getByText("The band starts the intro at 0:00.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearIntro = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Drums start at 0:00" })); + expect(onHearIntro).toHaveBeenCalledWith(0); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Drums starts the intro at 0:00.")).toBeTruthy(); + }); + + it("localizes the intro form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithIntro(); + song.sections[0]!.roles[0]!.name = "드럼"; + + render(); + + expect(screen.getByText("0:00 인트로에서 드럼 파트가 시작합니다.")).toBeTruthy(); + expect(screen.queryByText(/intro에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstIntroCallout.tsx b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx new file mode 100644 index 000000000..51ff84e3b --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstIntroCallout.tsx @@ -0,0 +1,158 @@ +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 { formatIntroTime, resolveFirstIntro, resolveFirstIntroSectionIndex } from "./firstIntro"; + +/** Props for the first-intro rehearsal callout. */ +export interface FirstIntroCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearIntro?: (atSeconds: number) => void; +} + +type IntroCopyValues = Readonly>; + +type HeardIntro = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate intro placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatIntroCopy(template: string, values: IntroCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof IntroCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredIntroScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled intro and offer only an action that the current surface can execute. */ +export function FirstIntroCallout({ + song, + actionMode = "workspace-scroll", + onHearIntro +}: FirstIntroCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + let songId = ""; + try { + const idDescriptor = runtimeSong + ? Object.getOwnPropertyDescriptor(runtimeSong, "id") + : undefined; + const rawId = idDescriptor && "value" in idDescriptor ? idDescriptor.value : undefined; + songId = typeof rawId === "string" ? rawId : ""; + } catch { + songId = ""; + } + const intro = resolveFirstIntro(song); + const introSectionIndex = intro ? resolveFirstIntroSectionIndex(song, intro.section) : -1; + const [heardIntro, setHeardIntro] = useState(null); + + useEffect(() => { + setHeardIntro(null); + }, [songId, introSectionIndex, intro?.section.id, intro?.holdingRole?.id, intro?.atSeconds]); + + if (!intro) { + return ( + + ); + } + + const heard = + heardIntro?.songId === songId && + heardIntro.sectionId === intro.section.id && + heardIntro.sectionIndex === introSectionIndex && + heardIntro.holdingRoleId === (intro.holdingRole?.id ?? null) && + heardIntro.atSeconds === intro.atSeconds; + const at = formatIntroTime(intro.atSeconds); + const copyValues: IntroCopyValues = { + role: intro.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, intro.section.label), + at + }; + const hasRole = intro.holdingRole !== null; + const actionLabel = formatIntroCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstIntroAction" + : "firstIntroActionBand" + : hasRole + ? "firstIntroOpenAction" + : "firstIntroOpenActionBand" + ), + copyValues + ); + const body = formatIntroCopy(t(hasRole ? "firstIntroBody" : "firstIntroBodyBand"), copyValues); + const armed = formatIntroCopy(t(hasRole ? "firstIntroArmed" : "firstIntroArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearIntro === "function"; + /** Record completion only after the owning surface has executed the selected intro action. */ + const markIntroActionComplete = () => { + setHeardIntro({ + songId, + sectionId: intro.section.id, + sectionIndex: introSectionIndex, + holdingRoleId: intro.holdingRole?.id ?? null, + atSeconds: intro.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.localization.test.tsx b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx new file mode 100644 index 000000000..8bb2bfae9 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.localization.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace timeline localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps the Korean workspace timeline summary in Korean", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect( + screen.getByText(`${song.sections.length}개 섹션 · 그루브 · 역할 · 코드 · 신뢰도`) + ).toBeTruthy(); + expect(screen.queryByText(/mapped with groove, role cues, and chord confidence notes/i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx b/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx new file mode 100644 index 000000000..9887f5610 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.summary-copy.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace summary copy", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("keeps English workspace summary terms capitalized consistently", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText(/Groove · Roles · Chord · Confidence/)).toBeTruthy(); + expect(screen.queryByText(/Groove · Roles · Chord · confidence/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..122e86443 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,48 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first intro as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + intro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + intro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + + 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 Drums intro at 0:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Count in with Drums at 0:00. Start together./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..9456fe37d 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 { FirstIntroCallout } from "./FirstIntroCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -225,6 +226,11 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const sectionCountLabel = t( + song.sections.length === 1 + ? "metricConfidenceSectionCountSingular" + : "metricConfidenceSectionCountPlural" + ).replace("{count}", String(song.sections.length)); /** Documented. */ const handleExportCueSheet = () => { @@ -314,7 +320,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceSongTimelineLabel")}

- {song.sections.length} section{song.sections.length === 1 ? "" : "s"} mapped with groove, role cues, and chord confidence notes. + {sectionCountLabel} · {t("sectionGrooveLabel")} · {t("navRoles")} · {t("sectionChordLabel")} · {t("roleConfidence")}

@@ -353,6 +359,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp + +
diff --git a/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts new file mode 100644 index 000000000..be36d02ab --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.inherited-metadata.test.ts @@ -0,0 +1,127 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstIntro } from "./firstIntro"; + +function songWithIntro() { + const song = createDemoRehearsalSong(); + const intro = structuredClone(song.sections[0]!); + intro.id = "intro-own"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + song.sections = [intro]; + return { song, intro }; +} + +describe("resolveFirstIntro inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, intro } = songWithIntro(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstIntro(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(intro) as typeof intro; + song.sections = [inheritedSection]; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, intro } = songWithIntro(); + intro.timeRange = Object.create({ start: 0, end: 8 }) as typeof intro.timeRange; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, intro } = songWithIntro(); + const role = intro.roles[0]!; + const node = intro.partGraph[0]!; + intro.roles = [Object.create(role) as typeof role]; + intro.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, intro } = songWithIntro(); + const arraySection = Object.assign([], intro) as unknown as typeof intro; + song.sections = [arraySection]; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("contains an own timeRange accessor that throws", () => { + const { song, intro } = songWithIntro(); + Object.defineProperty(intro, "timeRange", { + configurable: true, + enumerable: true, + get() { + throw new Error("timeRange getter must stay data"); + } + }); + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("contains a nested timeRange Proxy whose descriptor trap throws", () => { + const { song, intro } = songWithIntro(); + intro.timeRange = new Proxy( + { start: 0, end: 8 }, + { + getOwnPropertyDescriptor() { + throw new Error("timeRange descriptor trap"); + } + } + ) as typeof intro.timeRange; + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("contains a section Proxy whose descriptor trap throws", () => { + const { song, intro } = songWithIntro(); + song.sections = [ + new Proxy(intro, { + getOwnPropertyDescriptor() { + throw new Error("section descriptor trap"); + } + }) + ]; + + expect(() => resolveFirstIntro(song)).not.toThrow(); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips a throwing malformed candidate and preserves a later valid intro", () => { + const { song, intro } = songWithIntro(); + const malformed = structuredClone(intro); + malformed.id = "broken-intro"; + Object.defineProperty(malformed, "label", { + configurable: true, + enumerable: true, + get() { + throw new Error("candidate label getter must not poison later intros"); + } + }); + song.sections = [malformed, intro]; + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.atSeconds).toBe(0); + }); + + it("keeps a valid intro when one role exposes a throwing identity accessor", () => { + const { song, intro } = songWithIntro(); + const role = intro.roles[0]!; + Object.defineProperty(role, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("role id getter must not poison the intro"); + } + }); + + const resolved = resolveFirstIntro(song); + expect(resolved?.section.id).toBe("intro-own"); + expect(resolved?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstIntro.test.ts b/apps/desktop/src/features/workspace/firstIntro.test.ts new file mode 100644 index 000000000..27e2a3950 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatIntroTime, resolveFirstIntro } from "./firstIntro"; + +function withIntroSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = overrides.id ?? "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: overrides.start ?? 0, end: overrides.end ?? 8 }; + const roleId = overrides.roleId ?? "drums"; + intro.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Drums", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + intro.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [intro, verse]; + return song; +} + +describe("resolveFirstIntro", () => { + it("returns null when the demo song has no labeled intro", () => { + expect(resolveFirstIntro(createDemoRehearsalSong())).toBeNull(); + expect(formatIntroTime(Number.NaN)).toBe("0:00"); + expect(formatIntroTime(-4)).toBe("0:00"); + }); + + it("does not invent an intro from a verse, chorus, pickup, stop, or handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + const pickup = structuredClone(verse); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [verse, pickup, stop, chorus, handoff]; + + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("does not treat the first unlabeled section as an intro", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { start: 0, end: 16 }; + expect(song.sections[0]!.label).toBe("verse"); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("picks the earliest labeled intro and the part that counts it in", () => { + const song = withIntroSection({ start: 0, end: 8 }); + const intro = resolveFirstIntro(song); + + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole?.id).toBe("drums"); + expect(intro?.atSeconds).toBe(0); + expect(formatIntroTime(intro?.atSeconds ?? -1)).toBe("0:00"); + }); + + it("prefers the earlier of two labeled intros", () => { + const song = withIntroSection({ id: "intro-late", start: 64, end: 72 }); + const verse = song.sections[1]!; + const earlier = structuredClone(song.sections[0]!); + earlier.id = "intro-early"; + earlier.timeRange = { start: 0, end: 8 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[1]!, song.sections[0]!, earlier]; + + const intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-early"); + expect(intro?.holdingRole?.id).toBe("bass-guitar"); + expect(intro?.atSeconds).toBe(0); + }); + + it("breaks same-time intro ties with locale-independent id ordering", () => { + const song = withIntroSection({ id: "ä-intro", start: 0, end: 8 }); + const asciiIntro = structuredClone(song.sections[0]!); + asciiIntro.id = "z-intro"; + song.sections = [song.sections[0]!, asciiIntro]; + + expect(resolveFirstIntro(song)?.section.id).toBe("z-intro"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withIntroSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const intro = song.sections[0]!; + const asciiRole = { ...intro.roles[0]!, id: "z-role", name: "ASCII role" }; + intro.roles = [intro.roles[0]!, asciiRole]; + intro.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstIntro(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide start when no active ranked role holds it", () => { + const song = withIntroSection({ isActive: false }); + const intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole).toBeNull(); + expect(intro?.atSeconds).toBe(0); + }); + + it("skips an intro whose rehearsal window is unbounded", () => { + const song = withIntroSection({ start: Number.NaN, end: 8 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips an intro whose end precedes its start", () => { + const song = withIntroSection({ start: 8, end: 0 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips a zero-length intro window", () => { + const song = withIntroSection({ start: 0, end: 0 }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("skips an intro whose endpoint overflows the shared timing bound", () => { + const song = withIntroSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }); + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstIntro(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withIntroSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstIntro(song)).toBeNull(); + }); + + it("keeps the start band-wide when role identities are duplicated", () => { + const song = withIntroSection(); + 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 intro = resolveFirstIntro(song); + expect(intro?.section.id).toBe("intro-1"); + expect(intro?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstIntro.ts b/apps/desktop/src/features/workspace/firstIntro.ts new file mode 100644 index 000000000..eaab1fc6a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstIntro.ts @@ -0,0 +1,277 @@ +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; + +type RankedRoleCandidate = { + role: RehearsalRole; + id: string; + priority: keyof typeof PRIORITY_RANK; +}; + +type IntroSectionCandidate = { + section: RehearsalSection; + id: string; + start: number; +}; + +/** Tonight's first labeled intro: the earliest start and the part that counts it in. */ +export type FirstIntro = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative intro time as m:ss for rehearsal copy. */ +export function formatIntroTime(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); +} + +/** Read an own data property without invoking accessors or letting Proxy descriptor traps escape. */ +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + return undefined; + } + return descriptor.value; + } catch { + return undefined; + } +} + +/** Return whether materialized enumerable keys exactly cover a runtime array's numeric indices. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + try { + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + const keys = Object.keys(value); + return keys.length === length && keys.every((key, index) => key === String(index)); + } catch { + return false; + } +} + +/** Snapshot the bounded integer rehearsal window from own data properties only. */ +function readBoundedTimeRange(section: object): { start: number; end: number } | null { + const timeRange = readOwnDataProperty(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + + const start = readOwnDataProperty(timeRange, "start"); + const end = readOwnDataProperty(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** Snapshot the ranking fields needed from one untrusted role without invoking accessors. */ +function readRankedRoleCandidate(value: unknown): RankedRoleCandidate | null { + if (!isRuntimeObject(value)) { + return null; + } + const id = readOwnDataProperty(value, "id"); + const name = readOwnDataProperty(value, "name"); + const priority = readOwnDataProperty(value, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof priority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, priority) + ) { + return null; + } + return { + role: value as RehearsalRole, + id, + priority: priority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a valid labeled intro candidate so later sorting cannot invoke untrusted accessors. */ +function readIntroSectionCandidate(value: unknown): IntroSectionCandidate | null { + if (!isRuntimeObject(value)) { + return null; + } + const label = readOwnDataProperty(value, "label"); + const id = readOwnDataProperty(value, "id"); + const timeRange = readBoundedTimeRange(value); + if (label !== "intro" || typeof id !== "string" || id.trim().length === 0 || !timeRange) { + return null; + } + return { + section: value as RehearsalSection, + id, + start: timeRange.start + }; +} + +/** 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(candidates: RankedRoleCandidate[]): RehearsalRole | null { + if (candidates.length === 0) { + return null; + } + return ( + [...candidates].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.priority] - PRIORITY_RANK[right.priority]; + if (rankDelta !== 0) { + return rankDelta; + } + return compareStableId(left.id, right.id); + })[0]?.role ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RankedRoleCandidate[] { + const roles = readOwnDataProperty(section, "roles"); + const partGraph = readOwnDataProperty(section, "partGraph"); + if (!isDenseRuntimeArray(roles) || !isDenseRuntimeArray(partGraph)) { + return []; + } + + const roleCandidates = roles.map(readRankedRoleCandidate); + if (roleCandidates.some((candidate) => candidate === null)) { + return []; + } + const validRoleCandidates = roleCandidates.filter( + (candidate): candidate is RankedRoleCandidate => candidate !== null + ); + const repeatedRoleIds = repeatedIds(validRoleCandidates.map((candidate) => candidate.id)); + + const graphCandidates = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = readOwnDataProperty(node, "role_id"); + const isActive = readOwnDataProperty(node, "is_active"); + if (typeof roleId !== "string" || roleId.trim().length === 0) { + return []; + } + return [{ roleId, isActive }]; + }); + const repeatedGraphRoleIds = repeatedIds(graphCandidates.map((candidate) => candidate.roleId)); + const activeIds = new Set( + graphCandidates + .filter( + (candidate) => candidate.isActive === true && !repeatedGraphRoleIds.has(candidate.roleId) + ) + .map((candidate) => candidate.roleId) + ); + + return validRoleCandidates.filter( + (candidate) => !repeatedRoleIds.has(candidate.id) && activeIds.has(candidate.id) + ); +} + +/** Locate a resolved intro's section inside the song's own-data sections array, failing closed to -1. */ +export function resolveFirstIntroSectionIndex( + song: RehearsalSong, + section: RehearsalSection +): number { + try { + if (!isRuntimeObject(song)) { + return -1; + } + const sections = readOwnDataProperty(song, "sections"); + if (!isDenseRuntimeArray(sections)) { + return -1; + } + return sections.indexOf(section); + } catch { + return -1; + } +} + +/** Return the first labeled intro, or null when no safe start remains. */ +export function resolveFirstIntro(song: RehearsalSong): FirstIntro | null { + try { + if (!isRuntimeObject(song)) { + return null; + } + const sections = readOwnDataProperty(song, "sections"); + if (!isDenseRuntimeArray(sections)) { + return null; + } + + const introSections = sections + .map(readIntroSectionCandidate) + .filter((candidate): candidate is IntroSectionCandidate => candidate !== null) + .sort((left, right) => { + if (left.start !== right.start) { + return left.start - right.start; + } + return compareStableId(left.id, right.id); + }); + + const candidate = introSections[0]; + if (!candidate) { + return null; + } + + return { + section: candidate.section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(candidate.section)), + atSeconds: candidate.start + }; + } catch { + return null; + } +} diff --git a/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx b/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx new file mode 100644 index 000000000..dcd90a2e1 --- /dev/null +++ b/apps/desktop/src/features/workspace/runtimeSectionCollection.test.tsx @@ -0,0 +1,60 @@ +import { render } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSection } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { PlayerFeature } from "../player"; +import { resolveFirstIntro } from "./firstIntro"; + +function syntheticLengthSections() { + const target: RehearsalSection[] = []; + target.length = 256; + let numericDescriptorReads = 0; + + const sections = new Proxy(target, { + getOwnPropertyDescriptor(current, property) { + if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) { + numericDescriptorReads += 1; + return { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }; + } + return Reflect.getOwnPropertyDescriptor(current, property); + }, + has(current, property) { + if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) { + throw new Error("numeric membership scan must stay bounded by materialized keys"); + } + return Reflect.has(current, property); + }, + ownKeys() { + return ["length"]; + } + }); + + return { + sections, + numericDescriptorReads: () => numericDescriptorReads + }; +} + +describe("runtime section collection bounds", () => { + it("does not scan a synthetic array length while resolving the first intro", () => { + const song = createDemoRehearsalSong(); + const hostile = syntheticLengthSections(); + song.sections = hostile.sections; + + expect(resolveFirstIntro(song)).toBeNull(); + expect(hostile.numericDescriptorReads()).toBeLessThan(8); + }); + + it("fails closed without numeric membership scanning in the player summary", () => { + const song = createDemoRehearsalSong(); + const hostile = syntheticLengthSections(); + song.sections = hostile.sections; + + expect(() => render()).not.toThrow(); + expect(hostile.numericDescriptorReads()).toBeLessThan(8); + }); +}); diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..f5e6523a8 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,26 @@ describe("i18n", () => { } }); }); -}); + + describe("translateSectionFormLabel", () => { + it("localizes every supported section-form label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "intro")).toBe("인트로"); + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("ko", "chorus")).toBe("코러스"); + expect(translateSectionFormLabel("ko", "bridge")).toBe("브리지"); + expect(translateSectionFormLabel("ko", "outro")).toBe("아웃트로"); + expect(translateSectionFormLabel("ko", "tag")).toBe("태그"); + expect(translateSectionFormLabel("ko", "pickup")).toBe("픽업"); + expect(translateSectionFormLabel("ko", "stop")).toBe("스톱"); + expect(translateSectionFormLabel("ko", "handoff")).toBe("핸드오프"); + expect(translateSectionFormLabel("en", "intro")).toBe("intro"); + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..3dca55b4e 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,35 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + 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 +48,12 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for an own section-form entry, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale]; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : 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 d803a765e..4be9bf761 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -12,7 +12,7 @@ "sourceModeReference": "References the original file", "unsupportedLocalAudio": "Choose a WAV, MP3, FLAC, or M4A file to start analysis.", "sectionConfidence": "Section confidence", - "roleConfidence": "confidence", + "roleConfidence": "Confidence", "harmonySource": "harmony source", "manualOverride": "manual override", "startAnalysis": "Start analysis", @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "Local analysis", "metricConfidenceSectionSingular": "section", "metricConfidenceSectionPlural": "sections", + "metricConfidenceSectionCountSingular": "{count} section", + "metricConfidenceSectionCountPlural": "{count} sections", "metricPriorityFallback": "Pick track", "metricPriorityPendingDetail": "Choose or open audio", "loadProjectFailedPrefix": "Failed to load project", @@ -149,6 +151,18 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstIntroLabel": "Tonight's first intro", + "firstIntroAction": "Hear {role} start at {at}", + "firstIntroActionBand": "Hear the first intro at {at}", + "firstIntroOpenAction": "Open {role} intro at {at}", + "firstIntroOpenActionBand": "Open the first intro at {at}", + "firstIntroBody": "{role} starts the {section} at {at}.", + "firstIntroBodyBand": "The band starts the {section} at {at}.", + "firstIntroArmed": "Count in with {role} at {at}. Start together.", + "firstIntroArmedBand": "Count in at {at}. Start together.", + "firstIntroUnavailable": "No intro yet. Stay on tonight's map until the start is labeled.", + "firstIntroNeedsSong": "Analyze tonight's song first, then hear the first intro from this player.", + "playerPlaybackRequiresDesktop": "Audio playback requires the desktop app with a local audio source.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..caab0e875 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -139,6 +139,8 @@ "metricConfidenceLocalAnalysis": "로컬 분석", "metricConfidenceSectionSingular": "구간", "metricConfidenceSectionPlural": "구간", + "metricConfidenceSectionCountSingular": "{count}개 섹션", + "metricConfidenceSectionCountPlural": "{count}개 섹션", "metricPriorityFallback": "트랙 선택", "metricPriorityPendingDetail": "오디오를 선택하거나 여세요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", @@ -149,6 +151,18 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstIntroLabel": "오늘 첫 인트로", + "firstIntroAction": "{at}에 {role} 시작 듣기", + "firstIntroActionBand": "{at} 첫 인트로 듣기", + "firstIntroOpenAction": "{at} {role} 인트로 위치 열기", + "firstIntroOpenActionBand": "{at} 첫 인트로 위치 열기", + "firstIntroBody": "{at} {section}에서 {role} 파트가 시작합니다.", + "firstIntroBodyBand": "밴드가 {at} {section}에서 시작합니다.", + "firstIntroArmed": "{at}에서 {role} 파트와 함께 카운트인하세요. 같이 시작하세요.", + "firstIntroArmedBand": "{at}에서 카운트인하세요. 같이 시작하세요.", + "firstIntroUnavailable": "아직 인트로가 없습니다. 시작이 표시될 때까지 오늘 지도에 머무르세요.", + "firstIntroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 인트로를 들으세요.", + "playerPlaybackRequiresDesktop": "오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..e9ad457dd 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 Intro Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstIntroCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `intro` start, and the time. Do not invent a start from `verse`, `chorus`, `pickup`, `stop`, `handoff`, or the first unlabeled section. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearIntro` exists and delegates the exact intro second to that callback. Keep the unavailable state guidance-only. Distinct from loop-first-section #903, first-notes #905, first-chorus #939, and first-stop #934. | | 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-intro-navigation.md b/docs/doctoring/reduced-motion-first-intro-navigation.md new file mode 100644 index 000000000..eb2286a17 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-intro-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-intro navigation + +Workspace map navigation for tonight's first intro follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstIntroCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Intro resolution, action-mode authority, and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: intro 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. +- 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, and copy interpolation runs once. +- Test points: inherited song/section/timing/role/graph metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.