@@ -91,10 +93,14 @@ 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)}
+ {translateSectionFormLabel(locale, section.label)} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}
{section.groove}
@@ -121,7 +127,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R
/** Documented. */
export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) {
const [activeRole, setActiveRole] = useState
(null);
- const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const locale = useMemo(() => detectPreferredLocale(), []);
+ const t = useMemo(() => createTranslator(locale), [locale]);
+ // Rehearsal priorities focus copy localizes section form labels like every
+ // other surface; unknown focus strings fall back to their raw value.
+ const focusSummary = song.exportSummary?.focusSections?.length
+ ? song.exportSummary.focusSections
+ .map((label) => translateSectionFormLabel(locale, label as SectionFormLabel))
+ .join(", ")
+ : song.sections[0]
+ ? translateSectionFormLabel(locale, song.sections[0].label)
+ : t("workspaceFocusFallback");
// Extract all unique roles from the song's sections
const roleMap = useMemo(() => {
@@ -157,9 +173,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
t(firstRange.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"),
{
roleName: firstRange.roleName,
- lowestNote: firstRange.lowestNote,
- highestNote: firstRange.highestNote,
- sectionLabel: firstRange.sectionLabel
+ lowestNote: firstRange.lowestNote,
+ highestNote: firstRange.highestNote,
+ sectionLabel: translateSectionFormLabel(locale, firstRange.sectionLabel)
}
)
: t("workspaceFirstRangeMissing");
@@ -348,12 +364,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{t("workspaceRehearsalPrioritiesLabel")}
- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}.
+ {t("workspaceRehearsalPrioritiesFocusPrefix")} {focusSummary}.
-
+
+
+
@@ -512,4 +530,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
);
-}
+}
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts
index 643935954..1d98ea992 100644
--- a/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts
+++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.test.ts
@@ -122,6 +122,15 @@ describe("firstRangeSqueeze", () => {
});
});
+ it("fails closed when runtime metadata supplies an unsupported section label", () => {
+ const song = createDemoRehearsalSong();
+ for (const section of song.sections) {
+ (section as unknown as { label: string }).label = "custom-section";
+ }
+
+ expect(firstRangeSqueeze(song)).toBeNull();
+ });
+
it("limits the squeeze to the selected role", () => {
const squeeze = firstRangeSqueeze(createDemoRehearsalSong(), "lead-vocal");
diff --git a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts
index 47270d2a9..1f0f9d6ae 100644
--- a/apps/desktop/src/features/workspace/firstRangeSqueeze.ts
+++ b/apps/desktop/src/features/workspace/firstRangeSqueeze.ts
@@ -1,8 +1,10 @@
-import type { RehearsalSong } from "@bandscope/shared-types";
+import { SECTION_FORM_LABELS, type RehearsalSong, type SectionFormLabel } from "@bandscope/shared-types";
+
+const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS);
/** Tonight's first named playable span on the rehearsal map. */
export type FirstRangeSqueeze = {
- sectionLabel: string;
+ sectionLabel: SectionFormLabel;
roleName: string;
lowestNote: string;
highestNote: string;
@@ -29,6 +31,11 @@ const ACCIDENTAL_OFFSET: Record = {
const NOTE_PATTERN = /^([A-Ga-g])([#b♯♭]?)(-?\d{1,2})$/u;
+/** Return whether a runtime section label belongs to the shared form contract. */
+function isSectionFormLabel(value: string): value is SectionFormLabel {
+ return SECTION_FORM_LABEL_SET.has(value);
+}
+
/** Return whether an untrusted runtime value is a plain object record. */
function isRuntimeObject(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -109,7 +116,7 @@ export function firstRangeSqueeze(
continue;
}
const sectionLabel = meaningfulRangeText(sectionValue.label);
- if (!sectionLabel) {
+ if (!sectionLabel || !isSectionFormLabel(sectionLabel)) {
continue;
}
diff --git a/apps/desktop/src/features/workspace/firstTranspositionPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstTranspositionPlan.inherited-metadata.test.ts
new file mode 100644
index 000000000..7c9e5faa8
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstTranspositionPlan.inherited-metadata.test.ts
@@ -0,0 +1,94 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { resolveFirstTranspositionPlan } from "./firstTranspositionPlan";
+
+function songWithTranspositionPlan() {
+ const song = createDemoRehearsalSong();
+ const section = structuredClone(song.sections[0]!);
+ section.id = "transpose-own";
+ section.roles = [
+ {
+ ...section.roles[0]!,
+ id: "bass-guitar",
+ name: "Bass Guitar",
+ rehearsalPriority: "high",
+ transpositionPlan: "If the singer drops to B minor, keep the shape a whole step lower."
+ }
+ ];
+ section.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }];
+ song.sections = [section];
+ return { song, section };
+}
+
+describe("resolveFirstTranspositionPlan inherited metadata", () => {
+ it("rejects a song or section whose required metadata is inherited", () => {
+ const { song, section } = songWithTranspositionPlan();
+ const inheritedSong = Object.create({ sections: song.sections }) as typeof song;
+ expect(resolveFirstTranspositionPlan(inheritedSong)).toBeNull();
+
+ const inheritedSection = Object.create(section) as typeof section;
+ song.sections = [inheritedSection];
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("rejects inherited timing fields", () => {
+ const { song, section } = songWithTranspositionPlan();
+ section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange;
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("contains exceptions from own runtime accessors instead of trusting them", () => {
+ const { song, section } = songWithTranspositionPlan();
+ Object.defineProperty(section.roles[0]!, "transpositionPlan", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ throw new Error("hostile transpositionPlan getter");
+ }
+ });
+
+ expect(() => resolveFirstTranspositionPlan(song)).not.toThrow();
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("does not treat own accessors as stable transposition-plan identity authority", () => {
+ const { song, section } = songWithTranspositionPlan();
+ Object.defineProperty(section, "id", {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return "transpose-own";
+ }
+ });
+
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("does not let inherited transposition plans establish the named copy", () => {
+ const { song, section } = songWithTranspositionPlan();
+ const inheritedRole = Object.create({
+ transpositionPlan: "Inherited transpose plan"
+ }) as (typeof section.roles)[0];
+ Object.defineProperties(inheritedRole, {
+ id: { configurable: true, enumerable: true, value: "bass-guitar" },
+ name: { configurable: true, enumerable: true, value: "Bass Guitar" },
+ rehearsalPriority: { configurable: true, enumerable: true, value: "high" }
+ });
+ section.roles = [inheritedRole];
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("does not let inherited role or graph metadata establish the holding part", () => {
+ const { song, section } = songWithTranspositionPlan();
+ const node = section.partGraph[0]!;
+ section.partGraph = [Object.create(node) as typeof node];
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("rejects arrays masquerading as section records", () => {
+ const { song, section } = songWithTranspositionPlan();
+ const arraySection = Object.assign([], section) as unknown as typeof section;
+ song.sections = [arraySection];
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstTranspositionPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstTranspositionPlan.section-label.test.ts
new file mode 100644
index 000000000..d033220d2
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstTranspositionPlan.section-label.test.ts
@@ -0,0 +1,15 @@
+import { createDemoRehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import { resolveFirstTranspositionPlan } from "./firstTranspositionPlan";
+
+describe("resolveFirstTranspositionPlan section-label authority", () => {
+ it("fails closed when runtime metadata supplies a label outside the shared SectionFormLabel contract", () => {
+ const song = createDemoRehearsalSong();
+ const section = song.sections[0]!;
+ expect(resolveFirstTranspositionPlan(song)?.section.id).toBe(section.id);
+
+ (section as unknown as { label: string }).label = "verse-legacy";
+
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstTranspositionPlan.test.ts b/apps/desktop/src/features/workspace/firstTranspositionPlan.test.ts
new file mode 100644
index 000000000..67d9bd57f
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstTranspositionPlan.test.ts
@@ -0,0 +1,277 @@
+import { describe, expect, it } from "vitest";
+import {
+ MAX_SECTION_TIME_SECONDS,
+ createDemoRehearsalSong,
+ type RehearsalPriority,
+ type SectionFormLabel
+} from "@bandscope/shared-types";
+import { formatTranspositionPlanTime, resolveFirstTranspositionPlan } from "./firstTranspositionPlan";
+
+const DEMO_TRANSPOSITION_PLAN =
+ "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones.";
+
+function withTranspositionSection(
+ overrides: {
+ id?: string;
+ start?: number;
+ end?: number;
+ transpositionPlan?: string;
+ label?: SectionFormLabel;
+ roleId?: string;
+ roleName?: string;
+ priority?: RehearsalPriority;
+ isActive?: boolean;
+ functionLabel?: string;
+ } = {}
+) {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ const section = structuredClone(verse);
+ section.id = overrides.id ?? "verse-transpose";
+ 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 ?? "lead-vocal";
+ section.roles = [
+ {
+ ...verse.roles[2]!,
+ id: roleId,
+ name: overrides.roleName ?? "Lead Vocal",
+ rehearsalPriority: overrides.priority ?? "medium",
+ cue: { kind: "lyric", value: "city lights" },
+ range: { lowestNote: "G#3", highestNote: "C#5" },
+ setupNote: "Watch the breath before the last line of the verse.",
+ simplification: "Keep the sustained note centered; skip the ad-lib on the first pass.",
+ overlapWarnings: ["Melodic overlap: competing with Keyboard 1 Right Hand."],
+ harmony: {
+ chord: "C#m7",
+ functionLabel: overrides.functionLabel ?? "vi melodic pull",
+ source: "model"
+ },
+ harmonicExplanation:
+ "The melody leans on the ninth over vi, so the vocal line should feel like a lift rather than a strict chord-tone outline.",
+ confidence: {
+ level: "high",
+ source: "user",
+ notes: "Singer confirmed the pickup phrasing in rehearsal notes."
+ },
+ transpositionPlan:
+ overrides.transpositionPlan ??
+ "If the room wants more ease, move the section down a whole step and keep the pickup breath mark in the same place.",
+ manualOverrides: []
+ }
+ ];
+ section.partGraph = [
+ {
+ role_id: roleId,
+ is_active: overrides.isActive ?? true,
+ handoff_to: [],
+ handoff_from: []
+ }
+ ];
+ song.sections = [section];
+ return song;
+}
+
+describe("resolveFirstTranspositionPlan", () => {
+ it("picks the demo song's earliest high-priority transposition plan and the part that owns it", () => {
+ const resolved = resolveFirstTranspositionPlan(createDemoRehearsalSong());
+ expect(resolved?.section.id).toBe("verse-1");
+ expect(resolved?.holdingRole.id).toBe("bass-guitar");
+ expect(resolved?.transpositionPlan).toBe(DEMO_TRANSPOSITION_PLAN);
+ expect(resolved?.atSeconds).toBe(10);
+ expect(formatTranspositionPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10");
+ expect(formatTranspositionPlanTime(Number.NaN)).toBe("0:00");
+ expect(formatTranspositionPlanTime(-4)).toBe("0:00");
+ });
+
+ it("does not invent a transposition plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, confirmed overrides, harmonic explanations, or confidence notes", () => {
+ const song = withTranspositionSection();
+ delete song.sections[0]!.roles[0]!.transpositionPlan;
+ song.sections[0]!.groove = "Straight eighths with a late snare feel";
+ song.sections[0]!.roles[0]!.simplification = "Keep the sustained note centered.";
+ song.sections[0]!.roles[0]!.setupNote = "Watch the breath before the last line of the verse.";
+ song.sections[0]!.roles[0]!.cue = { kind: "lyric", value: "city lights" };
+ song.sections[0]!.roles[0]!.range = { lowestNote: "G#3", highestNote: "C#5" };
+ song.sections[0]!.roles[0]!.overlapWarnings = ["Melodic overlap: competing with Keyboard 1 Right Hand."];
+ song.sections[0]!.roles[0]!.harmony = {
+ chord: "C#m7",
+ functionLabel: "vi melodic pull",
+ source: "user"
+ };
+ song.sections[0]!.roles[0]!.harmonicExplanation = "The ninth is the reason this lift works.";
+ song.sections[0]!.roles[0]!.manualOverrides = [
+ {
+ field: "harmony",
+ value: {
+ chord: "C#m11",
+ functionLabel: "vi suspended lift",
+ source: "user"
+ },
+ source: "user"
+ }
+ ];
+ song.sections[0]!.roles[0]!.confidence = {
+ level: "high",
+ source: "user",
+ notes: "If the singer drops to B minor, keep the shape a whole step lower."
+ };
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("skips a blank transposition plan", () => {
+ expect(resolveFirstTranspositionPlan(withTranspositionSection({ transpositionPlan: " " }))).toBeNull();
+ });
+
+ it("skips a multi-line transposition plan", () => {
+ expect(
+ resolveFirstTranspositionPlan(withTranspositionSection({ transpositionPlan: "Drop a step.\nKeep the pickup." }))
+ ).toBeNull();
+ });
+
+ it("prefers the earlier of two transposition plans", () => {
+ const song = withTranspositionSection({
+ id: "verse-late",
+ start: 40,
+ end: 56,
+ roleId: "keys-right",
+ transpositionPlan: "Late keyboard voicing."
+ });
+ const earlier = structuredClone(song.sections[0]!);
+ earlier.id = "verse-early";
+ earlier.roles = [
+ {
+ ...earlier.roles[0]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "low",
+ transpositionPlan: "Earlier vocal drop."
+ }
+ ];
+ earlier.timeRange = { start: 8, end: 24 };
+ earlier.partGraph = [{ role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }];
+ song.sections = [song.sections[0]!, earlier];
+
+ const resolved = resolveFirstTranspositionPlan(song);
+ expect(resolved?.section.id).toBe("verse-early");
+ expect(resolved?.holdingRole.id).toBe("lead-vocal");
+ expect(resolved?.transpositionPlan).toBe("Earlier vocal drop.");
+ expect(resolved?.atSeconds).toBe(8);
+ });
+
+ it("breaks same-time transposition-plan ties with locale-independent id ordering", () => {
+ const song = withTranspositionSection({ id: "ä-transpose", start: 10, end: 26 });
+ const ascii = structuredClone(song.sections[0]!);
+ ascii.id = "z-transpose";
+ song.sections = [song.sections[0]!, ascii];
+
+ expect(resolveFirstTranspositionPlan(song)?.section.id).toBe("z-transpose");
+ });
+
+ it("prefers a high-priority transpose part over a low-priority part in the same section", () => {
+ const song = withTranspositionSection({
+ roleId: "keys-right",
+ roleName: "Keys",
+ priority: "low",
+ transpositionPlan: "Low-priority voicing."
+ });
+ const section = song.sections[0]!;
+ const highRole = {
+ ...section.roles[0]!,
+ id: "lead-vocal",
+ name: "Lead Vocal",
+ rehearsalPriority: "high" as const,
+ transpositionPlan: "High-priority vocal drop."
+ };
+ section.roles = [section.roles[0]!, highRole];
+ section.partGraph = [
+ { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] },
+ { role_id: "lead-vocal", is_active: true, handoff_to: [], handoff_from: [] }
+ ];
+
+ expect(resolveFirstTranspositionPlan(song)?.holdingRole.id).toBe("lead-vocal");
+ expect(resolveFirstTranspositionPlan(song)?.transpositionPlan).toBe("High-priority vocal drop.");
+ });
+
+ it("breaks equal-priority role ties with locale-independent id ordering", () => {
+ const song = withTranspositionSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" });
+ const section = song.sections[0]!;
+ const asciiRole = {
+ ...section.roles[0]!,
+ id: "z-role",
+ name: "ASCII role",
+ transpositionPlan: "ASCII transpose."
+ };
+ section.roles = [section.roles[0]!, asciiRole];
+ section.partGraph = [
+ { role_id: "ä-role", is_active: true, handoff_to: [], handoff_from: [] },
+ { role_id: "z-role", is_active: true, handoff_to: [], handoff_from: [] }
+ ];
+
+ expect(resolveFirstTranspositionPlan(song)?.holdingRole.id).toBe("z-role");
+ expect(resolveFirstTranspositionPlan(song)?.transpositionPlan).toBe("ASCII transpose.");
+ });
+
+ it("skips a transposition plan whose graph node is inactive", () => {
+ expect(resolveFirstTranspositionPlan(withTranspositionSection({ isActive: false }))).toBeNull();
+ });
+
+ it("skips a transposition plan whose rehearsal window is unbounded", () => {
+ expect(resolveFirstTranspositionPlan(withTranspositionSection({ start: Number.NaN, end: 30 }))).toBeNull();
+ });
+
+ it("skips a transposition plan whose end precedes its start", () => {
+ expect(resolveFirstTranspositionPlan(withTranspositionSection({ start: 30, end: 10 }))).toBeNull();
+ });
+
+ it("skips a zero-length transposition-plan window", () => {
+ expect(resolveFirstTranspositionPlan(withTranspositionSection({ start: 10, end: 10 }))).toBeNull();
+ });
+
+ it("skips a transposition plan whose endpoint overflows the shared timing bound", () => {
+ expect(
+ resolveFirstTranspositionPlan(
+ withTranspositionSection({
+ start: MAX_SECTION_TIME_SECONDS,
+ end: MAX_SECTION_TIME_SECONDS + 1
+ })
+ )
+ ).toBeNull();
+ });
+
+ it("returns null for a non-object song root", () => {
+ expect(resolveFirstTranspositionPlan(null as never)).toBeNull();
+ });
+
+ it("returns null when the runtime section collection is sparse", () => {
+ const song = withTranspositionSection();
+ const sparseSections: typeof song.sections = new Array(2);
+ sparseSections[1] = song.sections[0]!;
+ song.sections = sparseSections;
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("keeps the transposition plan unnamed when role identities are duplicated", () => {
+ const song = withTranspositionSection();
+ 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: [] }
+ ];
+ expect(resolveFirstTranspositionPlan(song)).toBeNull();
+ });
+
+ it("bounds the transposition plan to 180 Unicode code points", () => {
+ const song = withTranspositionSection({ transpositionPlan: `${"G".repeat(200)}` });
+ const resolved = resolveFirstTranspositionPlan(song);
+ expect(resolved?.transpositionPlan.length).toBe(180);
+ });
+
+ it("does not split a Unicode surrogate pair at the transposition-plan boundary", () => {
+ const song = withTranspositionSection({ transpositionPlan: `${"a".repeat(179)}😀tail` });
+ const resolved = resolveFirstTranspositionPlan(song);
+ expect(Array.from(resolved?.transpositionPlan ?? "")).toHaveLength(180);
+ expect(resolved?.transpositionPlan.endsWith("😀")).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstTranspositionPlan.ts b/apps/desktop/src/features/workspace/firstTranspositionPlan.ts
new file mode 100644
index 000000000..34a091709
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstTranspositionPlan.ts
@@ -0,0 +1,283 @@
+import {
+ MAX_SECTION_TIME_SECONDS,
+ SECTION_FORM_LABELS,
+ type RehearsalRole,
+ type RehearsalSection,
+ type RehearsalSong
+} from "@bandscope/shared-types";
+
+const PRIORITY_RANK = { high: 0, medium: 1, low: 2 } as const;
+const MAX_TRANSPOSITION_PLAN_CHARACTERS = 180;
+const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS);
+
+/** Tonight's first transposition plan: the earliest labeled section and the part that owns it. */
+export type FirstTranspositionPlan = {
+ section: RehearsalSection;
+ holdingRole: RehearsalRole;
+ transpositionPlan: string;
+ atSeconds: number;
+};
+
+/** Format a non-negative transposition-plan time as m:ss for rehearsal copy. */
+export function formatTranspositionPlanTime(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 a bounded own transposition plan, or null when it cannot be shown. */
+function ownedTranspositionPlan(role: unknown): string | null {
+ if (!isRuntimeObject(role) || !hasOwnData(role, "transpositionPlan")) {
+ return null;
+ }
+ const transpositionPlan = (role as { transpositionPlan?: unknown }).transpositionPlan;
+ if (typeof transpositionPlan !== "string") {
+ return null;
+ }
+ const trimmed = transpositionPlan.trim();
+ if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) {
+ return null;
+ }
+ return truncateCodePoints(trimmed, MAX_TRANSPOSITION_PLAN_CHARACTERS);
+}
+
+/** 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 canonical form label from the shared contract. */
+function hasSupportedSectionLabel(section: RehearsalSection): boolean {
+ return (
+ hasOwnData(section, "label") &&
+ typeof section.label === "string" &&
+ SECTION_FORM_LABEL_SET.has(section.label)
+ );
+}
+
+/** 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;
+}
+
+/** Prefer the earlier ranked role, then rehearsal priority, then a locale-independent id. */
+function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null {
+ if (roles.length === 0) {
+ return null;
+ }
+ return (
+ [...roles].sort((left, right) => {
+ const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority];
+ if (priorityDelta !== 0) {
+ return priorityDelta;
+ }
+ return compareStableId(left.id, right.id);
+ })[0] ?? null
+ );
+}
+
+/** 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)
+ );
+}
+
+/** Resolve a transposition plan after the runtime root has passed its structural boundary checks. */
+function resolveSafeFirstTranspositionPlan(song: RehearsalSong): FirstTranspositionPlan | null {
+ if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) {
+ return null;
+ }
+
+ const candidates = song.sections
+ .filter(
+ (section) =>
+ isRuntimeObject(section) &&
+ hasSupportedSectionLabel(section) &&
+ hasOwnData(section, "id") &&
+ typeof section.id === "string" &&
+ section.id.trim().length > 0 &&
+ hasBoundedTimeRange(section)
+ )
+ .flatMap((section) => {
+ const holdingRole = pickHoldingRole(
+ rankedActiveRoles(section).filter((role) => ownedTranspositionPlan(role) !== null)
+ );
+ if (!holdingRole) {
+ return [];
+ }
+ const transpositionPlan = ownedTranspositionPlan(holdingRole);
+ if (!transpositionPlan) {
+ return [];
+ }
+ return [
+ {
+ section,
+ holdingRole,
+ transpositionPlan,
+ atSeconds: section.timeRange.start
+ }
+ ];
+ })
+ .sort((left, right) => {
+ if (left.atSeconds !== right.atSeconds) {
+ return left.atSeconds - right.atSeconds;
+ }
+ return compareStableId(left.section.id, right.section.id);
+ });
+
+ return candidates[0] ?? null;
+}
+
+/** Return the first named transposition plan, or null when untrusted runtime metadata cannot be read safely. */
+export function resolveFirstTranspositionPlan(song: RehearsalSong): FirstTranspositionPlan | null {
+ try {
+ return resolveSafeFirstTranspositionPlan(song);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts
index dc49a0a25..aeabc0edc 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,52 @@ describe("i18n", () => {
}
});
});
+
+ describe("translateSectionFormLabel", () => {
+ it("localizes every supported Korean section form label", () => {
+ 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-transposition-plan next-action copy particle-safe", () => {
+ const t = createTranslator("ko");
+ expect(t("firstTranspositionPlanOpenAction")).toBe("{at} {role} 이조 위치 열기");
+ expect(t("firstTranspositionPlanBody")).toBe("{at} {section}에서 {role} 파트의 이조 계획이 있습니다.");
+ expect(t("firstTranspositionPlanArmed")).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..60de49ef7 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -49,6 +49,7 @@
"workspaceTranspositionLabel": "Transpose / simplify",
"workspaceStemsLabel": "Stems",
"workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities",
+ "workspaceRehearsalPrioritiesFocusPrefix": "Focus:",
"workspaceRolesHarmonyLabel": "Roles & Harmony",
"sectionRoadmapTitle": "Section Roadmap",
"sectionRoadmapScrollHint": "Scroll for more sections →",
@@ -149,10 +150,16 @@
"practiceProgressLabel": "Practice Progress",
"decreasePracticeProgressLabel": "Decrease progress",
"increasePracticeProgressLabel": "Increase progress",
+ "firstTranspositionPlanLabel": "Tonight's first transpose plan",
+ "firstTranspositionPlanOpenAction": "Open {role} transpose at {at}",
+ "firstTranspositionPlanBody": "{role} still has a transpose plan in the {section} at {at}.",
+ "firstTranspositionPlanArmed": "Lock that transpose on {role} at {at} before the room starts.",
+ "firstTranspositionPlanUnavailable": "No part has a transpose plan yet. Keep working from tonight's map until one appears.",
"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}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
"sectionRangeLabel": "Range",
- "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
+ "sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}.",
+ "workspaceFocusFallback": "first pass"
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..2530d632a 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -49,6 +49,7 @@
"workspaceTranspositionLabel": "전조 / 단순화",
"workspaceStemsLabel": "스템",
"workspaceRehearsalPrioritiesLabel": "합주 우선순위",
+ "workspaceRehearsalPrioritiesFocusPrefix": "집중:",
"workspaceRolesHarmonyLabel": "역할과 화성",
"sectionRoadmapTitle": "구간 흐름",
"sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →",
@@ -149,10 +150,16 @@
"practiceProgressLabel": "연습 진척도",
"decreasePracticeProgressLabel": "진척도 감소",
"increasePracticeProgressLabel": "진척도 증가",
+ "firstTranspositionPlanLabel": "오늘 첫 이조 계획",
+ "firstTranspositionPlanOpenAction": "{at} {role} 이조 위치 열기",
+ "firstTranspositionPlanBody": "{at} {section}에서 {role} 파트의 이조 계획이 있습니다.",
+ "firstTranspositionPlanArmed": "{at}에서 {role} 파트의 이조를 맞춘 다음 합주를 시작하세요.",
+ "firstTranspositionPlanUnavailable": "이조 계획이 있는 파트가 없습니다. 합주용 이조가 있는 파트가 생길 때까지 오늘 맵에 머무르세요.",
"workspaceFirstRangeTitle": "오늘 먼저 볼 음역",
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
"sectionRangeLabel": "음역",
- "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
+ "sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요.",
+ "workspaceFocusFallback": "첫 마디 연습"
}
diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md
index 22602c313..1818b37da 100644
--- a/docs/design-system/component-contract.md
+++ b/docs/design-system/component-contract.md
@@ -94,6 +94,7 @@ These Figma patterns are valid visual guidance but are not yet extracted as stan
| Status Pill | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when assignment/comment/approval status UI is reused. |
| Song Structure Timeline | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when timeline editing or playback controls are added. |
| Export Action Group | `apps/desktop/src/features/workspace/Workspace.tsx` | Extract when export controls are reused outside the workspace header. |
+| First Transposition Plan Callout | `apps/desktop/src/features/workspace/FirstTranspositionPlanCallout.tsx` | No Figma node yet. Extract when a second workspace next-action callout reuses the pattern. Behavioral contract: name the owning part only when an active graph node corroborates it, the owned `transpositionPlan` copy, the labeled section start, and the time exist; never invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section; keep the unavailable state guidance-only. Distinct from first-entrance Tempo/Key/Transpose cockpit work (#987). |
## PR Review Rules
diff --git a/docs/doctoring/reduced-motion-first-transposition-plan-navigation.md b/docs/doctoring/reduced-motion-first-transposition-plan-navigation.md
new file mode 100644
index 000000000..e2a57f0f6
--- /dev/null
+++ b/docs/doctoring/reduced-motion-first-transposition-plan-navigation.md
@@ -0,0 +1,5 @@
+# Reduced-motion first transposition-plan navigation
+
+When `prefers-reduced-motion: reduce` matches, `FirstTranspositionPlanCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`.
+
+Open still names the owning part, labeled section, and time. Analysis `section.id` is never DOM-ID authority. This map next-action is distinct from the first-entrance Tempo/Key/Transpose cockpit metrics.