(null);
+
+ useEffect(() => {
+ setOpenedApproval(null);
+ }, [
+ songIdentity,
+ approvalSectionIndex,
+ approval?.section?.id,
+ approval?.approval.id,
+ approval?.atSeconds
+ ]);
+
+ if (!approval) {
+ return (
+
+ {t("firstApprovalLabel")}
+ {t("firstApprovalUnavailable")}
+
+ );
+ }
+
+ const hasSection = approval.section !== null && approval.atSeconds !== null;
+ const opened =
+ openedApproval !== null &&
+ openedApproval.songIdentity === songIdentity &&
+ openedApproval.sectionId === (approval.section?.id ?? null) &&
+ openedApproval.sectionIndex === approvalSectionIndex &&
+ openedApproval.approvalId === approval.approval.id &&
+ openedApproval.atSeconds === approval.atSeconds;
+ const at = hasSection ? formatApprovalTime(approval.atSeconds ?? 0) : "";
+ const copyValues: ApprovalCopyValues = {
+ owner: approval.approval.owner,
+ scope: approval.scope,
+ section: approval.section ? translateSectionFormLabel(locale, approval.section.label) : "",
+ at
+ };
+ const isChangesRequested = approval.approval.status === "changes_requested";
+ const bodyKey = hasSection
+ ? isChangesRequested
+ ? "firstApprovalBodyChanges"
+ : "firstApprovalBody"
+ : isChangesRequested
+ ? "firstApprovalBodyChangesBand"
+ : "firstApprovalBodyBand";
+ const armedKey = hasSection ? "firstApprovalArmed" : "firstApprovalArmedBand";
+ const body = formatApprovalCopy(t(bodyKey), copyValues);
+ const armed = formatApprovalCopy(t(armedKey), copyValues);
+ const actionLabel = hasSection
+ ? formatApprovalCopy(t("firstApprovalOpenAction"), copyValues)
+ : "";
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx
new file mode 100644
index 000000000..750822e7c
--- /dev/null
+++ b/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx
@@ -0,0 +1,52 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it, vi } from "vitest";
+import { FirstApprovalCallout } from "./FirstApprovalCallout";
+
+describe("FirstApprovalCallout 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 verse approval 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..c37b4bdae 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -137,7 +137,7 @@ describe("Workspace", () => {
expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy();
expect(screen.getByText(/whole step lower/i)).toBeTruthy();
expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy();
- expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy();
+ expect(screen.getAllByText(/Verse harmony pass/i).length).toBeGreaterThan(0);
});
it("names tonight's first playable range and the next instrument check", () => {
@@ -326,4 +326,15 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
+
+ it("names tonight's first pending approval on the mounted map", () => {
+ setNavigatorLanguage("en-US");
+ render( );
+
+ expect(screen.getByText("Tonight's first approval")).toBeTruthy();
+ expect(
+ screen.getByText("MD still needs to sign off on Verse harmony pass in the verse at 0:10.")
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Open verse approval at 0:10" })).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..398ff4e73 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 { FirstApprovalCallout } from "./FirstApprovalCallout";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
@@ -353,6 +354,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+
+
diff --git a/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts
new file mode 100644
index 000000000..5473d65af
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts
@@ -0,0 +1,107 @@
+import { createDemoRehearsalSong, type RehearsalApproval } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { resolveFirstApproval } from "./firstApproval";
+
+function songWithApproval() {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "approve-own";
+ song.sections = [section];
+ song.collaboration = {
+ syncMode: "local_only",
+ syncNote: "Keep approvals local for now.",
+ assignments: [],
+ comments: [],
+ approvals: [
+ {
+ id: "approval-harmony-pass",
+ scope: "Verse harmony pass",
+ owner: "MD",
+ status: "pending"
+ }
+ ]
+ };
+ return { song, section };
+}
+
+describe("resolveFirstApproval inherited metadata", () => {
+ it("rejects a song or collaboration whose required metadata is inherited", () => {
+ const { song } = songWithApproval();
+ const inheritedSong = Object.create({ collaboration: song.collaboration, sections: song.sections }) as typeof song;
+ expect(resolveFirstApproval(inheritedSong)).toBeNull();
+
+ const inheritedCollaboration = Object.create(song.collaboration!) as NonNullable;
+ song.collaboration = inheritedCollaboration;
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("rejects inherited approval fields", () => {
+ const { song } = songWithApproval();
+ song.collaboration!.approvals = [
+ Object.create(song.collaboration!.approvals[0]!) as (typeof song.collaboration.approvals)[number]
+ ];
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("rejects inherited timing fields when a unique section is required", () => {
+ const { song, section } = songWithApproval();
+ section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange;
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-harmony-pass");
+ expect(resolved?.section).toBeNull();
+ expect(resolved?.atSeconds).toBeNull();
+ });
+
+ it("contains exceptions from own runtime accessors instead of trusting them", () => {
+ const { song } = songWithApproval();
+ Object.defineProperty(song.collaboration!.approvals[0]!, "scope", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ throw new Error("hostile scope getter");
+ }
+ });
+
+ expect(() => resolveFirstApproval(song)).not.toThrow();
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("does not treat own accessors as stable approval identity authority", () => {
+ const { song } = songWithApproval();
+ Object.defineProperty(song.collaboration!.approvals[0]!, "id", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return "approval-harmony-pass";
+ }
+ });
+
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("does not let inherited section metadata host the approval", () => {
+ const { song, section } = songWithApproval();
+ const inheritedSection = Object.create(section) as typeof section;
+ song.sections = [inheritedSection];
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-harmony-pass");
+ expect(resolved?.section).toBeNull();
+ });
+
+ it("rejects arrays masquerading as section records", () => {
+ const { song, section } = songWithApproval();
+ const arraySection = Object.assign([], section) as unknown as typeof section;
+ song.sections = [arraySection];
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-harmony-pass");
+ expect(resolved?.section).toBeNull();
+ });
+
+ it("rejects sparse approval arrays", () => {
+ const { song } = songWithApproval();
+ const sparse: RehearsalApproval[] = [];
+ sparse[1] = song.collaboration!.approvals[0]!;
+ song.collaboration!.approvals = sparse;
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstApproval.test.ts b/apps/desktop/src/features/workspace/firstApproval.test.ts
new file mode 100644
index 000000000..02d4ca371
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstApproval.test.ts
@@ -0,0 +1,255 @@
+import { describe, expect, it } from "vitest";
+import {
+ createDemoRehearsalSong,
+ type RehearsalApproval,
+ type SectionFormLabel
+} from "@bandscope/shared-types";
+import { formatApprovalTime, resolveFirstApproval } from "./firstApproval";
+
+function withApproval(
+ overrides: {
+ approvalId?: string;
+ scope?: string;
+ owner?: string;
+ status?: RehearsalApproval["status"];
+ sectionId?: string;
+ start?: number;
+ end?: number;
+ label?: SectionFormLabel;
+ } = {}
+) {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const section = structuredClone(verse);
+ section.id = overrides.sectionId ?? "verse-approve";
+ section.label = overrides.label ?? "verse";
+ section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 };
+ song.sections = [section];
+ song.collaboration = {
+ syncMode: "local_only",
+ syncNote: "Keep approvals local for now.",
+ assignments: [],
+ comments: [],
+ approvals: [
+ {
+ id: overrides.approvalId ?? "approval-harmony-pass",
+ scope: overrides.scope ?? "Verse harmony pass",
+ owner: overrides.owner ?? "MD",
+ status: overrides.status ?? "pending"
+ }
+ ]
+ };
+ return song;
+}
+
+describe("resolveFirstApproval", () => {
+ it("picks the demo song's pending verse harmony sign-off", () => {
+ const resolved = resolveFirstApproval(createDemoRehearsalSong());
+ expect(resolved?.approval.id).toBe("approval-harmony-pass");
+ expect(resolved?.approval.owner).toBe("MD");
+ expect(resolved?.scope).toBe("Verse harmony pass");
+ expect(resolved?.section?.id).toBe("verse-1");
+ expect(resolved?.atSeconds).toBe(10);
+ expect(formatApprovalTime(resolved?.atSeconds ?? -1)).toBe("0:10");
+ expect(formatApprovalTime(Number.NaN)).toBe("0:00");
+ expect(formatApprovalTime(-4)).toBe("0:00");
+ });
+
+ it("does not invent an approval from assignments, comments, approved scopes, or empty scope", () => {
+ const song = withApproval({ scope: " " });
+ 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: "bass-guitar",
+ status: "in_progress"
+ }
+ ];
+ song.collaboration!.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: song.sections[0]!.id,
+ roleId: "keys-right",
+ status: "open"
+ }
+ ];
+ song.collaboration!.approvals = [
+ {
+ id: "approval-vocal-shape",
+ scope: "Lead vocal simplification",
+ owner: "Lead Vocal",
+ status: "approved"
+ }
+ ];
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("does not treat an empty or whitespace scope as a named approval", () => {
+ expect(resolveFirstApproval(withApproval({ scope: "" }))).toBeNull();
+ expect(resolveFirstApproval(withApproval({ scope: " \n\t " }))).toBeNull();
+ });
+
+ it("skips already-approved scopes instead of treating them as tonight's next action", () => {
+ expect(resolveFirstApproval(withApproval({ status: "approved" }))).toBeNull();
+ });
+
+ it("prefers a changes-requested approval over an earlier pending one", () => {
+ const song = withApproval({
+ approvalId: "approval-late",
+ start: 40,
+ end: 56,
+ label: "chorus",
+ status: "changes_requested",
+ scope: "Chorus lift pass"
+ });
+ const earlier = structuredClone(song.sections[0]!);
+ earlier.id = "verse-early";
+ earlier.label = "verse";
+ earlier.timeRange = { start: 8, end: 24 };
+ song.sections = [song.sections[0]!, earlier];
+ song.collaboration!.approvals = [
+ song.collaboration!.approvals[0]!,
+ {
+ id: "approval-early-pending",
+ scope: "Verse harmony pass",
+ owner: "Lead Vocal",
+ status: "pending"
+ }
+ ];
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-late");
+ expect(resolved?.atSeconds).toBe(40);
+ });
+
+ it("prefers the earlier of two pending approvals", () => {
+ const song = withApproval({
+ approvalId: "approval-late",
+ start: 40,
+ end: 56,
+ label: "chorus",
+ scope: "Chorus lift pass"
+ });
+ const earlier = structuredClone(song.sections[0]!);
+ earlier.id = "verse-early";
+ earlier.label = "verse";
+ earlier.timeRange = { start: 8, end: 24 };
+ song.sections = [song.sections[0]!, earlier];
+ song.collaboration!.approvals = [
+ song.collaboration!.approvals[0]!,
+ {
+ id: "approval-early",
+ scope: "Verse harmony pass",
+ owner: "Lead Vocal",
+ status: "pending"
+ }
+ ];
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-early");
+ expect(resolved?.atSeconds).toBe(8);
+ });
+
+ it("does not invent a section from chorus when the scope names pre-chorus", () => {
+ const song = withApproval({
+ scope: "Pre-chorus lift pass",
+ label: "pre-chorus",
+ start: 24,
+ end: 32
+ });
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 56 };
+ song.sections = [song.sections[0]!, chorus];
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.section?.id).toBe("verse-approve");
+ expect(resolved?.section?.label).toBe("pre-chorus");
+ expect(resolved?.atSeconds).toBe(24);
+ });
+
+ it("treats a space as a token separator so 'pre chorus' names the pre-chorus section", () => {
+ const song = withApproval({
+ scope: "pre chorus lift pass",
+ label: "pre-chorus",
+ start: 24,
+ end: 32
+ });
+ const chorus = structuredClone(song.sections[0]!);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 40, end: 56 };
+ song.sections = [song.sections[0]!, chorus];
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.section?.id).toBe("verse-approve");
+ expect(resolved?.section?.label).toBe("pre-chorus");
+ });
+
+ it("keeps the approval band-wide when two verse sections share the named form", () => {
+ const song = withApproval({ scope: "Verse harmony pass" });
+ const second = structuredClone(song.sections[0]!);
+ second.id = "verse-2";
+ second.timeRange = { start: 40, end: 56 };
+ song.sections = [song.sections[0]!, second];
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.approval.id).toBe("approval-harmony-pass");
+ expect(resolved?.section).toBeNull();
+ expect(resolved?.atSeconds).toBeNull();
+ });
+
+ it("does not invent a section from assignments, comments, or Korean scope tokens", () => {
+ const song = withApproval({ scope: "벌스 화성 패스" });
+ expect(resolveFirstApproval(song)?.section).toBeNull();
+ expect(resolveFirstApproval(song)?.scope).toBe("벌스 화성 패스");
+ });
+
+ it("bounds a long owned scope without splitting a surrogate pair", () => {
+ const song = withApproval({
+ scope: `${"a".repeat(179)}\uD83D\uDE80trailing`
+ });
+ expect(resolveFirstApproval(song)?.scope).toBe(`${"a".repeat(179)}\uD83D\uDE80`);
+ });
+
+ it("ties equal pending times with a stable id", () => {
+ const song = withApproval({ approvalId: "z-late", scope: "Verse later pass" });
+ song.collaboration!.approvals = [
+ song.collaboration!.approvals[0]!,
+ {
+ id: "a-early",
+ scope: "Verse earlier pass",
+ owner: "MD",
+ status: "pending"
+ }
+ ];
+ expect(resolveFirstApproval(song)?.approval.id).toBe("a-early");
+ });
+
+ it("drops a duplicated approval id even when only one side stays actionable", () => {
+ const song = withApproval({ approvalId: "dup-1", scope: "Verse harmony pass" });
+ song.collaboration!.approvals = [
+ song.collaboration!.approvals[0]!,
+ { ...song.collaboration!.approvals[0]!, status: "approved" }
+ ];
+
+ expect(resolveFirstApproval(song)).toBeNull();
+ });
+
+ it("matches labels on the full scope and bounds only the reported copy", () => {
+ const song = withApproval({
+ // 179 chars then " verseline": truncation before matching would
+ // manufacture a "verse" token boundary that never existed.
+ scope: `${"a".repeat(174)} verseline`
+ });
+
+ const resolved = resolveFirstApproval(song);
+ expect(resolved?.section).toBeNull();
+ expect(Array.from(resolved?.scope ?? "")).toHaveLength(180);
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstApproval.ts b/apps/desktop/src/features/workspace/firstApproval.ts
new file mode 100644
index 000000000..40b14cb98
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstApproval.ts
@@ -0,0 +1,296 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ SECTION_FORM_LABELS,
+ type CollaborationApprovalStatus,
+ type RehearsalApproval,
+ type RehearsalSection,
+ type RehearsalSong,
+ type SectionFormLabel
+} from "@bandscope/shared-types";
+
+const ACTIONABLE_STATUS_RANK = { changes_requested: 0, pending: 1 } as const;
+const FORM_LABELS_BY_LENGTH = [...SECTION_FORM_LABELS].sort((left, right) => right.length - left.length);
+
+/** Tonight's first named approval: the earliest owned sign-off and the unique section it names. */
+export type FirstApproval = {
+ section: RehearsalSection | null;
+ approval: RehearsalApproval;
+ atSeconds: number | null;
+ scope: string;
+};
+
+/** Format a non-negative approval time as m:ss for rehearsal copy. */
+export function formatApprovalTime(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;
+}
+
+const MAX_APPROVAL_SCOPE_CHARACTERS = 180;
+
+/** 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 the approval's own trimmed scope for label matching, or null when unusable. */
+function ownedApprovalScope(approval: RehearsalApproval): string | null {
+ if (!hasOwnData(approval, "scope") || typeof approval.scope !== "string") {
+ return null;
+ }
+ const scope = approval.scope.trim();
+ if (scope.length === 0) {
+ return null;
+ }
+ return scope;
+}
+
+/** Return true when the approval owns identity, owner, status, and a named scope. */
+function isActionableApproval(approval: RehearsalApproval): boolean {
+ return (
+ isRuntimeObject(approval) &&
+ hasOwnData(approval, "id") &&
+ typeof approval.id === "string" &&
+ approval.id.trim().length > 0 &&
+ hasOwnData(approval, "owner") &&
+ typeof approval.owner === "string" &&
+ approval.owner.trim().length > 0 &&
+ hasOwnData(approval, "status") &&
+ Object.prototype.hasOwnProperty.call(ACTIONABLE_STATUS_RANK, approval.status) &&
+ ownedApprovalScope(approval) !== null
+ );
+}
+
+/** 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
+ );
+}
+
+/** Map unique owned ids onto their records; duplicated ids are not authority. */
+function uniqueOwnedById(
+ items: T[],
+ readId: (item: T) => string | null
+): Map {
+ const unique = new Map();
+ const repeated = new Set();
+ for (const item of items) {
+ if (!isRuntimeObject(item)) {
+ continue;
+ }
+ const id = readId(item);
+ if (id === null || repeated.has(id)) {
+ continue;
+ }
+ if (unique.has(id)) {
+ unique.delete(id);
+ repeated.add(id);
+ continue;
+ }
+ unique.set(id, item);
+ }
+ return unique;
+}
+
+/** Collect canonical form labels that appear as whole tokens in an owned scope. */
+function matchedFormLabels(scope: string): Set {
+ // Hyphens and whitespace are equivalent token separators, so free-text
+ // scopes like "pre chorus" still reach the canonical "pre-chorus" label.
+ const normalized = scope.toLowerCase().replace(/[-\s]+/g, " ");
+ const occupied = Array.from({ length: normalized.length }, () => false);
+ const matched = new Set();
+
+ for (const label of FORM_LABELS_BY_LENGTH) {
+ const key = label.replace(/-/g, " ");
+ let from = 0;
+ while (from <= normalized.length - key.length) {
+ const index = normalized.indexOf(key, from);
+ if (index === -1) {
+ break;
+ }
+ const beforeOk = index === 0 || /[^a-z]/.test(normalized[index - 1] ?? "");
+ const afterIndex = index + key.length;
+ const afterOk = afterIndex === normalized.length || /[^a-z]/.test(normalized[afterIndex] ?? "");
+ let alreadyOccupied = false;
+ for (let cursor = index; cursor < afterIndex; cursor += 1) {
+ if (occupied[cursor]) {
+ alreadyOccupied = true;
+ break;
+ }
+ }
+ if (beforeOk && afterOk && !alreadyOccupied) {
+ matched.add(label);
+ for (let cursor = index; cursor < afterIndex; cursor += 1) {
+ occupied[cursor] = true;
+ }
+ }
+ from = index + 1;
+ }
+ }
+
+ return matched;
+}
+
+/** Return owned sections that can host an approval. */
+function uniqueReadySections(song: RehearsalSong): Map {
+ if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) {
+ return new Map();
+ }
+
+ return uniqueOwnedById(
+ song.sections.filter(
+ (section) =>
+ isRuntimeObject(section) &&
+ hasOwnData(section, "label") &&
+ typeof section.label === "string" &&
+ section.label.trim().length > 0 &&
+ hasOwnData(section, "id") &&
+ typeof section.id === "string" &&
+ section.id.trim().length > 0 &&
+ hasBoundedTimeRange(section)
+ ),
+ (section) => (hasOwnData(section, "id") && typeof section.id === "string" ? section.id : null)
+ );
+}
+
+/** Return the unique ready section named by the owned scope, or null when the pointer is ambiguous. */
+function resolveNamedSection(scope: string, sections: Map): RehearsalSection | null {
+ const labels = matchedFormLabels(scope);
+ if (labels.size !== 1) {
+ return null;
+ }
+ const named = [...sections.values()].filter(
+ (section) => hasOwnData(section, "label") && labels.has(section.label)
+ );
+ return named.length === 1 ? (named[0] ?? null) : null;
+}
+
+/** Resolve an approval after the runtime root has passed its structural boundary checks. */
+function resolveSafeFirstApproval(song: RehearsalSong): FirstApproval | null {
+ if (
+ !isRuntimeObject(song) ||
+ !hasOwnData(song, "collaboration") ||
+ !isRuntimeObject(song.collaboration) ||
+ !hasOwnData(song.collaboration, "approvals") ||
+ !isDenseRuntimeArray(song.collaboration.approvals)
+ ) {
+ return null;
+ }
+
+ const sections = uniqueReadySections(song);
+ // Deduplicate ids before status filtering: duplicated ids are not authority,
+ // so a duplicate pair must vanish even when only one side is actionable.
+ const uniqueApprovals = uniqueOwnedById(
+ song.collaboration.approvals.filter((approval) => isRuntimeObject(approval)),
+ (approval) => (hasOwnData(approval, "id") && typeof approval.id === "string" ? approval.id : null)
+ );
+
+ const candidates = [...uniqueApprovals.values()].filter((approval) => isActionableApproval(approval as RehearsalApproval))
+ .flatMap((approval) => {
+ const scope = ownedApprovalScope(approval);
+ if (scope === null) {
+ return [];
+ }
+ const section = resolveNamedSection(scope, sections);
+ return [
+ {
+ section,
+ approval,
+ atSeconds: section ? section.timeRange.start : null,
+ scope: truncateCodePoints(scope, MAX_APPROVAL_SCOPE_CHARACTERS)
+ }
+ ];
+ })
+ .sort((left, right) => {
+ const statusDelta =
+ ACTIONABLE_STATUS_RANK[left.approval.status as Exclude] -
+ ACTIONABLE_STATUS_RANK[right.approval.status as Exclude];
+ if (statusDelta !== 0) {
+ return statusDelta;
+ }
+ const leftTime = left.atSeconds ?? Number.POSITIVE_INFINITY;
+ const rightTime = right.atSeconds ?? Number.POSITIVE_INFINITY;
+ if (leftTime !== rightTime) {
+ return leftTime - rightTime;
+ }
+ return compareStableId(left.approval.id, right.approval.id);
+ });
+
+ return candidates[0] ?? null;
+}
+
+/** Return the first named approval, or null when untrusted runtime metadata cannot be read safely. */
+export function resolveFirstApproval(song: RehearsalSong): FirstApproval | null {
+ try {
+ return resolveSafeFirstApproval(song);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..47260c2cf 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,34 @@ describe("i18n", () => {
}
});
});
+
+ describe("translateSectionFormLabel", () => {
+ it("localizes Korean section form labels without treating inherited keys as labels", () => {
+ expect(translateSectionFormLabel("ko", "verse")).toBe("벌스");
+ expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스");
+ expect(translateSectionFormLabel("en", "verse")).toBe("verse");
+ const inheritedKey = "toString" as never;
+ expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString");
+ });
+
+ it("keeps Korean first-approval next-action copy particle-safe", () => {
+ const t = createTranslator("ko");
+ expect(t("firstApprovalOpenAction")).toBe("{at} {section} 승인 위치 열기");
+ expect(t("firstApprovalBody")).toBe("{owner}님이 {at} {section}의 {scope} 승인을 기다리고 있습니다.");
+ expect(t("firstApprovalArmed")).toBe("{at} {section}에서 {scope} 승인을 이어서 하세요.");
+ });
+
+ it("never attaches the Korean object particle directly to the free-text approval scope", () => {
+ const t = createTranslator("ko");
+ // The scope is owner-supplied free text whose final character can end in
+ // either a vowel or a consonant, so object particles attach to a fixed
+ // Korean noun instead of the interpolated scope.
+ expect(t("firstApprovalBodyChanges")).toBe(
+ "{owner}님이 {at} {section}의 {scope} 부분을 다시 봐 달라고 했습니다."
+ );
+ expect(t("firstApprovalBodyChangesBand")).toBe(
+ "{owner}님이 {scope} 부분을 다시 봐 달라고 했습니다."
+ );
+ });
+ });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..28d9bfd95 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,14 +12,47 @@ 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];
};
}
-/** Documented. */
+/** 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);
+}
+
+/** Detect Korean from the runtime navigator, otherwise English. */
export function detectPreferredLocale(): Locale {
if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) {
return "ko";
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..be127309d 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -149,6 +149,15 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
+ "firstApprovalLabel": "Tonight's first approval",
+ "firstApprovalOpenAction": "Open {section} approval at {at}",
+ "firstApprovalBody": "{owner} still needs to sign off on {scope} in the {section} at {at}.",
+ "firstApprovalBodyBand": "{owner} still needs to sign off on {scope}.",
+ "firstApprovalBodyChanges": "{owner} asked for another pass on {scope} in the {section} at {at}.",
+ "firstApprovalBodyChangesBand": "{owner} asked for another pass on {scope}.",
+ "firstApprovalArmed": "Keep the {section} approval moving at {at}. Sign it off together.",
+ "firstApprovalArmedBand": "Keep the approval moving. Sign it off together.",
+ "firstApprovalUnavailable": "No pending approval yet. Stay on tonight's map until a scope needs a sign-off.",
"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..881e3aa72 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,15 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstApprovalLabel": "오늘 첫 승인",
+ "firstApprovalOpenAction": "{at} {section} 승인 위치 열기",
+ "firstApprovalBody": "{owner}님이 {at} {section}의 {scope} 승인을 기다리고 있습니다.",
+ "firstApprovalBodyBand": "{owner}님이 {scope} 승인을 기다리고 있습니다.",
+ "firstApprovalBodyChanges": "{owner}님이 {at} {section}의 {scope} 부분을 다시 봐 달라고 했습니다.",
+ "firstApprovalBodyChangesBand": "{owner}님이 {scope} 부분을 다시 봐 달라고 했습니다.",
+ "firstApprovalArmed": "{at} {section}에서 {scope} 승인을 이어서 하세요.",
+ "firstApprovalArmedBand": "{scope} 승인을 이어서 하세요.",
+ "firstApprovalUnavailable": "아직 대기 중인 승인이 없습니다. 범위가 사인오프될 때까지 오늘 지도에 머무르세요.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..0f4042263 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -35,6 +35,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro
| 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()`. |
+| First Approval Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstApprovalCallout.tsx` | Name the owner, the owned scope, and the uniquely named labeled section and time. Prefer `changes_requested` over `pending`. Do not invent a sign-off from assignments, comments, already-approved scopes, empty/whitespace scopes, or inherited runtime metadata. Open scrolls the renderer-owned song-structure section when the owned scope uniquely names a form label. Keep the unavailable and band-wide states guidance-only. Distinct from first-assignment (#996), first-open-comment (#997), first-count (#995), first-lyric (#913), and export-and-priority (#900). |
## Prop And State Mapping
diff --git a/docs/doctoring/reduced-motion-first-approval-navigation.md b/docs/doctoring/reduced-motion-first-approval-navigation.md
new file mode 100644
index 000000000..192db607d
--- /dev/null
+++ b/docs/doctoring/reduced-motion-first-approval-navigation.md
@@ -0,0 +1,14 @@
+# Reduced-motion first-approval navigation
+
+Workspace map navigation for tonight's first pending approval follows the operating-system reduced-motion preference.
+
+When `prefers-reduced-motion: reduce` matches, `FirstApprovalCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`.
+
+This is a presentation contract only. Approval resolution and analysis-id isolation stay unchanged.
+
+## Security Notes
+
+- Untrusted input: song, collaboration, approval identity/owner/scope/status, section, time-range, and form-label tokens inside an owned scope are runtime data; inherited properties and arrays masquerading as record metadata are not authority.
+- Trust boundary: approval 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 approval scope is interpolated once as copy and is never rescanned as template syntax. Assignments, comments, and already-approved scopes cannot invent a pending approval. Canonical English form-label tokens may uniquely name a section; Korean or free-text scope copy cannot invent navigation.
+- 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 approval scope is bounded to 180 Unicode code points.
+- Test points: inherited song/collaboration/approval/section/timing metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.