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 ` + + ); +} \ No newline at end of file diff --git a/apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstEarCheckCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..f123c6c58 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstEarCheckCallout.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 { FirstEarCheckCallout } from "./FirstEarCheckCallout"; + +describe("FirstEarCheckCallout 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 Bass Guitar ear check 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..d4590f47d 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -326,4 +326,27 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first ear check as workspace navigation", () => { + 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("Watch the slide into the turnaround.").length).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar ear check at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect(screen.getByText(/Confirm Bass Guitar by ear 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..03b258b4c 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 { FirstEarCheckCallout } from "./FirstEarCheckCallout"; 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/earCheckCoverageContract.test.ts b/apps/desktop/src/features/workspace/earCheckCoverageContract.test.ts new file mode 100644 index 000000000..57d458fd4 --- /dev/null +++ b/apps/desktop/src/features/workspace/earCheckCoverageContract.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 first ear-check resolver and callout inside the coverage gate", () => { + expect(DESKTOP_OWNED_PRODUCTION_COVERAGE).toEqual( + expect.arrayContaining([ + "src/features/workspace/firstEarCheck.ts", + "src/features/workspace/FirstEarCheckCallout.tsx" + ]) + ); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.inactive-role-hint.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.inactive-role-hint.test.ts new file mode 100644 index 000000000..0f25fcc1a --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.inactive-role-hint.test.ts @@ -0,0 +1,53 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstEarCheck } from "./firstEarCheck"; + +describe("resolveFirstEarCheck inactive-role hint ownership", () => { + function songWithInactiveOnlyUncertainty() { + const song = createDemoRehearsalSong(); + const section = song.sections[0]!; + const role = section.roles[0]!; + + section.confidence = { + level: "high", + source: "model", + notes: "Section is not uncertain." + }; + role.confidence = { + level: "medium", + source: "model", + notes: "Inactive role note must not become band-wide guidance." + }; + section.roles = [role]; + section.partGraph = [ + { + role_id: role.id, + is_active: false, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; + } + + it("skips a section whose only uncertainty belongs to an inactive part", () => { + expect(resolveFirstEarCheck(songWithInactiveOnlyUncertainty())).toBeNull(); + }); + + it("reports a band-wide ear check from section uncertainty without surfacing inactive-part notes", () => { + const song = songWithInactiveOnlyUncertainty(); + const section = song.sections[0]!; + section.confidence = { + level: "medium", + source: "model", + notes: "Section-level notes carry tonight's guidance." + }; + + const resolved = resolveFirstEarCheck(song); + + expect(resolved?.section.id).toBe(section.id); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Section-level notes carry tonight's guidance."); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts new file mode 100644 index 000000000..484764ce8 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.inherited-metadata.test.ts @@ -0,0 +1,126 @@ +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstEarCheck } from "./firstEarCheck"; + +function songWithEarCheck() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "ear-check-own"; + section.confidence = { + level: "high", + source: "model", + notes: "Ready to trust the form." + }; + section.roles = [ + { + ...section.roles[0]!, + confidence: { + level: "medium", + source: "model", + notes: "Watch the slide into the turnaround." + } + } + ]; + song.sections = [section]; + return { song, section }; +} + +describe("resolveFirstEarCheck inherited metadata", () => { + it("rejects a song or section whose required metadata is inherited", () => { + const { song, section } = songWithEarCheck(); + const inheritedSong = Object.create({ sections: song.sections }) as typeof song; + expect(resolveFirstEarCheck(inheritedSong)).toBeNull(); + + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("rejects inherited timing fields", () => { + const { song, section } = songWithEarCheck(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song, section } = songWithEarCheck(); + Object.defineProperty(section.roles[0]!, "confidence", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile confidence getter"); + } + }); + + expect(() => resolveFirstEarCheck(song)).not.toThrow(); + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("does not treat own accessors as stable ear-check identity authority", () => { + const { song, section } = songWithEarCheck(); + Object.defineProperty(section, "id", { + configurable: true, + enumerable: true, + get() { + return "ear-check-own"; + } + }); + + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("does not let inherited confidence establish the ear check", () => { + const { song, section } = songWithEarCheck(); + const inheritedRole = Object.create({ + confidence: { + level: "low", + source: "model", + notes: "Inherited ear check" + } + }) as typeof section.roles[0]; + Object.defineProperties(inheritedRole, { + id: { configurable: true, enumerable: true, value: "bass-guitar" }, + name: { configurable: true, enumerable: true, value: "Bass Guitar" }, + rehearsalPriority: { configurable: true, enumerable: true, value: "high" } + }); + section.roles = [inheritedRole]; + section.confidence = { + level: "high", + source: "model", + notes: "Ready to trust the form." + }; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("fails the ear check closed when inherited role or graph metadata cannot prove activity", () => { + const { song, section } = songWithEarCheck(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("keeps a section-owned ear check band-wide without trusting inherited graph metadata", () => { + const { song, section } = songWithEarCheck(); + const node = section.partGraph[0]!; + section.partGraph = [Object.create(node) as typeof node]; + section.confidence = { + level: "medium", + source: "model", + notes: "Section-level notes carry tonight's guidance." + }; + + const resolved = resolveFirstEarCheck(song); + + expect(resolved?.section.id).toBe("ear-check-own"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Section-level notes carry tonight's guidance."); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithEarCheck(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.section-label.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.section-label.test.ts new file mode 100644 index 000000000..490b15f91 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.section-label.test.ts @@ -0,0 +1,27 @@ +import { + createDemoRehearsalSong, + type RehearsalSection, + type RehearsalSong +} from "@bandscope/shared-types"; +import { expect, it } from "vitest"; +import { resolveFirstEarCheck } from "./firstEarCheck"; + +it("rejects a noncanonical section label instead of surfacing untranslated rehearsal copy", () => { + const song = createDemoRehearsalSong(); + const section = song.sections[0]; + if (!section) { + throw new Error("demo song must contain an opening section"); + } + + const runtimeSong = { + ...song, + sections: [ + { + ...section, + label: "verse-custom" + } as unknown as RehearsalSection + ] + } as RehearsalSong; + + expect(resolveFirstEarCheck(runtimeSong)).toBeNull(); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.test.ts b/apps/desktop/src/features/workspace/firstEarCheck.test.ts new file mode 100644 index 000000000..00015567e --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { MAX_SECTION_TIME_SECONDS, createDemoRehearsalSong } from "@bandscope/shared-types"; +import { formatEarCheckTime, resolveFirstEarCheck } from "./firstEarCheck"; + +function withEarCheckSection( + overrides: { + id?: string; + start?: number; + end?: number; + notes?: string; + level?: "low" | "medium" | "high"; + sectionLevel?: "low" | "medium" | "high"; + label?: "intro" | "verse" | "chorus" | "bridge" | "outro" | "tag"; + roleId?: string; + roleName?: string; + priority?: "low" | "medium" | "high"; + isActive?: boolean; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.id ?? "verse-ear-check"; + section.label = overrides.label ?? "verse"; + section.groove = "Straight eighths with a late snare feel"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + section.confidence = { + level: overrides.sectionLevel ?? "high", + source: "model", + notes: "Section-level notes should not invent a clash." + }; + const roleId = overrides.roleId ?? "bass-guitar"; + section.roles = [ + { + ...verse.roles[0]!, + id: roleId, + name: overrides.roleName ?? "Bass Guitar", + rehearsalPriority: overrides.priority ?? "high", + overlapWarnings: [], + setupNote: "Keep the attack short so the verse breathes.", + simplification: "Stay on roots if the chorus entrance gets muddy.", + cue: { kind: "transition", value: "Hold through the pickup." }, + range: { lowestNote: "C#2", highestNote: "E3" }, + confidence: { + level: overrides.level ?? "medium", + source: "model", + notes: overrides.notes ?? "Watch the slide into the turnaround." + } + } + ]; + section.partGraph = [ + { + role_id: roleId, + is_active: overrides.isActive ?? true, + handoff_to: [], + handoff_from: [] + } + ]; + song.sections = [section]; + return song; +} + +describe("resolveFirstEarCheck", () => { + it("picks the demo song's earliest named ear check and the part that carries it", () => { + const resolved = resolveFirstEarCheck(createDemoRehearsalSong()); + expect(resolved?.section.id).toBe("verse-1"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.atSeconds).toBe(10); + expect(resolved?.hint).toBe("Watch the slide into the turnaround."); + expect(formatEarCheckTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatEarCheckTime(Number.NaN)).toBe("0:00"); + expect(formatEarCheckTime(-4)).toBe("0:00"); + }); + + it("does not invent an ear check from groove, cue, setup, simplification, overlap, or range copy", () => { + const song = withEarCheckSection({ level: "high", sectionLevel: "high", notes: " " }); + song.sections[0]!.groove = "Straight eighths with a late snare feel"; + song.sections[0]!.roles[0]!.setupNote = "Keep the attack short so the verse breathes."; + song.sections[0]!.roles[0]!.simplification = "Stay on roots if the chorus entrance gets muddy."; + song.sections[0]!.roles[0]!.cue = { kind: "transition", value: "Hold through the pickup." }; + song.sections[0]!.roles[0]!.range = { lowestNote: "C#2", highestNote: "E3" }; + song.sections[0]!.roles[0]!.overlapWarnings = [ + "Density warning: competing with Keyboard Left Hand in low register." + ]; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("still names an ear check when owned notes are empty", () => { + const resolved = resolveFirstEarCheck(withEarCheckSection({ notes: " " })); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.hint).toBe(""); + }); + + it("prefers the earlier of two named ear checks", () => { + const song = withEarCheckSection({ id: "verse-late", start: 40, end: 56, roleId: "keys-right" }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.roles = [ + { + ...earlier.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "medium", + confidence: { + level: "low", + source: "model", + notes: "Bass entrance is still a guess." + } + } + ]; + earlier.timeRange = { start: 8, end: 24 }; + earlier.partGraph = [{ role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] }]; + song.sections = [song.sections[0]!, earlier]; + + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-early"); + expect(resolved?.holdingRole?.id).toBe("bass-guitar"); + expect(resolved?.hint).toBe("Bass entrance is still a guess."); + expect(resolved?.atSeconds).toBe(8); + }); + + it("breaks same-time ear-check ties with locale-independent id ordering", () => { + const song = withEarCheckSection({ id: "ä-ear-check", start: 10, end: 26 }); + const ascii = structuredClone(song.sections[0]!); + ascii.id = "z-ear-check"; + song.sections = [song.sections[0]!, ascii]; + + expect(resolveFirstEarCheck(song)?.section.id).toBe("z-ear-check"); + }); + + it("prefers a low-confidence role over a medium-confidence role in the same section", () => { + const song = withEarCheckSection({ roleId: "keys-right", roleName: "Keys", level: "medium" }); + const section = song.sections[0]!; + const lowRole = { + ...section.roles[0]!, + id: "bass-guitar", + name: "Bass Guitar", + rehearsalPriority: "low", + confidence: { + level: "low" as const, + source: "model" as const, + notes: "Bass still needs an ear check." + } + }; + section.roles = [section.roles[0]!, lowRole]; + section.partGraph = [ + { role_id: "keys-right", is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: "bass-guitar", is_active: true, handoff_to: [], handoff_from: [] } + ]; + + expect(resolveFirstEarCheck(song)?.holdingRole?.id).toBe("bass-guitar"); + expect(resolveFirstEarCheck(song)?.hint).toBe("Bass still needs an ear check."); + }); + + it("breaks equal-uncertainty role ties with locale-independent id ordering", () => { + const song = withEarCheckSection({ roleId: "ä-role", roleName: "Umlaut role", priority: "high" }); + const section = song.sections[0]!; + const asciiRole = { + ...section.roles[0]!, + id: "z-role", + name: "ASCII role", + confidence: { + level: "medium" as const, + source: "model" as const, + notes: "ASCII ear check" + } + }; + 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(resolveFirstEarCheck(song)?.holdingRole?.id).toBe("z-role"); + }); + + it("keeps a section-wide ear check when no active ranked role carries it", () => { + const song = withEarCheckSection({ isActive: false, sectionLevel: "medium" }); + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole).toBeNull(); + expect(resolved?.hint).toBe("Section-level notes should not invent a clash."); + }); + + it("skips an ear check whose rehearsal window is unbounded", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: Number.NaN, end: 30 }))).toBeNull(); + }); + + it("skips an ear check whose end precedes its start", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: 30, end: 10 }))).toBeNull(); + }); + + it("skips a zero-length ear-check window", () => { + expect(resolveFirstEarCheck(withEarCheckSection({ start: 10, end: 10 }))).toBeNull(); + }); + + it("skips an ear check whose endpoint overflows the shared timing bound", () => { + expect( + resolveFirstEarCheck( + withEarCheckSection({ + start: MAX_SECTION_TIME_SECONDS, + end: MAX_SECTION_TIME_SECONDS + 1 + }) + ) + ).toBeNull(); + }); + + it("returns null for a non-object song root", () => { + expect(resolveFirstEarCheck(null as never)).toBeNull(); + }); + + it("returns null when the runtime section collection is sparse", () => { + const song = withEarCheckSection(); + const sparseSections: typeof song.sections = new Array(2); + sparseSections[1] = song.sections[0]!; + song.sections = sparseSections; + expect(resolveFirstEarCheck(song)).toBeNull(); + }); + + it("keeps the ear check section-wide when role identities are duplicated", () => { + const song = withEarCheckSection({ sectionLevel: "medium" }); + const role = song.sections[0]!.roles[0]!; + song.sections[0]!.roles = [role, { ...role }]; + song.sections[0]!.partGraph = [ + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] }, + { role_id: role.id, is_active: true, handoff_to: [], handoff_from: [] } + ]; + const resolved = resolveFirstEarCheck(song); + expect(resolved?.section.id).toBe("verse-ear-check"); + expect(resolved?.holdingRole).toBeNull(); + }); + + it("bounds the ear-check hint to 180 Unicode code points", () => { + const song = withEarCheckSection({ notes: `${"a".repeat(200)}` }); + const resolved = resolveFirstEarCheck(song); + expect(resolved?.hint.length).toBe(180); + }); + + it("does not split a Unicode surrogate pair at the hint boundary", () => { + const song = withEarCheckSection({ notes: `${"a".repeat(179)}😀tail` }); + const resolved = resolveFirstEarCheck(song); + expect(Array.from(resolved?.hint ?? "")).toHaveLength(180); + expect(resolved?.hint.endsWith("😀")).toBe(true); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstEarCheck.ts b/apps/desktop/src/features/workspace/firstEarCheck.ts new file mode 100644 index 000000000..149b8a6f8 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstEarCheck.ts @@ -0,0 +1,329 @@ +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 UNCERTAINTY_RANK = { low: 0, medium: 1 } as const; +const MAX_EAR_CHECK_CHARACTERS = 180; + +/** Tonight's first named ear check: the earliest uncertain labeled section and the part that carries it. */ +export type FirstEarCheck = { + section: RehearsalSection; + holdingRole: RehearsalRole | null; + atSeconds: number; + hint: string; +}; + +/** Format a non-negative ear-check time as m:ss for rehearsal copy. */ +export function formatEarCheckTime(totalSeconds: number): string { + const safeSeconds = Number.isFinite(totalSeconds) && totalSeconds >= 0 ? totalSeconds : 0; + const minutes = Math.floor(safeSeconds / 60); + const seconds = Math.floor(safeSeconds % 60) + .toString() + .padStart(2, "0"); + return `${minutes}:${seconds}`; +} + +/** Compare opaque ids by Unicode code units so tie-breaking never depends on host locale. */ +function compareStableId(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +/** Return whether an untrusted runtime value can be inspected as a record. */ +function isRuntimeObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Return whether a runtime record owns a stable data property rather than inherited/accessor state. */ +function hasOwnData(value: object, key: PropertyKey): boolean { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && Object.prototype.hasOwnProperty.call(descriptor, "value"); +} + +/** Return whether every numeric index is an own data element in a bounded runtime array. */ +function isDenseRuntimeArray(value: unknown): value is unknown[] { + if (!Array.isArray(value)) { + return false; + } + const length = Number(value.length); + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffffffff) { + return false; + } + for (let index = 0; index < length; index += 1) { + if (!hasOwnData(value, index)) { + return false; + } + } + return true; +} + +/** Bound buyer-visible text by Unicode code points without splitting a surrogate pair. */ +function truncateCodePoints(value: string, maximum: number): string { + let codePoints = 0; + let endIndex = 0; + for (const character of value) { + if (codePoints >= maximum) { + break; + } + endIndex += character.length; + codePoints += 1; + } + return endIndex === value.length ? value : value.slice(0, endIndex); +} + +/** Return the owned low/medium confidence level, or null when the field cannot be shown. */ +function ownedEarCheckLevel(record: object): "low" | "medium" | null { + if (!hasOwnData(record, "confidence")) { + return null; + } + const confidence = (record as { confidence?: unknown }).confidence; + if (!isRuntimeObject(confidence) || !hasOwnData(confidence, "level")) { + return null; + } + const level = (confidence as { level?: unknown }).level; + if (level === "low" || level === "medium") { + return level; + } + return null; +} + +/** Return bounded owned confidence notes, or an empty string when none can be shown. */ +function ownedEarCheckNotes(record: object): string { + if (!hasOwnData(record, "confidence")) { + return ""; + } + const confidence = (record as { confidence?: unknown }).confidence; + if (!isRuntimeObject(confidence) || !hasOwnData(confidence, "notes")) { + return ""; + } + const notes = (confidence as { notes?: unknown }).notes; + if (typeof notes !== "string") { + return ""; + } + return truncateCodePoints(notes.trim(), MAX_EAR_CHECK_CHARACTERS); +} + +/** Return true when the role has safe owned identity/copy and ranked rehearsal priority. */ +function hasRankedPriority(role: RehearsalRole): boolean { + return ( + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 && + hasOwnData(role, "name") && + typeof role.name === "string" && + role.name.trim().length > 0 && + hasOwnData(role, "rehearsalPriority") && + Object.prototype.hasOwnProperty.call(PRIORITY_RANK, role.rehearsalPriority) + ); +} + +/** Return whether a section owns a bounded, positive-length integer rehearsal window. */ +function hasBoundedTimeRange(section: RehearsalSection): boolean { + if (!hasOwnData(section, "timeRange")) { + return false; + } + const timeRange = section.timeRange as Partial | null; + if ( + !isRuntimeObject(timeRange) || + !hasOwnData(timeRange, "start") || + !hasOwnData(timeRange, "end") + ) { + return false; + } + + const start = timeRange.start ?? -1; + const end = timeRange.end ?? -1; + return ( + Number.isInteger(start) && + start >= 0 && + start <= MAX_SECTION_TIME_SECONDS && + Number.isInteger(end) && + end > start && + end <= MAX_SECTION_TIME_SECONDS + ); +} + +/** Return safe identities that appear more than once in one section-local collection. */ +function repeatedIds(ids: string[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const id of ids) { + if (seen.has(id)) { + repeated.add(id); + } else { + seen.add(id); + } + } + return repeated; +} + +/** Prefer the most uncertain ranked role, then rehearsal priority, then a locale-independent id. */ +function pickHoldingRole(roles: RehearsalRole[]): RehearsalRole | null { + if (roles.length === 0) { + return null; + } + return ( + [...roles].sort((left, right) => { + const leftLevel = ownedEarCheckLevel(left); + const rightLevel = ownedEarCheckLevel(right); + const leftRank = leftLevel === null ? Number.POSITIVE_INFINITY : UNCERTAINTY_RANK[leftLevel]; + const rightRank = rightLevel === null ? Number.POSITIVE_INFINITY : UNCERTAINTY_RANK[rightLevel]; + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + const priorityDelta = PRIORITY_RANK[left.rehearsalPriority] - PRIORITY_RANK[right.rehearsalPriority]; + if (priorityDelta !== 0) { + return priorityDelta; + } + return compareStableId(left.id, right.id); + })[0] ?? null + ); +} + +/** Return ranked roles whose unique graph node is explicitly active. */ +function rankedActiveRoles(section: RehearsalSection): RehearsalRole[] { + if ( + !hasOwnData(section, "roles") || + !hasOwnData(section, "partGraph") || + !isDenseRuntimeArray(section.roles) || + !isDenseRuntimeArray(section.partGraph) + ) { + return []; + } + + const safeRoleIds = section.roles + .filter( + (role) => + isRuntimeObject(role) && + hasOwnData(role, "id") && + typeof role.id === "string" && + role.id.trim().length > 0 + ) + .map((role) => role.id); + const safeGraphRoleIds = section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 + ) + .map((node) => node.role_id); + const repeatedRoleIds = repeatedIds(safeRoleIds); + const repeatedGraphRoleIds = repeatedIds(safeGraphRoleIds); + const activeIds = new Set( + section.partGraph + .filter( + (node) => + isRuntimeObject(node) && + hasOwnData(node, "is_active") && + node.is_active === true && + hasOwnData(node, "role_id") && + typeof node.role_id === "string" && + node.role_id.trim().length > 0 && + !repeatedGraphRoleIds.has(node.role_id) + ) + .map((node) => node.role_id) + ); + + return section.roles.filter( + (role) => + isRuntimeObject(role) && + hasRankedPriority(role) && + !repeatedRoleIds.has(role.id) && + activeIds.has(role.id) + ); +} + +/** + * Return whether a section carries an actionable ear check: uncertainty owned by the section + * itself, or uncertainty owned by a provably active ranked part. Uncertainty that lives only on + * inactive or unrankable parts never claims tonight's ear check — band-wide copy is reserved for + * sections whose own confidence carries it. + */ +function sectionHasEarCheck(section: RehearsalSection): boolean { + if (ownedEarCheckLevel(section) !== null) { + return true; + } + return rankedActiveRoles(section).some((role) => ownedEarCheckLevel(role) !== null); +} + +/** Return notes owned by the named holding part, else notes owned by the section itself. */ +function ownedEarCheckHint(section: RehearsalSection, holdingRole: RehearsalRole | null): string { + if (holdingRole) { + return ownedEarCheckNotes(holdingRole); + } + if (ownedEarCheckLevel(section) !== null) { + return ownedEarCheckNotes(section); + } + return ""; +} + +/** Return whether the section label belongs to the shared canonical form vocabulary. */ +function hasCanonicalSectionLabel(section: RehearsalSection): boolean { + return ( + hasOwnData(section, "label") && + typeof section.label === "string" && + SECTION_FORM_LABELS.some((label) => label === section.label) + ); +} + +/** Resolve an ear check after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstEarCheck(song: RehearsalSong): FirstEarCheck | null { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return null; + } + + const candidates = song.sections + .filter( + (section) => + isRuntimeObject(section) && + hasCanonicalSectionLabel(section) && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) && + sectionHasEarCheck(section) + ) + .sort((left, right) => { + if (left.timeRange.start !== right.timeRange.start) { + return left.timeRange.start - right.timeRange.start; + } + return compareStableId(left.id, right.id); + }); + + const section = candidates[0]; + if (!section) { + return null; + } + + const holdingRole = pickHoldingRole( + rankedActiveRoles(section).filter((role) => ownedEarCheckLevel(role) !== null) + ); + + return { + section, + holdingRole, + atSeconds: section.timeRange.start, + hint: ownedEarCheckHint(section, holdingRole) + }; +} + +/** Return the first named ear check, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstEarCheck(song: RehearsalSong): FirstEarCheck | null { + try { + return resolveSafeFirstEarCheck(song); + } catch { + return null; + } +} \ No newline at end of file diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..294fdb7c4 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 section form label for Korean rehearsal copy", () => { + 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-ear-check next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstEarCheckOpenAction")).toBe("{at} {role} 귀 확인 위치 열기"); + expect(t("firstEarCheckBody")).toBe("{at} {section}에서 {role} 파트는 아직 귀 확인이 필요합니다."); + expect(t("firstEarCheckArmed")).toBe("{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요."); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..352eff65e 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import type { SectionFormLabel } from "@bandscope/shared-types"; import enCommon from "../locales/en/common.json"; import koCommon from "../locales/ko/common.json"; @@ -11,13 +12,46 @@ const dictionaries = { ko: koCommon } as const; -/** Documented. */ +const sectionFormLabels: Readonly>>> = { + en: { + intro: "intro", + verse: "verse", + "pre-chorus": "pre-chorus", + chorus: "chorus", + bridge: "bridge", + outro: "outro", + tag: "tag", + pickup: "pickup", + stop: "stop", + handoff: "handoff" + }, + ko: { + intro: "인트로", + verse: "벌스", + "pre-chorus": "프리코러스", + chorus: "코러스", + bridge: "브리지", + outro: "아웃트로", + tag: "태그", + pickup: "픽업", + stop: "스톱", + handoff: "핸드오프" + } +}; + +/** Create a locale-aware translation lookup that falls back to English copy. */ export function createTranslator(locale: Locale = "en") { return function t(key: TranslationKey): string { return dictionaries[locale][key] ?? dictionaries.en[key]; }; } +/** Return the localized display label for a supported rehearsal section form. */ +export function translateSectionFormLabel(locale: Locale, label: SectionFormLabel): string { + const labels = sectionFormLabels[locale] as Readonly>; + return Object.prototype.hasOwnProperty.call(labels, label) ? labels[label] : String(label); +} + /** Documented. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..36fdfb6c8 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstEarCheckLabel": "Tonight's first ear check", + "firstEarCheckOpenAction": "Open {role} ear check at {at}", + "firstEarCheckOpenActionBand": "Open the first ear check at {at}", + "firstEarCheckBody": "{role} still needs an ear check in the {section} at {at}.", + "firstEarCheckBodyBand": "The {section} still needs an ear check at {at}.", + "firstEarCheckArmed": "Confirm {role} by ear at {at} before the room starts.", + "firstEarCheckArmedBand": "Confirm the {section} by ear at {at} before the room starts.", + "firstEarCheckUnavailable": "Nothing still needs an ear check. Stay on tonight's map until a part is marked uncertain.", "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..9eaaf8075 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,14 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstEarCheckLabel": "오늘 첫 귀 확인", + "firstEarCheckOpenAction": "{at} {role} 귀 확인 위치 열기", + "firstEarCheckOpenActionBand": "{at} 첫 귀 확인 위치 열기", + "firstEarCheckBody": "{at} {section}에서 {role} 파트는 아직 귀 확인이 필요합니다.", + "firstEarCheckBodyBand": "{at} {section}에서 아직 귀 확인이 필요합니다.", + "firstEarCheckArmed": "{at}에서 {role} 파트를 귀로 확인한 다음 합주를 시작하세요.", + "firstEarCheckArmedBand": "{at} {section}에서 귀로 확인한 다음 합주를 시작하세요.", + "firstEarCheckUnavailable": "아직 귀로 확인할 구간이 없습니다. 확신이 낮은 파트가 표시될 때까지 오늘 지도에 머무르세요.", "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..45b45349a 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/firstEarCheck.ts", + "src/features/workspace/FirstEarCheckCallout.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..ce9ba6ef2 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -32,6 +32,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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. | | 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. | +| First Ear Check Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstEarCheckCallout.tsx` | Name the uncertain part when an active graph node corroborates it, the owned `confidence.notes` text, the labeled section start, and the time. Do not invent an ear check from `groove`, cue text, `setupNote`, `simplification`, overlap warnings, range copy, or `high` confidence. Open scrolls the renderer-owned song-structure section. Keep the unavailable state guidance-only. Distinct from first-overlap, first-groove, first-simplification, first-range, and first-form-label work. | | Source Control Stack | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-655 | `apps/desktop/src/App.tsx` | Feature-local source controls for local audio, YouTube URL import, project actions, and Start Analysis; keep before metrics at 375px. | | Export Action Group | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=19-731 | `apps/desktop/src/features/workspace/Workspace.tsx` | Feature-local export buttons call `handleExportCueSheet`, `handleExportChart`, and `handleExportHandoff`. | | Workspace State Matrix | https://www.figma.com/design/zthWmqfNKUgJBECvv002Qk/Bandscope-Design-System-v1?node-id=99-560 | `apps/desktop/src/features/workspace/WorkspaceStates.tsx`, `apps/desktop/src/App.tsx` | Whole-workspace empty, loading, error, and ready state routing; use before changing `renderWorkspaceState()`. | diff --git a/docs/doctoring/reduced-motion-first-ear-check-navigation.md b/docs/doctoring/reduced-motion-first-ear-check-navigation.md new file mode 100644 index 000000000..3df1ecb95 --- /dev/null +++ b/docs/doctoring/reduced-motion-first-ear-check-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-ear-check navigation + +Workspace map navigation for tonight's first ear check follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstEarCheckCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Ear-check resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, section, time-range, role, confidence marker, and section-local graph metadata are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: ear-check resolution accepts required fields only when the inspected record owns them, while renderer-owned song-structure children remain the only navigation targets; analysis `section.id` is never DOM-ID authority. The owned confidence-notes string is rendered as a text node and is never rescanned as template syntax. Groove, cue, setup, simplification, overlap, and range copy cannot invent an ear check. High confidence is not an ear check. +- Mitigations: runtime record guards reject arrays, dense collections require own indexed elements, required metadata fields must be own properties, `matchMedia` is read-only, scroll targets come from renderer child index, copy interpolation runs once, and the ear-check hint is bounded to 180 Unicode code points. +- Test points: inherited song/section/timing/role/graph/confidence metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.