From a398bd76774066dca1eaf12c06370c8f5d527d72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 23:11:38 +0000 Subject: [PATCH 01/23] feat(workspace): guide tonight's first outro on map and player Name the earliest labeled outro and the holding part so the room can finish together. Map Open scrolls the renderer-owned section; player Hear only appears when a seek callback exists. Do not invent an ending from intro, verse, chorus, tag, pickup, stop, handoff, or unlabeled sections. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 110 +++++++++ apps/desktop/src/features/player/index.tsx | 78 +++++- .../FirstOutroCallout.particle.test.tsx | 37 +++ .../FirstOutroCallout.reduced-motion.test.tsx | 69 ++++++ .../workspace/FirstOutroCallout.test.tsx | 170 +++++++++++++ .../features/workspace/FirstOutroCallout.tsx | 152 ++++++++++++ .../src/features/workspace/Workspace.test.tsx | 44 ++++ .../src/features/workspace/Workspace.tsx | 3 + .../firstOutro.inherited-metadata.test.ts | 50 ++++ .../src/features/workspace/firstOutro.test.ts | 206 ++++++++++++++++ .../src/features/workspace/firstOutro.ts | 224 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 18 +- apps/desktop/src/i18n/index.ts | 14 ++ apps/desktop/src/locales/en/common.json | 15 +- apps/desktop/src/locales/ko/common.json | 15 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-outro-navigation.md | 14 ++ 21 files changed, 1210 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOutroCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstOutroCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstOutro.test.ts create mode 100644 apps/desktop/src/features/workspace/firstOutro.ts create mode 100644 docs/doctoring/reduced-motion-first-outro-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..3379ab6cd 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 outro with the holding part when an active role is corroborated, the labeled ending, 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..80ba0a3e5 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 outro must name the holding part when corroborated, the labeled ending, and the time so the next action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..f98b2a1f4 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 labeled outro on the workspace and player so the room can finish 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..3f357af61 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). Workspace and player name tonight's first labeled outro so the room can finish 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. `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..c793a793b --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,110 @@ +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 songWithOutro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + outro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + outro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, outro]; + 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 outro from this player.") + ).toBeTruthy(); + }); + + it("keeps the outro hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull(); + expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy(); + }); + + it("delegates the outro hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Drums land at 3:00" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(180); + }); + + it("localizes the section count and labeled outro badge 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("outro")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithOutro(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithOutro(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithOutro(); + song.sections = [null, song.sections[0]!] 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 = songWithOutro(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..508db569c 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,49 +1,105 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstOutroCallout } from "../workspace/FirstOutroCallout"; +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; +}; + +/** 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 trusting runtime collection metadata. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = song.sections as unknown; + if (!Array.isArray(sections)) { + return []; + } + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + for (let index = 0; index < length; index += 1) { + if (!(index in sections)) { + return []; + } + } + return sections.filter(isPlayerSummarySection); +} + +/** Player surface that names tonight's first labeled outro 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("firstOutroNeedsSong")}

); } + const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; + 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)} ))}
diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.particle.test.tsx new file mode 100644 index 000000000..9d3361093 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.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 { FirstOutroCallout } from "./FirstOutroCallout"; + +describe("FirstOutroCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the outro action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + const outro = structuredClone(seed); + outro.id = "outro-particle"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + outro.roles = [{ ...seed.roles[0]!, id: "piano", name: "피아노", rehearsalPriority: "high" }]; + outro.partGraph = [ + { role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] } + ]; + song.sections = [outro]; + + const onHearOutro = vi.fn(); + render(); + + expect(screen.getByText("3:00 아웃트로에서 피아노 파트가 끝맺습니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "3:00에 피아노 끝맺음 듣기" })); + + expect(onHearOutro).toHaveBeenCalledWith(180); + expect(screen.getByText("3:00에서 피아노 파트와 함께 마지막 마디를 잡으세요. 같이 끝내세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..2072bfc13 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.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 { FirstOutroCallout } from "./FirstOutroCallout"; + +function songWithOutro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + outro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + outro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, outro]; + return song; +} + +describe("FirstOutroCallout 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 first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums outro at 3:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx new file mode 100644 index 000000000..555493064 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx @@ -0,0 +1,170 @@ +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 { FirstOutroCallout } from "./FirstOutroCallout"; + +function songWithOutro() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + outro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + outro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, outro]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstOutroCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.") + ).toBeTruthy(); + }); + + it("names the first outro 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 outro at 3:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Hold with Drums at 3:00. Finish 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 outro at 3:00" })); + + expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy(); + expect(screen.queryByText(/Hold with Drums at 3:00. Finish together./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearOutro = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums outro at 3:00" })); + expect(onHearOutro).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 = songWithOutro(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Drums outro at 3:00" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first outro changes or returns later", () => { + const initialSong = songWithOutro(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Drums outro at 3:00" })); + expect(screen.getByText(/Hold with Drums at 3:00. Finish together./)).toBeTruthy(); + + const nextSong = songWithOutro(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 200, end: 216 }; + rerender(); + expect(screen.getByText("Drums holds the outro at 3:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable outro guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No outro yet. Stay on tonight's map until the ending is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide landing when no part holds the outro", () => { + const song = songWithOutro(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first outro at 3:00" })).toBeTruthy(); + expect(screen.getByText("The band lands the outro at 3:00.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearOutro = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Drums land at 3:00" })); + expect(onHearOutro).toHaveBeenCalledWith(180); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy(); + }); + + it("localizes the outro form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithOutro(); + song.sections[1]!.roles[0]!.name = "드럼"; + + render(); + + expect(screen.getByText("3:00 아웃트로에서 드럼 파트가 끝맺습니다.")).toBeTruthy(); + expect(screen.queryByText(/outro에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx new file mode 100644 index 000000000..678eee5fe --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx @@ -0,0 +1,152 @@ +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 { formatOutroTime, resolveFirstOutro } from "./firstOutro"; + +/** Props for the first-outro rehearsal callout. */ +export interface FirstOutroCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearOutro?: (atSeconds: number) => void; +} + +type OutroCopyValues = Readonly>; + +type HeardOutro = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate outro placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatOutroCopy(template: string, values: OutroCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof OutroCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredOutroScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled outro and offer only an action that the current surface can execute. */ +export function FirstOutroCallout({ + song, + actionMode = "workspace-scroll", + onHearOutro +}: FirstOutroCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const outro = resolveFirstOutro(song); + const outroSectionIndex = + outro && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(outro.section) + : -1; + const [heardOutro, setHeardOutro] = useState(null); + + useEffect(() => { + setHeardOutro(null); + }, [songId, outroSectionIndex, outro?.section.id, outro?.holdingRole?.id, outro?.atSeconds]); + + if (!outro) { + return ( + + ); + } + + const heard = + heardOutro?.songId === songId && + heardOutro.sectionId === outro.section.id && + heardOutro.sectionIndex === outroSectionIndex && + heardOutro.holdingRoleId === (outro.holdingRole?.id ?? null) && + heardOutro.atSeconds === outro.atSeconds; + const at = formatOutroTime(outro.atSeconds); + const copyValues: OutroCopyValues = { + role: outro.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, outro.section.label), + at + }; + const hasRole = outro.holdingRole !== null; + const actionLabel = formatOutroCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstOutroAction" + : "firstOutroActionBand" + : hasRole + ? "firstOutroOpenAction" + : "firstOutroOpenActionBand" + ), + copyValues + ); + const body = formatOutroCopy(t(hasRole ? "firstOutroBody" : "firstOutroBodyBand"), copyValues); + const armed = formatOutroCopy(t(hasRole ? "firstOutroArmed" : "firstOutroArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearOutro === "function"; + /** Record completion only after the owning surface has executed the selected outro action. */ + const markOutroActionComplete = () => { + setHeardOutro({ + songId, + sectionId: outro.section.id, + sectionIndex: outroSectionIndex, + holdingRoleId: outro.holdingRole?.id ?? null, + atSeconds: outro.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..0f1659f6a 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,48 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first outro as workspace navigation", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + outro.roles = [ + { + ...verse.roles[0]!, + id: "drums", + name: "Drums", + rehearsalPriority: "high" + } + ]; + outro.partGraph = [ + { + role_id: "drums", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, outro]; + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(1); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + const action = screen.getByRole("button", { + name: "Open Drums outro at 3:00" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Hold with Drums at 3:00. Finish together./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..23a4b0290 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 { FirstOutroCallout } from "./FirstOutroCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts new file mode 100644 index 000000000..1611f6458 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts @@ -0,0 +1,50 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstOutro } from "./firstOutro"; + +function songWithOutro() { + const song = createDemoRehearsalSong(); + const outro = structuredClone(song.sections[0]!); + outro.id = "outro-own"; + outro.label = "outro"; + outro.timeRange = { start: 180, end: 196 }; + song.sections = [outro]; + return { song, outro }; +} + +describe("resolveFirstOutro inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, outro } = songWithOutro(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstOutro(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(outro) as typeof outro; + song.sections = [inheritedSection]; + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, outro } = songWithOutro(); + outro.timeRange = Object.create({ start: 180, end: 196 }) as typeof outro.timeRange; + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, outro } = songWithOutro(); + const role = outro.roles[0]!; + const node = outro.partGraph[0]!; + outro.roles = [Object.create(role) as typeof role]; + outro.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstOutro(song); + expect(resolved?.section.id).toBe("outro-own"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, outro } = songWithOutro(); + const arraySection = Object.assign([], outro) as unknown as typeof outro; + song.sections = [arraySection]; + expect(resolveFirstOutro(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstOutro.test.ts b/apps/desktop/src/features/workspace/firstOutro.test.ts new file mode 100644 index 000000000..c5b6acfea --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOutro.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatOutroTime, resolveFirstOutro } from "./firstOutro"; + +function withOutroSection( + 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 outro = structuredClone(verse); + outro.id = overrides.id ?? "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: overrides.start ?? 180, end: overrides.end ?? 196 }; + const roleId = overrides.roleId ?? "drums"; + outro.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Drums", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + outro.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, outro]; + return song; +} + +describe("resolveFirstOutro", () => { + it("returns null when the demo song has no labeled outro", () => { + expect(resolveFirstOutro(createDemoRehearsalSong())).toBeNull(); + expect(formatOutroTime(Number.NaN)).toBe("0:00"); + expect(formatOutroTime(-4)).toBe("0:00"); + }); + + it("does not invent an outro from a verse, chorus, intro, tag, 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 intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + const tag = structuredClone(verse); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 200, end: 208 }; + 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 = [intro, verse, pickup, stop, chorus, handoff, tag]; + + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("does not treat the last unlabeled section as an outro", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.timeRange = { start: 180, end: 196 }; + expect(song.sections[0]!.label).toBe("verse"); + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("picks the earliest labeled outro and the part that lands it", () => { + const song = withOutroSection({ start: 180, end: 196 }); + const outro = resolveFirstOutro(song); + + expect(outro?.section.id).toBe("outro-1"); + expect(outro?.holdingRole?.id).toBe("drums"); + expect(outro?.atSeconds).toBe(180); + expect(formatOutroTime(outro?.atSeconds ?? -1)).toBe("3:00"); + }); + + it("prefers the earlier of two labeled outros", () => { + const song = withOutroSection({ id: "outro-late", start: 220, end: 236 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "outro-early"; + earlier.timeRange = { start: 180, end: 196 }; + 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[0]!, song.sections[1]!, earlier]; + + const outro = resolveFirstOutro(song); + expect(outro?.section.id).toBe("outro-early"); + expect(outro?.holdingRole?.id).toBe("bass-guitar"); + expect(outro?.atSeconds).toBe(180); + }); + + it("breaks same-time outro ties with locale-independent id ordering", () => { + const song = withOutroSection({ id: "ä-outro", start: 180, end: 196 }); + const asciiOutro = structuredClone(song.sections[1]!); + asciiOutro.id = "z-outro"; + song.sections = [song.sections[0]!, song.sections[1]!, asciiOutro]; + + expect(resolveFirstOutro(song)?.section.id).toBe("z-outro"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withOutroSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const outro = song.sections[1]!; + const asciiRole = { ...outro.roles[0]!, id: "z-role", name: "ASCII role" }; + outro.roles = [outro.roles[0]!, asciiRole]; + outro.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstOutro(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide landing when no active ranked role holds it", () => { + const song = withOutroSection({ isActive: false }); + const outro = resolveFirstOutro(song); + expect(outro?.section.id).toBe("outro-1"); + expect(outro?.holdingRole).toBeNull(); + expect(outro?.atSeconds).toBe(180); + }); + + it("skips an outro whose rehearsal window is unbounded", () => { + const song = withOutroSection({ start: Number.NaN, end: 196 }); + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("skips an outro whose end precedes its start", () => { + const song = withOutroSection({ start: 196, end: 180 }); + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("skips a zero-length outro window", () => { + const song = withOutroSection({ start: 180, end: 180 }); + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("skips an outro whose endpoint overflows the shared timing bound", () => { + const song = withOutroSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }); + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstOutro(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withOutroSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstOutro(song)).toBeNull(); + }); + + it("keeps the landing band-wide when role identities are duplicated", () => { + const song = withOutroSection(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const outro = resolveFirstOutro(song); + expect(outro?.section.id).toBe("outro-1"); + expect(outro?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstOutro.ts b/apps/desktop/src/features/workspace/firstOutro.ts new file mode 100644 index 000000000..3e231ea44 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstOutro.ts @@ -0,0 +1,224 @@ +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; + +/** Tonight's first labeled outro: the earliest ending and the part that lands it. */ +export type FirstOutro = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative outro time as m:ss for rehearsal copy. */ +export function formatOutroTime(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 the named field rather than inheriting it. */ +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** Return whether every numeric index is an own 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 (!hasOwn(value, index)) { + return false; + } + } + return true; +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwn(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwn(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwn(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 (!hasOwn(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if ( + !isRuntimeObject(timeRange) || + !hasOwn(timeRange, "start") || + !hasOwn(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 ( + !hasOwn(section, "roles") || + !hasOwn(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwn(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwn(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) && + hasOwn(node, "is_active") && + node.is_active === true && + hasOwn(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 labeled outro, or null when no safe ending remains. */ +export function resolveFirstOutro(song: RehearsalSong): FirstOutro | null { + if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const outroSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwn(section, "label") && + section.label === "outro" && + hasOwn(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .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 = outroSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..c3d2e7d99 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,20 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes an outro label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "outro")).toBe("아웃트로"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("preserves unlabeled form values as data", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f3ed598d2 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,13 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + en: { outro: "outro" }, + ko: { outro: "아웃트로" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,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) : 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..d69786d7a 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -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", @@ -148,5 +150,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstOutroLabel": "Tonight's first outro", + "firstOutroAction": "Hear {role} land at {at}", + "firstOutroActionBand": "Hear the first outro at {at}", + "firstOutroOpenAction": "Open {role} outro at {at}", + "firstOutroOpenActionBand": "Open the first outro at {at}", + "firstOutroBody": "{role} holds the {section} at {at}.", + "firstOutroBodyBand": "The band lands the {section} at {at}.", + "firstOutroArmed": "Hold with {role} at {at}. Finish together.", + "firstOutroArmedBand": "Hold at {at}. Finish together.", + "firstOutroUnavailable": "No outro yet. Stay on tonight's map until the ending is labeled.", + "firstOutroNeedsSong": "Analyze tonight's song first, then hear the first outro from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..fb4de4445 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": "프로젝트를 불러오지 못했습니다", @@ -148,5 +150,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstOutroLabel": "오늘 첫 아웃트로", + "firstOutroAction": "{at}에 {role} 끝맺음 듣기", + "firstOutroActionBand": "{at} 첫 아웃트로 듣기", + "firstOutroOpenAction": "{at} {role} 아웃트로 위치 열기", + "firstOutroOpenActionBand": "{at} 첫 아웃트로 위치 열기", + "firstOutroBody": "{at} {section}에서 {role} 파트가 끝맺습니다.", + "firstOutroBodyBand": "밴드가 {at} {section}에서 끝맺습니다.", + "firstOutroArmed": "{at}에서 {role} 파트와 함께 마지막 마디를 잡으세요. 같이 끝내세요.", + "firstOutroArmedBand": "{at}에서 마지막 마디를 잡으세요. 같이 끝내세요.", + "firstOutroUnavailable": "아직 아웃트로가 없습니다. 끝맺음이 표시될 때까지 오늘 지도에 머무르세요.", + "firstOutroNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 아웃트로를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..4bfc58906 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 Outro Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstOutroCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `outro` start, and the time. Do not invent an ending from `intro`, `verse`, `chorus`, `tag`, `pickup`, `stop`, `handoff`, or the last 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 `onHearOutro` exists and delegates the exact outro second to that callback. Keep the unavailable state guidance-only. Distinct from first-intro, first-chorus, first-stop, and first-tag 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-outro-navigation.md b/docs/doctoring/reduced-motion-first-outro-navigation.md new file mode 100644 index 000000000..04d4de194 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-outro-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-outro navigation + +Workspace map navigation for tonight's first outro follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstOutroCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Outro 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: outro 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`. From ee819fb4a4992317a3c2727841be7a6ecfa85522 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:29:16 -0700 Subject: [PATCH 02/23] test(player): require consistent Korean section terminology --- apps/desktop/src/features/player/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index c793a793b..32530ed0c 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -59,7 +59,7 @@ describe("PlayerFeature", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); try { render(); - expect(screen.getByText("2개 섹션")).toBeTruthy(); + expect(screen.getByText("2개 구간")).toBeTruthy(); expect(screen.queryByText("2 sections")).toBeNull(); expect(screen.getByText("아웃트로")).toBeTruthy(); expect(screen.queryByText("outro")).toBeNull(); From 3a06ec4c1599332c793534e408fd3cebe115963f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:29:45 -0700 Subject: [PATCH 03/23] fix(i18n): use consistent Korean section terminology --- apps/desktop/src/locales/ko/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index fb4de4445..c53c48bed 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -139,8 +139,8 @@ "metricConfidenceLocalAnalysis": "로컬 분석", "metricConfidenceSectionSingular": "구간", "metricConfidenceSectionPlural": "구간", - "metricConfidenceSectionCountSingular": "{count}개 섹션", - "metricConfidenceSectionCountPlural": "{count}개 섹션", + "metricConfidenceSectionCountSingular": "{count}개 구간", + "metricConfidenceSectionCountPlural": "{count}개 구간", "metricPriorityFallback": "트랙 선택", "metricPriorityPendingDetail": "오디오를 선택하거나 여세요", "loadProjectFailedPrefix": "프로젝트를 불러오지 못했습니다", From 384404b650d161ca1fc5fa2fbaa041efd6efc2d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:30:59 -0700 Subject: [PATCH 04/23] test(i18n): require complete Korean section-form localization --- apps/desktop/src/i18n/index.test.ts | 40 +++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index c3d2e7d99..d4986d4c7 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -77,13 +77,43 @@ describe("i18n", () => { }); describe("translateSectionFormLabel", () => { - it("localizes an outro label for Korean rehearsal copy", () => { - expect(translateSectionFormLabel("ko", "outro")).toBe("아웃트로"); - expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + 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 unlabeled form values as data", () => { - expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "intro")).toBe("intro"); + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus"); + expect(translateSectionFormLabel("en", "chorus")).toBe("chorus"); + expect(translateSectionFormLabel("en", "bridge")).toBe("bridge"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + expect(translateSectionFormLabel("en", "tag")).toBe("tag"); + expect(translateSectionFormLabel("en", "pickup")).toBe("pickup"); + expect(translateSectionFormLabel("en", "stop")).toBe("stop"); + expect(translateSectionFormLabel("en", "handoff")).toBe("handoff"); }); it("does not treat inherited object keys as localized section labels", () => { From 939f0b26c41f123dd44e709b74ddfdb0b0aa6ade Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:31:23 -0700 Subject: [PATCH 05/23] fix(i18n): localize all supported section-form labels --- apps/desktop/src/i18n/index.ts | 36 ++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index f3ed598d2..dbbabe295 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -12,11 +12,31 @@ const dictionaries = { ko: koCommon } as const; -const sectionFormLabels: Readonly< - Record>> -> = { - en: { outro: "outro" }, - ko: { outro: "아웃트로" } +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } }; /** Documented. */ @@ -26,10 +46,10 @@ export function createTranslator(locale: Locale = "en") { }; } -/** Return localized copy for an own section-form entry, preserving unknown labels as data. */ +/** Return localized buyer copy for an own supported section-form entry. */ export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { - const labels = sectionFormLabels[locale]; - return Object.prototype.hasOwnProperty.call(labels, label) ? (labels[label] ?? label) : label; + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); } /** Documented. */ From 59e3691744abaa66cf3fb2deb39c55df0adb4660 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:36:30 -0700 Subject: [PATCH 06/23] test(workspace): decouple outro navigation from child order --- .../desktop/src/features/workspace/FirstOutroCallout.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx index 555493064..532939ef9 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx @@ -34,13 +34,17 @@ function appendSongStructureTarget() { const grid = document.createElement("div"); grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); + first.dataset.sectionIndex = "0"; + const unrelatedSibling = document.createElement("div"); const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; const scrollIntoView = vi.fn(); Object.defineProperty(target, "scrollIntoView", { configurable: true, value: scrollIntoView }); grid.appendChild(first); + grid.appendChild(unrelatedSibling); grid.appendChild(target); document.body.appendChild(grid); return { grid, scrollIntoView }; From ba405db8996d0885b305b5b7571d67c625a62673 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:37:00 -0700 Subject: [PATCH 07/23] fix(workspace): target outro by renderer-owned section marker --- .../desktop/src/features/workspace/FirstOutroCallout.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx index 678eee5fe..3b46fc0ad 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx @@ -133,7 +133,14 @@ export function FirstOutroCallout({ return; } const grid = document.querySelector('[data-testid="song-structure-grid"]'); - const target = outroSectionIndex >= 0 ? grid?.children.item(outroSectionIndex) : null; + const target = + outroSectionIndex >= 0 + ? Array.from(grid?.children ?? []).find( + (child) => + child instanceof HTMLElement && + child.dataset.sectionIndex === String(outroSectionIndex) + ) + : undefined; if (typeof target?.scrollIntoView !== "function") { return; } From 093de2eed94bd47d8a6aa08234da230960a337e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:37:57 -0700 Subject: [PATCH 08/23] fix(workspace): mark rendered sections for stable outro navigation --- apps/desktop/src/features/workspace/Workspace.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 23a4b0290..e2ce0140b 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -91,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)}

From 9399b702cb5770074876574651204ed415539bc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:39:38 -0700 Subject: [PATCH 09/23] test(workspace): require production-owned song structure hook --- apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx index 532939ef9..532ba822f 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.test.tsx @@ -32,7 +32,7 @@ function songWithOutro() { function appendSongStructureTarget() { const grid = document.createElement("div"); - grid.dataset.testid = "song-structure-grid"; + grid.id = "song-structure-grid"; const first = document.createElement("div"); first.dataset.sectionIndex = "0"; const unrelatedSibling = document.createElement("div"); From 6facbe25ce56e931b2a0bad77842f405f1ed70ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:40:05 -0700 Subject: [PATCH 10/23] fix(workspace): use production song structure anchor --- apps/desktop/src/features/workspace/FirstOutroCallout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx index 3b46fc0ad..e8e594024 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx @@ -132,7 +132,7 @@ export function FirstOutroCallout({ markOutroActionComplete(); return; } - const grid = document.querySelector('[data-testid="song-structure-grid"]'); + const grid = document.getElementById("song-structure-grid"); const target = outroSectionIndex >= 0 ? Array.from(grid?.children ?? []).find( From c087b3c9c26f13317f00dc2f5f271a954890bcdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:41:09 -0700 Subject: [PATCH 11/23] fix(workspace): navigate via renderer-owned section marker --- .../desktop/src/features/workspace/FirstOutroCallout.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx index e8e594024..c86c6b5cc 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.tsx @@ -132,15 +132,12 @@ export function FirstOutroCallout({ markOutroActionComplete(); return; } - const grid = document.getElementById("song-structure-grid"); const target = outroSectionIndex >= 0 - ? Array.from(grid?.children ?? []).find( - (child) => - child instanceof HTMLElement && - child.dataset.sectionIndex === String(outroSectionIndex) + ? document.querySelector( + `[data-section-index="${outroSectionIndex}"]` ) - : undefined; + : null; if (typeof target?.scrollIntoView !== "function") { return; } From 8f57bc8de1518b6de38413e87c252cfc602eeef6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:41:49 -0700 Subject: [PATCH 12/23] test(player): require localized playback guidance --- apps/desktop/src/features/player/index.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 32530ed0c..bb9899e0f 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -55,7 +55,7 @@ describe("PlayerFeature", () => { expect(onPlayFromSeconds).toHaveBeenCalledWith(180); }); - it("localizes the section count and labeled outro badge instead of mixing English player copy", () => { + it("localizes the section count, labels, and playback guidance instead of mixing English player copy", () => { vi.stubGlobal("navigator", { language: "ko-KR" }); try { render(); @@ -63,6 +63,12 @@ describe("PlayerFeature", () => { expect(screen.queryByText("2 sections")).toBeNull(); expect(screen.getByText("아웃트로")).toBeTruthy(); expect(screen.queryByText("outro")).toBeNull(); + expect( + screen.getByText("오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.") + ).toBeTruthy(); + expect( + screen.queryByText("Audio playback requires the desktop app with a local audio source.") + ).toBeNull(); } finally { vi.unstubAllGlobals(); } From f80eaed4339d6fe69eb694a23020f6234c8839ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:42:28 -0700 Subject: [PATCH 13/23] feat(i18n): add player playback guidance copy --- apps/desktop/src/locales/en/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d69786d7a..080aaa5ff 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -151,6 +151,7 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "playerPlaybackRequiresLocalAudio": "Audio playback requires the desktop app with a local audio source.", "firstOutroLabel": "Tonight's first outro", "firstOutroAction": "Hear {role} land at {at}", "firstOutroActionBand": "Hear the first outro at {at}", From 7da2268486dee792d464f5a2ef275ccd4d1c92ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:43:20 -0700 Subject: [PATCH 14/23] feat(i18n): localize player playback guidance --- apps/desktop/src/locales/ko/common.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index c53c48bed..2f14df233 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -151,6 +151,7 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "playerPlaybackRequiresLocalAudio": "오디오 재생은 로컬 오디오 소스가 있는 데스크톱 앱에서 사용할 수 있습니다.", "firstOutroLabel": "오늘 첫 아웃트로", "firstOutroAction": "{at}에 {role} 끝맺음 듣기", "firstOutroActionBand": "{at} 첫 아웃트로 듣기", From e4526bdc15ad50d8455d2aee64d326bb7551bd9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:43:42 -0700 Subject: [PATCH 15/23] fix(player): localize playback guidance --- apps/desktop/src/features/player/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 508db569c..937893fac 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -104,7 +104,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP ))}
- Audio playback requires the desktop app with a local audio source. + {t("playerPlaybackRequiresLocalAudio")}
From f7fe779ea94bc42e3e458c0066ca4762eb81d857 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 17:46:41 -0700 Subject: [PATCH 16/23] test(workspace): align reduced-motion fixture with section marker --- .../workspace/FirstOutroCallout.reduced-motion.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx index 2072bfc13..ab57ba478 100644 --- a/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx +++ b/apps/desktop/src/features/workspace/FirstOutroCallout.reduced-motion.test.tsx @@ -48,9 +48,10 @@ describe("FirstOutroCallout reduced motion", () => { })); const grid = document.createElement("div"); - grid.dataset.testid = "song-structure-grid"; const first = document.createElement("div"); + first.dataset.sectionIndex = "0"; const target = document.createElement("div"); + target.dataset.sectionIndex = "1"; const scrollIntoView = vi.fn(); Object.defineProperty(target, "scrollIntoView", { configurable: true, From 4eea95ae11f09ebcc0655ad070db3cb45cb87af4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:05:25 -0700 Subject: [PATCH 17/23] test(player): reject unreachable outro Hear action --- apps/desktop/src/features/player/index.test.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index bb9899e0f..2d60f2e60 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { createDemoRehearsalSong } from "@bandscope/shared-types"; import { describe, expect, it, vi } from "vitest"; import { PlayerFeature } from "./index"; @@ -45,14 +45,12 @@ describe("PlayerFeature", () => { expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy(); }); - it("delegates the outro hear action to the owning player callback", () => { + it("does not expose an unreachable Hear action from the unmounted player placeholder", () => { const onPlayFromSeconds = vi.fn(); render(); - fireEvent.click(screen.getByRole("button", { name: "Hear Drums land at 3:00" })); - - expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); - expect(onPlayFromSeconds).toHaveBeenCalledWith(180); + expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull(); + expect(onPlayFromSeconds).not.toHaveBeenCalled(); }); it("localizes the section count, labels, and playback guidance instead of mixing English player copy", () => { From ae63713ffbfb5049b662468970514ac4378b7e1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:05:54 -0700 Subject: [PATCH 18/23] fix(player): remove unreachable outro playback authority --- apps/desktop/src/features/player/index.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 937893fac..c7382dd88 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -10,7 +10,6 @@ import { createTranslator, detectPreferredLocale, translateSectionFormLabel } fr type PlayerFeatureProps = { title: string; song?: RehearsalSong | null; - onPlayFromSeconds?: (startSeconds: number) => void; }; /** Return whether one runtime section is safe to summarize in the player. */ @@ -45,8 +44,8 @@ function playerSummarySections(song: RehearsalSong): RehearsalSection[] { return sections.filter(isPlayerSummarySection); } -/** Player surface that names tonight's first labeled outro and delegates playback to the owning player. */ -export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { +/** Player summary remains guidance-only until a mounted playback owner provides real seek authority. */ +export function PlayerFeature({ title, song }: PlayerFeatureProps) { const locale = detectPreferredLocale(); const t = createTranslator(locale); @@ -70,7 +69,7 @@ export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureP return (

{title}

- +
Date: Sat, 22 Aug 2026 18:06:22 -0700 Subject: [PATCH 19/23] test(player): pin placeholder playback authority boundary --- apps/desktop/src/features/player/index.test.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx index 2d60f2e60..e09f81bfd 100644 --- a/apps/desktop/src/features/player/index.test.tsx +++ b/apps/desktop/src/features/player/index.test.tsx @@ -38,19 +38,20 @@ describe("PlayerFeature", () => { ).toBeTruthy(); }); - it("keeps the outro hear action unavailable without a player playback callback", () => { + it("keeps the outro hear action unavailable without a mounted playback owner", () => { render(); expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull(); expect(screen.getByText("Drums holds the outro at 3:00.")).toBeTruthy(); }); - it("does not expose an unreachable Hear action from the unmounted player placeholder", () => { - const onPlayFromSeconds = vi.fn(); - render(); + it("does not grant seek authority to the unmounted player placeholder", () => { + // @ts-expect-error PlayerFeature is not a mounted playback owner and must not accept seek authority. + const unreachablePlayer = undefined} />; + expect(unreachablePlayer).toBeTruthy(); + render(); expect(screen.queryByRole("button", { name: "Hear Drums land at 3:00" })).toBeNull(); - expect(onPlayFromSeconds).not.toHaveBeenCalled(); }); it("localizes the section count, labels, and playback guidance instead of mixing English player copy", () => { From 0495f92e1826233ab35d532826f4a900e969b9ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:07:04 -0700 Subject: [PATCH 20/23] docs(changelog): keep first-outro playback claims truthful --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98b2a1f4..60533c669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. -- Name tonight's first labeled outro on the workspace and player so the room can finish 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. +- Name tonight's first labeled outro in the mounted rehearsal workspace so the room can finish together; the Open action moves to the matching rendered map section. The unmounted player placeholder remains guidance-only and does not claim playback authority. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. ## [0.1.3] - 2026-04-29 From e060f5286601302c726a47d760e7053ca8253661 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:07:35 -0700 Subject: [PATCH 21/23] docs(architecture): bound player playback authority to mounted owners --- ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 80ba0a3e5..1e04b6f4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,7 +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 outro must name the holding part when corroborated, the labeled ending, and the time so the next action is obvious. +- The mounted workspace copy for tonight's first labeled outro must name the holding part when corroborated, the labeled ending, and the time so the next action is obvious. The current `PlayerFeature` placeholder is not mounted by `App.tsx` and must remain guidance-only until a real playback owner provides seek authority. ## Security source From 4222d99c025723329e522f98b7088486a4c3b5ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 18:08:03 -0700 Subject: [PATCH 22/23] docs(claude): keep player seek authority code-current --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3f357af61..418a9be99 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). Workspace and player name tonight's first labeled outro so the room can finish 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. `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 labeled outro and opens the matching rendered map section. The current `PlayerFeature` placeholder is not mounted by `App.tsx`; it is guidance-only and must not accept seek authority or expose a buyer-facing Hear action until a real playback owner is wired into the product. `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. From 525cb83df4562cc546d16f7c239b803e7a46f0de Mon Sep 17 00:00:00 2001 From: seonghobae Date: Sat, 29 Aug 2026 05:12:50 +0900 Subject: [PATCH 23/23] fix(desktop): fail closed on hostile outro metadata --- .../firstOutro.inherited-metadata.test.ts | 14 +++++ .../src/features/workspace/firstOutro.ts | 60 ++++++++++--------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts index 1611f6458..a557aac5b 100644 --- a/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts +++ b/apps/desktop/src/features/workspace/firstOutro.inherited-metadata.test.ts @@ -47,4 +47,18 @@ describe("resolveFirstOutro inherited metadata", () => { song.sections = [arraySection]; expect(resolveFirstOutro(song)).toBeNull(); }); + + it("fails closed when role metadata throws during ranking", () => { + const { song, outro } = songWithOutro(); + Object.defineProperty(outro.roles[0]!, "rehearsalPriority", { + configurable: true, + enumerable: true, + get() { + throw new Error("priority getter must stay data"); + } + }); + + expect(() => resolveFirstOutro(song)).not.toThrow(); + expect(resolveFirstOutro(song)).toBeNull(); + }); }); diff --git a/apps/desktop/src/features/workspace/firstOutro.ts b/apps/desktop/src/features/workspace/firstOutro.ts index 3e231ea44..4c15e4a75 100644 --- a/apps/desktop/src/features/workspace/firstOutro.ts +++ b/apps/desktop/src/features/workspace/firstOutro.ts @@ -189,36 +189,40 @@ function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { /** Return the first labeled outro, or null when no safe ending remains. */ export function resolveFirstOutro(song: RehearsalSong): FirstOutro | null { - if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { - return null; - } + try { + if (!isRuntimeObject(song) || !hasOwn(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } - const outroSections = song.sections - .filter( - (section) => - isRuntimeObject(section) && - hasOwn(section, "label") && - section.label === "outro" && - hasOwn(section, "id") && - typeof section.id === "string" && - section.id.trim().length > 0 && - hasBoundedTimeRange(section) - ) - .sort((left, right) => { - if (left.timeRange.start !== right.timeRange.start) { - return left.timeRange.start - right.timeRange.start; - } - return compareStableId(left.id, right.id); - }); + const outroSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwn(section, "label") && + section.label === "outro" && + hasOwn(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .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 = outroSections[0]; + if (!section) { + return null; + } - const section = outroSections[0]; - if (!section) { + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; + } catch { return null; } - - return { - section, - holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), - atSeconds: section.timeRange.start - }; }