diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..c287a433f 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 open rehearsal comment with the author, the corroborated holding part, the labeled section, and the time so the next action is obvious.
- Do not reduce the product to a chord analyzer when form, timing, player coordination, 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..ff19d2a74 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -5,6 +5,7 @@ Last updated: 2026-03-11
## Brand source
- Product identity, UX tone, copy rules, and prioritization tie-breakers live in `docs/brand-story.md`.
+- The mounted workspace copy for tonight's first open rehearsal comment must name the author, the corroborated holding part, the labeled section, and the time so the next action is obvious. Open moves to the matching rendered map section. Do not invent a note from resolved comments, assignments, approvals, cues, groove, setup, simplification, overlap, or range copy.
- Future PRDs, TRDs, onboarding copy, empty states, error messages, and marketing copy should use that document as the single brand source of truth.
## Security source
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..bf282b41b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
+- Name tonight's first open rehearsal comment in the mounted rehearsal workspace so the room can read the MD note and open the matching rendered map section, while inherited or accessor-backed runtime metadata remains guidance-only instead of becoming navigation authority.
- 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..530d8d771 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). The mounted workspace names tonight's first open rehearsal comment and opens the matching rendered map section. The ready workspace names tonight's first playable range and the next instrument check. Do not invent a note from resolved comments, assignments, approvals, cues, groove, setup, simplification, overlap, or range copy. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.particle.test.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.particle.test.tsx
new file mode 100644
index 000000000..b6024272a
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.particle.test.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+describe("FirstOpenCommentCallout Korean role copy", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("keeps vowel-ending dynamic role names particle-safe before and after the note action", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ seed.roles = [
+ {
+ ...seed.roles[1]!,
+ id: "piano",
+ name: "피아노",
+ rehearsalPriority: "high"
+ }
+ ];
+ seed.partGraph = [{ role_id: "piano", is_active: true, handoff_to: [], handoff_from: [] }];
+ song.collaboration!.comments = [
+ {
+ id: "comment-piano-color",
+ author: "MD",
+ body: "첫 패스에서는 톤을 부드럽게 유지하세요.",
+ sectionId: seed.id,
+ roleId: "piano",
+ status: "open"
+ }
+ ];
+
+ const grid = document.createElement("div");
+ grid.dataset.testid = "song-structure-grid";
+ grid.setAttribute("role", "region");
+ grid.setAttribute("aria-label", "Scrollable song structure timeline");
+ const target = document.createElement("div");
+ target.dataset.sectionIndex = "0";
+ Object.defineProperty(target, "scrollIntoView", {
+ configurable: true,
+ value: vi.fn()
+ });
+ grid.appendChild(target);
+ document.body.appendChild(grid);
+
+ render();
+
+ expect(screen.getByText("MD님이 0:10 벌스에서 피아노 파트에 메모를 남겼습니다.")).toBeTruthy();
+ expect(screen.queryByText(/피아노이/)).toBeNull();
+
+ fireEvent.click(screen.getByRole("button", { name: "0:10 피아노 위치 열기" }));
+
+ expect(screen.getByText("0:10에서 피아노 파트 메모를 확인하세요. 그 파트를 보고 있으세요.")).toBeTruthy();
+ expect(screen.queryByText(/피아노과/)).toBeNull();
+
+ grid.remove();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.proxy.test.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.proxy.test.tsx
new file mode 100644
index 000000000..e2d2dbdac
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.proxy.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+describe("FirstOpenCommentCallout hostile runtime identity", () => {
+ it("contains a song identity descriptor trap without discarding the valid owned comment", () => {
+ const song = createDemoRehearsalSong();
+ const hostileSong = new Proxy(song, {
+ getOwnPropertyDescriptor(target, property) {
+ if (property === "id") {
+ throw new Error("hostile song id descriptor trap");
+ }
+ return Reflect.getOwnPropertyDescriptor(target, property);
+ }
+ });
+
+ expect(() => render()).not.toThrow();
+ expect(
+ screen.getByText("MD left a note for Keyboard 1 Right Hand in the verse at 0:10.")
+ ).toBeTruthy();
+ expect(
+ screen.getByText(
+ "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward."
+ )
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.reduced-motion.test.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.reduced-motion.test.tsx
new file mode 100644
index 000000000..ff502cfff
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.reduced-motion.test.tsx
@@ -0,0 +1,43 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+describe("FirstOpenCommentCallout reduced motion", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("scrolls immediately when the operating system requests reduced motion", () => {
+ vi.stubGlobal("matchMedia", (query: string) => ({
+ matches: query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ }));
+
+ const grid = document.createElement("div");
+ grid.dataset.testid = "song-structure-grid";
+ grid.setAttribute("role", "region");
+ grid.setAttribute("aria-label", "Scrollable song structure timeline");
+ const target = document.createElement("div");
+ target.dataset.sectionIndex = "0";
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(target, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView
+ });
+ grid.appendChild(target);
+ document.body.appendChild(grid);
+
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "auto" });
+
+ grid.remove();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.stories.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.stories.tsx
new file mode 100644
index 000000000..352388d8e
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+const availableSong = createDemoRehearsalSong();
+const unavailableSong = createDemoRehearsalSong();
+for (const comment of unavailableSong.collaboration?.comments ?? []) {
+ comment.status = "resolved";
+}
+
+const meta = {
+ title: "Workspace/First Open Comment Callout",
+ component: FirstOpenCommentCallout,
+ parameters: { layout: "padded" },
+ decorators: [
+ (Story) => (
+
+
+
+ Song structure target · section 1
+
+
+
+
+ )
+ ]
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/** Figma workspace next-action pattern with the earliest owned open rehearsal note. */
+export const Available: Story = {
+ args: { song: availableSong }
+};
+
+/** Guidance-only state when tonight's map has no owned open rehearsal note. */
+export const Unavailable: Story = {
+ args: { song: unavailableSong }
+};
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.test.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.test.tsx
new file mode 100644
index 000000000..3e9369ad1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.test.tsx
@@ -0,0 +1,221 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+function songWithOpenComment() {
+ return createDemoRehearsalSong();
+}
+
+function appendSongStructureTarget(ariaLabel = "Scrollable song structure timeline") {
+ const timeline = document.createElement("div");
+ timeline.setAttribute("role", "region");
+ timeline.setAttribute("aria-label", ariaLabel);
+ const grid = document.createElement("div");
+ grid.dataset.testid = "song-structure-grid";
+ const target = document.createElement("div");
+ target.dataset.sectionIndex = "0";
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(target, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView
+ });
+ grid.appendChild(target);
+ timeline.appendChild(grid);
+ document.body.appendChild(timeline);
+ return { grid: timeline, scrollIntoView };
+}
+
+describe("FirstOpenCommentCallout", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("contains a malformed runtime song root instead of crashing the callout", () => {
+ render();
+
+ expect(
+ screen.getByText("No open note yet. Stay on tonight's map until someone leaves a rehearsal comment.")
+ ).toBeTruthy();
+ });
+
+ it("contains a hostile song identity accessor instead of crashing the callout", () => {
+ const song = songWithOpenComment();
+ Object.defineProperty(song, "id", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ throw new Error("hostile song id getter");
+ }
+ });
+
+ expect(() => render()).not.toThrow();
+ expect(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" })).toBeTruthy();
+ });
+
+ it("keeps armed guidance when an unrelated song edit preserves the first-open-note identity", () => {
+ const firstSong = songWithOpenComment();
+ const nextSong = { ...firstSong };
+ const { grid } = appendSongStructureTarget();
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+
+ rerender();
+
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+ expect(screen.queryByText("MD left a note for Keyboard 1 Right Hand in the verse at 0:10.")).toBeNull();
+
+ grid.remove();
+ });
+
+ it("names the first open note as map navigation, scrolls to its rendered section, and arms that action", () => {
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render();
+
+ expect(
+ screen.getByText("Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.")
+ ).toBeTruthy();
+ const action = screen.getByRole("button", {
+ name: "Open Keyboard 1 Right Hand at 0:10"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+
+ grid.remove();
+ });
+
+ it("keeps map navigation stable when the renderer accessible name is localized", () => {
+ const { grid, scrollIntoView } = appendSongStructureTarget("스크롤 가능한 곡 구조 타임라인");
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+
+ grid.remove();
+ });
+
+ it("does not claim map navigation completed when the rendered section target is missing", () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+
+ expect(screen.getByText("MD left a note for Keyboard 1 Right Hand in the verse at 0:10.")).toBeTruthy();
+ expect(
+ screen.queryByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeNull();
+ });
+
+ it("navigates by renderer-owned section position instead of untrusted analysis ids", () => {
+ const song = songWithOpenComment();
+ song.sections[0]!.id = "analysis section / duplicate";
+ song.collaboration!.comments[0]!.sectionId = "analysis section / duplicate";
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+
+ grid.remove();
+ });
+
+ it("scopes map navigation to the song-structure renderer when another surface reuses an index", () => {
+ const decoy = document.createElement("div");
+ decoy.dataset.sectionIndex = "0";
+ const decoyScrollIntoView = vi.fn();
+ Object.defineProperty(decoy, "scrollIntoView", {
+ configurable: true,
+ value: decoyScrollIntoView
+ });
+ document.body.appendChild(decoy);
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+
+ expect(decoyScrollIntoView).not.toHaveBeenCalled();
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+
+ decoy.remove();
+ grid.remove();
+ });
+
+ it("shows fresh guidance when the first open note changes or returns later", () => {
+ const initialSong = songWithOpenComment();
+ const { grid } = appendSongStructureTarget();
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Open Keyboard 1 Right Hand at 0:10" }));
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+
+ const nextSong = songWithOpenComment();
+ nextSong.id = "next-song";
+ nextSong.sections[0]!.timeRange = { start: 20, end: 40 };
+ rerender();
+ expect(screen.getByText("MD left a note for Keyboard 1 Right Hand in the verse at 0:20.")).toBeTruthy();
+
+ grid.remove();
+ });
+
+ it("keeps an unavailable note guidance-only", () => {
+ const song = songWithOpenComment();
+ for (const comment of song.collaboration!.comments) {
+ comment.status = "resolved";
+ }
+ render();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(
+ screen.getByText("No open note yet. Stay on tonight's map until someone leaves a rehearsal comment.")
+ ).toBeTruthy();
+ });
+
+ it("names a section-wide note when no part carries it", () => {
+ const song = songWithOpenComment();
+ delete song.collaboration!.comments[0]!.roleId;
+ const { grid, scrollIntoView } = appendSongStructureTarget();
+ render();
+ const action = screen.getByRole("button", { name: "Open the first note at 0:10" });
+ expect(screen.getByText("MD left a note in the verse at 0:10.")).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(screen.getByText("Read MD's note at 0:10. Keep that section in view.")).toBeTruthy();
+ grid.remove();
+ });
+
+ it("localizes the comment form label instead of exposing its raw enum in Korean copy", () => {
+ vi.stubGlobal("navigator", { language: "ko-KR" });
+ const song = songWithOpenComment();
+ song.sections[0]!.roles[1]!.name = "키보드 오른손";
+
+ render();
+
+ expect(screen.getByText("MD님이 0:10 벌스에서 키보드 오른손 파트에 메모를 남겼습니다.")).toBeTruthy();
+ expect(screen.queryByText(/verse에서/)).toBeNull();
+ });
+
+ it("renders the owned comment body as a text node instead of template syntax", () => {
+ const song = songWithOpenComment();
+ song.collaboration!.comments[0]!.body = "Keep {role} gentle at {at}";
+ render();
+ expect(screen.getByText("Keep {role} gentle at {at}")).toBeTruthy();
+ expect(screen.queryByText("Keep Keyboard 1 Right Hand gentle at 0:10")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.tsx
new file mode 100644
index 000000000..6b56f8df1
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.tsx
@@ -0,0 +1,183 @@
+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 { formatOpenCommentTime, resolveFirstOpenComment } from "./firstOpenComment";
+
+/** Props for the first-open-comment rehearsal callout. */
+export interface FirstOpenCommentCalloutProps {
+ song: RehearsalSong;
+}
+
+type OpenCommentCopyValues = Readonly>;
+
+type OpenedOpenComment = Readonly<{
+ songIdentifier: string | null;
+ commentId: string;
+ sectionId: string;
+ sectionIndex: number;
+ holdingRoleId: string | null;
+ atSeconds: number;
+}>;
+
+/** Read a stable song id only when runtime data owns it as a plain string value. */
+function ownedSongIdentifier(song: RehearsalSong): string | null {
+ if (song === null || typeof song !== "object" || Array.isArray(song)) {
+ return null;
+ }
+ let descriptor: PropertyDescriptor | undefined;
+ try {
+ descriptor = Object.getOwnPropertyDescriptor(song, "id");
+ } catch {
+ return null;
+ }
+ return descriptor !== undefined &&
+ Object.prototype.hasOwnProperty.call(descriptor, "value") &&
+ typeof descriptor.value === "string"
+ ? descriptor.value
+ : null;
+}
+
+/** Interpolate comment placeholders once so rehearsal data is never rescanned as template syntax. */
+function formatOpenCommentCopy(template: string, values: OpenCommentCopyValues): string {
+ return template.replace(/\{(author|role|section|at)\}/g, (placeholder) => {
+ const key = placeholder.slice(1, -1) as keyof OpenCommentCopyValues;
+ return values[key] ?? placeholder;
+ });
+}
+
+/** Use immediate scrolling when the operating system requests reduced motion. */
+function preferredOpenCommentScrollBehavior(): ScrollBehavior {
+ return typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ ? "auto"
+ : "smooth";
+}
+
+/** Resolve the song-structure renderer owned by this workspace, failing closed on ambiguous mounts. */
+function resolveOpenCommentRenderer(origin: HTMLElement): HTMLElement | null {
+ const selector = '[data-testid="song-structure-grid"]';
+ const localScope = origin.closest("aside")?.parentElement ?? null;
+ const localRenderers = localScope?.querySelectorAll(selector) ?? [];
+ if (localRenderers.length === 1) {
+ return localRenderers[0] ?? null;
+ }
+ if (localRenderers.length > 1) {
+ return null;
+ }
+
+ const globalRenderers = document.querySelectorAll(selector);
+ return globalRenderers.length === 1 ? (globalRenderers[0] ?? null) : null;
+}
+
+/** Name tonight's first open rehearsal comment and open the matching rendered map section. */
+export function FirstOpenCommentCallout({ song }: FirstOpenCommentCalloutProps) {
+ const locale = detectPreferredLocale();
+ const t = createTranslator(locale);
+ const songIdentifier = ownedSongIdentifier(song);
+ const runtimeSong = song as unknown as Partial | null;
+ const openComment = resolveFirstOpenComment(song);
+ const openCommentSectionIndex =
+ openComment && Array.isArray(runtimeSong?.sections)
+ ? runtimeSong.sections.indexOf(openComment.section)
+ : -1;
+ const [openedOpenComment, setOpenedOpenComment] = useState(null);
+
+ useEffect(() => {
+ setOpenedOpenComment(null);
+ }, [
+ songIdentifier,
+ openCommentSectionIndex,
+ openComment?.comment.id,
+ openComment?.section.id,
+ openComment?.holdingRole?.id,
+ openComment?.atSeconds
+ ]);
+
+ if (!openComment) {
+ return (
+
+ );
+ }
+
+ const opened =
+ openedOpenComment !== null &&
+ openedOpenComment.songIdentifier === songIdentifier &&
+ openedOpenComment.commentId === openComment.comment.id &&
+ openedOpenComment.sectionId === openComment.section.id &&
+ openedOpenComment.sectionIndex === openCommentSectionIndex &&
+ openedOpenComment.holdingRoleId === (openComment.holdingRole?.id ?? null) &&
+ openedOpenComment.atSeconds === openComment.atSeconds;
+ const at = formatOpenCommentTime(openComment.atSeconds);
+ const copyValues: OpenCommentCopyValues = {
+ author: openComment.author,
+ role: openComment.holdingRole?.name ?? "",
+ section: translateSectionFormLabel(locale, openComment.section.label),
+ at
+ };
+ const hasRole = openComment.holdingRole !== null;
+ const actionLabel = formatOpenCommentCopy(
+ t(hasRole ? "firstOpenCommentOpenAction" : "firstOpenCommentOpenActionBand"),
+ copyValues
+ );
+ const body = formatOpenCommentCopy(
+ t(hasRole ? "firstOpenCommentBody" : "firstOpenCommentBodyBand"),
+ copyValues
+ );
+ const armed = formatOpenCommentCopy(
+ t(hasRole ? "firstOpenCommentArmed" : "firstOpenCommentArmedBand"),
+ copyValues
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/FirstOpenCommentCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.workspace-scope.test.tsx
new file mode 100644
index 000000000..308828c27
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstOpenCommentCallout.workspace-scope.test.tsx
@@ -0,0 +1,54 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it, vi } from "vitest";
+import { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
+
+describe("FirstOpenCommentCallout workspace scope", () => {
+ it("opens the song-structure renderer owned by the current workspace", () => {
+ const firstSong = createDemoRehearsalSong();
+ const secondSong = createDemoRehearsalSong();
+ secondSong.id = "second-workspace-song";
+
+ const { container } = render(
+ <>
+
+
+ >
+ );
+
+ const targets = container.querySelectorAll('[data-section-index="0"]');
+ expect(targets).toHaveLength(2);
+ const firstScrollIntoView = vi.fn();
+ const secondScrollIntoView = vi.fn();
+ Object.defineProperty(targets[0]!, "scrollIntoView", {
+ configurable: true,
+ value: firstScrollIntoView
+ });
+ Object.defineProperty(targets[1]!, "scrollIntoView", {
+ configurable: true,
+ value: secondScrollIntoView
+ });
+
+ const actions = screen.getAllByRole("button", {
+ name: "Open Keyboard 1 Right Hand at 0:10"
+ });
+ expect(actions).toHaveLength(2);
+ fireEvent.click(actions[1]!);
+
+ expect(firstScrollIntoView).not.toHaveBeenCalled();
+ expect(secondScrollIntoView).toHaveBeenCalledWith({
+ block: "nearest",
+ behavior: "smooth"
+ });
+ });
+});
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..ab46b410e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -326,4 +326,32 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
+
+ it("names tonight's first open rehearsal comment as workspace navigation", () => {
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ const target = screen.getByTestId("song-structure-grid").children.item(0);
+ expect(target).toBeTruthy();
+ const scrollIntoView = vi.fn();
+ Object.defineProperty(target!, "scrollIntoView", {
+ configurable: true,
+ value: scrollIntoView
+ });
+
+ expect(
+ screen.getAllByText("Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.")
+ .length
+ ).toBeGreaterThan(0);
+ const action = screen.getByRole("button", {
+ name: "Open Keyboard 1 Right Hand at 0:10"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(
+ screen.getByText(/Read MD's note with Keyboard 1 Right Hand at 0:10. Keep that part in view./)
+ ).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..fcd43d3b2 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 { FirstOpenCommentCallout } from "./FirstOpenCommentCallout";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
data-testid="song-structure-grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }}
>
- {sections.map((section) => (
-
+ {sections.map((section, sectionIndex) => (
+
{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}
@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+
+
diff --git a/apps/desktop/src/features/workspace/firstOpenComment.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstOpenComment.inherited-metadata.test.ts
new file mode 100644
index 000000000..122cfff76
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstOpenComment.inherited-metadata.test.ts
@@ -0,0 +1,110 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { resolveFirstOpenComment } from "./firstOpenComment";
+
+function songWithOpenComment() {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "comment-own";
+ song.sections = [section];
+ song.collaboration = {
+ syncMode: "local_only",
+ syncNote: "Keep assignments local for now.",
+ assignments: [],
+ approvals: [],
+ comments: [
+ {
+ id: "comment-keys-color",
+ author: "MD",
+ body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.",
+ sectionId: "comment-own",
+ roleId: "keys-right",
+ status: "open"
+ }
+ ]
+ };
+ return { song, section };
+}
+
+describe("resolveFirstOpenComment inherited metadata", () => {
+ it("rejects a song or section whose required metadata is inherited", () => {
+ const { song, section } = songWithOpenComment();
+ const inheritedSong = Object.create({
+ sections: song.sections,
+ collaboration: song.collaboration
+ }) as typeof song;
+ expect(resolveFirstOpenComment(inheritedSong)).toBeNull();
+
+ const inheritedSection = Object.create(section) as typeof section;
+ song.sections = [inheritedSection];
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("rejects inherited timing fields", () => {
+ const { song, section } = songWithOpenComment();
+ section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange;
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("contains exceptions from own runtime accessors instead of trusting them", () => {
+ const { song } = songWithOpenComment();
+ Object.defineProperty(song.collaboration!.comments[0]!, "body", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ throw new Error("hostile comment body getter");
+ }
+ });
+
+ expect(() => resolveFirstOpenComment(song)).not.toThrow();
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("does not treat own accessors as stable comment identity authority", () => {
+ const { song } = songWithOpenComment();
+ Object.defineProperty(song.collaboration!.comments[0]!, "id", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return "comment-keys-color";
+ }
+ });
+
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("does not let inherited comment metadata establish the note", () => {
+ const { song } = songWithOpenComment();
+ song.collaboration!.comments = [
+ Object.create({
+ id: "comment-keys-color",
+ author: "MD",
+ body: "Inherited note",
+ sectionId: "comment-own",
+ roleId: "keys-right",
+ status: "open"
+ }) as (typeof song.collaboration.comments)[number]
+ ];
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("does not let inherited role or graph metadata establish the holding part", () => {
+ const { song, section } = songWithOpenComment();
+ const node = section.partGraph.find((item) => item.role_id === "keys-right")!;
+ section.partGraph = [Object.create(node) as typeof node];
+
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.section.id).toBe("comment-own");
+ expect(resolved?.holdingRole).toBeNull();
+ expect(resolved?.hint).toBe(
+ "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward."
+ );
+ });
+
+ it("rejects arrays masquerading as section records", () => {
+ const { song, section } = songWithOpenComment();
+ const arraySection = Object.assign([], section) as unknown as typeof section;
+ song.sections = [arraySection];
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstOpenComment.test.ts b/apps/desktop/src/features/workspace/firstOpenComment.test.ts
new file mode 100644
index 000000000..aff70e573
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstOpenComment.test.ts
@@ -0,0 +1,254 @@
+import { describe, expect, it } from "vitest";
+import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types";
+import { formatOpenCommentTime, resolveFirstOpenComment } from "./firstOpenComment";
+
+function withOpenComment(
+ overrides: {
+ commentId?: string;
+ sectionId?: string;
+ start?: number;
+ end?: number;
+ label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag";
+ roleId?: string;
+ roleName?: string;
+ author?: string;
+ body?: string;
+ status?: "open" | "resolved";
+ isActive?: boolean;
+ includeRoleId?: boolean;
+ } = {}
+) {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const section = structuredClone(verse);
+ section.id = overrides.sectionId ?? "verse-note";
+ section.label = overrides.label ?? "verse";
+ section.groove = "Straight eighths with a late snare feel";
+ section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 };
+ const roleId = overrides.roleId ?? "keys-right";
+ section.roles = [
+ {
+ ...verse.roles[1]!,
+ id: roleId,
+ name: overrides.roleName ?? "Keyboard 1 Right Hand",
+ rehearsalPriority: "high",
+ cue: { kind: "count", value: "Enter on beat 2 after the pickup." },
+ overlapWarnings: [],
+ setupNote: "Keep the patch bright enough to stay over the guitars.",
+ simplification: "Drop the top extension if the chorus turnaround still feels busy."
+ }
+ ];
+ section.partGraph = [
+ {
+ role_id: roleId,
+ is_active: overrides.isActive ?? true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [section];
+ song.collaboration = {
+ syncMode: "local_only",
+ syncNote: "Keep assignments local for now.",
+ assignments: [],
+ approvals: [],
+ comments: [
+ {
+ id: overrides.commentId ?? "comment-keys-color",
+ author: overrides.author ?? "MD",
+ body: overrides.body ?? "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.",
+ sectionId: section.id,
+ ...(overrides.includeRoleId === false ? {} : { roleId }),
+ status: overrides.status ?? "open"
+ }
+ ]
+ };
+ return song;
+}
+
+describe("resolveFirstOpenComment", () => {
+ it("picks the demo song's earliest open note and the part it names", () => {
+ const resolved = resolveFirstOpenComment(createDemoRehearsalSong());
+ expect(resolved?.section.id).toBe("verse-1");
+ expect(resolved?.holdingRole?.id).toBe("keys-right");
+ expect(resolved?.comment.id).toBe("comment-keys-color");
+ expect(resolved?.atSeconds).toBe(10);
+ expect(resolved?.author).toBe("MD");
+ expect(resolved?.hint).toBe(
+ "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward."
+ );
+ expect(formatOpenCommentTime(resolved?.atSeconds ?? -1)).toBe("0:10");
+ expect(formatOpenCommentTime(Number.NaN)).toBe("0:00");
+ expect(formatOpenCommentTime(-4)).toBe("0:00");
+ });
+
+ it("does not invent an open note from resolved comments, assignments, approvals, cues, groove, setup, simplification, overlap, or range copy", () => {
+ const song = withOpenComment({ status: "resolved" });
+ song.collaboration!.assignments = [
+ {
+ id: "assign-bass-entrance",
+ assignee: "Rhythm Section",
+ summary: "Lock the bass entrance against the pickup so the chorus lift lands together.",
+ sectionId: song.sections[0]!.id,
+ roleId: "keys-right",
+ status: "in_progress"
+ }
+ ];
+ song.collaboration!.approvals = [
+ {
+ id: "approval-harmony-pass",
+ scope: "Verse harmony pass",
+ owner: "MD",
+ status: "pending"
+ }
+ ];
+ song.sections[0]!.groove = "Straight eighths with a late snare feel";
+ song.sections[0]!.roles[0]!.setupNote = "Keep the patch bright enough to stay over the guitars.";
+ song.sections[0]!.roles[0]!.simplification = "Drop the top extension if the chorus turnaround still feels busy.";
+ song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" };
+ song.sections[0]!.roles[0]!.overlapWarnings = [
+ "Melodic overlap: top notes conflict with Lead Vocal range."
+ ];
+ song.sections[0]!.roles[0]!.range = { lowestNote: "B3", highestNote: "G#5" };
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("does not treat an empty or whitespace comment body as a named note", () => {
+ expect(resolveFirstOpenComment(withOpenComment({ body: "" }))).toBeNull();
+ expect(resolveFirstOpenComment(withOpenComment({ body: " \n\t " }))).toBeNull();
+ });
+
+ it("prefers the earlier of two open notes", () => {
+ const song = withOpenComment({ commentId: "comment-late", start: 40, end: 56, roleId: "lead-vocal" });
+ const earlier = structuredClone(song.sections[0]!);
+ earlier.id = "verse-early";
+ earlier.timeRange = { start: 8, end: 24 };
+ song.sections = [song.sections[0]!, earlier];
+ song.collaboration!.comments = [
+ song.collaboration!.comments[0]!,
+ {
+ id: "comment-early",
+ author: "MD",
+ body: "Catch the pickup before the downbeat.",
+ sectionId: "verse-early",
+ roleId: "keys-right",
+ status: "open"
+ }
+ ];
+
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.section.id).toBe("verse-early");
+ expect(resolved?.comment.id).toBe("comment-early");
+ expect(resolved?.hint).toBe("Catch the pickup before the downbeat.");
+ expect(resolved?.atSeconds).toBe(8);
+ });
+
+ it("breaks same-time comment ties with locale-independent id ordering", () => {
+ const song = withOpenComment({ commentId: "ä-note", start: 10, end: 26 });
+ song.collaboration!.comments = [
+ song.collaboration!.comments[0]!,
+ {
+ ...song.collaboration!.comments[0]!,
+ id: "z-note",
+ body: "ASCII note"
+ }
+ ];
+
+ expect(resolveFirstOpenComment(song)?.comment.id).toBe("z-note");
+ });
+
+ it("keeps a section-wide note when no active ranked role carries it", () => {
+ const song = withOpenComment({ isActive: false });
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.section.id).toBe("verse-note");
+ expect(resolved?.holdingRole).toBeNull();
+ expect(resolved?.hint).toBe(
+ "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward."
+ );
+ });
+
+ it("keeps a section-wide note when the comment does not name a part", () => {
+ const song = withOpenComment({ includeRoleId: false });
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.holdingRole).toBeNull();
+ expect(resolved?.author).toBe("MD");
+ });
+
+ it("skips a note whose rehearsal window is unbounded", () => {
+ expect(resolveFirstOpenComment(withOpenComment({ start: Number.NaN, end: 30 }))).toBeNull();
+ });
+
+ it("skips a note whose end precedes its start", () => {
+ expect(resolveFirstOpenComment(withOpenComment({ start: 30, end: 10 }))).toBeNull();
+ });
+
+ it("skips a zero-length note window", () => {
+ expect(resolveFirstOpenComment(withOpenComment({ start: 10, end: 10 }))).toBeNull();
+ });
+
+ it("skips a note whose endpoint overflows the shared timing bound", () => {
+ expect(
+ resolveFirstOpenComment(
+ withOpenComment({
+ start: MAX_SECTION_TIME_SECONDS,
+ end: MAX_SECTION_TIME_SECONDS + 1
+ })
+ )
+ ).toBeNull();
+ });
+
+ it("returns null for a non-object song root", () => {
+ expect(resolveFirstOpenComment(null as never)).toBeNull();
+ });
+
+ it("returns null when the runtime section collection is sparse", () => {
+ const song = withOpenComment();
+ const sparseSections: typeof song.sections = new Array(2);
+ sparseSections[1] = song.sections[0]!;
+ song.sections = sparseSections;
+ expect(resolveFirstOpenComment(song)).toBeNull();
+ });
+
+ it("keeps the note section-wide when role identities are duplicated", () => {
+ const song = withOpenComment();
+ const role = song.sections[0]!.roles[0]!;
+ song.sections[0]!.roles = [role, { ...role }];
+ song.sections[0]!.partGraph = [
+ { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] },
+ { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }
+ ];
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.section.id).toBe("verse-note");
+ expect(resolved?.holdingRole).toBeNull();
+ });
+
+ it("bounds the comment body to 180 Unicode code points", () => {
+ const song = withOpenComment({ body: `${"a".repeat(200)}` });
+ const resolved = resolveFirstOpenComment(song);
+ expect(resolved?.hint.length).toBe(180);
+ });
+
+ it("does not split a Unicode surrogate pair at the hint boundary", () => {
+ const song = withOpenComment({ body: `${"a".repeat(179)}😀tail` });
+ const resolved = resolveFirstOpenComment(song);
+ expect(Array.from(resolved?.hint ?? "")).toHaveLength(180);
+ expect(resolved?.hint.endsWith("😀")).toBe(true);
+ });
+
+ it("skips a comment whose author is empty", () => {
+ expect(resolveFirstOpenComment(withOpenComment({ author: " " }))).toBeNull();
+ });
+
+ it("contains throws from untrusted runtime property access", () => {
+ const song = withOpenComment();
+ const hostile = new Proxy(song, {
+ get(target, prop, receiver) {
+ if (prop === "collaboration") {
+ throw new Error("hostile collaboration");
+ }
+ return Reflect.get(target, prop, receiver);
+ }
+ });
+ expect(resolveFirstOpenComment(hostile as typeof song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstOpenComment.ts b/apps/desktop/src/features/workspace/firstOpenComment.ts
new file mode 100644
index 000000000..30b226685
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstOpenComment.ts
@@ -0,0 +1,320 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ type RehearsalComment,
+ type RehearsalRole,
+ type RehearsalSection,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+
+const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const;
+const MAX_OPEN_COMMENT_CHARACTERS = 180;
+const MAX_OPEN_COMMENT_AUTHOR_CHARACTERS = 80;
+
+/** Tonight's first open rehearsal comment: the earliest owned note and the part it names. */
+export type FirstOpenComment = {
+ section: RehearsalSection;
+ holdingRole: RehearsalRole | null;
+ comment: RehearsalComment;
+ atSeconds: number;
+ author: string;
+ hint: string;
+};
+
+/** Format a non-negative comment time as m:ss for rehearsal copy. */
+export function formatOpenCommentTime(totalSeconds: number): string {
+ const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0;
+ const minutes = Math.floor(safeSeconds / 60);
+ const seconds = Math.floor(safeSeconds % 60)
+ .toString()
+ .padStart(2, "0");
+ return `${minutes}:${seconds}`;
+}
+
+/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */
+function compareStableId(left: string, right: string): number {
+ if (left < right) {
+ return -1;
+ }
+ if (left > right) {
+ return 1;
+ }
+ return 0;
+}
+
+/** Return whether an untrusted runtime value can be inspected as a record. */
+function isRuntimeObject(value: unknown): value is object {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */
+function hasOwnData(value: object, key: PropertyKey): boolean {
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value");
+}
+
+/** Return whether every numeric index is an own data element in a bounded runtime array. */
+function isDenseRuntimeArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ const length = Number(value.length);
+ if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
+ return false;
+ }
+ for (let index = 0; index < length; index += 1) {
+ if (!hasOwnData(value, index)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */
+function truncateCodePoints(value: string, maximum: number): string {
+ let codePoints = 0;
+ let endIndex = 0;
+ for (const character of value) {
+ if (codePoints >= maximum) {
+ break;
+ }
+ endIndex += character.length;
+ codePoints += 1;
+ }
+ return endIndex === value.length ? value : value.slice(0, endIndex);
+}
+
+/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */
+function hasRankedPriority(role: RehearsalRole): boolean {
+ return (
+ hasOwnData(role, "id") &&
+ typeof role.id === "string" &&
+ role.id.trim().length > 0 &&
+ hasOwnData(role, "name") &&
+ typeof role.name === "string" &&
+ role.name.trim().length > 0 &&
+ hasOwnData(role, "rehearsalPriority") &&
+ Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority)
+ );
+}
+
+/** Return whether a section owns a bounded, positive-length integer rehearsal window. */
+function hasBoundedTimeRange(section: RehearsalSection): boolean {
+ if (!hasOwnData(section, "timeRange")) {
+ return false;
+ }
+ const timeRange = section.timeRange as Partial | null;
+ if (
+ !isRuntimeObject(timeRange) ||
+ !hasOwnData(timeRange, "start") ||
+ !hasOwnData(timeRange, "end")
+ ) {
+ return false;
+ }
+
+ const start = timeRange.start ?? -1;
+ const end = timeRange.end ?? -1;
+ return (
+ Number.isInteger(start) &&
+ start >= 0 &&
+ start <= MAX_SECTION_TIME_SECONDS &&
+ Number.isInteger(end) &&
+ end > start &&
+ end <= MAX_SECTION_TIME_SECONDS
+ );
+}
+
+/** Return safe identities that appear more than once in one section-local collection. */
+function repeatedIds(ids: string[]): Set {
+ const seen = new Set();
+ const repeated = new Set();
+ for (const id of ids) {
+ if (seen.has(id)) {
+ repeated.add(id);
+ } else {
+ seen.add(id);
+ }
+ }
+ return repeated;
+}
+
+/** Return ranked roles whose unique graph node is explicitly active. */
+function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] {
+ if (
+ !hasOwnData(section, "roles") ||
+ !hasOwnData(section, "partGraph") ||
+ !isDenseRuntimeArray(section.roles) ||
+ !isDenseRuntimeArray(section.partGraph)
+ ) {
+ return [];
+ }
+
+ const safeRoleIds = section.roles
+ .filter(
+ (role) =>
+ isRuntimeObject(role) &&
+ hasOwnData(role, "id") &&
+ typeof role.id === "string" &&
+ role.id.trim().length > 0
+ )
+ .map((role) => role.id);
+ const safeGraphRoleIds = section.partGraph
+ .filter(
+ (node) =>
+ isRuntimeObject(node) &&
+ hasOwnData(node, "role_id") &&
+ typeof node.role_id === "string" &&
+ node.role_id.trim().length > 0
+ )
+ .map((node) => node.role_id);
+ const repeatedRoleIds = repeatedIds(safeRoleIds);
+ const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds);
+ const activeIds = new Set(
+ section.partGraph
+ .filter(
+ (node) =>
+ isRuntimeObject(node) &&
+ hasOwnData(node, "is_active") &&
+ node.is_active === true &&
+ hasOwnData(node, "role_id") &&
+ typeof node.role_id === "string" &&
+ node.role_id.trim().length > 0 &&
+ !repeatedGraphRoleIds.has(node.role_id)
+ )
+ .map((node) => node.role_id)
+ );
+
+ return section.roles.filter(
+ (role) =>
+ isRuntimeObject(role) &&
+ hasRankedPriority(role) &&
+ !repeatedRoleIds.has(role.id) &&
+ activeIds.has(role.id)
+ );
+}
+
+/** Return a bounded owned open-comment body, or null when the field cannot be shown. */
+function ownedOpenCommentHint(comment: RehearsalComment): string | null {
+ if (
+ !hasOwnData(comment, "status") ||
+ comment.status !== "open" ||
+ !hasOwnData(comment, "body") ||
+ typeof comment.body !== "string"
+ ) {
+ return null;
+ }
+ const hint = comment.body.trim();
+ if (hint.length === 0) {
+ return null;
+ }
+ return truncateCodePoints(hint, MAX_OPEN_COMMENT_CHARACTERS);
+}
+
+/** Return a bounded owned author name, or null when the field cannot be shown. */
+function ownedOpenCommentAuthor(comment: RehearsalComment): string | null {
+ if (!hasOwnData(comment, "author") || typeof comment.author !== "string") {
+ return null;
+ }
+ const author = comment.author.trim();
+ if (author.length === 0) {
+ return null;
+ }
+ return truncateCodePoints(author, MAX_OPEN_COMMENT_AUTHOR_CHARACTERS);
+}
+
+/** Return whether a comment owns the identity fields required to stay on tonight's map. */
+function hasOwnedCommentIdentity(comment: RehearsalComment): boolean {
+ return (
+ hasOwnData(comment, "id") &&
+ typeof comment.id === "string" &&
+ comment.id.trim().length > 0 &&
+ hasOwnData(comment, "sectionId") &&
+ typeof comment.sectionId === "string" &&
+ comment.sectionId.trim().length > 0
+ );
+}
+
+/** Return the matching owned section for a comment, or null when the map target is unsafe. */
+function sectionForComment(song: RehearsalSong, sectionId: string): RehearsalSection | null {
+ if (!hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) {
+ return null;
+ }
+ const matches = song.sections.filter(
+ (section) =>
+ isRuntimeObject(section) &&
+ hasOwnData(section, "id") &&
+ typeof section.id === "string" &&
+ section.id === sectionId &&
+ hasOwnData(section, "label") &&
+ typeof section.label === "string" &&
+ section.label.trim().length > 0 &&
+ hasBoundedTimeRange(section)
+ );
+ if (matches.length !== 1) {
+ return null;
+ }
+ return matches[0] ?? null;
+}
+
+/** Return the corroborated holding part, or null when the comment stays section-wide. */
+function holdingRoleForComment(section: RehearsalSection, comment: RehearsalComment): RehearsalRole | null {
+ if (!hasOwnData(comment, "roleId") || typeof comment.roleId !== "string" || comment.roleId.trim().length === 0) {
+ return null;
+ }
+ const matches = rankedActiveRoles(section).filter((role) => role.id === comment.roleId);
+ return matches.length === 1 ? (matches[0] ?? null) : null;
+}
+
+/** Resolve an open comment after the runtime root has passed its structural boundary checks. */
+function resolveSafeFirstOpenComment(song: RehearsalSong): FirstOpenComment | null {
+ if (
+ !isRuntimeObject(song) ||
+ !hasOwnData(song, "collaboration") ||
+ !isRuntimeObject(song.collaboration) ||
+ !hasOwnData(song.collaboration, "comments") ||
+ !isDenseRuntimeArray(song.collaboration.comments)
+ ) {
+ return null;
+ }
+
+ const candidates: FirstOpenComment[] = [];
+ for (const comment of song.collaboration.comments) {
+ if (!isRuntimeObject(comment) || !hasOwnedCommentIdentity(comment)) {
+ continue;
+ }
+ const hint = ownedOpenCommentHint(comment);
+ const author = ownedOpenCommentAuthor(comment);
+ if (hint === null || author === null) {
+ continue;
+ }
+ const section = sectionForComment(song, comment.sectionId);
+ if (!section) {
+ continue;
+ }
+ candidates.push({
+ section,
+ holdingRole: holdingRoleForComment(section, comment),
+ comment,
+ atSeconds: section.timeRange.start,
+ author,
+ hint
+ });
+ }
+
+ candidates.sort((left, right) => {
+ if (left.atSeconds !== right.atSeconds) {
+ return left.atSeconds - right.atSeconds;
+ }
+ return compareStableId(left.comment.id, right.comment.id);
+ });
+
+ return candidates[0] ?? null;
+}
+
+/** Return the first open rehearsal comment, or null when untrusted runtime metadata cannot be read safely. */
+export function resolveFirstOpenComment(song: RehearsalSong): FirstOpenComment | null {
+ try {
+ return resolveSafeFirstOpenComment(song);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..f103e40c7 100644
--- a/apps/desktop/src/i18n/index.test.ts
+++ b/apps/desktop/src/i18n/index.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { createTranslator, detectPreferredLocale } from "./index";
+import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index";
import koCommon from "../locales/ko/common.json";
describe("i18n", () => {
@@ -75,4 +75,51 @@ describe("i18n", () => {
}
});
});
+
+ describe("translateSectionFormLabel", () => {
+ it("localizes every supported section form label for Korean rehearsal copy", () => {
+ expect(
+ [
+ "intro",
+ "verse",
+ "pre-chorus",
+ "chorus",
+ "bridge",
+ "outro",
+ "tag",
+ "pickup",
+ "stop",
+ "handoff"
+ ].map((label) => translateSectionFormLabel("ko", label as never))
+ ).toEqual([
+ "인트로",
+ "벌스",
+ "프리코러스",
+ "코러스",
+ "브리지",
+ "아웃트로",
+ "태그",
+ "픽업",
+ "스톱",
+ "핸드오프"
+ ]);
+ });
+
+ it("preserves every supported English section form label", () => {
+ expect(translateSectionFormLabel("en", "verse")).toBe("verse");
+ expect(translateSectionFormLabel("en", "outro")).toBe("outro");
+ });
+
+ it("does not treat inherited object keys as localized section labels", () => {
+ const inheritedKey = "toString" as never;
+ expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString");
+ });
+
+ it("keeps Korean first-open-comment next-action copy particle-safe", () => {
+ const t = createTranslator("ko");
+ expect(t("firstOpenCommentOpenAction")).toBe("{at} {role} 위치 열기");
+ expect(t("firstOpenCommentBody")).toBe("{author}님이 {at} {section}에서 {role} 파트에 메모를 남겼습니다.");
+ expect(t("firstOpenCommentArmed")).toBe("{at}에서 {role} 파트 메모를 확인하세요. 그 파트를 보고 있으세요.");
+ });
+ });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..352eff65e 100644
--- a/apps/desktop/src/i18n/index.ts
+++ b/apps/desktop/src/i18n/index.ts
@@ -1,3 +1,4 @@
+import type { SectionFormLabel } from "@bandscope/shared-types";
import enCommon from "../locales/en/common.json";
import koCommon from "../locales/ko/common.json";
@@ -11,13 +12,46 @@ const dictionaries = {
ko: koCommon
} as const;
-/** Documented. */
+const sectionFormLabels: Readonly>>> = {
+ en: {
+ intro: "intro",
+ verse: "verse",
+ "pre-chorus": "pre-chorus",
+ chorus: "chorus",
+ bridge: "bridge",
+ outro: "outro",
+ tag: "tag",
+ pickup: "pickup",
+ stop: "stop",
+ handoff: "handoff"
+ },
+ ko: {
+ intro: "인트로",
+ verse: "벌스",
+ "pre-chorus": "프리코러스",
+ chorus: "코러스",
+ bridge: "브리지",
+ outro: "아웃트로",
+ tag: "태그",
+ pickup: "픽업",
+ stop: "스톱",
+ handoff: "핸드오프"
+ }
+};
+
+/** Create a locale-aware translation lookup that falls back to English copy. */
export function createTranslator(locale: Locale = "en") {
return function t(key: TranslationKey): string {
return dictionaries[locale][key] ?? dictionaries.en[key];
};
}
+/** Return the localized display label for a supported rehearsal section form. */
+export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string {
+ const labels = sectionFormLabels[locale] as Readonly>;
+ return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label);
+}
+
/** Documented. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..4ba6733cc 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -149,6 +149,14 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
+ "firstOpenCommentLabel": "Tonight's first note",
+ "firstOpenCommentOpenAction": "Open {role} at {at}",
+ "firstOpenCommentOpenActionBand": "Open the first note at {at}",
+ "firstOpenCommentBody": "{author} left a note for {role} in the {section} at {at}.",
+ "firstOpenCommentBodyBand": "{author} left a note in the {section} at {at}.",
+ "firstOpenCommentArmed": "Read {author}'s note with {role} at {at}. Keep that part in view.",
+ "firstOpenCommentArmedBand": "Read {author}'s note at {at}. Keep that section in view.",
+ "firstOpenCommentUnavailable": "No open note yet. Stay on tonight's map until someone leaves a rehearsal comment.",
"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..d4b5a2793 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,14 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstOpenCommentLabel": "오늘 첫 메모",
+ "firstOpenCommentOpenAction": "{at} {role} 위치 열기",
+ "firstOpenCommentOpenActionBand": "{at} 첫 메모 위치 열기",
+ "firstOpenCommentBody": "{author}님이 {at} {section}에서 {role} 파트에 메모를 남겼습니다.",
+ "firstOpenCommentBodyBand": "{author}님이 {at} {section}에 메모를 남겼습니다.",
+ "firstOpenCommentArmed": "{at}에서 {role} 파트 메모를 확인하세요. 그 파트를 보고 있으세요.",
+ "firstOpenCommentArmedBand": "{at}에서 메모를 확인하세요. 그 섹션을 보고 있으세요.",
+ "firstOpenCommentUnavailable": "아직 열린 메모가 없습니다. 누군가 합주 메모를 남길 때까지 오늘 지도에 머무르세요.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts
index f1db6f2b8..778f5b403 100644
--- a/apps/desktop/vite.config.ts
+++ b/apps/desktop/vite.config.ts
@@ -25,7 +25,9 @@ export default defineConfig({
"src/i18n/index.ts",
"src/features/score/ScoreViewer.tsx",
"src/features/score/ScoreView.tsx",
- "src/features/score/scoreStorage.ts"
+ "src/features/score/scoreStorage.ts",
+ "src/features/workspace/firstOpenComment.ts",
+ "src/features/workspace/FirstOpenCommentCallout.tsx"
],
thresholds: {
lines: 90,
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..6f17ee3d5 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 Open Comment Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstOpenCommentCallout.tsx` | Name the author, the corroborated holding part, the labeled section start, and the time for the earliest owned `status === "open"` rehearsal comment. Do not invent a note from resolved comments, assignments, approvals, cues, groove, setup, simplification, overlap, range copy, or empty/whitespace bodies. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-lyric (#913), first-count (#995), first-transition (#994), and ready-board (#901). |
| 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-open-comment-navigation.md b/docs/doctoring/reduced-motion-first-open-comment-navigation.md
new file mode 100644
index 000000000..b04e4b5ee
--- /dev/null
+++ b/docs/doctoring/reduced-motion-first-open-comment-navigation.md
@@ -0,0 +1,14 @@
+# Reduced-motion first-open-comment navigation
+
+Workspace map navigation for tonight's first open rehearsal comment follows the operating-system reduced-motion preference.
+
+When `prefers-reduced-motion: reduce` matches, `FirstOpenCommentCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`.
+
+This is a presentation contract only. Open-comment resolution and analysis-id isolation stay unchanged.
+
+## Security Notes
+
+- Untrusted input: song, collaboration, comment, section, time-range, role, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority.
+- Trust boundary: comment resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned open-comment body is rendered as a text node and is never rescanned as template syntax. Resolved comments, assignments, approvals, cues, groove, setup, simplification, overlap, and range copy cannot invent an open rehearsal note.
+- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the comment body is bounded to 180 Unicode code points.
+- Test points: inherited song/section/timing/role/graph/comment metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.