From 4d13986d7d486caae8f87db7d468388624acd29f Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 19 Aug 2026 18:19:10 +0000 Subject: [PATCH] feat(workspace): guide tonight's first bridge on map and player Name the earliest labeled bridge so the room can open the turn on the map or hear it from the player. Fail closed on intro, verse, pre-chorus, chorus, outro, tag, pickup, stop, and handoff labels, and keep Hear behind a real seek callback. --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- .../src/features/player/index.test.tsx | 97 ++++++++++ apps/desktop/src/features/player/index.tsx | 70 ++++++- ...FirstBridgeCallout.reduced-motion.test.tsx | 69 +++++++ .../workspace/FirstBridgeCallout.test.tsx | 162 ++++++++++++++++ .../features/workspace/FirstBridgeCallout.tsx | 147 ++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 40 ++++ .../src/features/workspace/Workspace.tsx | 3 + .../features/workspace/firstBridge.test.ts | 182 ++++++++++++++++++ .../src/features/workspace/firstBridge.ts | 181 +++++++++++++++++ apps/desktop/src/i18n/index.test.ts | 13 +- apps/desktop/src/i18n/index.ts | 15 +- apps/desktop/src/locales/en/common.json | 13 +- apps/desktop/src/locales/ko/common.json | 13 +- docs/design-system/component-contract.md | 1 + .../reduced-motion-first-bridge-navigation.md | 20 ++ 19 files changed, 1016 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/features/player/index.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBridgeCallout.reduced-motion.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBridgeCallout.test.tsx create mode 100644 apps/desktop/src/features/workspace/FirstBridgeCallout.tsx create mode 100644 apps/desktop/src/features/workspace/firstBridge.test.ts create mode 100644 apps/desktop/src/features/workspace/firstBridge.ts create mode 100644 docs/doctoring/reduced-motion-first-bridge-navigation.md diff --git a/AGENTS.md b/AGENTS.md index fca448ce9..d132fd301 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Keep UI and analysis engine decoupled through shared contracts. - Prefer minimal, test-first changes for production code. - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. +- Name tonight's first labeled bridge with the holding part when an active role is corroborated, the labeled turn, 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..f8b7d4646 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`. +- Workspace and player copy for tonight's first labeled bridge must name the holding part when corroborated, the labeled turn, and the time so the next action is obvious. - 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..fbcebee22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Name tonight's first labeled bridge on the workspace and player so the room can catch the turn; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. - 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..686517af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into Three layers, decoupled through shared contracts: -- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. +- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). Workspace and player name tonight's first labeled bridge so the room can catch the turn; the workspace action opens the matching map section, while the player exposes a Hear action only when its owning playback surface supplies a seek callback. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri. - `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis. - `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules. diff --git a/apps/desktop/src/features/player/index.test.tsx b/apps/desktop/src/features/player/index.test.tsx new file mode 100644 index 000000000..f51767fe9 --- /dev/null +++ b/apps/desktop/src/features/player/index.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { PlayerFeature } from "./index"; + +function songWithBridge() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const bridge = structuredClone(verse); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 30, end: 46 }; + bridge.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + bridge.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, bridge]; + return song; +} + +describe("PlayerFeature", () => { + it("asks the room to analyze first when no song is loaded", () => { + render(); + expect( + screen.getByText("Analyze tonight's song first, then hear the first bridge from this player.") + ).toBeTruthy(); + }); + + it("keeps the bridge hear action unavailable without a player playback callback", () => { + render(); + + expect(screen.queryByRole("button", { name: "Hear Lead Vocal turn at 0:30" })).toBeNull(); + expect(screen.getByText("Lead Vocal takes the bridge at 0:30.")).toBeTruthy(); + }); + + it("delegates the bridge hear action to the owning player callback", () => { + const onPlayFromSeconds = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal turn at 0:30" })); + + expect(onPlayFromSeconds).toHaveBeenCalledTimes(1); + expect(onPlayFromSeconds).toHaveBeenCalledWith(30); + }); + + it("renders a safe empty summary when the runtime section collection is not an array", () => { + const song = songWithBridge(); + (song as unknown as { sections: unknown }).sections = null; + + render(); + + expect(screen.getByText("No bridge yet. Stay on tonight's map until the turn is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("renders a safe empty summary when the runtime section collection is sparse", () => { + const song = songWithBridge(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + + render(); + + expect(screen.getByText("No bridge yet. Stay on tonight's map until the turn is labeled.")).toBeTruthy(); + expect(screen.getByText("0 sections")).toBeTruthy(); + }); + + it("omits malformed runtime section elements without crashing the player summary", () => { + const song = songWithBridge(); + song.sections = [null, song.sections[0]!] as unknown as typeof song.sections; + + render(); + + expect(screen.getByText("1 section")).toBeTruthy(); + expect(screen.getByText("verse")).toBeTruthy(); + }); + + it("does not pass an object-valued runtime song title into React copy", () => { + const song = songWithBridge(); + (song as unknown as { title: unknown }).title = { unsafe: "not-copy" }; + + expect(() => render()).not.toThrow(); + expect(screen.queryByText("not-copy")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/player/index.tsx b/apps/desktop/src/features/player/index.tsx index 37bc12f71..873bc9597 100644 --- a/apps/desktop/src/features/player/index.tsx +++ b/apps/desktop/src/features/player/index.tsx @@ -1,46 +1,96 @@ -import type { RehearsalSong } from "@bandscope/shared-types"; +import { + SECTION_FORM_LABELS, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { FirstBridgeCallout } from "../workspace/FirstBridgeCallout"; +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; +}; + +/** Return whether one runtime section is safe to summarize in the player. */ +function isPlayerSummarySection(value: unknown): value is RehearsalSection { + if (value === null || typeof value !== "object") { + return false; + } + const section = value as Partial; + return ( + typeof section.id === "string" && + section.id.trim().length > 0 && + typeof section.label === "string" && + SECTION_FORM_LABELS.includes(section.label as SectionFormLabel) + ); +} + +/** Return dense, individually valid sections without trusting runtime collection metadata. */ +function playerSummarySections(song: RehearsalSong): RehearsalSection[] { + const sections = song.sections as unknown; + if (!Array.isArray(sections)) { + return []; + } + const length = Number(sections.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return []; + } + for (let index = 0; index < length; index += 1) { + if (!(index in sections)) { + return []; + } + } + return sections.filter(isPlayerSummarySection); +} + +/** Player surface that names tonight's first labeled bridge 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.

+

{t("firstBridgeNeedsSong")}

); } + const sections = playerSummarySections(song); + const songTitle = typeof song.title === "string" ? song.title : ""; + return (

{title}

+
- {song.title} + {songTitle} - {song.sections.length} {song.sections.length === 1 ? "section" : "sections"} + {sections.length} {sections.length === 1 ? "section" : "sections"}
- {song.sections.map((section) => ( + {sections.map((section, sectionIndex) => ( {section.label} diff --git a/apps/desktop/src/features/workspace/FirstBridgeCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstBridgeCallout.reduced-motion.test.tsx new file mode 100644 index 000000000..2ac61428d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBridgeCallout.reduced-motion.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBridgeCallout } from "./FirstBridgeCallout"; + +function songWithBridge() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const bridge = structuredClone(verse); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 30, end: 46 }; + bridge.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + bridge.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, bridge]; + return song; +} + +describe("FirstBridgeCallout reduced motion", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("scrolls immediately when the operating system requests reduced motion", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal bridge at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" }); + + grid.remove(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBridgeCallout.test.tsx b/apps/desktop/src/features/workspace/FirstBridgeCallout.test.tsx new file mode 100644 index 000000000..4c286dc4d --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBridgeCallout.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstBridgeCallout } from "./FirstBridgeCallout"; + +function songWithBridge() { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const bridge = structuredClone(verse); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 30, end: 46 }; + bridge.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + bridge.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, bridge]; + return song; +} + +function appendSongStructureTarget() { + const grid = document.createElement("div"); + grid.dataset.testid = "song-structure-grid"; + const first = document.createElement("div"); + const target = document.createElement("div"); + const scrollIntoView = vi.fn(); + Object.defineProperty(target, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + grid.appendChild(first); + grid.appendChild(target); + document.body.appendChild(grid); + return { grid, scrollIntoView }; +} + +describe("FirstBridgeCallout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("names the first bridge as map navigation, scrolls to its rendered section, and arms that action", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + const action = screen.getByRole("button", { + name: "Open Lead Vocal bridge at 0:30" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Catch Lead Vocal's turn at 0:30. Play the next line./)).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 Lead Vocal bridge at 0:30" })); + + expect(screen.getByText("Lead Vocal takes the bridge at 0:30.")).toBeTruthy(); + expect(screen.queryByText(/Catch Lead Vocal's turn at 0:30. Play the next line./)).toBeNull(); + }); + + it("keeps workspace-scroll authoritative even when a playback callback is also supplied", () => { + const { grid, scrollIntoView } = appendSongStructureTarget(); + const onHearBridge = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal bridge at 0:30" })); + expect(onHearBridge).not.toHaveBeenCalled(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("navigates by renderer-owned section position instead of untrusted analysis ids", () => { + const song = songWithBridge(); + song.sections[1]!.id = "analysis section / duplicate"; + const { grid, scrollIntoView } = appendSongStructureTarget(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal bridge at 0:30" })); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + grid.remove(); + }); + + it("shows fresh guidance when the first bridge changes or returns later", () => { + const initialSong = songWithBridge(); + const { grid } = appendSongStructureTarget(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Open Lead Vocal bridge at 0:30" })); + expect(screen.getByText(/Catch Lead Vocal's turn at 0:30. Play the next line./)).toBeTruthy(); + + const nextSong = songWithBridge(); + nextSong.id = "next-song"; + nextSong.sections[1]!.timeRange = { start: 64, end: 80 }; + rerender(); + expect(screen.getByText("Lead Vocal takes the bridge at 1:04.")).toBeTruthy(); + + grid.remove(); + }); + + it("keeps an unavailable bridge guidance-only", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect( + screen.getByText("No bridge yet. Stay on tonight's map until the turn is labeled.") + ).toBeTruthy(); + }); + + it("names a band-wide turn when no part holds the bridge", () => { + const song = songWithBridge(); + song.sections[1]!.partGraph[0]!.is_active = false; + render(); + expect(screen.getByRole("button", { name: "Open the first bridge at 0:30" })).toBeTruthy(); + expect(screen.getByText("The band takes the bridge at 0:30.")).toBeTruthy(); + }); + + it("renders Hear only in callback-only mode when a seek callback exists", () => { + const onHearBridge = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Hear Lead Vocal turn at 0:30" })); + expect(onHearBridge).toHaveBeenCalledWith(30); + }); + + it("hides the Hear action in callback-only mode without a seek callback", () => { + render(); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.getByText("Lead Vocal takes the bridge at 0:30.")).toBeTruthy(); + }); + + it("localizes the bridge form label instead of exposing its raw enum in Korean copy", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + const song = songWithBridge(); + song.sections[1]!.roles[0]!.name = "리드 보컬"; + + render(); + + expect(screen.getByText("리드 보컬이 0:30 브리지에서 받습니다.")).toBeTruthy(); + expect(screen.queryByText(/bridge에서/)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstBridgeCallout.tsx b/apps/desktop/src/features/workspace/FirstBridgeCallout.tsx new file mode 100644 index 000000000..0b6d29f4e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstBridgeCallout.tsx @@ -0,0 +1,147 @@ +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 { formatBridgeTime, resolveFirstBridge } from "./firstBridge"; + +/** Props for the first-bridge rehearsal callout. */ +export interface FirstBridgeCalloutProps { + song: RehearsalSong; + actionMode?: "workspace-scroll" | "callback-only"; + onHearBridge?: (atSeconds: number) => void; +} + +type BridgeCopyValues = Readonly>; + +type HeardBridge = Readonly<{ + songId: string; + sectionId: string; + sectionIndex: number; + holdingRoleId: string | null; + atSeconds: number; +}>; + +/** Interpolate bridge placeholders once so rehearsal data is never rescanned as template syntax. */ +function formatBridgeCopy(template: string, values: BridgeCopyValues): string { + return template.replace(/\{(role|section|at)\}/g, (placeholder) => { + const key = placeholder.slice(1, -1) as keyof BridgeCopyValues; + return values[key] ?? placeholder; + }); +} + +/** Use immediate scrolling when the operating system requests reduced motion. */ +function preferredBridgeScrollBehavior(): ScrollBehavior { + return typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; +} + +/** Name tonight's first labeled bridge and offer only an action that the current surface can execute. */ +export function FirstBridgeCallout({ + song, + actionMode = "workspace-scroll", + onHearBridge +}: FirstBridgeCalloutProps) { + const locale = detectPreferredLocale(); + const t = createTranslator(locale); + const bridge = resolveFirstBridge(song); + const bridgeSectionIndex = bridge ? song.sections.indexOf(bridge.section) : -1; + const [heardBridge, setHeardBridge] = useState(null); + + useEffect(() => { + setHeardBridge(null); + }, [song?.id, bridgeSectionIndex, bridge?.section.id, bridge?.holdingRole?.id, bridge?.atSeconds]); + + if (!bridge) { + return ( + + ); + } + + const heard = + heardBridge?.songId === song.id && + heardBridge.sectionId === bridge.section.id && + heardBridge.sectionIndex === bridgeSectionIndex && + heardBridge.holdingRoleId === (bridge.holdingRole?.id ?? null) && + heardBridge.atSeconds === bridge.atSeconds; + const at = formatBridgeTime(bridge.atSeconds); + const copyValues: BridgeCopyValues = { + role: bridge.holdingRole?.name ?? "", + section: translateSectionFormLabel(locale, bridge.section.label), + at + }; + const hasRole = bridge.holdingRole !== null; + const actionLabel = formatBridgeCopy( + t( + actionMode === "callback-only" + ? hasRole + ? "firstBridgeAction" + : "firstBridgeActionBand" + : hasRole + ? "firstBridgeOpenAction" + : "firstBridgeOpenActionBand" + ), + copyValues + ); + const body = formatBridgeCopy(t(hasRole ? "firstBridgeBody" : "firstBridgeBodyBand"), copyValues); + const armed = formatBridgeCopy(t(hasRole ? "firstBridgeArmed" : "firstBridgeArmedBand"), copyValues); + const canExecuteAction = actionMode === "workspace-scroll" || typeof onHearBridge === "function"; + /** Record completion only after the owning surface has executed the selected bridge action. */ + const markBridgeActionComplete = () => { + setHeardBridge({ + songId: song.id, + sectionId: bridge.section.id, + sectionIndex: bridgeSectionIndex, + holdingRoleId: bridge.holdingRole?.id ?? null, + atSeconds: bridge.atSeconds + }); + }; + + return ( + + ); +} diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..90a768363 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -270,4 +270,44 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first labeled bridge as the next map action", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const bridge = structuredClone(verse); + bridge.id = "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: 64, end: 80 }; + bridge.roles = [ + { + ...verse.roles[2]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" + } + ]; + bridge.partGraph = [ + { + role_id: "lead-vocal", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, bridge]; + + render(); + + expect(screen.getByRole("button", { name: "Open Lead Vocal bridge at 1:04" })).toBeTruthy(); + expect(screen.getByText("Lead Vocal takes the bridge at 1:04.")).toBeTruthy(); + }); + + it("keeps unavailable first-bridge guidance on the map without inventing a turn", () => { + render(); + + expect(screen.queryByRole("button", { name: /bridge at/ })).toBeNull(); + expect( + screen.getByText("No bridge yet. Stay on tonight's map until the turn is labeled.") + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..b0f5ce08f 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 { FirstBridgeCallout } from "./FirstBridgeCallout"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; @@ -331,6 +332,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstBridge.test.ts b/apps/desktop/src/features/workspace/firstBridge.test.ts new file mode 100644 index 000000000..b0f33e402 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBridge.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatBridgeTime, resolveFirstBridge } from "./firstBridge"; + +function withBridgeSection( + overrides: { + id?: string; + start?: number; + end?: number; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const bridge = structuredClone(verse); + bridge.id = overrides.id ?? "bridge-1"; + bridge.label = "bridge"; + bridge.timeRange = { start: overrides.start ?? 30, end: overrides.end ?? 46 }; + const roleId = overrides.roleId ?? "lead-vocal"; + bridge.roles = [ + { + ...verse.roles[2]!, + id: roleId, + name: overrides.roleName ?? "Lead Vocal", + rehearsalPriority: overrides.priority ?? "high" + } + ]; + bridge.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [verse, bridge]; + return song; +} + +describe("resolveFirstBridge", () => { + it("returns null when the demo song has no labeled bridge", () => { + expect(resolveFirstBridge(createDemoRehearsalSong())).toBeNull(); + expect(formatBridgeTime(Number.NaN)).toBe("0:00"); + expect(formatBridgeTime(-4)).toBe("0:00"); + }); + + it("does not invent a bridge from an intro, verse, pre-chorus, chorus, outro, tag, pickup, stop, or handoff", () => { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const intro = structuredClone(verse); + intro.id = "intro-1"; + intro.label = "intro"; + intro.timeRange = { start: 0, end: 8 }; + const preChorus = structuredClone(verse); + preChorus.id = "pre-chorus-1"; + preChorus.label = "pre-chorus"; + preChorus.timeRange = { start: 20, end: 28 }; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 46 }; + const outro = structuredClone(verse); + outro.id = "outro-1"; + outro.label = "outro"; + outro.timeRange = { start: 90, end: 102 }; + const tag = structuredClone(verse); + tag.id = "tag-1"; + tag.label = "tag"; + tag.timeRange = { start: 102, end: 108 }; + const pickup = structuredClone(verse); + pickup.id = "pickup-1"; + pickup.label = "pickup"; + pickup.timeRange = { start: 8, end: 10 }; + const stop = structuredClone(verse); + stop.id = "stop-1"; + stop.label = "stop"; + stop.timeRange = { start: 18, end: 19 }; + const handoff = structuredClone(verse); + handoff.id = "handoff-1"; + handoff.label = "handoff"; + handoff.timeRange = { start: 22, end: 24 }; + song.sections = [intro, verse, pickup, stop, preChorus, chorus, handoff, outro, tag]; + + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("picks the earliest labeled bridge and the part that carries the turn", () => { + const song = withBridgeSection({ start: 30, end: 46 }); + const first = resolveFirstBridge(song); + + expect(first?.section.id).toBe("bridge-1"); + expect(first?.holdingRole?.id).toBe("lead-vocal"); + expect(first?.atSeconds).toBe(30); + expect(formatBridgeTime(first?.atSeconds ?? -1)).toBe("0:30"); + }); + + it("prefers the earlier of two labeled bridges", () => { + const song = withBridgeSection({ id: "bridge-late", start: 80, end: 96 }); + const verse = song.sections[0]!; + const earlier = structuredClone(song.sections[1]!); + earlier.id = "bridge-early"; + earlier.timeRange = { start: 30, end: 46 }; + earlier.roles = [ + { + ...verse.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium" + } + ]; + earlier.partGraph = [ + { + role_id: "bass-guitar", + is_active: true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [song.sections[0]!, song.sections[1]!, earlier]; + + const first = resolveFirstBridge(song); + expect(first?.section.id).toBe("bridge-early"); + expect(first?.holdingRole?.id).toBe("bass-guitar"); + expect(first?.atSeconds).toBe(30); + }); + + it("keeps a band-wide turn when no active ranked role holds it", () => { + const song = withBridgeSection({ isActive: false }); + const first = resolveFirstBridge(song); + expect(first?.section.id).toBe("bridge-1"); + expect(first?.holdingRole).toBeNull(); + expect(first?.atSeconds).toBe(30); + }); + + it("skips a bridge whose rehearsal window is unbounded", () => { + const song = withBridgeSection({ start: Number.NaN, end: 46 }); + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("skips a bridge whose end precedes its start", () => { + const song = withBridgeSection({ start: 46, end: 30 }); + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("skips a zero-length bridge window", () => { + const song = withBridgeSection({ start: 30, end: 30 }); + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("skips a bridge whose endpoint overflows the shared timing bound", () => { + const song = withBridgeSection({ start: MAX_SECTION_TIME_SECONDS, end: MAX_SECTION_TIME_SECONDS + 1 }); + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstBridge(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withBridgeSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[1]!; + song.sections = sparseSections; + expect(resolveFirstBridge(song)).toBeNull(); + }); + + it("keeps the turn band-wide when role identities are duplicated", () => { + const song = withBridgeSection(); + const role = song.sections[1]!.roles[0]!; + song.sections[1]!.roles = [role, { ...role }]; + song.sections[1]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const first = resolveFirstBridge(song); + expect(first?.section.id).toBe("bridge-1"); + expect(first?.holdingRole).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstBridge.ts b/apps/desktop/src/features/workspace/firstBridge.ts new file mode 100644 index 000000000..20088345a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstBridge.ts @@ -0,0 +1,181 @@ +import { + MAX_SECTION_TIME_SECONDS, + type RehearsalRole, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; + +const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const; + +/** Tonight's first labeled bridge: the earliest turn and the part that carries it. */ +export type FirstBridge = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; +}; + +/** Format a non-negative bridge time as m:ss for rehearsal copy. */ +export function formatBridgeTime(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 whether an untrusted runtime value can be inspected as an object. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object"; +} + +/** Return whether every numeric index is present 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 (!(index in value)) { + return false; + } + } + return true; +} + +/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + typeof role.id === "string" && + role.id.trim().length > 0 && + typeof role.name === "string" && + role.name.trim().length > 0 && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section has a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + const timeRange = section.timeRange as Partial | null; + if (timeRange === null || typeof timeRange !== "object") { + 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 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 left.id.localeCompare(right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => isRuntimeObject(node) && 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) && + node.is_active === true && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** Return the first labeled bridge, or null when no safe turn remains. */ +export function resolveFirstBridge(song: RehearsalSong): FirstBridge | null { + if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const bridgeSections = song.sections + .filter( + (section) => + isRuntimeObject(section) && + section.label === "bridge" && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return left.id.localeCompare(right.id); + }); + + const section = bridgeSections[0]; + if (!section) { + return null; + } + + return { + section, + holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)), + atSeconds: section.timeRange.start + }; +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..2c1d486a6 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,15 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes a bridge label for Korean rehearsal copy", () => { + expect(translateSectionFormLabel("ko", "bridge")).toBe("브리지"); + expect(translateSectionFormLabel("en", "bridge")).toBe("bridge"); + }); + + it("preserves unlabeled form values as data", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("verse"); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..bea2ff99e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,6 +12,13 @@ const dictionaries = { ko: koCommon } as const; +const sectionFormLabels: Readonly< + Record>> +> = { + en: { bridge: "bridge" }, + ko: { bridge: "브리지" } +}; + /** Documented. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { @@ -18,6 +26,11 @@ export function createTranslator(locale: Locale = "en") { }; } +/** Return localized copy for a section form label, preserving unknown labels as data. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + return sectionFormLabels[locale][label] ?? label; +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { @@ -25,4 +38,4 @@ export function detectPreferredLocale(): Locale { } return "en"; -} +} \ No newline at end of file diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..2fa22d27c 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "Practice Progress", "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", - "increasePracticeProgressLabel": "Increase progress" + "increasePracticeProgressLabel": "Increase progress", + "firstBridgeLabel": "Tonight's first bridge", + "firstBridgeAction": "Hear {role} turn at {at}", + "firstBridgeActionBand": "Hear the first bridge at {at}", + "firstBridgeOpenAction": "Open {role} bridge at {at}", + "firstBridgeOpenActionBand": "Open the first bridge at {at}", + "firstBridgeBody": "{role} takes the {section} at {at}.", + "firstBridgeBodyBand": "The band takes the {section} at {at}.", + "firstBridgeArmed": "Catch {role}'s turn at {at}. Play the next line.", + "firstBridgeArmedBand": "Catch the turn at {at}. Play the next line.", + "firstBridgeUnavailable": "No bridge yet. Stay on tonight's map until the turn is labeled.", + "firstBridgeNeedsSong": "Analyze tonight's song first, then hear the first bridge from this player." } diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..634a024ef 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -148,5 +148,16 @@ "practiceProgressRegionLabel": "연습 진척도", "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", - "increasePracticeProgressLabel": "진척도 증가" + "increasePracticeProgressLabel": "진척도 증가", + "firstBridgeLabel": "오늘 첫 브리지", + "firstBridgeAction": "{at}에 {role} 브리지 듣기", + "firstBridgeActionBand": "{at} 첫 브리지 듣기", + "firstBridgeOpenAction": "{at} {role} 브리지 위치 열기", + "firstBridgeOpenActionBand": "{at} 첫 브리지 위치 열기", + "firstBridgeBody": "{role}이 {at} {section}에서 받습니다.", + "firstBridgeBodyBand": "밴드가 {at} {section}에서 받습니다.", + "firstBridgeArmed": "{at}에서 {role} 브리지를 잡으세요. 다음 프레이즈를 연주하세요.", + "firstBridgeArmedBand": "{at}에서 브리지를 잡으세요. 다음 프레이즈를 연주하세요.", + "firstBridgeUnavailable": "아직 브리지가 없습니다. 브리지가 표시될 때까지 오늘 지도에 머무르세요.", + "firstBridgeNeedsSong": "먼저 오늘 곡을 분석한 다음, 이 플레이어에서 첫 브리지를 들으세요." } diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..9890e12af 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 Bridge Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstBridgeCallout.tsx` | Name the holding part when an active graph node corroborates it, the labeled `bridge` turn, and the time. Do not invent a turn from `intro`, `verse`, `pre-chorus`, `chorus`, `outro`, `tag`, `pickup`, `stop`, or `handoff`. `workspace-scroll` always renders the Open map action and scrolls the renderer-owned section even if a playback callback is also present. `callback-only` renders Hear only when `onHearBridge` exists and delegates the exact bridge second to that callback. Keep the unavailable state guidance-only. Distinct from intro #943, chorus #939, pickup #916, stop #934, and labeled handoff #937. | | 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-bridge-navigation.md b/docs/doctoring/reduced-motion-first-bridge-navigation.md new file mode 100644 index 000000000..684ae8279 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-bridge-navigation.md @@ -0,0 +1,20 @@ +# Reduced-motion first-bridge navigation + +## Buyer-visible contract + +Tonight's first labeled bridge is a next action, not a decorative motion. Opening the matching map section must remain usable when the operating system requests reduced motion. + +## Behavior + +- Workspace `Open` scrolls the renderer-owned song-structure cell for the first labeled bridge. +- When `window.matchMedia("(prefers-reduced-motion: reduce)")` matches, that scroll uses `behavior: "auto"`. +- When reduced motion is not requested, the same scroll uses `behavior: "smooth"`. +- The player `Hear` path is unaffected: it only runs when the owning surface supplies a seek callback. + +## Security Notes + +- Untrusted input: analysis section identities and times. Navigation uses the renderer-owned section index, not an untrusted id selector. +- Trust boundary: React workspace click handler → existing song-structure grid → `Element.scrollIntoView`. +- Safe failure: missing grid or missing cell leaves the guidance unarmed and does not invent a destination. +- Privacy: no path, payload, or network access is introduced. +- Test points: `FirstBridgeCallout.reduced-motion.test.tsx` locks the `auto` scroll contract.