(null);
+
+ useEffect(() => {
+ setHeardPreChorus(null);
+ }, [
+ songIdentity,
+ preChorusSectionIndex,
+ preChorus?.section.id,
+ preChorus?.holdingRole?.id,
+ preChorus?.atSeconds
+ ]);
+
+ if (!preChorus) {
+ return (
+
+ );
+ }
+
+ const heard =
+ heardPreChorus?.songIdentity === songIdentity &&
+ heardPreChorus.sectionId === preChorus.section.id &&
+ heardPreChorus.sectionIndex === preChorusSectionIndex &&
+ heardPreChorus.holdingRoleId === (preChorus.holdingRole?.id ?? null) &&
+ heardPreChorus.atSeconds === preChorus.atSeconds;
+ const at = formatPreChorusTime(preChorus.atSeconds);
+ const copyValues: PreChorusCopyValues = {
+ role: preChorus.holdingRole?.name ?? "",
+ section: translateSectionFormLabel(locale, preChorus.section.label),
+ at
+ };
+ const hasRole = preChorus.holdingRole !== null;
+ const actionLabel = formatPreChorusCopy(
+ t(hasRole ? "firstPreChorusOpenAction" : "firstPreChorusOpenActionBand"),
+ copyValues
+ );
+ const body = formatPreChorusCopy(t(hasRole ? "firstPreChorusBody" : "firstPreChorusBodyBand"), copyValues);
+ const armed = formatPreChorusCopy(t(hasRole ? "firstPreChorusArmed" : "firstPreChorusArmedBand"), copyValues);
+
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..b4ef2d88e 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -326,4 +326,50 @@ describe("Workspace", () => {
expect(screen.getByText("합주 우선순위")).toBeTruthy();
expect(screen.getByText("역할과 화성")).toBeTruthy();
});
+
+ it("names tonight's first pre-chorus as workspace navigation", () => {
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ const preChorus = structuredClone(seed);
+ preChorus.id = "pre-chorus-1";
+ preChorus.label = "pre-chorus";
+ preChorus.timeRange = { start: 20, end: 28 };
+ preChorus.roles = [
+ {
+ ...seed.roles[2]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high"
+ }
+ ];
+ preChorus.partGraph = [
+ {
+ role_id: "lead-vocal",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [preChorus];
+
+ 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
+ });
+
+ const action = screen.getByRole("button", {
+ name: "Open Lead Vocal pre-chorus at 0:20"
+ });
+ expect(action).toBeTruthy();
+ fireEvent.click(action);
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
+ expect(
+ screen.getByText(/Learn Lead Vocal's pre-chorus at 0:20. Play the lift into the chorus./)
+ ).toBeTruthy();
+ });
});
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..444e2f227 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 { FirstPreChorusCallout } from "./FirstPreChorusCallout";
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/firstPreChorus.test.ts b/apps/desktop/src/features/workspace/firstPreChorus.test.ts
new file mode 100644
index 000000000..05f59c952
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstPreChorus.test.ts
@@ -0,0 +1,240 @@
+import { describe, expect, it } from "vitest";
+import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types";
+import { formatPreChorusTime, resolveFirstPreChorus } from "./firstPreChorus";
+
+function withPreChorusSection(
+ overrides: {
+ id?: string;
+ start?: number;
+ end?: number;
+ roleId?: string;
+ roleName?: string;
+ priority?: "low" | "medium" | "high";
+ isActive?: boolean;
+ } = {}
+) {
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ const preChorus = structuredClone(seed);
+ preChorus.id = overrides.id ?? "pre-chorus-1";
+ preChorus.label = "pre-chorus";
+ preChorus.timeRange = { start: overrides.start ?? 20, end: overrides.end ?? 28 };
+ const roleId = overrides.roleId ?? "lead-vocal";
+ preChorus.roles = [
+ {
+ ...seed.roles[2]!,
+ id: roleId,
+ name: overrides.roleName ?? "Lead Vocal",
+ rehearsalPriority: overrides.priority ?? "high"
+ }
+ ];
+ preChorus.partGraph = [
+ {
+ role_id: roleId,
+ is_active: overrides.isActive ?? true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [preChorus];
+ return song;
+}
+
+describe("resolveFirstPreChorus", () => {
+ it("does not invent a pre-chorus from the demo verse", () => {
+ expect(resolveFirstPreChorus(createDemoRehearsalSong())).toBeNull();
+ expect(formatPreChorusTime(Number.NaN)).toBe("0:00");
+ expect(formatPreChorusTime(-4)).toBe("0:00");
+ });
+
+ it("does not invent a pre-chorus from an intro, verse, chorus, bridge, outro, tag, pickup, stop, or handoff", () => {
+ const song = createDemoRehearsalSong();
+ const seed = song.sections[0]!;
+ const intro = structuredClone(seed);
+ intro.id = "intro-1";
+ intro.label = "intro";
+ intro.timeRange = { start: 0, end: 8 };
+ const verse = structuredClone(seed);
+ verse.id = "verse-1";
+ verse.label = "verse";
+ verse.timeRange = { start: 10, end: 20 };
+ const chorus = structuredClone(seed);
+ chorus.id = "chorus-1";
+ chorus.label = "chorus";
+ chorus.timeRange = { start: 30, end: 46 };
+ const bridge = structuredClone(seed);
+ bridge.id = "bridge-1";
+ bridge.label = "bridge";
+ bridge.timeRange = { start: 64, end: 80 };
+ const outro = structuredClone(seed);
+ outro.id = "outro-1";
+ outro.label = "outro";
+ outro.timeRange = { start: 90, end: 102 };
+ const tag = structuredClone(seed);
+ tag.id = "tag-1";
+ tag.label = "tag";
+ tag.timeRange = { start: 102, end: 108 };
+ const pickup = structuredClone(seed);
+ pickup.id = "pickup-1";
+ pickup.label = "pickup";
+ pickup.timeRange = { start: 8, end: 10 };
+ const stop = structuredClone(seed);
+ stop.id = "stop-1";
+ stop.label = "stop";
+ stop.timeRange = { start: 18, end: 19 };
+ const handoff = structuredClone(seed);
+ handoff.id = "handoff-1";
+ handoff.label = "handoff";
+ handoff.timeRange = { start: 22, end: 24 };
+ song.sections = [intro, pickup, stop, verse, chorus, handoff, bridge, outro, tag];
+
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("picks the earliest labeled pre-chorus and the part that carries the lift", () => {
+ const song = withPreChorusSection({ start: 20, end: 28 });
+ const first = resolveFirstPreChorus(song);
+
+ expect(first?.section.id).toBe("pre-chorus-1");
+ expect(first?.holdingRole?.id).toBe("lead-vocal");
+ expect(first?.atSeconds).toBe(20);
+ expect(formatPreChorusTime(first?.atSeconds ?? -1)).toBe("0:20");
+ });
+
+ it("prefers the earlier of two labeled pre-choruses", () => {
+ const song = withPreChorusSection({ id: "pre-chorus-late", start: 48, end: 56 });
+ const seed = song.sections[0]!;
+ const earlier = structuredClone(seed);
+ earlier.id = "pre-chorus-early";
+ earlier.timeRange = { start: 20, end: 28 };
+ earlier.roles = [
+ {
+ ...seed.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ rehearsalPriority: "medium"
+ }
+ ];
+ earlier.partGraph = [
+ {
+ role_id: "bass-guitar",
+ is_active: true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [song.sections[0]!, earlier];
+
+ const first = resolveFirstPreChorus(song);
+ expect(first?.section.id).toBe("pre-chorus-early");
+ expect(first?.holdingRole?.id).toBe("bass-guitar");
+ expect(first?.atSeconds).toBe(20);
+ });
+
+ it("keeps a band-wide lift when no active ranked role holds it", () => {
+ const song = withPreChorusSection({ isActive: false });
+ const first = resolveFirstPreChorus(song);
+ expect(first?.section.id).toBe("pre-chorus-1");
+ expect(first?.holdingRole).toBeNull();
+ expect(first?.atSeconds).toBe(20);
+ });
+
+ it("skips a pre-chorus whose rehearsal window is unbounded", () => {
+ const song = withPreChorusSection({ start: Number.NaN, end: 28 });
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("skips a pre-chorus whose end precedes its start", () => {
+ const song = withPreChorusSection({ start: 28, end: 20 });
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("skips a zero-length pre-chorus window", () => {
+ const song = withPreChorusSection({ start: 20, end: 20 });
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("skips a pre-chorus whose endpoint overflows the shared timing bound", () => {
+ const song = withPreChorusSection({
+ start: MAX_SECTION_TIME_SECONDS,
+ end: MAX_SECTION_TIME_SECONDS + 1
+ });
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("returns null for a non-object song root", () => {
+ expect(resolveFirstPreChorus(null as never)).toBeNull();
+ });
+
+ it("returns null when the runtime section collection is sparse", () => {
+ const song = withPreChorusSection();
+ const sparseSections: typeof song.sections = new Array(2);
+ sparseSections[1] = song.sections[0]!;
+ song.sections = sparseSections;
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("keeps the lift band-wide when role identities are duplicated", () => {
+ const song = withPreChorusSection();
+ 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 first = resolveFirstPreChorus(song);
+ expect(first?.section.id).toBe("pre-chorus-1");
+ expect(first?.holdingRole).toBeNull();
+ });
+
+ it("tie-breaks same-start pre-choruses by locale-independent id order", () => {
+ const song = withPreChorusSection({ id: "pre-chorus-z", start: 20, end: 28 });
+ const earlierId = structuredClone(song.sections[0]!);
+ earlierId.id = "pre-chorus-a";
+ earlierId.roles = [
+ {
+ ...song.sections[0]!.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ rehearsalPriority: "medium"
+ }
+ ];
+ earlierId.partGraph = [
+ { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }
+ ];
+ song.sections = [song.sections[0]!, earlierId];
+ const first = resolveFirstPreChorus(song);
+ expect(first?.section.id).toBe("pre-chorus-a");
+ expect(first?.holdingRole?.id).toBe("bass-guitar");
+ });
+
+ it("skips a pre-chorus whose time range is missing", () => {
+ const song = withPreChorusSection();
+ (song.sections[0] as unknown as { timeRange: unknown }).timeRange = null;
+ expect(resolveFirstPreChorus(song)).toBeNull();
+ });
+
+ it("keeps the lift band-wide when roles or graph nodes are not dense objects", () => {
+ const song = withPreChorusSection();
+ (song.sections[0] as unknown as { roles: unknown }).roles = null;
+ expect(resolveFirstPreChorus(song)?.holdingRole).toBeNull();
+
+ const graphSong = withPreChorusSection();
+ graphSong.sections[0]!.roles = [
+ {
+ ...graphSong.sections[0]!.roles[0]!,
+ id: " ",
+ name: "",
+ rehearsalPriority: "urgent" as never
+ }
+ ];
+ graphSong.sections[0]!.partGraph = [
+ { role_id: " ", is_active: false, handoff_to: [], handoff_from: [] } as never
+ ];
+ expect(resolveFirstPreChorus(graphSong)?.holdingRole).toBeNull();
+ });
+
+ it("formats minute-bounded rehearsal times", () => {
+ expect(formatPreChorusTime(70)).toBe("1:10");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts b/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts
new file mode 100644
index 000000000..6452c79e5
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstPreChorus.time-bound.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { MAX_SECTION_TIME_SECONDS } from "@bandscope/shared-types";
+import { formatPreChorusTime } from "./firstPreChorus";
+
+describe("formatPreChorusTime", () => {
+ it("fails closed when formatter input exceeds the shared timing bound", () => {
+ expect(formatPreChorusTime(MAX_SECTION_TIME_SECONDS + 1)).toBe("0:00");
+ expect(formatPreChorusTime(Number.MAX_VALUE)).toBe("0:00");
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstPreChorus.ts b/apps/desktop/src/features/workspace/firstPreChorus.ts
new file mode 100644
index 000000000..9ca8ad9a8
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstPreChorus.ts
@@ -0,0 +1,197 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ type RehearsalRole,
+ type RehearsalSection,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+
+const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const;
+
+/** Tonight's first labeled pre-chorus: the earliest lift and the part that carries it. */
+export type FirstPreChorus = {
+ section: RehearsalSection;
+ holdingRole: RehearsalRole | null;
+ atSeconds: number;
+};
+
+/** Format a bounded, non-negative pre-chorus time as m:ss for rehearsal copy. */
+export function formatPreChorusTime(totalSeconds: number): string {
+ const safeSeconds =
+ Number.isFinite(totalSeconds) &&
+ totalSeconds >= 0 &&
+ totalSeconds <= MAX_SECTION_TIME_SECONDS
+ ? 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 an object. */
+function isRuntimeObject(value: unknown): value is object {
+ return value !== null && typeof value === "object";
+}
+
+/** Return whether every numeric index is present in a bounded runtime array. */
+function isDenseRuntimeArray(value: unknown): value is unknown[] {
+ if (!Array.isArray(value)) {
+ return false;
+ }
+ const length = Number(value.length);
+ if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) {
+ return false;
+ }
+ for (let index = 0; index < length; index += 1) {
+ if (!(index in value)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** Return true when the role has safe runtime identity/copy and ranked rehearsal priority. */
+function hasRankedPriority(role: RehearsalRole): boolean {
+ return (
+ typeof role.id === "string" &&
+ role.id.trim().length > 0 &&
+ typeof role.name === "string" &&
+ role.name.trim().length > 0 &&
+ Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority)
+ );
+}
+
+/** Return whether a section has a bounded, positive-length integer rehearsal window. */
+function hasBoundedTimeRange(section: RehearsalSection): boolean {
+ const timeRange = section.timeRange as Partial | null;
+ if (timeRange === null || typeof timeRange !== "object") {
+ return false;
+ }
+
+ const start = timeRange.start ?? -1;
+ const end = timeRange.end ?? -1;
+ return (
+ Number.isInteger(start) &&
+ start >= 0 &&
+ start <= MAX_SECTION_TIME_SECONDS &&
+ Number.isInteger(end) &&
+ end > start &&
+ end <= MAX_SECTION_TIME_SECONDS
+ );
+}
+
+/** Return safe identities that appear more than once in one section-local collection. */
+function repeatedIds(ids: string[]): Set {
+ const seen = new Set();
+ const repeated = new Set();
+ for (const id of ids) {
+ if (seen.has(id)) {
+ repeated.add(id);
+ } else {
+ seen.add(id);
+ }
+ }
+ return repeated;
+}
+
+/** Prefer the highest-priority ranked role, then a locale-independent stable id order. */
+function pickHighestPriorityRole(roles: RehearsalRole[]): RehearsalRole | null {
+ if (roles.length === 0) {
+ return null;
+ }
+ return (
+ [...roles].sort((left, right) => {
+ const rankDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority];
+ if (rankDelta !== 0) {
+ return rankDelta;
+ }
+ return compareStableId(left.id, right.id);
+ })[0] ?? null
+ );
+}
+
+/** Return ranked roles whose unique graph node is explicitly active. */
+function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] {
+ if (!isDenseRuntimeArray(section.roles) || !isDenseRuntimeArray(section.partGraph)) {
+ return [];
+ }
+
+ const safeRoleIds = section.roles
+ .filter(
+ (role) => isRuntimeObject(role) && typeof role.id === "string" && role.id.trim().length > 0
+ )
+ .map((role) => role.id);
+ const safeGraphRoleIds = section.partGraph
+ .filter(
+ (node) => isRuntimeObject(node) && typeof node.role_id === "string" && node.role_id.trim().length > 0
+ )
+ .map((node) => node.role_id);
+ const repeatedRoleIds = repeatedIds(safeRoleIds);
+ const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds);
+ const activeIds = new Set(
+ section.partGraph
+ .filter(
+ (node) =>
+ isRuntimeObject(node) &&
+ node.is_active === true &&
+ typeof node.role_id === "string" &&
+ node.role_id.trim().length > 0 &&
+ !repeatedGraphRoleIds.has(node.role_id)
+ )
+ .map((node) => node.role_id)
+ );
+
+ return section.roles.filter(
+ (role) =>
+ isRuntimeObject(role) &&
+ hasRankedPriority(role) &&
+ !repeatedRoleIds.has(role.id) &&
+ activeIds.has(role.id)
+ );
+}
+
+/** Return the first labeled pre-chorus, or null when no safe lift remains. */
+export function resolveFirstPreChorus(song: RehearsalSong): FirstPreChorus | null {
+ if (!isRuntimeObject(song) || !isDenseRuntimeArray(song.sections)) {
+ return null;
+ }
+
+ const preChorusSections = song.sections
+ .filter(
+ (section) =>
+ isRuntimeObject(section) &&
+ section.label === "pre-chorus" &&
+ typeof section.id === "string" &&
+ section.id.trim().length > 0 &&
+ hasBoundedTimeRange(section)
+ )
+ .sort((left, right) => {
+ if (left.timeRange.start !== right.timeRange.start) {
+ return left.timeRange.start - right.timeRange.start;
+ }
+ return compareStableId(left.id, right.id);
+ });
+
+ const section = preChorusSections[0];
+ if (!section) {
+ return null;
+ }
+
+ return {
+ section,
+ holdingRole: pickHighestPriorityRole(rankedActiveRoles(section)),
+ atSeconds: section.timeRange.start
+ };
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..f814bead9 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,20 @@ describe("i18n", () => {
}
});
});
+
+ describe("translateSectionFormLabel", () => {
+ it("localizes a pre-chorus label for Korean rehearsal copy", () => {
+ expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스");
+ expect(translateSectionFormLabel("en", "pre-chorus")).toBe("pre-chorus");
+ });
+
+ it("preserves unlabeled form values as data", () => {
+ expect(translateSectionFormLabel("ko", "intro")).toBe("intro");
+ });
+
+ it("does not treat inherited object keys as localized section labels", () => {
+ const inheritedKey = "toString" as never;
+ expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString");
+ });
+ });
});
diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts
index 1a9f471f0..dbac2891a 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,29 @@ const dictionaries = {
ko: koCommon
} as const;
-/** Documented. */
+const sectionFormLabels: Readonly<
+ Record>>
+> = {
+ en: { "pre-chorus": "pre-chorus" },
+ ko: { "pre-chorus": "프리코러스" }
+};
+
+/** Create a translation function that resolves keys for the selected locale. */
export function createTranslator(locale: Locale = "en") {
return function t(key: TranslationKey): string {
return dictionaries[locale][key] ?? dictionaries.en[key];
};
}
+/** Return localized copy for an own section-form entry, preserving unknown labels as data. */
+export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string {
+ const labels = sectionFormLabels[locale];
+ if (!Object.prototype.hasOwnProperty.call(labels, label)) {
+ return label;
+ }
+ return labels[label] as string;
+}
+
/** 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..8045a5cf4 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",
+ "firstPreChorusLabel": "Tonight's first pre-chorus",
+ "firstPreChorusOpenAction": "Open {role} pre-chorus at {at}",
+ "firstPreChorusOpenActionBand": "Open the first pre-chorus at {at}",
+ "firstPreChorusBody": "{role} carries the {section} at {at}.",
+ "firstPreChorusBodyBand": "The band carries the {section} at {at}.",
+ "firstPreChorusArmed": "Learn {role}'s pre-chorus at {at}. Play the lift into the chorus.",
+ "firstPreChorusArmedBand": "Learn the pre-chorus at {at}. Play the lift into the chorus.",
+ "firstPreChorusUnavailable": "No pre-chorus yet. Stay on tonight's map until the first lift is labeled.",
"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..60ac63474 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -149,6 +149,14 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstPreChorusLabel": "오늘 첫 프리코러스",
+ "firstPreChorusOpenAction": "{at} {role} 프리코러스 위치 열기",
+ "firstPreChorusOpenActionBand": "{at} 첫 프리코러스 위치 열기",
+ "firstPreChorusBody": "{role}이 {at} {section}에서 리프트를 잡습니다.",
+ "firstPreChorusBodyBand": "밴드가 {at} {section}에서 리프트를 잡습니다.",
+ "firstPreChorusArmed": "{at}에서 {role} 프리코러스를 익히세요. 코러스로 들어가세요.",
+ "firstPreChorusArmedBand": "{at}에서 프리코러스를 익히세요. 코러스로 들어가세요.",
+ "firstPreChorusUnavailable": "아직 프리코러스가 없습니다. 첫 리프트가 표시될 때까지 오늘 지도에 머무르세요.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
diff --git a/docs/doctoring/reduced-motion-first-prechorus-navigation.md b/docs/doctoring/reduced-motion-first-prechorus-navigation.md
new file mode 100644
index 000000000..45cc4c03d
--- /dev/null
+++ b/docs/doctoring/reduced-motion-first-prechorus-navigation.md
@@ -0,0 +1,14 @@
+# Reduced-motion first-pre-chorus navigation
+
+Workspace map navigation for tonight's first pre-chorus follows the operating-system reduced-motion preference.
+
+When `prefers-reduced-motion: reduce` matches, `FirstPreChorusCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`.
+
+This is a presentation contract only. Pre-chorus resolution, action-mode authority, and analysis-id isolation stay unchanged.
+
+## Security Notes
+
+- Untrusted input: song, section, and role identifiers are used as copy values, local completion-state identity, and effect dependencies; they are not DOM-ID authority.
+- Trust boundary: renderer-owned song-structure children; analysis `section.id` is never DOM-ID authority.
+- Mitigations: `matchMedia` is read-only, scroll targets come from renderer child index, and copy interpolation runs once.
+- Test points: reduced-motion scroll uses `auto`; default motion uses `smooth`.