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/FirstSoloPlanCallout.unavailable-copy.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx new file mode 100644 index 000000000..00bc40cfd --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.unavailable-copy.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +function songWithoutSoloPlan() { + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.soloPlan = ""; + } + } + return song; +} + +describe("FirstSoloPlanCallout unavailable copy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not assert why the English solo plan is unavailable", () => { + render(); + + expect(screen.getByText("No solo plan is available. Stay on tonight's map for the next rehearsal cue.")).toBeTruthy(); + }); + + it("does not assert why the Korean solo plan is unavailable", () => { + vi.stubGlobal("navigator", { language: "ko-KR" }); + + render(); + + expect(screen.getByText("사용 가능한 솔로 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..f8450fdaf --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstSoloPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; + +describe("FirstSoloPlanCallout 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 firstSectionId = firstSong.sections[0]!.id; + const secondSectionId = secondSong.sections[0]!.id; + + const { container } = render( + <> +
+ +
+
+
+
+
+ +
+
+
+
+ + ); + + const targets = container.querySelectorAll("[data-section-id]"); + 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 solo at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); + + it("navigates to the stable section identity even when rendered section order changes", () => { + const song = createDemoRehearsalSong(); + const expectedSectionId = song.sections[0]!.id; + const { container } = render( +
+ +
+
+
+
+
+ ); + + const targets = container.querySelectorAll("[data-section-id]"); + expect(targets).toHaveLength(2); + const wrongScrollIntoView = vi.fn(); + const expectedScrollIntoView = vi.fn(); + Object.defineProperty(targets[0]!, "scrollIntoView", { + configurable: true, + value: wrongScrollIntoView + }); + Object.defineProperty(targets[1]!, "scrollIntoView", { + configurable: true, + value: expectedScrollIntoView + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(wrongScrollIntoView).not.toHaveBeenCalled(); + expect(expectedScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); + + it("fails closed when the current workspace contains multiple song-structure renderers", () => { + const song = createDemoRehearsalSong(); + const sectionId = song.sections[0]!.id; + const { container } = render( +
+ +
+
+
+
+
+
+
+ ); + + const targets = container.querySelectorAll("[data-section-id]"); + 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 + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + }); + + it("does not navigate through a renderer owned by another workspace", () => { + const song = createDemoRehearsalSong(); + const sectionId = song.sections[0]!.id; + const { container } = render( + <> +
+ +
+
+
+
+
+
+ + ); + + const target = container.querySelector("[data-section-id]"); + expect(target).not.toBeNull(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + fireEvent.click( + screen.getByRole("button", { name: "Open Keyboard 1 Right Hand solo at 0:10" }) + ); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(screen.getByText("Keyboard 1 Right Hand still has a solo plan in the verse at 0:10.")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..b7d27dd98 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -107,6 +107,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first solo plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const firstSectionId = song.sections[0]!.id; + const target = Array.from(document.querySelectorAll("[data-section-id]")).find( + (candidate) => candidate.dataset.sectionId === firstSectionId + ); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Keyboard 1 Right Hand solo at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that solo 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..b874eb728 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 { FirstSoloPlanCallout } from "./FirstSoloPlanCallout"; 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/coverageContract.test.ts b/apps/desktop/src/features/workspace/coverageContract.test.ts new file mode 100644 index 000000000..4ebda235e --- /dev/null +++ b/apps/desktop/src/features/workspace/coverageContract.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import desktopViteConfig, { DESKTOP_OWNED_PRODUCTION_COVERAGE } from "../../../vite.config"; + +describe("desktop owned production coverage", () => { + it("keeps the first solo-plan resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstSoloPlan.ts", + "src/features/workspace/FirstSoloPlanCallout.tsx" + ]) + ); + }); + + it("wires the owned production list into Vitest coverage", () => { + expect(desktopViteConfig).toMatchObject({ + test: { + coverage: { + include: DESKTOP_OWNED_PRODUCTION_COVERAGE + } + } + }); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts new file mode 100644 index 000000000..72992c4f0 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.inherited-metadata.test.ts @@ -0,0 +1,94 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +function songWithSoloPlan() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "solo-own"; + section.roles = [ + { + ...section.roles[2]!, + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high", + soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land." + } + ]; + section.partGraph = [{ role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstSoloPlan inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithSoloPlan(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstSoloPlan(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithSoloPlan(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithSoloPlan(); + Object.defineProperty(section.roles[0]!, "soloPlan", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile soloPlan getter"); + } + }); + + expect(() => resolveFirstSoloPlan(song)).not.toThrow(); + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not treat own accessors as stable solo-plan identity authority", () => { + const { song, section } = songWithSoloPlan(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "solo-own"; + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not let inherited solo plans establish the named copy", () => { + const { song, section } = songWithSoloPlan(); + const inheritedRole = Object.create({ + soloPlan: "Inherited solo plan" + }) as (typeof section.roles)[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "lead-vocal" }, + name: { configurable: true, enumerable: true, value: "Lead Vocal" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("does not let inherited role or graph metadata establish the holding part", () => { + const { song, section } = songWithSoloPlan(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithSoloPlan(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts new file mode 100644 index 000000000..242c8ff74 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.proxy-authority.test.ts @@ -0,0 +1,121 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +describe("resolveFirstSoloPlan runtime authority", () => { + it("fails closed when a Proxy get trap can substitute solo-plan copy", () => { + 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 solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "soloPlan") { + return "Injected proxy solo."; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("fails closed when a Proxy get trap can substitute the time range", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + expect(section).toBeDefined(); + if (!section) { + throw new Error("Demo solo-plan fixture is missing the expected verse section."); + } + const expectedStart = section.timeRange.start; + section.timeRange = new Proxy(section.timeRange, { + get(target, property, receiver) { + if (property === "start") { + return expectedStart + 15; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("fails closed when a Proxy get trap can substitute role identity or display copy", () => { + 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 solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + section.roles[roleIndex] = new Proxy(role, { + get(target, property, receiver) { + if (property === "id") { + return "injected-proxy-id"; + } + if (property === "name") { + return "Injected proxy role"; + } + return Reflect.get(target, property, receiver); + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("fails closed when a Proxy descriptor trap fabricates solo-plan authority", () => { + 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 solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + section.roles[roleIndex] = new Proxy(role, { + getOwnPropertyDescriptor(target, property) { + if (property === "soloPlan") { + return { + configurable: true, + enumerable: true, + writable: true, + value: "Injected descriptor solo." + }; + } + return Reflect.getOwnPropertyDescriptor(target, property); + } + }); + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("fails closed when an exotic Map instance carries otherwise valid role metadata", () => { + const song = createDemoRehearsalSong(); + const section = song.sections.find((candidate) => candidate.id === "verse-1"); + const roleIndex = section?.roles.findIndex((role) => role.id === "keys-right") ?? -1; + expect(section).toBeDefined(); + if (!section || roleIndex < 0) { + throw new Error("Demo solo-plan fixture is missing the expected Keyboard 1 Right Hand role."); + } + + const exoticRole = Object.assign(new Map(), { + id: "keys-right", + name: "Keyboard 1 Right Hand", + rehearsalPriority: "high" as const, + soloPlan: "Hold the owned solo before the room returns." + }); + section.roles[roleIndex] = exoticRole as unknown as (typeof section.roles)[number]; + + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts new file mode 100644 index 000000000..0f0485b93 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.section-label.test.ts @@ -0,0 +1,14 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstSoloPlan } from "./firstSoloPlan"; + +describe("resolveFirstSoloPlan 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(resolveFirstSoloPlan(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.test.ts b/apps/desktop/src/features/workspace/firstSoloPlan.test.ts new file mode 100644 index 000000000..f29336784 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatSoloPlanTime, resolveFirstSoloPlan } from "./firstSoloPlan"; + +const DEMO_SOLO_PLAN = + "Hold the verse solo; everyone else drops to a two-bar pad so the run can land."; + +function withSoloSection( + overrides: { + id?: string; + start?: number; + end?: number; + soloPlan?: 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-solo"; + 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." + }, + soloPlan: + overrides.soloPlan ?? + DEMO_SOLO_PLAN, + manualOverrides: [] + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstSoloPlan", () => { + it("picks the demo song's earliest solo plan and the part that owns it", () => { + const resolved = resolveFirstSoloPlan(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole.id).toBe("keys-right"); + expect(resolved?.soloPlan).toBe(DEMO_SOLO_PLAN); + expect(resolved?.atSeconds).toBe(10); + expect(formatSoloPlanTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatSoloPlanTime(Number.NaN)).toBe("0:00"); + expect(formatSoloPlanTime(-4)).toBe("0:00"); + }); + + it("does not invent a solo plan from groove, cue, simplification, overlap, range, chords, function labels, setup notes, transposition plans, fill plans, tuning plans, dynamics plans, articulation plans, hook plans, confirmed overrides, harmonic explanations, or confidence notes", () => { + const song = withSoloSection(); + delete song.sections[0]!.roles[0]!.soloPlan; + 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 = DEMO_SOLO_PLAN; + 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 { fillPlan?: string }).fillPlan = + "Walk eight notes into the chorus downbeat; leave the vocal pickup empty."; + (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 = + "Shorten the last chorus vowel so the band can hear the cutoff."; + (song.sections[0]!.roles[0] as { hookPlan?: string }).hookPlan = + "Lead vocal carries the chorus hook; lock the melody before anyone stacks harmony."; + 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: DEMO_SOLO_PLAN + }; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("skips a blank solo plan", () => { + expect(resolveFirstSoloPlan(withSoloSection({ soloPlan: " " }))).toBeNull(); + }); + + it("skips a multi-line solo plan", () => { + expect( + resolveFirstSoloPlan(withSoloSection({ soloPlan: "Keep the melody centered.\nLeave the stack." })) + ).toBeNull(); + }); + + it("prefers the earlier of two solo plans", () => { + const song = withSoloSection({ + id: "verse-late-solo", + start: 40, + end: 56, + roleId: "keys-right", + soloPlan: "Late solo." + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "low", + soloPlan: "Earlier solo." + } + ]; + 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 = resolveFirstSoloPlan(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole.id).toBe("lead-vocal"); + expect(resolved?.soloPlan).toBe("Earlier solo."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time solo-plan ties with locale-independent id ordering", () => { + const song = withSoloSection({ id: "ä-solo", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-solo"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstSoloPlan(song)?.section.id).toBe("z-solo"); + }); + + it("prefers a high-priority solo part over a low-priority part in the same section", () => { + const song = withSoloSection({ + roleId: "keys-right", + roleName: "Keys", + priority: "low", + soloPlan: "Low-priority solo." + }); + const section = song.sections[0]!; + const highRole = { + ...section.roles[0]!, + id: "lead-vocal", + name: "Lead Vocal", + rehearsalPriority: "high" as const, + soloPlan: "High-priority solo." + }; + 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(resolveFirstSoloPlan(song)?.holdingRole.id).toBe("lead-vocal"); + expect(resolveFirstSoloPlan(song)?.soloPlan).toBe("High-priority solo."); + }); + + it("breaks equal-priority role ties with locale-independent id ordering", () => { + const song = withSoloSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + soloPlan: "ASCII solo." + }; + 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(resolveFirstSoloPlan(song)?.holdingRole.id).toBe("z-role"); + expect(resolveFirstSoloPlan(song)?.soloPlan).toBe("ASCII solo."); + }); + + it("skips a solo plan whose graph node is inactive", () => { + expect(resolveFirstSoloPlan(withSoloSection({ isActive: false }))).toBeNull(); + }); + + it("skips a solo plan whose rehearsal window is unbounded", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips a solo plan whose end precedes its start", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length solo-plan window", () => { + expect(resolveFirstSoloPlan(withSoloSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips a solo plan whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstSoloPlan( + withSoloSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstSoloPlan(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withSoloSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("keeps the solo plan unnamed when role identities are duplicated", () => { + const song = withSoloSection(); + 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(resolveFirstSoloPlan(song)).toBeNull(); + }); + + it("bounds the solo plan to 180 Unicode code points", () => { + const song = withSoloSection({ soloPlan: `${"G".repeat(200)}` }); + const resolved = resolveFirstSoloPlan(song); + expect(resolved?.soloPlan.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the solo-plan boundary", () => { + const song = withSoloSection({ soloPlan: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstSoloPlan(song); + expect(Array.from(resolved?.soloPlan ?? "")).toHaveLength(180); + expect(resolved?.soloPlan.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstSoloPlan.ts b/apps/desktop/src/features/workspace/firstSoloPlan.ts new file mode 100644 index 000000000..f7b1ca3bf --- /dev/null +++ b/apps/desktop/src/features/workspace/firstSoloPlan.ts @@ -0,0 +1,409 @@ +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_SOLO_PLAN_CHARACTERS = 180; +const MAX_RUNTIME_GRAPH_PROPERTIES = 100_000; +const SECTION_FORM_LABEL_SET = new Set(SECTION_FORM_LABELS); + +type RankedRoleMetadata = Readonly<{ + role: RehearsalRole; + id: string; + name: string; + rehearsalPriority: keyof typeof PRIORITY_RANK; +}>; + +type RuntimeGraphBudget = { + properties: number; +}; + +/** Tonight's first solo plan: the earliest labeled section and the part that owns it. */ +export type FirstSoloPlan = { + section: RehearsalSection; + sectionId: string; + sectionLabel: RehearsalSection["label"]; + holdingRole: RehearsalRole; + holdingRoleId: string; + holdingRoleName: string; + soloPlan: string; + atSeconds: number; +}; + +/** Format a non-negative solo-plan time as m:ss for rehearsal copy. */ +export function formatSoloPlanTime(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); +} + +/** Accept only ordinary structured-data containers, never built-in or class-instance exotics. */ +function hasPlainStructuredPrototype(value: object): boolean { + const prototype = Object.getPrototypeOf(value); + if (Array.isArray(value)) { + return prototype === Array.prototype; + } + return prototype === Object.prototype || prototype === null; +} + +/** + * Verify that a structured-data graph contains only plain containers and own data properties. + * + * Descriptor inspection avoids executing application getters. Prototype checks reject built-in + * and class-instance exotic objects before their attached properties can become buyer-visible + * authority. The subsequent HTML structured-clone probe rejects Proxy exotic objects, and the + * property budget keeps this untrusted boundary finite before the resolver walks it. + */ +function hasSafeStructuredDataDescriptors( + value: unknown, + seen: Set, + budget: RuntimeGraphBudget +): boolean { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "undefined" || + typeof value === "bigint" + ) { + return true; + } + if (typeof value !== "object" || !hasPlainStructuredPrototype(value)) { + return false; + } + if (seen.has(value)) { + return true; + } + seen.add(value); + + const keys = Reflect.ownKeys(value); + budget.properties += keys.length; + if (budget.properties > MAX_RUNTIME_GRAPH_PROPERTIES) { + return false; + } + + for (const key of keys) { + if (typeof key === "symbol") { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !Object.prototype.hasOwnProperty.call(descriptor, "value") || + !hasSafeStructuredDataDescriptors(descriptor.value, seen, budget) + ) { + return false; + } + } + return true; +} + +/** Fail closed unless the entire runtime graph is plain, descriptor-only, and clone compatible. */ +function hasSafeStructuredRuntimeGraph(value: unknown): boolean { + if (typeof structuredClone !== "function") { + return false; + } + try { + if (!hasSafeStructuredDataDescriptors(value, new Set(), { properties: 0 })) { + return false; + } + structuredClone(value); + return true; + } catch { + return false; + } +} + +/** 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"); +} + +/** Snapshot one owned data-property value without invoking a getter or Proxy get trap. */ +function ownDataValue(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value") + ? descriptor.value + : undefined; +} + +/** Snapshot every numeric own data element from a bounded runtime array. */ +function ownedDenseRuntimeArray(value: unknown): unknown[] | null { + if (!Array.isArray(value)) { + return null; + } + const length = ownDataValue(value, "length"); + if ( + typeof length !== "number" || + !Number.isSafeInteger(length) || + length < 0 || + length > 0xffffffff + ) { + return null; + } + const items: unknown[] = []; + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return null; + } + items.push(ownDataValue(value, index)); + } + 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 solo plan, or null when it cannot be shown. */ +function ownedSoloPlan(role: unknown): string | null { + if (!isRuntimeObject(role)) { + return null; + } + const soloPlan = ownDataValue(role, "soloPlan"); + if (typeof soloPlan !== "string") { + return null; + } + const trimmed = soloPlan.trim(); + if (trimmed.length === 0 || trimmed.includes("\n") || trimmed.includes("\r")) { + return null; + } + return truncateCodePoints(trimmed, MAX_SOLO_PLAN_CHARACTERS); +} + +/** Snapshot trusted role identity, display name, and priority without accessor authority. */ +function ownedRankedRoleMetadata(role: unknown): RankedRoleMetadata | null { + if (!isRuntimeObject(role)) { + return null; + } + const id = ownDataValue(role, "id"); + const name = ownDataValue(role, "name"); + const rehearsalPriority = ownDataValue(role, "rehearsalPriority"); + if ( + typeof id !== "string" || + id.trim().length === 0 || + typeof name !== "string" || + name.trim().length === 0 || + typeof rehearsalPriority !== "string" || + !Object.prototype.hasOwnProperty.call(PRIORITY_RANK, rehearsalPriority) + ) { + return null; + } + return { + role: role as RehearsalRole, + id, + name, + rehearsalPriority: rehearsalPriority as keyof typeof PRIORITY_RANK + }; +} + +/** Snapshot a section's bounded positive-length integer rehearsal window. */ +function ownedBoundedTimeRange( + section: RehearsalSection +): RehearsalSection["timeRange"] | null { + const timeRange = ownDataValue(section, "timeRange"); + if (!isRuntimeObject(timeRange)) { + return null; + } + const start = ownDataValue(timeRange, "start"); + const end = ownDataValue(timeRange, "end"); + if ( + typeof start !== "number" || + !Number.isInteger(start) || + start < 0 || + start > MAX_SECTION_TIME_SECONDS || + typeof end !== "number" || + !Number.isInteger(end) || + end <= start || + end > MAX_SECTION_TIME_SECONDS + ) { + return null; + } + return { start, end }; +} + +/** 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: RankedRoleMetadata[]): RankedRoleMetadata | 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): RankedRoleMetadata[] { + const roles = ownedDenseRuntimeArray(ownDataValue(section, "roles")); + const partGraph = ownedDenseRuntimeArray(ownDataValue(section, "partGraph")); + if (!roles || !partGraph) { + return []; + } + + const safeRoleIds = roles.flatMap((role) => { + if (!isRuntimeObject(role)) { + return []; + } + const id = ownDataValue(role, "id"); + return typeof id === "string" && id.trim().length > 0 ? [id] : []; + }); + const safeGraphRoleIds = partGraph.flatMap((node) => { + if (!isRuntimeObject(node)) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && roleId.trim().length > 0 ? [roleId] : []; + }); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + partGraph.flatMap((node) => { + if (!isRuntimeObject(node) || ownDataValue(node, "is_active") !== true) { + return []; + } + const roleId = ownDataValue(node, "role_id"); + return typeof roleId === "string" && + roleId.trim().length > 0 && + !repeatedGraphRoleIds.has(roleId) + ? [roleId] + : []; + }) + ); + + return roles.flatMap((role) => { + const metadata = ownedRankedRoleMetadata(role); + return metadata !== null && + !repeatedRoleIds.has(metadata.id) && + activeIds.has(metadata.id) + ? [metadata] + : []; + }); +} + +/** Resolve a solo plan after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { + if (!isRuntimeObject(song) || !hasSafeStructuredRuntimeGraph(song)) { + return null; + } + const sections = ownedDenseRuntimeArray(ownDataValue(song, "sections")); + if (!sections) { + return null; + } + + const candidates = sections + .flatMap((section) => { + if (!isRuntimeObject(section)) { + return []; + } + const sectionId = ownDataValue(section, "id"); + const sectionLabel = ownDataValue(section, "label"); + const timeRange = ownedBoundedTimeRange(section as RehearsalSection); + if ( + typeof sectionId !== "string" || + sectionId.trim().length === 0 || + typeof sectionLabel !== "string" || + !SECTION_FORM_LABEL_SET.has(sectionLabel) || + timeRange === null + ) { + return []; + } + + const holdingRole = pickHoldingRole( + rankedActiveRoles(section as RehearsalSection).filter( + (metadata) => ownedSoloPlan(metadata.role) !== null + ) + ); + if (!holdingRole) { + return []; + } + const soloPlan = ownedSoloPlan(holdingRole.role); + if (!soloPlan) { + return []; + } + return [ + { + section: section as RehearsalSection, + sectionId, + sectionLabel: sectionLabel as RehearsalSection["label"], + holdingRole: holdingRole.role, + holdingRoleId: holdingRole.id, + holdingRoleName: holdingRole.name, + soloPlan, + atSeconds: timeRange.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 solo plan, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstSoloPlan(song: RehearsalSong): FirstSoloPlan | null { + try { + return resolveSafeFirstSoloPlan(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..34ab47f4f 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", "pre-chorus")).toBe("pre-chorus"); + }); + + it("does not read inherited Object keys as section labels", () => { + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("en", inheritedKey)).toBe("toString"); + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-solo-plan next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstSoloPlanOpenAction")).toBe("{at} {role} 솔로 열기"); + expect(t("firstSoloPlanBody")).toBe("{at} {section}에서 {role} 파트의 솔로 계획이 있습니다."); + expect(t("firstSoloPlanArmed")).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..3331287d9 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", + "firstSoloPlanLabel": "Tonight's first solo plan", + "firstSoloPlanOpenAction": "Open {role} solo at {at}", + "firstSoloPlanBody": "{role} still has a solo plan in the {section} at {at}.", + "firstSoloPlanArmed": "Lock that solo on {role} at {at} before the room starts.", + "firstSoloPlanUnavailable": "No solo 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..28089afcc 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,11 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstSoloPlanLabel": "오늘 첫 솔로 계획", + "firstSoloPlanOpenAction": "{at} {role} 솔로 열기", + "firstSoloPlanBody": "{at} {section}에서 {role} 파트의 솔로 계획이 있습니다.", + "firstSoloPlanArmed": "{at}에서 {role} 파트의 솔로를 맞춘 다음 합주를 시작하세요.", + "firstSoloPlanUnavailable": "사용 가능한 솔로 계획이 없습니다. 다음 합주 큐를 위해 오늘 맵에 머무르세요.", "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..f991a56d4 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -6,6 +6,19 @@ 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/firstSoloPlan.ts", + "src/features/workspace/FirstSoloPlanCallout.tsx" +]; + + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -19,14 +32,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..1443fbf3e 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 Solo Plan Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstSoloPlanCallout.tsx` | Name the owning part when an active graph node corroborates it, the owned `soloPlan` 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`, `fillPlan`, `tuningPlan`, `dynamicsPlan`, `articulationPlan`, `hookPlan`, confirmed overrides, `harmonicExplanation`, or confidence notes. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-hook-plan, first-fill-plan, 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-solo-plan-navigation.md b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md new file mode 100644 index 000000000..ea5d7fc7a --- /dev/null +++ b/docs/doctoring/reduced-motion-first-solo-plan-navigation.md @@ -0,0 +1,57 @@ +# First solo-plan runtime and navigation contract + +## Buyer outcome + +The first-solo callout may name a rehearsal plan only when the current runtime song graph provides an owned, corroborated solo plan for an active role in a labeled section. If that authority cannot be established safely, the customer stays on the rehearsal map and receives the unavailable next-action guidance instead of invented or partially trusted copy. + +## Runtime authority boundary + +`resolveFirstSoloPlan` treats the incoming song as untrusted runtime data. Before any buyer-visible solo guidance is selected, the graph must satisfy all of these conditions: + +- the graph is finite under the repository's bounded property budget; +- only ordinary arrays and plain objects are accepted as structured-data containers; built-in exotics and class instances are rejected; +- every traversed own property is a data property, so application getters are never executed to establish rehearsal authority; +- symbol/function and accessor-backed authority is rejected; +- the graph is compatible with the HTML structured clone algorithm; and +- Proxy objects fail closed rather than supplying fabricated own-property descriptors. + +The plain-container check prevents `Map`, `Set`, `Date`, typed arrays, or class instances with attached rehearsal-looking properties from becoming product authority. The structured-clone probe remains a second boundary for Proxy/exotic behavior: the HTML Living Standard requires structured serialization to throw `DataCloneError` for unsupported exotic objects and explicitly gives a proxy object as an example. Descriptor-only preflight therefore avoids executing ordinary getters, while the clone probe prevents a Proxy from becoming buyer-visible authority merely by trapping `getOwnPropertyDescriptor` (WHATWG, 2026). + +The resolver continues to require owned role identity, display name, rehearsal priority, bounded section time, unique active graph identity, and a bounded single-line `soloPlan`. Groove, cue, chord, simplification, overlap, setup, fill, tuning, dynamics, articulation, hook, transposition, override, harmonic explanation, and confidence text do not substitute for an owned solo plan. + +### Logging and privacy + +Rejected rehearsal metadata is not diagnostic payload. The resolver and navigation path do not log buyer-provided `soloPlan` text, role identifiers, section identifiers, or rejected graph contents. A safe failure returns guidance-only state and does not echo the rejected value, path, or object shape into logs. This keeps local rehearsal content on-device and prevents malformed runtime metadata from becoming an observability side channel. + +### Validation and test points + +The executable regression boundary covers: + +- own accessors and Proxy `get` / `getOwnPropertyDescriptor` substitution attempts; +- built-in exotic containers, including a `Map` carrying otherwise valid role metadata; +- malformed, sparse, duplicated, inherited, or unbounded runtime graph data; +- missing and duplicate rendered navigation targets, which must remain unarmed; +- stable `data-section-id` navigation when rendered order changes; and +- `prefers-reduced-motion: reduce`, which must use immediate (`auto`) scrolling. + +These checks belong to `resolveFirstSoloPlan`, `FirstSoloPlanCallout`, and the Workspace navigation regressions. New authority paths must extend those executable boundaries rather than relying on prose-only assurance. + +## Stable map navigation + +The Open action navigates by the exact stable `sectionId`, not by the section's current positional index. The action first requires one unambiguous song-structure renderer in the current workspace scope and then requires exactly one rendered element whose `data-section-id` equals the owned section identity. The id is compared as data rather than interpolated into a CSS selector, so punctuation or selector-like text cannot change selector semantics. Ambiguous or missing targets fail closed and do not switch the customer to the "locked" guidance state. + +The shared `SectionRoadmap` component exposes the stable section identity used by this navigation contract. Storybook's `Workspace/First Solo Plan Callout` stories exercise the reusable runtime callout and roadmap in both **Available** and **Unavailable** states rather than duplicating product markup. + +## Reduced motion + +When the operating-system preference `prefers-reduced-motion: reduce` matches, Open uses `behavior: "auto"` rather than smooth scrolling. W3C's current technique for interaction-triggered JavaScript motion recommends evaluating the reduced-motion media query so non-essential motion can be suppressed (World Wide Web Consortium [W3C], 2026). + +## Figma reconciliation status + +Repository runtime behavior and Storybook remain the executable source-backed contract. Fresh Figma metadata checked on 2026-08-25 for the repository-declared file `zthWmqfNKUgJBECvv002Qk` exposed only the `00 Cover` page (`16:2`), not the previously documented component-catalog page. This PR therefore does **not** claim a current Figma mapping for the first-solo callout. Figma/Storybook/source reconciliation remains tracked by BandScope issue #965 and must be corrected there before a Figma node is treated as implementation evidence. + +## References + +WHATWG. (2026). *HTML Living Standard: Safe passing of structured data*. https://html.spec.whatwg.org/multipage/structured-data.html + +World Wide Web Consortium. (2026). *SCR40: Using the CSS prefers-reduced-motion query in JavaScript to prevent motion*. https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR40 diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index cba4606a2..50591fb4e 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -139,6 +139,8 @@ export type RehearsalRole = { simplification: string; setupNote: string; transpositionPlan?: string; + /** Rehearsal-facing solo guidance owned by this role when runtime graph evidence corroborates it. */ + soloPlan?: string; manualOverrides: ManualOverride[]; overlapWarnings: string[]; transcription?: TranscriptionNote[]; @@ -506,6 +508,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.", + soloPlan: "Hold the verse solo; everyone else drops to a two-bar pad so the run can land.", manualOverrides: [], overlapWarnings: [ "Melodic overlap: top notes conflict with Lead Vocal range." @@ -1497,6 +1500,7 @@ function validateRehearsalRole(value: unknown, path: string): string | null { "simplification", "setupNote", "transpositionPlan", + "soloPlan", "manualOverrides", "overlapWarnings", "transcription", @@ -1552,6 +1556,9 @@ function validateRehearsalRole(value: unknown, path: string): string | null { if (value.transpositionPlan !== undefined && typeof value.transpositionPlan !== "string") { return invalidField(`${path}.transpositionPlan`); } + if (value.soloPlan !== undefined && typeof value.soloPlan !== "string") { + return invalidField(`${path}.soloPlan`); + } 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..7e120d3a9 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]?.soloPlan).toContain("verse solo"); 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[1].soloPlan", + payload: createInvalidSong((song) => { + song.sections[0]!.roles[1]!.soloPlan = 2 as never; + }) + }, { message: "sections[0].roles[0].practiceProgress", payload: createInvalidSong((song) => {