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 ` + ) : null} + + ); +} diff --git a/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..750822e7c --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstApprovalCallout.workspace-scope.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstApprovalCallout } from "./FirstApprovalCallout"; + +describe("FirstApprovalCallout 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 verse approval 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..c37b4bdae 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -137,7 +137,7 @@ describe("Workspace", () => { expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); expect(screen.getByText(/whole step lower/i)).toBeTruthy(); expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); - expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); + expect(screen.getAllByText(/Verse harmony pass/i).length).toBeGreaterThan(0); }); it("names tonight's first playable range and the next instrument check", () => { @@ -326,4 +326,15 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first pending approval on the mounted map", () => { + setNavigatorLanguage("en-US"); + render(); + + expect(screen.getByText("Tonight's first approval")).toBeTruthy(); + expect( + screen.getByText("MD still needs to sign off on Verse harmony pass in the verse at 0:10.") + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open verse approval at 0:10" })).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index d44e20777..398ff4e73 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 { FirstApprovalCallout } from "./FirstApprovalCallout"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; @@ -353,6 +354,8 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
+ +
diff --git a/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts b/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts new file mode 100644 index 000000000..5473d65af --- /dev/null +++ b/apps/desktop/src/features/workspace/firstApproval.inherited-metadata.test.ts @@ -0,0 +1,107 @@ +import { createDemoRehearsalSong, type RehearsalApproval } from "@bandscope/shared-types"; +import { describe, expect, it } from "vitest"; +import { resolveFirstApproval } from "./firstApproval"; + +function songWithApproval() { + const song = createDemoRehearsalSong(); + const section = structuredClone(song.sections[0]!); + section.id = "approve-own"; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep approvals local for now.", + assignments: [], + comments: [], + approvals: [ + { + id: "approval-harmony-pass", + scope: "Verse harmony pass", + owner: "MD", + status: "pending" + } + ] + }; + return { song, section }; +} + +describe("resolveFirstApproval inherited metadata", () => { + it("rejects a song or collaboration whose required metadata is inherited", () => { + const { song } = songWithApproval(); + const inheritedSong = Object.create({ collaboration: song.collaboration, sections: song.sections }) as typeof song; + expect(resolveFirstApproval(inheritedSong)).toBeNull(); + + const inheritedCollaboration = Object.create(song.collaboration!) as NonNullable; + song.collaboration = inheritedCollaboration; + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("rejects inherited approval fields", () => { + const { song } = songWithApproval(); + song.collaboration!.approvals = [ + Object.create(song.collaboration!.approvals[0]!) as (typeof song.collaboration.approvals)[number] + ]; + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("rejects inherited timing fields when a unique section is required", () => { + const { song, section } = songWithApproval(); + section.timeRange = Object.create({ start: 10, end: 30 }) as typeof section.timeRange; + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-harmony-pass"); + expect(resolved?.section).toBeNull(); + expect(resolved?.atSeconds).toBeNull(); + }); + + it("contains exceptions from own runtime accessors instead of trusting them", () => { + const { song } = songWithApproval(); + Object.defineProperty(song.collaboration!.approvals[0]!, "scope", { + configurable: true, + enumerable: true, + get() { + throw new Error("hostile scope getter"); + } + }); + + expect(() => resolveFirstApproval(song)).not.toThrow(); + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("does not treat own accessors as stable approval identity authority", () => { + const { song } = songWithApproval(); + Object.defineProperty(song.collaboration!.approvals[0]!, "id", { + configurable: true, + enumerable: true, + get() { + return "approval-harmony-pass"; + } + }); + + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("does not let inherited section metadata host the approval", () => { + const { song, section } = songWithApproval(); + const inheritedSection = Object.create(section) as typeof section; + song.sections = [inheritedSection]; + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-harmony-pass"); + expect(resolved?.section).toBeNull(); + }); + + it("rejects arrays masquerading as section records", () => { + const { song, section } = songWithApproval(); + const arraySection = Object.assign([], section) as unknown as typeof section; + song.sections = [arraySection]; + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-harmony-pass"); + expect(resolved?.section).toBeNull(); + }); + + it("rejects sparse approval arrays", () => { + const { song } = songWithApproval(); + const sparse: RehearsalApproval[] = []; + sparse[1] = song.collaboration!.approvals[0]!; + song.collaboration!.approvals = sparse; + expect(resolveFirstApproval(song)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstApproval.test.ts b/apps/desktop/src/features/workspace/firstApproval.test.ts new file mode 100644 index 000000000..02d4ca371 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstApproval.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest"; +import { + createDemoRehearsalSong, + type RehearsalApproval, + type SectionFormLabel +} from "@bandscope/shared-types"; +import { formatApprovalTime, resolveFirstApproval } from "./firstApproval"; + +function withApproval( + overrides: { + approvalId?: string; + scope?: string; + owner?: string; + status?: RehearsalApproval["status"]; + sectionId?: string; + start?: number; + end?: number; + label?: SectionFormLabel; + } = {} +) { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const section = structuredClone(verse); + section.id = overrides.sectionId ?? "verse-approve"; + section.label = overrides.label ?? "verse"; + section.timeRange = { start: overrides.start ?? 10, end: overrides.end ?? 30 }; + song.sections = [section]; + song.collaboration = { + syncMode: "local_only", + syncNote: "Keep approvals local for now.", + assignments: [], + comments: [], + approvals: [ + { + id: overrides.approvalId ?? "approval-harmony-pass", + scope: overrides.scope ?? "Verse harmony pass", + owner: overrides.owner ?? "MD", + status: overrides.status ?? "pending" + } + ] + }; + return song; +} + +describe("resolveFirstApproval", () => { + it("picks the demo song's pending verse harmony sign-off", () => { + const resolved = resolveFirstApproval(createDemoRehearsalSong()); + expect(resolved?.approval.id).toBe("approval-harmony-pass"); + expect(resolved?.approval.owner).toBe("MD"); + expect(resolved?.scope).toBe("Verse harmony pass"); + expect(resolved?.section?.id).toBe("verse-1"); + expect(resolved?.atSeconds).toBe(10); + expect(formatApprovalTime(resolved?.atSeconds ?? -1)).toBe("0:10"); + expect(formatApprovalTime(Number.NaN)).toBe("0:00"); + expect(formatApprovalTime(-4)).toBe("0:00"); + }); + + it("does not invent an approval from assignments, comments, approved scopes, or empty scope", () => { + const song = withApproval({ scope: " " }); + song.collaboration!.assignments = [ + { + id: "assign-bass-entrance", + assignee: "Rhythm Section", + summary: "Lock the bass entrance against the pickup so the chorus lift lands together.", + sectionId: song.sections[0]!.id, + roleId: "bass-guitar", + status: "in_progress" + } + ]; + song.collaboration!.comments = [ + { + id: "comment-keys-color", + author: "MD", + body: "Keep the keyboard color tone gentle on the first pass so the vocal cue stays forward.", + sectionId: song.sections[0]!.id, + roleId: "keys-right", + status: "open" + } + ]; + song.collaboration!.approvals = [ + { + id: "approval-vocal-shape", + scope: "Lead vocal simplification", + owner: "Lead Vocal", + status: "approved" + } + ]; + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("does not treat an empty or whitespace scope as a named approval", () => { + expect(resolveFirstApproval(withApproval({ scope: "" }))).toBeNull(); + expect(resolveFirstApproval(withApproval({ scope: " \n\t " }))).toBeNull(); + }); + + it("skips already-approved scopes instead of treating them as tonight's next action", () => { + expect(resolveFirstApproval(withApproval({ status: "approved" }))).toBeNull(); + }); + + it("prefers a changes-requested approval over an earlier pending one", () => { + const song = withApproval({ + approvalId: "approval-late", + start: 40, + end: 56, + label: "chorus", + status: "changes_requested", + scope: "Chorus lift pass" + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.label = "verse"; + earlier.timeRange = { start: 8, end: 24 }; + song.sections = [song.sections[0]!, earlier]; + song.collaboration!.approvals = [ + song.collaboration!.approvals[0]!, + { + id: "approval-early-pending", + scope: "Verse harmony pass", + owner: "Lead Vocal", + status: "pending" + } + ]; + + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-late"); + expect(resolved?.atSeconds).toBe(40); + }); + + it("prefers the earlier of two pending approvals", () => { + const song = withApproval({ + approvalId: "approval-late", + start: 40, + end: 56, + label: "chorus", + scope: "Chorus lift pass" + }); + const earlier = structuredClone(song.sections[0]!); + earlier.id = "verse-early"; + earlier.label = "verse"; + earlier.timeRange = { start: 8, end: 24 }; + song.sections = [song.sections[0]!, earlier]; + song.collaboration!.approvals = [ + song.collaboration!.approvals[0]!, + { + id: "approval-early", + scope: "Verse harmony pass", + owner: "Lead Vocal", + status: "pending" + } + ]; + + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-early"); + expect(resolved?.atSeconds).toBe(8); + }); + + it("does not invent a section from chorus when the scope names pre-chorus", () => { + const song = withApproval({ + scope: "Pre-chorus lift pass", + label: "pre-chorus", + start: 24, + end: 32 + }); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 56 }; + song.sections = [song.sections[0]!, chorus]; + + const resolved = resolveFirstApproval(song); + expect(resolved?.section?.id).toBe("verse-approve"); + expect(resolved?.section?.label).toBe("pre-chorus"); + expect(resolved?.atSeconds).toBe(24); + }); + + it("treats a space as a token separator so 'pre chorus' names the pre-chorus section", () => { + const song = withApproval({ + scope: "pre chorus lift pass", + label: "pre-chorus", + start: 24, + end: 32 + }); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 40, end: 56 }; + song.sections = [song.sections[0]!, chorus]; + + const resolved = resolveFirstApproval(song); + expect(resolved?.section?.id).toBe("verse-approve"); + expect(resolved?.section?.label).toBe("pre-chorus"); + }); + + it("keeps the approval band-wide when two verse sections share the named form", () => { + const song = withApproval({ scope: "Verse harmony pass" }); + const second = structuredClone(song.sections[0]!); + second.id = "verse-2"; + second.timeRange = { start: 40, end: 56 }; + song.sections = [song.sections[0]!, second]; + + const resolved = resolveFirstApproval(song); + expect(resolved?.approval.id).toBe("approval-harmony-pass"); + expect(resolved?.section).toBeNull(); + expect(resolved?.atSeconds).toBeNull(); + }); + + it("does not invent a section from assignments, comments, or Korean scope tokens", () => { + const song = withApproval({ scope: "벌스 화성 패스" }); + expect(resolveFirstApproval(song)?.section).toBeNull(); + expect(resolveFirstApproval(song)?.scope).toBe("벌스 화성 패스"); + }); + + it("bounds a long owned scope without splitting a surrogate pair", () => { + const song = withApproval({ + scope: `${"a".repeat(179)}\uD83D\uDE80trailing` + }); + expect(resolveFirstApproval(song)?.scope).toBe(`${"a".repeat(179)}\uD83D\uDE80`); + }); + + it("ties equal pending times with a stable id", () => { + const song = withApproval({ approvalId: "z-late", scope: "Verse later pass" }); + song.collaboration!.approvals = [ + song.collaboration!.approvals[0]!, + { + id: "a-early", + scope: "Verse earlier pass", + owner: "MD", + status: "pending" + } + ]; + expect(resolveFirstApproval(song)?.approval.id).toBe("a-early"); + }); + + it("drops a duplicated approval id even when only one side stays actionable", () => { + const song = withApproval({ approvalId: "dup-1", scope: "Verse harmony pass" }); + song.collaboration!.approvals = [ + song.collaboration!.approvals[0]!, + { ...song.collaboration!.approvals[0]!, status: "approved" } + ]; + + expect(resolveFirstApproval(song)).toBeNull(); + }); + + it("matches labels on the full scope and bounds only the reported copy", () => { + const song = withApproval({ + // 179 chars then " verseline": truncation before matching would + // manufacture a "verse" token boundary that never existed. + scope: `${"a".repeat(174)} verseline` + }); + + const resolved = resolveFirstApproval(song); + expect(resolved?.section).toBeNull(); + expect(Array.from(resolved?.scope ?? "")).toHaveLength(180); + }); +}); diff --git a/apps/desktop/src/features/workspace/firstApproval.ts b/apps/desktop/src/features/workspace/firstApproval.ts new file mode 100644 index 000000000..40b14cb98 --- /dev/null +++ b/apps/desktop/src/features/workspace/firstApproval.ts @@ -0,0 +1,296 @@ +import { + MAX_SECTION_TIME_SECONDS, + SECTION_FORM_LABELS, + type CollaborationApprovalStatus, + type RehearsalApproval, + type RehearsalSection, + type RehearsalSong, + type SectionFormLabel +} from "@bandscope/shared-types"; + +const ACTIONABLE_STATUS_RANK = { changes_requested: 0, pending: 1 } as const; +const FORM_LABELS_BY_LENGTH = [...SECTION_FORM_LABELS].sort((left, right) => right.length - left.length); + +/** Tonight's first named approval: the earliest owned sign-off and the unique section it names. */ +export type FirstApproval = { + section: RehearsalSection | null; + approval: RehearsalApproval; + atSeconds: number | null; + scope: string; +}; + +/** Format a non-negative approval time as m:ss for rehearsal copy. */ +export function formatApprovalTime(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; +} + +const MAX_APPROVAL_SCOPE_CHARACTERS = 180; + +/** 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 approval's own trimmed scope for label matching, or null when unusable. */ +function ownedApprovalScope(approval: RehearsalApproval): string | null { + if (!hasOwnData(approval, "scope") || typeof approval.scope !== "string") { + return null; + } + const scope = approval.scope.trim(); + if (scope.length === 0) { + return null; + } + return scope; +} + +/** Return true when the approval owns identity, owner, status, and a named scope. */ +function isActionableApproval(approval: RehearsalApproval): boolean { + return ( + isRuntimeObject(approval) && + hasOwnData(approval, "id") && + typeof approval.id === "string" && + approval.id.trim().length > 0 && + hasOwnData(approval, "owner") && + typeof approval.owner === "string" && + approval.owner.trim().length > 0 && + hasOwnData(approval, "status") && + Object.prototype.hasOwnProperty.call(ACTIONABLE_STATUS_RANK, approval.status) && + ownedApprovalScope(approval) !== null + ); +} + +/** 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 + ); +} + +/** Map unique owned ids onto their records; duplicated ids are not authority. */ +function uniqueOwnedById( + items: T[], + readId: (item: T) => string | null +): Map { + const unique = new Map(); + const repeated = new Set(); + for (const item of items) { + if (!isRuntimeObject(item)) { + continue; + } + const id = readId(item); + if (id === null || repeated.has(id)) { + continue; + } + if (unique.has(id)) { + unique.delete(id); + repeated.add(id); + continue; + } + unique.set(id, item); + } + return unique; +} + +/** Collect canonical form labels that appear as whole tokens in an owned scope. */ +function matchedFormLabels(scope: string): Set { + // Hyphens and whitespace are equivalent token separators, so free-text + // scopes like "pre chorus" still reach the canonical "pre-chorus" label. + const normalized = scope.toLowerCase().replace(/[-\s]+/g, " "); + const occupied = Array.from({ length: normalized.length }, () => false); + const matched = new Set(); + + for (const label of FORM_LABELS_BY_LENGTH) { + const key = label.replace(/-/g, " "); + let from = 0; + while (from <= normalized.length - key.length) { + const index = normalized.indexOf(key, from); + if (index === -1) { + break; + } + const beforeOk = index === 0 || /[^a-z]/.test(normalized[index - 1] ?? ""); + const afterIndex = index + key.length; + const afterOk = afterIndex === normalized.length || /[^a-z]/.test(normalized[afterIndex] ?? ""); + let alreadyOccupied = false; + for (let cursor = index; cursor < afterIndex; cursor += 1) { + if (occupied[cursor]) { + alreadyOccupied = true; + break; + } + } + if (beforeOk && afterOk && !alreadyOccupied) { + matched.add(label); + for (let cursor = index; cursor < afterIndex; cursor += 1) { + occupied[cursor] = true; + } + } + from = index + 1; + } + } + + return matched; +} + +/** Return owned sections that can host an approval. */ +function uniqueReadySections(song: RehearsalSong): Map { + if (!isRuntimeObject(song) || !hasOwnData(song, "sections") || !isDenseRuntimeArray(song.sections)) { + return new Map(); + } + + return uniqueOwnedById( + song.sections.filter( + (section) => + isRuntimeObject(section) && + hasOwnData(section, "label") && + typeof section.label === "string" && + section.label.trim().length > 0 && + hasOwnData(section, "id") && + typeof section.id === "string" && + section.id.trim().length > 0 && + hasBoundedTimeRange(section) + ), + (section) => (hasOwnData(section, "id") && typeof section.id === "string" ? section.id : null) + ); +} + +/** Return the unique ready section named by the owned scope, or null when the pointer is ambiguous. */ +function resolveNamedSection(scope: string, sections: Map): RehearsalSection | null { + const labels = matchedFormLabels(scope); + if (labels.size !== 1) { + return null; + } + const named = [...sections.values()].filter( + (section) => hasOwnData(section, "label") && labels.has(section.label) + ); + return named.length === 1 ? (named[0] ?? null) : null; +} + +/** Resolve an approval after the runtime root has passed its structural boundary checks. */ +function resolveSafeFirstApproval(song: RehearsalSong): FirstApproval | null { + if ( + !isRuntimeObject(song) || + !hasOwnData(song, "collaboration") || + !isRuntimeObject(song.collaboration) || + !hasOwnData(song.collaboration, "approvals") || + !isDenseRuntimeArray(song.collaboration.approvals) + ) { + return null; + } + + const sections = uniqueReadySections(song); + // Deduplicate ids before status filtering: duplicated ids are not authority, + // so a duplicate pair must vanish even when only one side is actionable. + const uniqueApprovals = uniqueOwnedById( + song.collaboration.approvals.filter((approval) => isRuntimeObject(approval)), + (approval) => (hasOwnData(approval, "id") && typeof approval.id === "string" ? approval.id : null) + ); + + const candidates = [...uniqueApprovals.values()].filter((approval) => isActionableApproval(approval as RehearsalApproval)) + .flatMap((approval) => { + const scope = ownedApprovalScope(approval); + if (scope === null) { + return []; + } + const section = resolveNamedSection(scope, sections); + return [ + { + section, + approval, + atSeconds: section ? section.timeRange.start : null, + scope: truncateCodePoints(scope, MAX_APPROVAL_SCOPE_CHARACTERS) + } + ]; + }) + .sort((left, right) => { + const statusDelta = + ACTIONABLE_STATUS_RANK[left.approval.status as Exclude] - + ACTIONABLE_STATUS_RANK[right.approval.status as Exclude]; + if (statusDelta !== 0) { + return statusDelta; + } + const leftTime = left.atSeconds ?? Number.POSITIVE_INFINITY; + const rightTime = right.atSeconds ?? Number.POSITIVE_INFINITY; + if (leftTime !== rightTime) { + return leftTime - rightTime; + } + return compareStableId(left.approval.id, right.approval.id); + }); + + return candidates[0] ?? null; +} + +/** Return the first named approval, or null when untrusted runtime metadata cannot be read safely. */ +export function resolveFirstApproval(song: RehearsalSong): FirstApproval | null { + try { + return resolveSafeFirstApproval(song); + } catch { + return null; + } +} diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..47260c2cf 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,34 @@ describe("i18n", () => { } }); }); + + describe("translateSectionFormLabel", () => { + it("localizes Korean section form labels without treating inherited keys as labels", () => { + expect(translateSectionFormLabel("ko", "verse")).toBe("벌스"); + expect(translateSectionFormLabel("ko", "pre-chorus")).toBe("프리코러스"); + expect(translateSectionFormLabel("en", "verse")).toBe("verse"); + const inheritedKey = "toString" as never; + expect(translateSectionFormLabel("ko", inheritedKey)).toBe("toString"); + }); + + it("keeps Korean first-approval next-action copy particle-safe", () => { + const t = createTranslator("ko"); + expect(t("firstApprovalOpenAction")).toBe("{at} {section} 승인 위치 열기"); + expect(t("firstApprovalBody")).toBe("{owner}님이 {at} {section}의 {scope} 승인을 기다리고 있습니다."); + expect(t("firstApprovalArmed")).toBe("{at} {section}에서 {scope} 승인을 이어서 하세요."); + }); + + it("never attaches the Korean object particle directly to the free-text approval scope", () => { + const t = createTranslator("ko"); + // The scope is owner-supplied free text whose final character can end in + // either a vowel or a consonant, so object particles attach to a fixed + // Korean noun instead of the interpolated scope. + expect(t("firstApprovalBodyChanges")).toBe( + "{owner}님이 {at} {section}의 {scope} 부분을 다시 봐 달라고 했습니다." + ); + expect(t("firstApprovalBodyChangesBand")).toBe( + "{owner}님이 {scope} 부분을 다시 봐 달라고 했습니다." + ); + }); + }); }); diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..28d9bfd95 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,14 +12,47 @@ 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]; }; } -/** Documented. */ +/** 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); +} + +/** Detect Korean from the runtime navigator, otherwise English. */ export function detectPreferredLocale(): Locale { if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("ko")) { return "ko"; diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index d803a765e..be127309d 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -149,6 +149,15 @@ "practiceProgressLabel": "Practice Progress", "decreasePracticeProgressLabel": "Decrease progress", "increasePracticeProgressLabel": "Increase progress", + "firstApprovalLabel": "Tonight's first approval", + "firstApprovalOpenAction": "Open {section} approval at {at}", + "firstApprovalBody": "{owner} still needs to sign off on {scope} in the {section} at {at}.", + "firstApprovalBodyBand": "{owner} still needs to sign off on {scope}.", + "firstApprovalBodyChanges": "{owner} asked for another pass on {scope} in the {section} at {at}.", + "firstApprovalBodyChangesBand": "{owner} asked for another pass on {scope}.", + "firstApprovalArmed": "Keep the {section} approval moving at {at}. Sign it off together.", + "firstApprovalArmedBand": "Keep the approval moving. Sign it off together.", + "firstApprovalUnavailable": "No pending approval yet. Stay on tonight's map until a scope needs a sign-off.", "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..881e3aa72 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -149,6 +149,15 @@ "practiceProgressLabel": "연습 진척도", "decreasePracticeProgressLabel": "진척도 감소", "increasePracticeProgressLabel": "진척도 증가", + "firstApprovalLabel": "오늘 첫 승인", + "firstApprovalOpenAction": "{at} {section} 승인 위치 열기", + "firstApprovalBody": "{owner}님이 {at} {section}의 {scope} 승인을 기다리고 있습니다.", + "firstApprovalBodyBand": "{owner}님이 {scope} 승인을 기다리고 있습니다.", + "firstApprovalBodyChanges": "{owner}님이 {at} {section}의 {scope} 부분을 다시 봐 달라고 했습니다.", + "firstApprovalBodyChangesBand": "{owner}님이 {scope} 부분을 다시 봐 달라고 했습니다.", + "firstApprovalArmed": "{at} {section}에서 {scope} 승인을 이어서 하세요.", + "firstApprovalArmedBand": "{scope} 승인을 이어서 하세요.", + "firstApprovalUnavailable": "아직 대기 중인 승인이 없습니다. 범위가 사인오프될 때까지 오늘 지도에 머무르세요.", "workspaceFirstRangeTitle": "오늘 먼저 볼 음역", "workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.", "workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.", diff --git a/docs/design-system/component-contract.md b/docs/design-system/component-contract.md index 22602c313..0f4042263 100644 --- a/docs/design-system/component-contract.md +++ b/docs/design-system/component-contract.md @@ -35,6 +35,7 @@ The authoritative Figma view is `31 Component Contract Catalog`. This file mirro | 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()`. | +| First Approval Callout | workspace next-action pattern | `apps/desktop/src/features/workspace/FirstApprovalCallout.tsx` | Name the owner, the owned scope, and the uniquely named labeled section and time. Prefer `changes_requested` over `pending`. Do not invent a sign-off from assignments, comments, already-approved scopes, empty/whitespace scopes, or inherited runtime metadata. Open scrolls the renderer-owned song-structure section when the owned scope uniquely names a form label. Keep the unavailable and band-wide states guidance-only. Distinct from first-assignment (#996), first-open-comment (#997), first-count (#995), first-lyric (#913), and export-and-priority (#900). | ## Prop And State Mapping diff --git a/docs/doctoring/reduced-motion-first-approval-navigation.md b/docs/doctoring/reduced-motion-first-approval-navigation.md new file mode 100644 index 000000000..192db607d --- /dev/null +++ b/docs/doctoring/reduced-motion-first-approval-navigation.md @@ -0,0 +1,14 @@ +# Reduced-motion first-approval navigation + +Workspace map navigation for tonight's first pending approval follows the operating-system reduced-motion preference. + +When `prefers-reduced-motion: reduce` matches, `FirstApprovalCallout` scrolls the renderer-owned song-structure section with `behavior: "auto"`. Otherwise it uses `behavior: "smooth"`. + +This is a presentation contract only. Approval resolution and analysis-id isolation stay unchanged. + +## Security Notes + +- Untrusted input: song, collaboration, approval identity/owner/scope/status, section, time-range, and form-label tokens inside an owned scope are runtime data; inherited properties and arrays masquerading as record metadata are not authority. +- Trust boundary: approval 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 approval scope is interpolated once as copy and is never rescanned as template syntax. Assignments, comments, and already-approved scopes cannot invent a pending approval. Canonical English form-label tokens may uniquely name a section; Korean or free-text scope copy cannot invent navigation. +- 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 approval scope is bounded to 180 Unicode code points. +- Test points: inherited song/collaboration/approval/section/timing metadata is rejected, array-backed section records are rejected, reduced-motion scroll uses `auto`, and default motion uses `smooth`.