From 4d353ca99a389189b2d7c1eda04067a071e1ac79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 13:15:13 +0000 Subject: [PATCH] feat(workspace): name tonight's first count on the map Name the entering part when corroborated, the owned count cue, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent an entrance from lyric, transition, groove, setup, simplification, overlap, or range copy. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../FirstCountCueCallout.particle.test.tsx | 51 +++ ...rstCountCueCallout.reduced-motion.test.tsx | 43 +++ .../workspace/FirstCountCueCallout.test.tsx | 221 +++++++++++++ .../workspace/FirstCountCueCallout.tsx | 132 ++++++++ .../src/features/workspace/Workspace.test.tsx | 23 ++ .../src/features/workspace/Workspace.tsx | 11 +- .../firstCountCue.inherited-metadata.test.ts | 89 +++++ .../features/workspace/firstCountCue.test.ts | 236 ++++++++++++++ .../src/features/workspace/firstCountCue.ts | 307 ++++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 49 ++- apps/desktop/src/i18n/index.ts | 36 +- apps/desktop/src/locales/en/common.json | 10 +- apps/desktop/src/locales/ko/common.json | 10 +- apps/desktop/vite.config.ts | 4 +- docs/design-system/component-contract.md | 1 + ...duced-motion-first-count-cue-navigation.md | 14 + 20 files changed, 1234 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/features/workspace/FirstCountCueCallout.particle.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCountCueCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCountCueCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstCountCueCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstCountCue.inherited-metadata.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCountCue.test.ts create mode 100644 apps/desktop/src/features/workspace/firstCountCue.ts create mode 100644 docs/doctoring/reduced-motion-first-count-cue-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..1d125a1ea 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 count with the entering part when an active role is corroborated, the owned count cue, the labeled section, and the time so the next action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..d389e115c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ Last updated: 2026-03-11 ## Brand source - Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`. +- The mounted workspace copy for tonight's first count must name the entering part when corroborated, the owned count cue, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent an entrance from lyric, transition, groove, setup, simplification, overlap, or range copy. - Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..b8e3fe441 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first count in the mounted rehearsal workspace so the room can catch the entrance together; the Open action moves to the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/CLAUDE.md b/CLAUDE.md index 82c2c704a..b4f01e3d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The mounted workspace names tonight's first count and opens the matching rendered map section. Do not invent an entrance from lyric, transition, groove, setup, simplification, overlap, or range copy. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/workspace/FirstCountCueCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstCountCueCallout.particle.test.tsx new file mode 100644 index 000000000..9e78d5388 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCountCueCallout.particle.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstCountCueCallout } from "./FirstCountCueCallout"; + +describe("FirstCountCueCallout Korean role copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps vowel-ending dynamic role names particle-safe before and after the count action", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + const seed = song.sections[0]!; + seed.roles = [ + { + ...seed.roles[1]!, + id: "piano", + name: "피아노", + rehearsalPriority: "high", + cue: { kind: "count", value: "픽업 다음 2박에 들어가세요." } + } + ]; + seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }]; + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + + expect(screen.getByText("0:10 벌스에서 피아노 파트가 카운트합니다.")).toBeTruthy(); + expect(screen.queryByText(/피아노이/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 카운트 위치 열기" })); + + expect(screen.getByText("0:10에서 피아노 파트 카운트를 잡으세요. 함께 들어가세요.")).toBeTruthy(); + expect(screen.queryByText(/피아노과/)).toBeNull(); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCountCueCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstCountCueCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..32381ae30 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCountCueCallout.reduced-motion.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstCountCueCallout } from "./FirstCountCueCallout"; + +describe("FirstCountCueCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + grid.setAttribute("role", "region"); + grid.setAttribute("aria-label", "Scrollable song structure timeline"); + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCountCueCallout.test.tsx b/apps/desktop/src/features/workspace/FirstCountCueCallout.test.tsx new file mode 100644 index 000000000..ebdce2627 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCountCueCallout.test.tsx @@ -0,0 +1,221 @@ +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 { FirstCountCueCallout } from "./FirstCountCueCallout"; + +function songWithCount() { + return createDemoRehearsalSong(); +} + +function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") { + const timeline = document.createElement("div"); + timeline.setAttribute("role", "region"); + timeline.setAttribute("aria-label", ariaLabel); + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const target = document.createElement("div"); + target.dataset.sectionIndex = "0"; + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + timeline.appendChild(grid); + document.body.appendChild(timeline); + return { grid: timeline, scrollIntoView }; +} + +describe("FirstCountCueCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No count yet. Stay on tonight's map until a part names the entrance.") + ).toBeTruthy(); + }); + + it("contains a hostile song identity accessor instead of crashing the callout", () => { + const song = songWithCount(); + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })).toBeTruthy(); + }); + + it("resets armed guidance when accessor-id songs change with the same count signature", () => { + const firstSong = songWithCount(); + const nextSong = songWithCount(); + for (const song of [firstSong, nextSong]) { + Object.defineProperty(song, "id", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile song id getter"); + } + }); + } + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + expect(screen.getByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeTruthy(); + + rerender(); + + expect(screen.getByText("Keyboard 1 Right Hand counts in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeNull(); + + grid.remove(); + }); + + it("names the first count as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + expect(screen.getByText("Enter on beat 2 after the pickup.")).toBeTruthy(); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand count at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeTruthy(); + + grid.remove(); + }); + + it("keeps map navigation stable when the renderer accessible name is localized", () => { + const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인"); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter 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 Keyboard 1 Right Hand count at 0:10" })); + + expect(screen.getByText("Keyboard 1 Right Hand counts in the verse at 0:10.")).toBeTruthy(); + expect(screen.queryByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeNull(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithCount(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => { + const decoy = document.createElement("div"); + decoy.dataset.sectionIndex = "0"; + const decoyScrollIntoView = vi.fn(); + Object.defineProperty(decoy, "scrollIntoView", { + configurable: true, + value: decoyScrollIntoView + }); + document.body.appendChild(decoy); + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + + expect(decoyScrollIntoView).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + decoy.remove(); + grid.remove(); + }); + + it("shows fresh guidance when the first count changes or returns later", () => { + const initialSong = songWithCount(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand count at 0:10" })); + expect(screen.getByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeTruthy(); + + const nextSong = songWithCount(); + nextSong.id = "next-song"; + nextSong.sections[0]!.timeRange = { start: 20, end: 40 }; + rerender(); + expect(screen.getByText("Keyboard 1 Right Hand counts in the verse at 0:20.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable count guidance-only", () => { + const song = songWithCount(); + for (const role of song.sections[0]!.roles) { + role.cue = { kind: "lyric", value: "city lights" }; + } + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No count yet. Stay on tonight's map until a part names the entrance.") + ).toBeTruthy(); + }); + + it("names a band-wide count when no part carries it", () => { + const song = songWithCount(); + for (const node of song.sections[0]!.partGraph) { + node.is_active = false; + } + const { grid, scrollIntoView } = appendSongStructureTarget(); + render(); + const action = screen.getByRole("button", { name: "Open the first count at 0:10" }); + expect(screen.getByText("The band counts in the verse at 0:10.")).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText("Catch the count at 0:10. Enter together.")).toBeTruthy(); + grid.remove(); + }); + + it("localizes the count form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithCount(); + song.sections[0]!.roles[1]!.name = "키보드 오른손"; + song.sections[0]!.roles[0]!.rehearsalPriority = "low"; + song.sections[0]!.roles[2]!.rehearsalPriority = "low"; + + render(); + + expect(screen.getByText("0:10 벌스에서 키보드 오른손 파트가 카운트합니다.")).toBeTruthy(); + expect(screen.queryByText(/verse에서/)).toBeNull(); + }); + + it("renders the owned count cue as a text node instead of template syntax", () => { + const song = songWithCount(); + song.sections[0]!.roles[1]!.cue = { kind: "count", value: "Enter {role} at {at}" }; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup before the downbeat." }; + song.sections[0]!.roles[2]!.cue = { kind: "lyric", value: "city lights" }; + render(); + expect(screen.getByText("Enter {role} at {at}")).toBeTruthy(); + expect(screen.queryByText("Enter Keyboard 1 Right Hand at 0:10")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstCountCueCallout.tsx b/apps/desktop/src/features/workspace/FirstCountCueCallout.tsx new file mode 100644 index 000000000..5478a17b9 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstCountCueCallout.tsx @@ -0,0 +1,132 @@ +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 { formatCountCueTime, resolveFirstCountCue } from "./firstCountCue"; + +/** Props for the first-count rehearsal callout. */ +export interface FirstCountCueCalloutProps { + song: RehearsalSong; +} + +type CountCueCopyValues = Readonly>; + +type OpenedCountCue = Readonly<{ + songIdentity: unknown; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate count placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatCountCueCopy(template: string, values: CountCueCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof CountCueCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredCountCueScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first count and open the matching rendered map section. */ +export function FirstCountCueCallout({ song }: FirstCountCueCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const songIdentity: unknown = song; + const runtimeSong = song as unknown as Partial | null; + const countCue = resolveFirstCountCue(song); + const countCueSectionIndex = + countCue && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.indexOf(countCue.section) + : -1; + const [openedCountCue, setOpenedCountCue] = useState(null); + + useEffect(() => { + setOpenedCountCue(null); + }, [songIdentity, countCueSectionIndex, countCue?.section.id, countCue?.holdingRole?.id, countCue?.atSeconds]); + + if (!countCue) { + return ( + + ); + } + + const opened = + openedCountCue !== null && + openedCountCue.songIdentity === songIdentity && + openedCountCue.sectionId === countCue.section.id && + openedCountCue.sectionIndex === countCueSectionIndex && + openedCountCue.holdingRoleId === (countCue.holdingRole?.id ?? null) && + openedCountCue.atSeconds === countCue.atSeconds; + const at = formatCountCueTime(countCue.atSeconds); + const copyValues: CountCueCopyValues = { + role: countCue.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, countCue.section.label), + at + }; + const hasRole = countCue.holdingRole !== null; + const actionLabel = formatCountCueCopy( + t(hasRole ? "firstCountCueOpenAction" : "firstCountCueOpenActionBand"), + copyValues + ); + const body = formatCountCueCopy(t(hasRole ? "firstCountCueBody" : "firstCountCueBodyBand"), copyValues); + const armed = formatCountCueCopy(t(hasRole ? "firstCountCueArmed" : "firstCountCueArmedBand"), copyValues); + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..f8a13ec9b 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,27 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first count as workspace navigation", () => { + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect(screen.getAllByText("Enter on beat 2 after the pickup.").length).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand count at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch the count with Keyboard 1 Right Hand at 0:10. Enter together./)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..88ff550de 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 { FirstCountCueCallout } from "./FirstCountCueCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -90,8 +91,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -331,6 +336,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstCountCue.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstCountCue.inherited-metadata.test.ts new file mode 100644 index 000000000..b680ff8d3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountCue.inherited-metadata.test.ts @@ -0,0 +1,89 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstCountCue } from "./firstCountCue"; + +function songWithCount() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "count-own"; + section.roles = [ + { + ...section.roles[1]!, + cue: { kind: "count", value: "Enter on beat 2 after the pickup." } + } + ]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstCountCue inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithCount(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstCountCue(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithCount(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithCount(); + Object.defineProperty(section.roles[0]!, "cue", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile cue getter"); + } + }); + + expect(() => resolveFirstCountCue(song)).not.toThrow(); + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("does not treat own accessors as stable count identity authority", () => { + const { song, section } = songWithCount(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "count-own"; + } + }); + + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("does not let inherited cue metadata establish the entrance", () => { + const { song, section } = songWithCount(); + section.roles[0]!.cue = Object.create({ + kind: "count", + value: "Inherited count" + }) as typeof section.roles[0]["cue"]; + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithCount(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + + const resolved = resolveFirstCountCue(song); + expect(resolved?.section.id).toBe("count-own"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Enter on beat 2 after the pickup."); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithCount(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstCountCue(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCountCue.test.ts b/apps/desktop/src/features/workspace/firstCountCue.test.ts new file mode 100644 index 000000000..7091a09e3 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountCue.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatCountCueTime, resolveFirstCountCue } from "./firstCountCue"; + +function withCountSection( + overrides: { + id?: string; + start?: number; + end?: number; + hint?: string; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-count"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + const roleId = overrides.roleId ?? "keys-right"; + section.roles = [ + { + ...verse.roles[1]!, + id: roleId, + name: overrides.roleName ?? "Keyboard 1 Right Hand", + rehearsalPriority: overrides.priority ?? "high", + cue: { + kind: "count", + value: overrides.hint ?? "Enter on beat 2 after the pickup." + }, + overlapWarnings: [], + setupNote: "Keep the patch bright enough to stay over the guitars.", + simplification: "Drop the top extension if the chorus turnaround still feels busy." + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstCountCue", () => { + it("picks the demo song's earliest named count and the part that carries it", () => { + const resolved = resolveFirstCountCue(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("keys-right"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Enter on beat 2 after the pickup."); + expect(formatCountCueTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatCountCueTime(Number.NaN)).toBe("0:00"); + expect(formatCountCueTime(-4)).toBe("0:00"); + }); + + it("does not invent a count from lyric, transition, groove, setup, simplification, overlap, or range copy", () => { + const song = withCountSection({ hint: " " }); + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.setupNote = "Keep the patch bright enough to stay over the guitars."; + song.sections[0]!.roles[0]!.simplification = "Drop the top extension if the chorus turnaround still feels busy."; + song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "Enter on beat 2 after the pickup." }; + song.sections[0]!.roles[0]!.overlapWarnings = [ + "Melodic overlap: top notes conflict with Lead Vocal range." + ]; + song.sections[0]!.roles[0]!.range = { lowestNote: "B3", highestNote: "G#5" }; + expect(resolveFirstCountCue(song)).toBeNull(); + + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup before the downbeat." }; + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace count cue as a named entrance", () => { + expect(resolveFirstCountCue(withCountSection({ hint: "" }))).toBeNull(); + expect(resolveFirstCountCue(withCountSection({ hint: " \n\t " }))).toBeNull(); + }); + + it("prefers the earlier of two named counts", () => { + const song = withCountSection({ id: "verse-late", start: 40, end: 56, roleId: "lead-vocal" }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "medium", + cue: { kind: "count", value: "Come in on the and of four." } + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstCountCue(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole?.id).toBe("keys-right"); + expect(resolved?.hint).toBe("Come in on the and of four."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time count ties with locale-independent id ordering", () => { + const song = withCountSection({ id: "ä-count", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-count"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstCountCue(song)?.section.id).toBe("z-count"); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withCountSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + cue: { kind: "count" as const, value: "ASCII count" } + }; + section.roles = [section.roles[0]!, asciiRole]; + section.partGraph = [ + { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstCountCue(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a band-wide count when no active ranked role carries it", () => { + const song = withCountSection({ isActive: false }); + const resolved = resolveFirstCountCue(song); + expect(resolved?.section.id).toBe("verse-count"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Enter on beat 2 after the pickup."); + }); + + it("skips a count whose rehearsal window is unbounded", () => { + expect(resolveFirstCountCue(withCountSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a count whose end precedes its start", () => { + expect(resolveFirstCountCue(withCountSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length count window", () => { + expect(resolveFirstCountCue(withCountSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a count whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstCountCue( + withCountSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstCountCue(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withCountSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstCountCue(song)).toBeNull(); + }); + + it("keeps the count band-wide when role identities are duplicated", () => { + const song = withCountSection(); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const resolved = resolveFirstCountCue(song); + expect(resolved?.section.id).toBe("verse-count"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("bounds the count hint to 180 Unicode code points", () => { + const song = withCountSection({ hint: `${"a".repeat(200)}` }); + const resolved = resolveFirstCountCue(song); + expect(resolved?.hint.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the hint boundary", () => { + const song = withCountSection({ hint: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstCountCue(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); + + it("skips non-object roles while keeping a later owned count", () => { + const song = withCountSection(); + const validRole = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [42 as never, validRole]; + const resolved = resolveFirstCountCue(song); + expect(resolved?.holdingRole?.id).toBe("keys-right"); + expect(resolved?.hint).toBe("Enter on beat 2 after the pickup."); + }); + + it("keeps a deterministic winner when two named counts share time and id", () => { + const song = withCountSection({ id: "shared-id", start: 10, end: 26 }); + const twin = structuredClone(song.sections[0]!); + song.sections = [song.sections[0]!, twin]; + const resolved = resolveFirstCountCue(song); + expect(resolved?.section.id).toBe("shared-id"); + expect(resolved?.atSeconds).toBe(10); + }); + + it("contains throws from untrusted runtime property access", () => { + const song = withCountSection(); + const hostile = new Proxy(song, { + get(target, prop, receiver) { + if (prop === "sections") { + throw new Error("hostile sections"); + } + return Reflect.get(target, prop, receiver); + } + }); + expect(resolveFirstCountCue(hostile as typeof song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstCountCue.ts b/apps/desktop/src/features/workspace/firstCountCue.ts new file mode 100644 index 000000000..4fcfc9cba --- /dev/null +++ b/apps/desktop/src/features/workspace/firstCountCue.ts @@ -0,0 +1,307 @@ +import { + MAX_SECTION_TIME_SECONDS, + type CueAnchor, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; +const MAX_COUNT_CUE_CHARACTERS = 180; + +/** Tonight's first named count: the earliest owned entrance count and the part that carries it. */ +export type FirstCountCue = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative count time as m:ss for rehearsal copy. */ +export function formatCountCueTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return a bounded owned count cue, or null when the field cannot be shown. */ +function ownedCountHint(role: RehearsalRole): string | null { + if (!hasOwnData(role, "cue")) { + return null; + } + const cue = role.cue as Partial | null; + if ( + !isRuntimeObject(cue) || + !hasOwnData(cue, "kind") || + !hasOwnData(cue, "value") || + cue.kind !== "count" || + typeof cue.value !== "string" + ) { + return null; + } + const hint = cue.value.trim(); + if (hint.length === 0) { + return null; + } + return truncateCodePoints(hint, MAX_COUNT_CUE_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if ( + !isRuntimeObject(timeRange) || + !hasOwnData(timeRange, "start") || + !hasOwnData(timeRange, "end") + ) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the highest-priority ranked role, then a locale-independent stable id order. */ +function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (rankDelta !== 0) { + return rankDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the first owned count string on a section, preferring the holding part. */ +function sectionCountHint(section: RehearsalSection, holdingRole: RehearsalRole | null): string | null { + if (holdingRole) { + const fromHolder = ownedCountHint(holdingRole); + if (fromHolder !== null) { + return fromHolder; + } + } + if (!hasOwnData(section, "roles") || !isDenseRuntimeArray(section.roles)) { + return null; + } + for (const role of section.roles) { + if (!isRuntimeObject(role)) { + continue; + } + const hint = ownedCountHint(role); + if (hint !== null) { + return hint; + } + } + return null; +} + +/** Resolve a count after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstCountCue(song: RehearsalSong): FirstCountCue | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) && + sectionCountHint(section, null) !== null + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = candidates[0]; + if (!section) { + return null; + } + + const holdingRole = pickHighestPriorityRole( + rankedActiveRoles(section).filter((role) => ownedCountHint(role) !== null) + ); + const hint = sectionCountHint(section, holdingRole); + if (hint === null) { + return null; + } + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + hint + }; +} + +/** Return the first named count, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstCountCue(song: RehearsalSong): FirstCountCue | null { + try { + return resolveSafeFirstCountCue(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..8767b451a 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported section form label for Korean rehearsal copy", () => { + expect( + [ + "intro", + "verse", + "pre-chorus", + "chorus", + "bridge", + "outro", + "tag", + "pickup", + "stop", + "handoff" + ].map((label) => translateSectionFormLabel("ko", label as never)) + ).toEqual([ + "인트로", + "벌스", + "프리코러스", + "코러스", + "브리지", + "아웃트로", + "태그", + "픽업", + "스톱", + "핸드오프" + ]); + }); + + it("preserves every supported English section form label", () => { + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + expect(translateSectionFormLabel("en", "outro")).toBe("outro"); + }); + + it("does not treat inherited object keys as localized section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-count next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstCountCueOpenAction")).toBe("{at} {role} 카운트 위치 열기"); + expect(t("firstCountCueBody")).toBe("{at} {section}에서 {role} 파트가 카운트합니다."); + expect(t("firstCountCueArmed")).toBe("{at}에서 {role} 파트 카운트를 잡으세요. 함께 들어가세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..c20c6de78 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstCountCueLabel": "Tonight's first count", + "firstCountCueOpenAction": "Open {role} count at {at}", + "firstCountCueOpenActionBand": "Open the first count at {at}", + "firstCountCueBody": "{role} counts in the {section} at {at}.", + "firstCountCueBodyBand": "The band counts in the {section} at {at}.", + "firstCountCueArmed": "Catch the count with {role} at {at}. Enter together.", + "firstCountCueArmedBand": "Catch the count at {at}. Enter together.", + "firstCountCueUnavailable": "No count yet. Stay on tonight's map until a part names the entrance." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..07a0f32f5 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,13 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstCountCueLabel": "오늘 첫 카운트", + "firstCountCueOpenAction": "{at} {role} 카운트 위치 열기", + "firstCountCueOpenActionBand": "{at} 첫 카운트 위치 열기", + "firstCountCueBody": "{at} {section}에서 {role} 파트가 카운트합니다.", + "firstCountCueBodyBand": "밴드가 {at} {section}에서 카운트합니다.", + "firstCountCueArmed": "{at}에서 {role} 파트 카운트를 잡으세요. 함께 들어가세요.", + "firstCountCueArmedBand": "{at}에서 카운트를 잡으세요. 함께 들어가세요.", + "firstCountCueUnavailable": "아직 카운트가 없습니다. 파트가 입장을 표시할 때까지 오늘 지도에 머무르세요." } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..5ee934293 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -25,7 +25,9 @@ export default defineConfig({ "src/i18n/index.ts", "src/features/score/ScoreViewer.tsx", "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstCountCue.ts", + "src/features/workspace/FirstCountCueCallout.tsx" ], thresholds: { lines: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..c356f97b4 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 Count Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstCountCueCallout.tsx` | Name the entering part when an active graph node corroborates it, the owned `cue.kind === "count"` text, the labeled section start, and the time. Do not invent an entrance from lyric, transition, groove, setup, simplification, overlap, range copy, or empty/whitespace cues. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-lyric (#913), first-transition (#994), first-count-in (#904), and first-intro (#943). | | 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-count-cue-navigation.md b/docs/doctoring/reduced-motion-first-count-cue-navigation.md new file mode 100644 index 000000000..63375b5f9 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-count-cue-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-count navigation + +Workspace map navigation for tonight's first count follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstCountCueCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Count resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, cue kind/value, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: count resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned `cue.kind === "count"` string is rendered as a text node and is never rescanned as template syntax. Lyric, transition, groove, setup, simplification, overlap, and range copy cannot invent an entrance count. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the count hint is bounded to 180 Unicode code points. +- Test points: inherited song/section/timing/role/graph/cue metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.