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.