diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..b6ffcb3a9 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 entrance with the part, section, and start time so the next hearable action is obvious. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..c8e5f6b05 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 entrance must name the part, section, and start time so the next hearable action is obvious. ## Security source diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..22a459cc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first entrance on the workspace and player with part, section, and start time; the workspace action arms the entrance and opens the matching map section, honors the user's reduced-motion preference for that navigation, and the player exposes a Hear action only when its owning playback surface supplies a seek callback. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -74,4 +75,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..54a8ec216 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first entrance with its part, section, and start time; the workspace action arms the entrance and opens the mapped section, while the player renders the Hear action only when its owning playback surface supplies a seek callback. The ready workspace also names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. @@ -72,4 +72,4 @@ Supporting packages: - Documents under `docs/plans/` must include `Security Notes`; `scripts/checks/verify_security_notes.py` enforces this mechanically. - Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`. - CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them. -- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). +- Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). \ No newline at end of file diff --git a/apps/desktop/src/App.player-reachability.test.tsx b/apps/desktop/src/App.player-reachability.test.tsx new file mode 100644 index 000000000..37c41c365 --- /dev/null +++ b/apps/desktop/src/App.player-reachability.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +vi.mock("./features/score/pdfjs", () => ({ + configureScorePdfWorker: vi.fn(), + loadScorePdf: vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }), + destroy: vi.fn(() => Promise.resolve()) + })) +})); + +vi.mock("./lib/analysis", async (importActual) => { + const actual = await importActual(); + + return { + ...actual, + loadProject: vi.fn(async () => createDemoRehearsalSong()) + }; +}); + +describe("App player reachability", () => { + it("exposes the analyzed song's first-entrance guidance through the shipped Player view without a dead playback action", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /open project/i })); + + const playerButton = await screen.findByRole("button", { name: /^Player$/i }); + fireEvent.click(playerButton); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: /^Player$/i })).toBeTruthy(); + }); + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" }) + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index f3d678454..24e13e44d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -44,6 +44,7 @@ import { startAnalysisJob } from "./lib/analysis"; import { createTranslator, detectPreferredLocale, type TranslationKey } from "./i18n"; +import { PlayerFeature } from "./features/player"; import { ScoreView } from "./features/score/ScoreView"; import { Workspace } from "./features/workspace/Workspace"; import { EmptyState, ErrorState, LoadingState } from "./features/workspace/WorkspaceStates"; @@ -58,10 +59,11 @@ const LOCAL_PATH_PATTERN = /(?:[A-Za-z]:[\\/][^\s"'<>]+|\\\\[^\s"'<>]+|\/(?:User const URL_PATTERN = /\bhttps?:\/\/[^\s"'<>]+/gi; const SECRET_ASSIGNMENT_PATTERN = /\b(token|secret|password|api[_-]?key|access[_-]?token)\s*[:=]\s*[^\s,;]+/gi; -type RehearsalView = "workspace" | "score"; +type RehearsalView = "workspace" | "player" | "score"; const NAV_ITEMS = [ { labelKey: "navWorkspace", icon: Home, view: "workspace" }, + { labelKey: "navPlayer", icon: Play, view: "player" }, { labelKey: "navImport", icon: Upload, view: null }, { labelKey: "navExport", icon: Save, view: null }, { labelKey: "navSections", icon: ListMusic, view: null }, @@ -517,16 +519,16 @@ export function App() { return ; }; - const currentView: RehearsalView = jobResult && activeView === "score" ? "score" : "workspace"; + const currentView: RehearsalView = jobResult ? activeView : "workspace"; /** Resolve label, enablement, and active state for one sidebar item. */ const navButtonState = (item: (typeof NAV_ITEMS)[number]) => { - const enabled = item.view === "workspace" || (item.view === "score" && jobResult !== null); + const enabled = item.view === "workspace" || ((item.view === "player" || item.view === "score") && jobResult !== null); return { label: t(item.labelKey), enabled, active: enabled && item.view === currentView, - title: enabled ? undefined : item.view === "score" ? t("scoreNavDisabledHint") : t("comingSoon") + title: enabled ? undefined : item.view === "player" || item.view === "score" ? t("scoreNavDisabledHint") : t("comingSoon") }; }; @@ -848,6 +850,8 @@ export function App() { projectId={jobResultBootstrap?.projectId ?? null} onSongUpdate={handleSongUpdate} /> + ) : currentView === "player" && jobResult ? ( + ) : ( renderWorkspaceState() )} 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..9f519c887 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,35 @@ +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"; + +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 entrance from this player.") + ).toBeTruthy(); + }); + + it("names tonight's first entrance without a dead action when playback is unavailable", () => { + render(); + + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })).toBeNull(); + }); + + it("uses the player playback callback for the first entrance action", () => { + const onPlayFromSeconds = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Hear Bass Guitar enter the verse at 0:10" })); + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(10); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..8cbd4454d 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,56 +1,34 @@ import type { RehearsalSong } from "@bandscope/shared-types"; +import { FirstEntranceCallout } from "../workspace/FirstEntranceCallout"; +import { createTranslator, detectPreferredLocale } 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; +}; + +/** Player surface that names tonight's first entrance and delegates playback to the owning player. */ +export function PlayerFeature({ title, song, onPlayFromSeconds }: PlayerFeatureProps) { + const t = createTranslator(detectPreferredLocale()); if (!song) { return ( -
-

{title}

-

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

+
+

{title}

+

{t("firstEntranceNeedsSong")}

); } return ( -
-

{title}

-
-
- {song.title} - - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} - -
-
- {song.sections.map((section) => ( - - {section.label} - - ))} -
-
- Audio playback requires the desktop app with a local audio source. -
-
+
+

{title}

+
); } diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.accessible-name.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.accessible-name.test.tsx new file mode 100644 index 000000000..4c668a281 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.accessible-name.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { FirstEntranceCallout } from "./FirstEntranceCallout"; + +describe("FirstEntranceCallout accessible name", () => { + it("keeps the concise first-entrance region name when no entrance exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + + render(); + + expect( + screen.getByRole("complementary", { name: "Tonight's first entrance" }) + ).toBeTruthy(); + expect( + screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..0c04efd8f --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstEntranceCallout } from "./FirstEntranceCallout"; + +afterEach(() => { + vi.unstubAllGlobals(); + document.querySelector("#workspace-song-structure-grid")?.remove(); +}); + +/** Mount the renderer-owned song-structure target used by workspace navigation. */ +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.id = "workspace-song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return scrollIntoView; +} + +describe("FirstEntranceCallout reduced-motion navigation", () => { + it("avoids smooth scrolling when the user requests reduced motion", () => { + const scrollIntoView = appendSongStructureTarget(); + vi.stubGlobal( + "matchMedia", + vi.fn((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() + })) + ); + + render(); + fireEvent.click( + screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" }) + ); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "auto" + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx new file mode 100644 index 000000000..e517b8c30 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.test.tsx @@ -0,0 +1,178 @@ +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 { FirstEntranceCallout } from "./FirstEntranceCallout"; + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.id = "workspace-song-structure-grid"; + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstEntranceCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the first entrance as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + + grid.remove(); + }); + + it("does not claim completion when the renderer-owned section target is missing", () => { + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + expect(screen.queryByText(/Start on Bass Guitar in the verse at 0:10/)).toBeNull(); + }); + + it("fails closed when multiple renderer-owned song-structure grids are mounted", () => { + const first = appendSongStructureTarget(); + const second = appendSongStructureTarget(); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + + expect(first.scrollIntoView).not.toHaveBeenCalled(); + expect(second.scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + expect(screen.queryByText(/Start on Bass Guitar in the verse at 0:10/)).toBeNull(); + + first.grid.remove(); + second.grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the song changes", () => { + const initialSong = createDemoRehearsalSong(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + + const replacementSong = createDemoRehearsalSong(); + replacementSong.id = "demo-song-replacement"; + rerender(); + + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + grid.remove(); + }); + + it("forgets an armed entrance after switching away and back", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "demo-song-second"; + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open Bass Guitar entrance in the verse at 0:10" + }) + ); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + + rerender(); + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + + rerender(); + expect(screen.getByText(/^Bass Guitar enters the verse at 0:10\./)).toBeTruthy(); + grid.remove(); + }); + + it("keeps placeholder-looking rehearsal data literal", () => { + const song = createDemoRehearsalSong(); + const bassRole = song.sections[0]!.roles.find((role) => role.id === "bass-guitar"); + if (!bassRole) { + throw new Error("Demo rehearsal song must include the bass-guitar role."); + } + bassRole.name = "{section}"; + + render(); + + expect( + screen.getByRole("button", { + name: "Open {section} entrance in the verse at 0:10" + }) + ).toBeTruthy(); + }); + + it("tells the room to stay on the map when no entrance exists", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + render(); + expect( + screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") + ).toBeTruthy(); + }); + + it("contains a malformed runtime song root instead of crashing the callout", () => { + render(); + + expect( + screen.getByText("No first entrance yet. Stay on tonight's map until a section has a part.") + ).toBeTruthy(); + }); + + it("localizes the section form label and keeps dynamic Korean role names particle-safe", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0]!.name = "피아노"; + + render(); + + expect(screen.getByText(/^0:10에 벌스로 피아노 파트가 들어옵니다\./)).toBeTruthy(); + expect(screen.queryByText(/^피아노이 0:10에 벌스로 들어옵니다\./)).toBeNull(); + expect(screen.queryByText(/verse로 들어옵니다/)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx new file mode 100644 index 000000000..a4ff12793 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEntranceCallout.tsx @@ -0,0 +1,193 @@ +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 { formatEntranceTime, resolveFirstEntrance } from "./firstEntrance"; + +/** Props for the first-entrance rehearsal callout. */ +export interface FirstEntranceCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearEntrance?: (startSeconds: number) => void; +} + +type EntranceCopyValues = Readonly>; + +type HeardEntrance = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + roleId: string; + startSeconds: number; + cue: string; +}>; + +/** Interpolate entrance placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatEntranceCopy(template: string, values: EntranceCopyValues): string { + return template.replace(/\{(role|section|sectionParticle|start|cue)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof EntranceCopyValues; + return values[key]; + }); +} + +/** Choose the Korean locative particle for a rendered section label. */ +function koreanLocativeParticle(value: string): "로" | "으로" { + const lastCharacter = [...value].at(-1); + if (!lastCharacter) { + return "로"; + } + + const codePoint = lastCharacter.charCodeAt(0); + if (codePoint < 0xac00 || codePoint > 0xd7a3) { + return "로"; + } + + const finalSound = (codePoint - 0xac00) % 28; + return finalSound === 0 || finalSound === 8 ? "로" : "으로"; +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredEntranceScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Resolve the renderer-owned song-structure grid without depending on test-only markers. */ +function resolveSongStructureGrid(): Element | null { + const stableGrids = document.querySelectorAll("#workspace-song-structure-grid"); + if (stableGrids.length > 1) { + return null; + } + if (stableGrids.length === 1) { + return stableGrids.item(0); + } + + const cueSurfaces = document.querySelectorAll("#workspace-surface-cues"); + if (cueSurfaces.length !== 1) { + return null; + } + const songStructure = cueSurfaces.item(0).nextElementSibling; + const timelineRegion = songStructure?.querySelector('[role="region"]'); + return timelineRegion?.firstElementChild ?? null; +} + +/** Name tonight's first entrance and offer only an action that the current surface can execute. */ +export function FirstEntranceCallout({ + song, + actionMode = "workspace-scroll", + onHearEntrance +}: FirstEntranceCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const runtimeSong = song as unknown as Partial | null; + const songId = typeof runtimeSong?.id === "string" ? runtimeSong.id : ""; + const entrance = resolveFirstEntrance(song); + const entranceSectionIndex = + entrance && Array.isArray(runtimeSong?.sections) + ? runtimeSong.sections.findIndex((section) => section === entrance.section) + : -1; + const [heardEntrance, setHeardEntrance] = useState(null); + + useEffect(() => { + setHeardEntrance(null); + }, [ + songId, + entranceSectionIndex, + entrance?.section.id, + entrance?.role.id, + entrance?.startSeconds, + entrance?.role.cue.value + ]); + + if (!entrance) { + return ( + + ); + } + + const heard = + heardEntrance?.songId === songId && + heardEntrance.sectionId === entrance.section.id && + heardEntrance.sectionIndex === entranceSectionIndex && + heardEntrance.roleId === entrance.role.id && + heardEntrance.startSeconds === entrance.startSeconds && + heardEntrance.cue === entrance.role.cue.value; + const start = formatEntranceTime(entrance.startSeconds); + const section = translateSectionFormLabel(locale, entrance.section.label); + const copyValues: EntranceCopyValues = { + role: entrance.role.name, + section, + sectionParticle: locale === "ko" ? koreanLocativeParticle(section) : "", + start, + cue: entrance.role.cue.value + }; + const actionLabel = formatEntranceCopy( + t(actionMode === "callback-only" ? "firstEntranceAction" : "firstEntranceOpenAction"), + copyValues + ); + const body = formatEntranceCopy(t("firstEntranceBody"), copyValues); + const armed = formatEntranceCopy(t("firstEntranceArmed"), copyValues); + const canExecuteAction = + actionMode === "workspace-scroll" || typeof onHearEntrance === "function"; + + /** Record completion only after the owning surface executes the selected entrance action. */ + const markEntranceActionComplete = () => { + setHeardEntrance({ + songId, + sectionId: entrance.section.id, + sectionIndex: entranceSectionIndex, + roleId: entrance.role.id, + startSeconds: entrance.startSeconds, + cue: entrance.role.cue.value + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..46a9c9e45 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -28,11 +29,14 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: originalScrollIntoView + }); }); it("updates practice progress immutably through onSongUpdate", () => { const song = createDemoRehearsalSong(); - // Default mock setup puts "bass-guitar" as the role ID in index 0 song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "bass-guitar", @@ -42,21 +46,13 @@ describe("Workspace", () => { const onSongUpdate = vi.fn(); render(); - - // Select the Bass Guitar role to render PracticeProgress fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - - const increaseBtn = screen.getByRole("button", { name: "Increase progress" }); - fireEvent.click(increaseBtn); + fireEvent.click(screen.getByRole("button", { name: "Increase progress" })); expect(onSongUpdate).toHaveBeenCalledTimes(1); const updatedSong = onSongUpdate.mock.calls[0]?.[0] as RehearsalSong; - - // Ensure immutable update logic: reference equality of untouched sections expect(updatedSong).not.toBe(song); expect(updatedSong.sections).not.toBe(song.sections); - - // Ensure the specific role progress updated expect(updatedSong.sections[0]!.roles[0]!.practiceProgress).toBe(60); }); @@ -67,31 +63,33 @@ describe("Workspace", () => { render(); const grid = screen.getByTestId("song-structure-grid"); - expect(grid.style.gridTemplateColumns).not.toContain("repeat(0"); expect(grid.style.gridTemplateColumns).toContain("repeat(1"); }); + it("keeps analysis section ids out of song-structure DOM authority", () => { + const song = createDemoRehearsalSong(); + song.sections[0]!.id = "analysis section / duplicate"; + + render(); + + const firstRenderedSection = screen.getByTestId("song-structure-grid").children.item(0); + expect(firstRenderedSection).toBeTruthy(); + expect(firstRenderedSection?.hasAttribute("id")).toBe(false); + }); + it("falls back to safe timeline text for malformed section times", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); - song.sections[0].timeRange = { - start: Number.NaN, - end: Number.POSITIVE_INFINITY - }; + song.sections[0].timeRange = { start: Number.NaN, end: Number.POSITIVE_INFINITY }; render(); - expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); it("enables bass transcription from selected role metadata rather than role id text", () => { const song = createDemoRehearsalSong(); - song.sections[0]!.roles[0] = { - ...song.sections[0]!.roles[0]!, - id: "low-end", - name: "Bass Guitar" - }; + song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, id: "low-end", name: "Bass Guitar" }; render(); fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); @@ -131,9 +129,7 @@ describe("Workspace", () => { expect(screen.getByText("Collaboration")).toBeTruthy(); expect(screen.getByText(/2 Assignments/i)).toBeTruthy(); expect(screen.getByText(/Keep assignments local for now/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); expect(screen.getByText(/whole step lower/i)).toBeTruthy(); expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); @@ -199,24 +195,15 @@ describe("Workspace", () => { it("falls back from blank planning copy and tolerates partial collaboration payloads", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); - song.sections[0]!.roles[0] = { - ...song.sections[0]!.roles[0]!, - harmonicExplanation: " ", - transpositionPlan: "" - }; - song.collaboration = { - syncMode: "local_only", - syncNote: "Local-only draft" - } as RehearsalSong["collaboration"]; + song.sections[0]!.roles[0] = { ...song.sections[0]!.roles[0]!, harmonicExplanation: " ", transpositionPlan: "" }; + song.collaboration = { syncMode: "local_only", syncNote: "Local-only draft" } as RehearsalSong["collaboration"]; render(); expect(screen.getByText(/0 Assignments/i)).toBeTruthy(); expect(screen.getByText(/0 Comments/i)).toBeTruthy(); expect(screen.getByText(/0 Approvals/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); - expect(screen.getByText("vi pedal anchor")).toBeTruthy(); expect(screen.getAllByText("Stay on roots if the chorus entrance gets muddy.").length).toBeGreaterThan(0); }); @@ -239,14 +226,8 @@ describe("Workspace", () => { const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: createObjectUrl - }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectUrl - }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl }); render(); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -262,20 +243,12 @@ describe("Workspace", () => { it("exports metadata-only handoff when source bootstrap is invalid", async () => { const song = createDemoRehearsalSong(); - const invalidSourceBootstrap = { - projectId: "project-1" - } as ProjectBootstrapSummary; + const invalidSourceBootstrap = { projectId: "project-1" } as ProjectBootstrapSummary; const createObjectUrl = vi.fn(() => "blob:handoff"); const revokeObjectUrl = vi.fn(); const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); - Object.defineProperty(URL, "createObjectURL", { - configurable: true, - value: createObjectUrl - }); - Object.defineProperty(URL, "revokeObjectURL", { - configurable: true, - value: revokeObjectUrl - }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: createObjectUrl }); + Object.defineProperty(URL, "revokeObjectURL", { configurable: true, value: revokeObjectUrl }); render(); fireEvent.click(screen.getByRole("button", { name: /export handoff/i })); @@ -290,9 +263,7 @@ describe("Workspace", () => { it("validates source bootstrap before generating metadata handoff", () => { const song = createDemoRehearsalSong(); - const invalidSourceBootstrap = { - projectId: "project-1" - } as ProjectBootstrapSummary; + const invalidSourceBootstrap = { projectId: "project-1" } as ProjectBootstrapSummary; expect(() => { generateMetadataHandoffJson(song, { sourceBootstrap: invalidSourceBootstrap }); @@ -311,10 +282,7 @@ describe("Workspace", () => { it("localizes workspace navigation and rehearsal labels", () => { setNavigatorLanguage("ko-KR"); const song = createDemoRehearsalSong(); - song.exportSummary = { - ...song.exportSummary, - headline: "" - }; + song.exportSummary = { ...song.exportSummary, headline: "" }; render(); @@ -326,4 +294,19 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first entrance as workspace navigation", () => { + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + render(); + + const action = screen.getByRole("button", { name: "Open Bass Guitar entrance in the verse at 0:10" }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Start on Bass Guitar in the verse at 0:10/)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..853e279a1 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 { FirstEntranceCallout } from "./FirstEntranceCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -87,12 +88,13 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R aria-label="Scrollable song structure timeline" >
- {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

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

@@ -353,6 +355,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts b/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts new file mode 100644 index 000000000..b4461d449 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.active-part.test.ts @@ -0,0 +1,39 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance active-part authority", () => { + it("does not announce a higher-priority role that is inactive in the entrance section", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + const inactiveLead = { + ...section.roles[2]!, + id: "resting-lead", + rehearsalPriority: "high" as const + }; + const activeBass = { + ...section.roles[0]!, + id: "active-bass", + rehearsalPriority: "medium" as const + }; + + section.roles = [inactiveLead, activeBass]; + section.partGraph = [ + { + role_id: "resting-lead", + is_active: false, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstEntrance(song)?.role.id).toBe("active-bass"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts b/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts new file mode 100644 index 000000000..420f801da --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.activity-type.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstEntrance } from "./firstEntrance"; + +const runtimeStringFalse = "false" as unknown as boolean; + +describe("resolveFirstEntrance activity-type authority", () => { + it("does not treat a string false flag as active entrance evidence", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.timeRange = { start: 8, end: 20 }; + section.roles = [ + { + ...section.roles[2]!, + id: "resting-lead", + name: "Resting Lead", + rehearsalPriority: "high" + }, + { + ...section.roles[0]!, + id: "active-bass", + name: "Active Bass", + rehearsalPriority: "medium" + } + ]; + section.partGraph = [ + { + role_id: "resting-lead", + is_active: runtimeStringFalse, + handoff_to: [], + handoff_from: [] + }, + { + role_id: "active-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + const entrance = resolveFirstEntrance(song); + + expect(entrance?.role.id).toBe("active-bass"); + expect(entrance?.startSeconds).toBe(8); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts b/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts new file mode 100644 index 000000000..921299702 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.invalid-role-id.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance runtime role identity", () => { + it("ignores an active role whose runtime id is not a non-empty string", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + + const safeRole = { + ...section.roles[0]!, + id: "safe-bass", + name: "Safe Bass", + rehearsalPriority: "medium" as const + }; + const malformedRole = { + ...section.roles[2]!, + id: 42 as unknown as string, + name: "Malformed Runtime Role", + rehearsalPriority: "high" as const + }; + + section.roles = [safeRole, malformedRole]; + section.partGraph = [ + { + role_id: "safe-bass", + is_active: true, + handoff_to: [], + handoff_from: [] + }, + { + role_id: 42 as unknown as string, + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + + expect(resolveFirstEntrance(song)?.role.id).toBe("safe-bass"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntrance.test.ts b/apps/desktop/src/features/workspace/firstEntrance.test.ts new file mode 100644 index 000000000..f89527392 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.test.ts @@ -0,0 +1,150 @@ +import { createDemoRehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { formatEntranceTime, resolveFirstEntrance } from "./firstEntrance"; + +describe("resolveFirstEntrance", () => { + it("picks the earliest section and its highest-priority role", () => { + const song = createDemoRehearsalSong(); + const entrance = resolveFirstEntrance(song); + + expect(entrance?.section.id).toBe("verse-1"); + expect(entrance?.role.id).toBe("bass-guitar"); + expect(entrance?.startSeconds).toBe(10); + expect(formatEntranceTime(entrance?.startSeconds ?? -1)).toBe("0:10"); + expect(formatEntranceTime(Number.NaN)).toBe("0:00"); + }); + + it("returns null when no section has a part to hear", () => { + const song = createDemoRehearsalSong(); + song.sections = []; + expect(resolveFirstEntrance(song)).toBeNull(); + }); + + it("skips an earlier section that has no part to hear", () => { + const song = createDemoRehearsalSong(); + const originalSection = song.sections[0]!; + const laterSection = { + ...originalSection, + id: "later-section", + timeRange: { start: 42, end: 60 }, + roles: [...originalSection.roles] + }; + song.sections = [{ ...originalSection, roles: [] }, laterSection]; + + const entrance = resolveFirstEntrance(song); + expect(entrance?.section.id).toBe("later-section"); + expect(entrance?.role.id).toBe("bass-guitar"); + expect(entrance?.startSeconds).toBe(42); + }); + + it("ignores sections with non-finite or negative start times", () => { + const song = createDemoRehearsalSong(); + const originalSection = song.sections[0]!; + const laterSection = { + ...originalSection, + id: "valid-section", + timeRange: { start: 30, end: 50 }, + roles: [...originalSection.roles] + }; + song.sections = [ + { ...originalSection, id: "nan-section", timeRange: { start: Number.NaN, end: 20 } }, + { ...originalSection, id: "negative-section", timeRange: { start: -1, end: 20 } }, + laterSection + ]; + + expect(resolveFirstEntrance(song)?.section.id).toBe("valid-section"); + }); + + it("ignores roles with unknown rehearsal priorities", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const validRole = section.roles[1]; + if (!validRole) { + throw new Error("Demo rehearsal song must include at least two roles."); + } + const invalidRole = { + ...section.roles[0]!, + rehearsalPriority: "urgent" + } as unknown as RehearsalRole; + section.roles = [invalidRole, validRole]; + + expect(resolveFirstEntrance(song)?.role.id).toBe(validRole.id); + + section.roles = [invalidRole]; + expect(resolveFirstEntrance(song)).toBeNull(); + }); + + it("returns null instead of throwing for malformed nested runtime metadata", () => { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + const role = section.roles[0]!; + const malformedSections: unknown[] = [ + null, + { ...section, id: null }, + { ...section, label: null }, + { ...section, timeRange: null }, + { ...section, roles: null }, + { ...section, partGraph: null }, + { ...section, partGraph: [null] }, + { ...section, roles: [{ ...role, name: null }] }, + { ...section, roles: [{ ...role, cue: null }] } + ]; + + for (const malformedSection of malformedSections) { + song.sections = [malformedSection as never]; + expect(() => resolveFirstEntrance(song)).not.toThrow(); + expect(resolveFirstEntrance(song)).toBeNull(); + } + }); + + it("keeps earliest-section and priority ordering stable across valid metadata", () => { + fc.assert( + fc.property( + fc.integer({ min: 0, max: 600 }), + fc.integer({ min: 0, max: 600 }), + fc.array(fc.constantFrom("high", "medium", "low"), { minLength: 1, maxLength: 12 }), + (firstStart, secondStart, priorities) => { + const song = createDemoRehearsalSong(); + const originalSection = song.sections[0]!; + const baseRole = originalSection.roles[0]!; + const roles = priorities.map((priority, index) => ({ + ...baseRole, + id: `role-${index}`, + rehearsalPriority: priority + })); + const partGraph = roles.map((role) => ({ + role_id: role.id, + is_active: true, + handoff_to: [], + handoff_from: [] + })); + const firstSection = { + ...originalSection, + id: "first-generated-section", + timeRange: { start: firstStart, end: firstStart + 1 }, + roles, + partGraph + }; + const secondSection = { + ...originalSection, + id: "second-generated-section", + timeRange: { start: secondStart, end: secondStart + 1 }, + roles, + partGraph + }; + song.sections = [firstSection, secondSection]; + + const entrance = resolveFirstEntrance(song); + expect(entrance?.startSeconds).toBe(Math.min(firstStart, secondStart)); + const expectedPriority = priorities.includes("high") + ? "high" + : priorities.includes("medium") + ? "medium" + : "low"; + expect(entrance?.role.rehearsalPriority).toBe(expectedPriority); + } + ) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEntrance.ts b/apps/desktop/src/features/workspace/firstEntrance.ts new file mode 100644 index 000000000..908ded059 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEntrance.ts @@ -0,0 +1,113 @@ +import { SECTION_FORM_LABELS, type RehearsalRole, type RehearsalSection, type RehearsalSong } from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +type EntranceRole = Pick; +type EntranceSection = Pick & { + roles: unknown[]; + partGraph: unknown[]; +}; + +/** Return whether a runtime value is a non-null record. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Return whether a runtime role has the fields required by entrance guidance. */ +function isEntranceRole(value: unknown): value is EntranceRole { + return ( + isRecord(value) && + typeof value.id === "string" && + value.id.trim().length > 0 && + typeof value.name === "string" && + isRecord(value.cue) && + typeof value.cue.value === "string" && + typeof value.rehearsalPriority === "string" && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, value.rehearsalPriority) + ); +} + +/** Return whether a runtime graph node proves an active role. */ +function isActivePartGraphNode(value: unknown): value is { role_id: string; is_active: true } { + return ( + isRecord(value) && + value.is_active === true && + typeof value.role_id === "string" && + value.role_id.trim().length > 0 + ); +} + +/** Return whether a runtime section has the fields required by entrance guidance. */ +function isEntranceSection(value: unknown): value is EntranceSection { + if (!isRecord(value) || typeof value.id !== "string" || value.id.trim().length === 0) { + return false; + } + if (!SECTION_FORM_LABELS.includes(value.label as (typeof SECTION_FORM_LABELS)[number])) { + return false; + } + if (!isRecord(value.timeRange)) { + return false; + } + if ( + typeof value.timeRange.start !== "number" || + !Number.isFinite(value.timeRange.start) || + value.timeRange.start < 0 || + typeof value.timeRange.end !== "number" || + !Number.isFinite(value.timeRange.end) || + value.timeRange.end <= value.timeRange.start + ) { + return false; + } + return Array.isArray(value.roles) && Array.isArray(value.partGraph); +} + +/** Tonight's first entrance: earliest section, then the highest-priority active role in that section. */ +export type FirstEntrance = { + section: EntranceSection; + role: EntranceRole; + startSeconds: number; +}; + +/** Format a non-negative section start as m:ss for rehearsal copy. */ +export function formatEntranceTime(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}`; +} + +/** Return the first validated section/role the room should hear, or null when no safe candidate remains. */ +export function resolveFirstEntrance(song: RehearsalSong): FirstEntrance | null { + if (!song || !Array.isArray(song.sections)) { + return null; + } + + const candidate = song.sections + .filter(isEntranceSection) + .map((section) => { + const activeRoleIds = new Set( + section.partGraph.filter(isActivePartGraphNode).map((node) => node.role_id) + ); + return { + section, + roles: section.roles.filter((role) => isEntranceRole(role) && activeRoleIds.has(role.id)) + }; + }) + .filter(({ roles }) => roles.length > 0) + .sort((left, right) => left.section.timeRange.start - right.section.timeRange.start)[0]; + if (!candidate) { + return null; + } + + const role = [...candidate.roles].sort( + (left, right) => PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority] + )[0]!; + + return { + section: candidate.section, + role, + startSeconds: candidate.section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..aec0b6f9d 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,33 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +46,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized buyer copy for a validated section form label. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label]; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..5dfc29b7c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -96,6 +96,7 @@ "brandMarkAriaLabel": "BandScope circular equalizer mark", "rehearsalCockpit": "Rehearsal cockpit", "navWorkspace": "Workspace", + "navPlayer": "Player", "navImport": "Import", "navExport": "Export", "navSections": "Sections", @@ -149,6 +150,13 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstEntranceLabel": "Tonight's first entrance", + "firstEntranceAction": "Hear {role} enter the {section} at {start}", + "firstEntranceOpenAction": "Open {role} entrance in the {section} at {start}", + "firstEntranceBody": "{role} enters the {section} at {start}. {cue}", + "firstEntranceArmed": "Start on {role} in the {section} at {start}. {cue}", + "firstEntranceUnavailable": "No first entrance yet. Stay on tonight's map until a section has a part.", + "firstEntranceNeedsSong": "Analyze tonight's song first, then hear the first entrance from this player.", "workspaceFirstRangeTitle": "Tonight's first range", "workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.", "workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 0f6c6c66d..a98616850 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -96,6 +96,7 @@ "brandMarkAriaLabel": "BandScope 원형 이퀄라이저 마크", "rehearsalCockpit": "합주 컨트롤룸", "navWorkspace": "작업 공간", + "navPlayer": "플레이어", "navImport": "가져오기", "navExport": "내보내기", "navSections": "구간", @@ -149,6 +150,13 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstEntranceLabel": "오늘의 첫 진입", + "firstEntranceAction": "{start}에 {section}{sectionParticle} 들어오는 {role} 듣기", + "firstEntranceOpenAction": "{start} {section}의 {role} 진입 위치 열기", + "firstEntranceBody": "{start}에 {section}{sectionParticle} {role} 파트가 들어옵니다. {cue}", + "firstEntranceArmed": "{start} {section}의 {role}부터 시작하세요. {cue}", + "firstEntranceUnavailable": "첫 진입이 아직 없습니다. 역할이 있는 구간이 생길 때까지 오늘 지도에 머무르세요.", + "firstEntranceNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 진입을 들으세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..672aa8ace 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 Entrance Callout | Figma-only workspace next-action pattern (no dedicated component node) | `apps/desktop/src/features/workspace/FirstEntranceCallout.tsx` | Name the first hearable part, section, and start time; keep `id="workspace-surface-cues"` as the stable navigation anchor in available and unavailable states; the workspace may show the Hear button for map scrolling, while callback-only surfaces must hide it when no executable callback is supplied. | | 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-entrance-navigation.md b/docs/doctoring/reduced-motion-first-entrance-navigation.md new file mode 100644 index 000000000..188d91029 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-entrance-navigation.md @@ -0,0 +1,33 @@ +# Reduced-motion first-entrance navigation + +## Scope + +The workspace First Entrance action is an intentional user interaction that scrolls the song-structure map to the resolved section. Smooth scrolling is non-essential to finding that section, so BandScope uses the operating-system/user-agent reduced-motion preference to choose the navigation animation behavior. + +This record applies only to the renderer-owned workspace scroll performed by `FirstEntranceCallout`. Player playback remains owned by the explicit playback callback and is not altered by this decision. + +## Contract + +- The default workspace path preserves the existing smooth scroll to the renderer-owned section position. +- When `window.matchMedia("(prefers-reduced-motion: reduce)").matches` is true, the same action uses immediate (`auto`) scrolling instead of smooth animation. +- If `matchMedia` is unavailable, BandScope preserves the existing smooth behavior rather than inventing a preference. +- Reduced-motion handling does not change entrance selection, section authority, playback authority, or the fail-closed metadata validation contract. +- The regression test covers the preference-aware JavaScript path directly; deterministic product/security/coverage gates remain independent of model judgment. + +## Standards rationale + +WCAG 2.2 Success Criterion 2.3.3, Animation from Interactions (Level AAA), requires interaction-triggered motion animation to be disableable when it is not essential. W3C's Understanding document explicitly recommends honoring user motion preferences, and Technique SCR40 documents evaluating `prefers-reduced-motion` in JavaScript to prevent interaction-triggered motion. The First Entrance scroll animation is not essential to conveying which section is selected, so respecting the preference is the narrower behavior-preserving implementation. + +This is an implementation rationale and evidence record, not a claim that BandScope is WCAG certified or that this single behavior establishes conformance. + +## Verification + +`apps/desktop/src/features/workspace/FirstEntranceCallout.reduced-motion.test.tsx` sets the reduced-motion media query to `reduce`, activates the exact buyer-visible First Entrance workspace action, and requires the renderer-owned target to receive `scrollIntoView({ block: "nearest", behavior: "auto" })`. Existing First Entrance tests continue to cover the default smooth-scroll contract. + +## References + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation, December 12, 2024). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Understanding Success Criterion 2.3.3: Animation from interactions*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions/ + +World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *SCR40: Using the CSS prefers-reduced-motion query in JavaScript to prevent motion*. Retrieved August 18, 2026, from https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR40