diff --git a/.Jules/palette.md b/.Jules/palette.md deleted file mode 100644 index 5c1c16989..000000000 --- a/.Jules/palette.md +++ /dev/null @@ -1,39 +0,0 @@ -## 2024-05-18 - Added focus visible styles for keyboard navigation -**Learning:** Interactive inline buttons (like the chord editor) and scrollable regions with `tabIndex={0}` do not automatically get focus visible styles, meaning keyboard users tabbing through won't know they are focused on them. Unlike central ` + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..3e8b14117 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstVoicingPlanCallout } from "./FirstVoicingPlanCallout"; + +function songWithoutVoicingPlan() { + const song = createDemoRehearsalSong(); + for (const role of song.sections[0]!.roles) { + role.voicingPlan = ""; + } + return song; +} + +describe("FirstVoicingPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English voicing plan is unavailable", () => { + render(); + + expect(screen.getByText("No voicing plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean voicing plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 보이싱 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..a17c2dc6e --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstVoicingPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstVoicingPlanCallout } from "./FirstVoicingPlanCallout"; + +describe("FirstVoicingPlanCallout workspace scope", () => { + it("opens the song-structure renderer owned by the current workspace", () => { + const firstSong = createDemoRehearsalSong(); + const secondSong = createDemoRehearsalSong(); + secondSong.id = "second-workspace-song"; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll('[data-section-index="0"]'); + expect(targets).toHaveLength(2); + const firstScrollIntoView = vi.fn(); + const secondScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: firstScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: secondScrollIntoView + }); + + const actions = screen.getAllByRole("button", { + name: "Open Keyboard 1 Right Hand voicing 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..c7eeb6c9c 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first voicing plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Keep the verse voicing in first inversion so the top line still sings over the guitars." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand voicing at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that voicing on Keyboard 1 Right Hand at 0:10 before the room starts./) + ).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..3fb3f9556 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 { FirstVoicingPlanCallout } from "./FirstVoicingPlanCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -91,8 +92,12 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

{section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

@@ -353,6 +358,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..12acf782b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +function songWithVoicingPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "voicing-own"; + section.roles = [ + { + ...section.roles[1]!, + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high", + voicingPlan: "Keep the verse voicing in first inversion so the top line still sings over the guitars." + } + ]; + section.partGraph = [{ role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstVoicingPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithVoicingPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstVoicingPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithVoicingPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithVoicingPlan(); + Object.defineProperty(section.roles[0]!, "voicingPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile voicingPlan getter"); + } + }); + + expect(() => resolveFirstVoicingPlan(song)).not.toThrow(); + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable voicing-plan identity authority", () => { + const { song, section } = songWithVoicingPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "voicing-own"; + } + }); + + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("does not let inherited voicing plans establish the named copy", () => { + const { song, section } = songWithVoicingPlan(); + const inheritedRole = Object.create({ + voicingPlan: "Inherited voicing plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "keys-right" }, + name: { configurable: true, enumerable: true, value: "Keyboard 1 Right Hand" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithVoicingPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithVoicingPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.proxy-authority.test.ts new file mode 100644 index 000000000..cc980269e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.proxy-authority.test.ts @@ -0,0 +1,31 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +const DEMO_VOICING_PLAN = + "Keep the verse voicing in first inversion so the top line still sings over the guitars."; + +describe("resolveFirstVoicingPlan own-data authority", () => { + it("uses the snapshotted own-data voicing plan instead of a Proxy get trap", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo voicing-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "voicingPlan") { + return "Injected proxy voicing."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstVoicingPlan(song)?.voicingPlan).toBe(DEMO_VOICING_PLAN); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.role-authority.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.role-authority.test.ts new file mode 100644 index 000000000..d82abd09b --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.role-authority.test.ts @@ -0,0 +1,44 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +describe("resolveFirstVoicingPlan role authority", () => { + it("uses snapshotted own role identity and priority instead of Proxy get traps", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; + const role = roleIndex >= 0 ? section?.roles[roleIndex] : undefined; + expect(section).toBeDefined(); + expect(role).toBeDefined(); + if (!section || !role || roleIndex < 0) { + throw new Error("Demo voicing-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + const expectedId = Object.getOwnPropertyDescriptor(role, "id")?.value; + const expectedName = Object.getOwnPropertyDescriptor(role, "name")?.value; + const expectedPriority = Object.getOwnPropertyDescriptor(role, "rehearsalPriority")?.value; + expect(expectedId).toBe("keys-right"); + expect(expectedName).toBeTypeOf("string"); + expect(expectedPriority).toBe("high"); + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "id") { + return "bass"; + } + if (property === "name") { + return "Injected proxy role"; + } + if (property === "rehearsalPriority") { + return "low"; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstVoicingPlan(song); + expect(resolved?.holdingRoleId).toBe(expectedId); + expect(resolved?.holdingRoleName).toBe(expectedName); + expect(resolved?.voicingPlan).toContain("first inversion"); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.section-authority.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.section-authority.test.ts new file mode 100644 index 000000000..5f09eb917 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.section-authority.test.ts @@ -0,0 +1,43 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +describe("resolveFirstVoicingPlan section authority", () => { + it("uses snapshotted own section identity, label, and time instead of Proxy get traps", () => { + const song = createDemoRehearsalSong(); + const sectionIndex = song.sections.findIndex((section) => section.id === "verse-1"); + const section = sectionIndex >= 0 ? song.sections[sectionIndex] : undefined; + expect(section).toBeDefined(); + if (!section || sectionIndex < 0) { + throw new Error("Demo voicing-plan fixture is missing the expected verse section."); + } + + const expectedId = Object.getOwnPropertyDescriptor(section, "id")?.value; + const expectedLabel = Object.getOwnPropertyDescriptor(section, "label")?.value; + const expectedRange = Object.getOwnPropertyDescriptor(section, "timeRange")?.value; + expect(expectedId).toBe("verse-1"); + expect(expectedLabel).toBe("verse"); + expect(expectedRange?.start).toBe(10); + + song.sections[sectionIndex] = new Proxy(section, { + get(target, property, receiver) { + if (property === "id") { + return "chorus-injected"; + } + if (property === "label") { + return "chorus"; + } + if (property === "timeRange") { + return { start: 25, end: 40 }; + } + return Reflect.get(target, property, receiver); + } + }); + + const resolved = resolveFirstVoicingPlan(song); + expect(resolved).not.toBeNull(); + expect((resolved as unknown as { sectionId?: string })?.sectionId).toBe(expectedId); + expect((resolved as unknown as { sectionLabel?: string })?.sectionLabel).toBe(expectedLabel); + expect(resolved?.atSeconds).toBe(expectedRange?.start); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.section-label.test.ts new file mode 100644 index 000000000..d13062783 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +describe("resolveFirstVoicingPlan 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]!; + + (section as unknown as { label: string }).label = "verse-legacy"; + + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.test.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.test.ts new file mode 100644 index 000000000..78f3866b1 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatVoicingPlanTime, resolveFirstVoicingPlan } from "./firstVoicingPlan"; + +const DEMO_VOICING_PLAN = + "Keep the verse voicing in first inversion so the top line still sings over the guitars."; + +function withVoicingSection( + overrides: { + id?: string; + start?: number; + end?: number; + voicingPlan?: string; + label?: "intro" | "verse" | "pre-chorus" | "chorus" | "bridge" | "outro" | "tag" | "pickup" | "stop" | "handoff"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + functionLabel?: string; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-voicing"; + 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." + }, + voicingPlan: + overrides.voicingPlan ?? + "Keep the verse voicing in first inversion so the top line still sings over the guitars.", + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstVoicingPlan", () => { + it("picks the demo song's earliest high-priority voicing plan and the part that owns it", () => { + const resolved = resolveFirstVoicingPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("keys-right"); + expect(resolved?.voicingPlan).toBe(DEMO_VOICING_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatVoicingPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatVoicingPlanTime(Number.NaN)).toBe("0:00"); + expect(formatVoicingPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a voicing plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, tuning plans, dynamics plans, articulation plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withVoicingSection(); + delete song.sections[0]!.roles[0]!.voicingPlan; + 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 = + "Keep the verse voicing in first inversion so the top line still sings over the guitars."; + song.sections[0]!.roles[0]!.transpositionPlan = + "If the singer drops to B minor, keep the shape a whole step lower."; + (song.sections[0]!.roles[0] as { tuningPlan?: string }).tuningPlan = + "Tune the E string down to D so the verse riff sits on the open fifth."; + (song.sections[0]!.roles[0] as { dynamicsPlan?: string }).dynamicsPlan = + "Keep the verse under the vocal so the chorus still has somewhere to lift."; + (song.sections[0]!.roles[0] as { articulationPlan?: string }).articulationPlan = + "Keep the verse attack short so the chorus still has a longer sustain to land on."; + 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: "Keep the verse voicing in first inversion so the top line still sings over the guitars." + }; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("skips a blank voicing plan", () => { + expect(resolveFirstVoicingPlan(withVoicingSection({ voicingPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line voicing plan", () => { + expect( + resolveFirstVoicingPlan(withVoicingSection({ voicingPlan: "Drop under the vocal.\nKeep the pickup." })) + ).toBeNull(); + }); + + it("prefers the earlier of two voicing plans", () => { + const song = withVoicingSection({ + id: "verse-late", + start: 40, + end: 56, + roleId: "keys-right", + voicingPlan: "Late voicing." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + voicingPlan: "Earlier voicing." + } + ]; + 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 = resolveFirstVoicingPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.voicingPlan).toBe("Earlier voicing."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time voicing-plan ties with locale-independent id ordering", () => { + const song = withVoicingSection({ id: "ä-voicing", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-voicing"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstVoicingPlan(song)?.section.id).toBe("z-voicing"); + }); + + it("prefers a high-priority voicing part over a low-priority part in the same section", () => { + const song = withVoicingSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + voicingPlan: "Low-priority voicing." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + voicingPlan: "High-priority voicing." + }; + 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(resolveFirstVoicingPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstVoicingPlan(song)?.voicingPlan).toBe("High-priority voicing."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withVoicingSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + voicingPlan: "ASCII voicing." + }; + 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(resolveFirstVoicingPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstVoicingPlan(song)?.voicingPlan).toBe("ASCII voicing."); + }); + + it("skips a voicing plan whose graph node is inactive", () => { + expect(resolveFirstVoicingPlan(withVoicingSection({ isActive: false }))).toBeNull(); + }); + + it("skips a voicing plan whose rehearsal window is unbounded", () => { + expect(resolveFirstVoicingPlan(withVoicingSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a voicing plan whose end precedes its start", () => { + expect(resolveFirstVoicingPlan(withVoicingSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length voicing-plan window", () => { + expect(resolveFirstVoicingPlan(withVoicingSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a voicing plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstVoicingPlan( + withVoicingSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstVoicingPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withVoicingSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("keeps the voicing plan unnamed when role identities are duplicated", () => { + const song = withVoicingSection(); + 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(resolveFirstVoicingPlan(song)).toBeNull(); + }); + + it("bounds the voicing plan to 180 Unicode code points", () => { + const song = withVoicingSection({ voicingPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstVoicingPlan(song); + expect(resolved?.voicingPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the voicing-plan boundary", () => { + const song = withVoicingSection({ voicingPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstVoicingPlan(song); + expect(Array.from(resolved?.voicingPlan ?? "")).toHaveLength(180); + expect(resolved?.voicingPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstVoicingPlan.ts b/apps/desktop/src/features/workspace/firstVoicingPlan.ts new file mode 100644 index 000000000..16ffee46c --- /dev/null +++ b/apps/desktop/src/features/workspace/firstVoicingPlan.ts @@ -0,0 +1,347 @@ +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_VOICING_PLAN_CHARACTERS = 180; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); + +type RehearsalPriority = keyof typeof PRIORITY_RANK; + +type RankedRoleSnapshot = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: RehearsalPriority; +}>; + +type VoicingRoleSnapshot = RankedRoleSnapshot & + Readonly<{ + voicingPlan: string; + }>; + +type GraphNodeSnapshot = Readonly<{ + roleId: string; + isActive: boolean; +}>; + +type SectionSnapshot = Readonly<{ + section: RehearsalSection; + sectionIndex: number; + id: string; + label: RehearsalSection["label"]; + start: number; + end: number; + roles: unknown[]; + partGraph: unknown[]; +}>; + +/** Tonight's first voicing plan: the earliest labeled section and the part that owns it. */ +export type FirstVoicingPlan = { + section: RehearsalSection; + sectionIndex: number; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; + voicingPlan: string; + atSeconds: number; +}; + +/** Format a non-negative voicing-plan time as m:ss for rehearsal copy. */ +export function formatVoicingPlanTime(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 an own data-property descriptor, rejecting inherited or accessor state. */ +function ownDataDescriptor(value: object, key: PropertyKey): PropertyDescriptor | null { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor + : null; +} + +/** Snapshot one non-blank owned string without consulting a Proxy get trap. */ +function ownedNonBlankString(value: unknown, key: PropertyKey): string | null { + if (!isRuntimeObject(value)) { + return null; + } + const descriptor = ownDataDescriptor(value, key); + if (descriptor === null || typeof descriptor.value !== "string") { + return null; + } + return descriptor.value.trim().length > 0 ? descriptor.value : null; +} + +/** Snapshot a bounded dense array through own descriptors without later index reads. */ +function snapshotDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const lengthDescriptor = ownDataDescriptor(value, "length"); + const length = lengthDescriptor?.value; + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = ownDataDescriptor(value, index); + if (descriptor === null) { + return null; + } + items.push(descriptor.value); + } + return items; +} + +/** 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 snapshotted own voicing plan, or null when it cannot be shown. */ +function ownedVoicingPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const descriptor = ownDataDescriptor(role, "voicingPlan"); + if (descriptor === null || typeof descriptor.value !== "string") { + return null; + } + const trimmed = descriptor.value.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_VOICING_PLAN_CHARACTERS); +} + +/** Snapshot owned role identity and priority without later untrusted property reads. */ +function snapshotRankedRole(role: unknown): RankedRoleSnapshot | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownedNonBlankString(role, "id"); + const name = ownedNonBlankString(role, "name"); + const priorityDescriptor = ownDataDescriptor(role, "rehearsalPriority"); + const rehearsalPriority = priorityDescriptor?.value; + if ( + id === null || + name === null || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as RehearsalPriority + }; +} + +/** Attach one snapshotted owned voicing plan to an already validated role snapshot. */ +function snapshotVoicingRole(role: RankedRoleSnapshot): VoicingRoleSnapshot | null { + const voicingPlan = ownedVoicingPlan(role.role); + return voicingPlan === null ? null : { ...role, voicingPlan }; +} + +/** Snapshot one graph node's role identity and activity flag through own descriptors. */ +function snapshotGraphNode(node: unknown): GraphNodeSnapshot | null { + if (!isRuntimeObject(node)) { + return null; + } + const roleId = ownedNonBlankString(node, "role_id"); + const isActive = ownDataDescriptor(node, "is_active")?.value; + return roleId !== null && typeof isActive === "boolean" ? { roleId, isActive } : null; +} + +/** Snapshot one section's buyer/navigation authority without later untrusted property reads. */ +function snapshotSection(section: unknown, sectionIndex: number): SectionSnapshot | null { + if (!isRuntimeObject(section)) { + return null; + } + const id = ownedNonBlankString(section, "id"); + const label = ownDataDescriptor(section, "label")?.value; + const timeRange = ownDataDescriptor(section, "timeRange")?.value; + const roles = snapshotDenseRuntimeArray(ownDataDescriptor(section, "roles")?.value); + const partGraph = snapshotDenseRuntimeArray(ownDataDescriptor(section, "partGraph")?.value); + if ( + id === null || + typeof label !== "string" || + !SECTION_FORM_LABEL_SET.has(label) || + !isRuntimeObject(timeRange) || + roles === null || + partGraph === null + ) { + return null; + } + const start = ownDataDescriptor(timeRange, "start")?.value; + const end = ownDataDescriptor(timeRange, "end")?.value; + if ( + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { + section: section as RehearsalSection, + sectionIndex, + id, + label: label as RehearsalSection["label"], + start, + end, + roles, + partGraph + }; +} + +/** 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 rehearsal priority, then a locale-independent stable role id. */ +function pickHoldingRole(roles: VoicingRoleSnapshot[]): VoicingRoleSnapshot | 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 snapshotted ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: SectionSnapshot): RankedRoleSnapshot[] { + const roleSnapshots = section.roles.flatMap((role) => { + const snapshot = snapshotRankedRole(role); + return snapshot === null ? [] : [snapshot]; + }); + const graphSnapshots = section.partGraph.flatMap((node) => { + const snapshot = snapshotGraphNode(node); + return snapshot === null ? [] : [snapshot]; + }); + const repeatedRoleIds = repeatedIds(roleSnapshots.map((role) => role.id)); + const repeatedGraphRoleIds = repeatedIds(graphSnapshots.map((node) => node.roleId)); + const activeIds = new Set( + graphSnapshots.flatMap((node) => + node.isActive && !repeatedGraphRoleIds.has(node.roleId) ? [node.roleId] : [] + ) + ); + + return roleSnapshots.filter( + (role) => !repeatedRoleIds.has(role.id) && activeIds.has(role.id) + ); +} + +/** Resolve a voicing plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstVoicingPlan(song: RehearsalSong): FirstVoicingPlan | null { + if (!isRuntimeObject(song)) { + return null; + } + const sections = snapshotDenseRuntimeArray(ownDataDescriptor(song, "sections")?.value); + if (sections === null) { + return null; + } + + const candidates = sections + .flatMap((section, sectionIndex) => { + const sectionSnapshot = snapshotSection(section, sectionIndex); + if (sectionSnapshot === null) { + return []; + } + const voicingRoles = rankedActiveRoles(sectionSnapshot).flatMap((role) => { + const candidate = snapshotVoicingRole(role); + return candidate === null ? [] : [candidate]; + }); + const holdingRole = pickHoldingRole(voicingRoles); + if (!holdingRole) { + return []; + } + return [ + { + section: sectionSnapshot.section, + sectionIndex: sectionSnapshot.sectionIndex, + sectionId: sectionSnapshot.id, + sectionLabel: sectionSnapshot.label, + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, + voicingPlan: holdingRole.voicingPlan, + atSeconds: sectionSnapshot.start + } + ]; + }) + .sort((left, right) => { + if (left.atSeconds !== right.atSeconds) { + return left.atSeconds - right.atSeconds; + } + return compareStableId(left.sectionId, right.sectionId); + }); + + return candidates[0] ?? null; +} + +/** Return the first named voicing plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstVoicingPlan(song: RehearsalSong): FirstVoicingPlan | null { + try { + return resolveSafeFirstVoicingPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/features/workspace/voicingCoverageContract.test.ts b/apps/desktop/src/features/workspace/voicingCoverageContract.test.ts new file mode 100644 index 000000000..46dd6d279 --- /dev/null +++ b/apps/desktop/src/features/workspace/voicingCoverageContract.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the voicing-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstVoicingPlan.ts", + "src/features/workspace/FirstVoicingPlanCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..3e35726cf 100644 --- a/apps/desktop/src/i18n/index.test.ts +++ b/apps/desktop/src/i18n/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { createTranslator, detectPreferredLocale } from "./index"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -75,4 +75,51 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes every supported 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-voicing-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstVoicingPlanOpenAction")).toBe("{at} {role} 보이싱 열기"); + expect(t("firstVoicingPlanBody")).toBe("{at} {section}에서 {role} 파트의 보이싱 계획이 있습니다."); + expect(t("firstVoicingPlanArmed")).toBe("{at}에서 {role} 파트의 보이싱을 맞춘 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..f5656ce01 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 translator for the requested locale, falling back to the English dictionary for missing entries. */ 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..c98e411e6 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstVoicingPlanLabel": "Tonight's first voicing plan", + "firstVoicingPlanOpenAction": "Open {role} voicing at {at}", + "firstVoicingPlanBody": "{role} still has a voicing plan in the {section} at {at}.", + "firstVoicingPlanArmed": "Lock that voicing on {role} at {at} before the room starts.", + "firstVoicingPlanUnavailable": "No voicing plan is available. Stay on tonight's map for the next rehearsal cue.", "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..7b53eb569 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstVoicingPlanLabel": "오늘 첫 보이싱 계획", + "firstVoicingPlanOpenAction": "{at} {role} 보이싱 열기", + "firstVoicingPlanBody": "{at} {section}에서 {role} 파트의 보이싱 계획이 있습니다.", + "firstVoicingPlanArmed": "{at}에서 {role} 파트의 보이싱을 맞춘 다음 합주를 시작하세요.", + "firstVoicingPlanUnavailable": "사용 가능한 보이싱 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index f1db6f2b8..51457feba 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,18 @@ import { fileURLToPath } from "node:url"; const configDirectory = path.dirname(fileURLToPath(import.meta.url)); +/** Production files whose V8 coverage is owned by the desktop test gate. */ +export const DESKTOP_OWNED_PRODUCTION_COVERAGE = [ + "src/App.tsx", + "src/lib/export.ts", + "src/i18n/index.ts", + "src/features/score/ScoreViewer.tsx", + "src/features/score/ScoreView.tsx", + "src/features/score/scoreStorage.ts", + "src/features/workspace/firstVoicingPlan.ts", + "src/features/workspace/FirstVoicingPlanCallout.tsx" +]; + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +31,7 @@ export default defineConfig({ setupFiles: ["./src/setupTests.ts"], coverage: { provider: "v8", - include: [ - "src/App.tsx", - "src/lib/export.ts", - "src/i18n/index.ts", - "src/features/score/ScoreViewer.tsx", - "src/features/score/ScoreView.tsx", - "src/features/score/scoreStorage.ts" - ], + include: DESKTOP_OWNED_PRODUCTION_COVERAGE, thresholds: { lines: 90, functions: 90, diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..bda60db6f 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -30,6 +30,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | Status Pill | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-283 | `apps/desktop/src/features/workspace/Workspace.tsx` | Design pattern only. Current code uses `formatStatusLabel(status)` inside local badge-like markup. | | Role Switcher | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-337 | `apps/desktop/src/features/workspace/RoleSwitcher.tsx` | Use `roles`, `activeRole`, and `onRoleChange`; `null` means all roles. | | Section Roadmap Card | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-402 | `apps/desktop/src/features/workspace/SectionRoadmap.tsx` | Use `song`, `activeRole`, and optional `onSongUpdate`; avoid rebuilding its internal card layout. | +| First Voicing Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstVoicingPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `voicingPlan` copy, the labeled section start, and the time. Do not invent that copy from `groove`, cue text, `simplification`, overlap warnings, range copy, `harmony.chord`, `harmony.functionLabel`, `setupNote`, `transpositionPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-setup-note, first-transposition-plan, first-tuning-plan, first-dynamics-plan, and first-articulation-plan. | | Song Structure Timeline | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-457 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local `SongStructure({ sections, t })` memo component; not exported. | | Groove Map | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-526 | `apps/desktop/src/features/workspace/GrooveMap.tsx` | Use `notes?: TranscriptionNote[]` and `isLoading?: boolean`; preserve scrollable region semantics and note labels. | | 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. | diff --git a/docs/doctoring/reduced-motion-first-voicing-plan-navigation.md b/docs/doctoring/reduced-motion-first-voicing-plan-navigation.md new file mode 100644 index 000000000..5f7afa371 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-voicing-plan-navigation.md @@ -0,0 +1,3 @@ +# Reduced-motion first voicing-plan navigation + +Open tonight's first voicing plan with `behavior: "auto"` when `prefers-reduced-motion: reduce` matches. Do not keep a smooth scroll for that next action. diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..543ded6cd 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,7 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + voicingPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -506,6 +507,7 @@ const demoRehearsalSongSeed: RehearsalSong = { simplification: "Drop the top extension if the chorus turnaround still feels busy.", setupNote: "Keep the patch bright enough to stay over the guitars.", transpositionPlan: "If the band rehearses in D, keep the voicing in first inversion so the top line still sings.", + voicingPlan: "Keep the verse voicing in first inversion so the top line still sings over the guitars.", manualOverrides: [], overlapWarnings: [ "Melodic overlap: top notes conflict with Lead Vocal range." @@ -1497,6 +1499,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "voicingPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1555,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.voicingPlan !== undefined && typeof value.voicingPlan !== "string") { + return invalidField(`${path}.voicingPlan`); + } if (!isDenseArray(value.manualOverrides)) { return invalidField(`${path}.manualOverrides`); } diff --git a/packages/shared-types/test/index.test.ts b/packages/shared-types/test/index.test.ts index 564ee1827..a4d84347c 100644 --- a/packages/shared-types/test/index.test.ts +++ b/packages/shared-types/test/index.test.ts @@ -738,6 +738,7 @@ describe("shared type helpers", () => { expect(song.sections[0]?.roles[2]?.harmony?.source).toBe("model"); expect(song.sections[0]?.roles[0]?.harmonicExplanation).toContain("tonal floor"); expect(song.sections[0]?.roles[0]?.transpositionPlan).toContain("whole step lower"); + expect(song.sections[0]?.roles[1]?.voicingPlan).toContain("first inversion"); expect(song.collaboration?.assignments).toHaveLength(2); expect(song.collaboration?.comments[0]?.status).toBe("open"); expect(song.sections[0]?.roles[2]?.manualOverrides?.[0]).toMatchObject({ @@ -1257,6 +1258,12 @@ describe("shared type helpers", () => { song.sections[0]!.roles[0]!.transpositionPlan = 2 as never; }) }, + { + message: "sections[0].roles[0].voicingPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[0]!.voicingPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => {