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/FirstTranspositionPlanCallout.workspace-scope.test.tsx b/apps/desktop/src/features/workspace/FirstTranspositionPlanCallout.workspace-scope.test.tsx new file mode 100644 index 000000000..2e0475339 --- /dev/null +++ b/apps/desktop/src/features/workspace/FirstTranspositionPlanCallout.workspace-scope.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { describe, expect, it, vi } from "vitest"; +import { FirstTranspositionPlanCallout } from "./FirstTranspositionPlanCallout"; + +describe("FirstTranspositionPlanCallout 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 callouts = screen.getAllByRole("complementary", { + name: "Tonight's first transpose plan" + }); + expect(callouts).toHaveLength(2); + expect(callouts.every((callout) => callout.id.length > 0)).toBe(true); + expect(new Set(callouts.map((callout) => callout.id)).size).toBe(callouts.length); + + 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 transpose at 0:10" + }); + expect(actions).toHaveLength(2); + fireEvent.click(actions[1]!); + + expect(firstScrollIntoView).not.toHaveBeenCalled(); + expect(secondScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + }); + + it("preserves the opened action when the same named section moves to a new renderer index", () => { + const song = createDemoRehearsalSong(); + const namedSection = song.sections[0]!; + const { container, rerender } = render( +
+ +
+
+
+
+ ); + const firstTarget = container.querySelector('[data-section-index="0"]')!; + Object.defineProperty(firstTarget, "scrollIntoView", { + configurable: true, + value: vi.fn() + }); + + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transpose at 0:10" })); + expect( + screen.getByText(/Lock that transpose on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + + const precedingSection = { + ...namedSection, + id: "count-in-section", + label: "intro" as const, + timeRange: { start: 0, end: 5 }, + roles: namedSection.roles.map((role) => ({ ...role, transpositionPlan: "" })) + }; + rerender( +
+ +
+
+
+
+
+ ); + + const movedTarget = container.querySelector('[data-section-index="1"]')!; + const movedScrollIntoView = vi.fn(); + Object.defineProperty(movedTarget, "scrollIntoView", { + configurable: true, + value: movedScrollIntoView + }); + fireEvent.click(screen.getByRole("button", { name: "Open Bass Guitar transpose at 0:10" })); + + expect(movedScrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "smooth" + }); + expect( + screen.getByText(/Lock that transpose on Bass Guitar at 0:10 before the room starts./) + ).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 5b32019d2..ed94cb165 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -33,7 +33,7 @@ describe("SectionRoadmap", () => { expect(screen.getByText("사용자")).toBeTruthy(); expect(screen.getAllByText("음역").length).toBeGreaterThan(0); expect(screen.getByText("C#2 — E3")).toBeTruthy(); - expect(screen.getAllByText("verse 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); + expect(screen.getAllByText("벌스 들어가기 전에 이 음역을 악기로 확인해 보세요.").length).toBeGreaterThan(0); }); it("omits the range row when both notes are unnamed", () => { @@ -79,6 +79,16 @@ describe("SectionRoadmap", () => { expect(screen.queryByText(/Check this span on your instrument/i)).toBeNull(); }); + it("localizes section card headers like the timeline instead of rendering raw labels", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText("벌스")).toBeTruthy(); + expect(screen.queryByText("verse")).toBeNull(); + }); + it("uses localized copy for chord edit prompts and control labels", () => { setNavigatorLanguage("ko-KR"); const song = createDemoRehearsalSong(); @@ -87,7 +97,7 @@ describe("SectionRoadmap", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Bass Guitar의 verse 코드 수정, 현재 C#m7" })); + fireEvent.click(screen.getByRole("button", { name: "Bass Guitar의 벌스 코드 수정, 현재 C#m7" })); expect(promptSpy).toHaveBeenCalledWith("새 코드 입력:", "C#m7"); expect(screen.getAllByTitle("코드 수정").length).toBeGreaterThan(0); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 834d1e8f0..a6558fffc 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,6 +1,6 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; import { useId, useMemo } from "react"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { fillRangeCopy, playableRange } from "./firstRangeSqueeze"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -113,7 +113,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma >
-

{section.label}

+

{translateSectionFormLabel(locale, section.label)}

@@ -154,7 +154,7 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {t("sectionChordLabel")}
) : null} diff --git a/apps/desktop/src/features/workspace/Workspace.section-label-localization.test.tsx b/apps/desktop/src/features/workspace/Workspace.section-label-localization.test.tsx new file mode 100644 index 000000000..4aa5a1fab --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.section-label-localization.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, within } from "@testing-library/react"; +import { createDemoRehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string) { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace song-structure localization", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("uses the localized canonical form label in the rendered Korean timeline", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + const grid = within(screen.getByTestId("song-structure-grid")); + expect(grid.getByText(/^벌스 · /)).toBeTruthy(); + expect(grid.queryByText(/^verse · /i)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 7837bf80e..eccfdfce2 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -85,6 +85,16 @@ describe("Workspace", () => { expect(screen.getByText(/verse · 0:00–0:00/i)).toBeTruthy(); }); + it("localizes the rehearsal priorities focus line like the other surfaces", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByText(/집중: 벌스\./)).toBeTruthy(); + expect(screen.queryByText(/Focus: 벌스\./)).toBeNull(); + }); + it("enables bass transcription from selected role metadata rather than role id text", () => { const song = createDemoRehearsalSong(); song.sections[0]!.roles[0] = { @@ -135,7 +145,7 @@ describe("Workspace", () => { fireEvent.click(screen.getByRole("tab", { name: "Bass Guitar" })); expect(screen.getByText(/The bass holds the vi center/i)).toBeTruthy(); - expect(screen.getByText(/whole step lower/i)).toBeTruthy(); + expect(screen.getAllByText(/whole step lower/i).length).toBeGreaterThan(1); expect(screen.getByText(/Lock the bass entrance against the pickup/i)).toBeTruthy(); expect(screen.getByText(/Verse harmony pass/i)).toBeTruthy(); }); @@ -326,4 +336,34 @@ describe("Workspace", () => { expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names tonight's first transposition plan as workspace navigation", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const target = screen.getByTestId("song-structure-grid").children.item(0); + expect(target).toBeTruthy(); + const scrollIntoView = vi.fn(); + Object.defineProperty(target!, "scrollIntoView", { + configurable: true, + value: scrollIntoView + }); + + expect( + screen.getAllByText( + "If the singer drops to B minor, keep the shape a whole step lower and let keys keep the color tones." + ).length + ).toBeGreaterThan(0); + const action = screen.getByRole("button", { + name: "Open Bass Guitar transpose at 0:10" + }); + expect(action).toBeTruthy(); + fireEvent.click(action); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + expect( + screen.getByText(/Lock that transpose on Bass Guitar 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..575c8cffa 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,11 +1,12 @@ import { useState, useMemo, memo, type MouseEvent } from "react"; -import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; +import { parseProjectBootstrapSummary, type ProjectBootstrapSummary, type RehearsalSong, type RehearsalRole, type SectionFormLabel } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; +import { FirstTranspositionPlanCallout } from "./FirstTranspositionPlanCallout"; +import { createTranslator, detectPreferredLocale, translateSectionFormLabel } from "../../i18n"; import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardDescription } from "@/components/ui/card"; @@ -41,6 +42,7 @@ function downloadTextFile(contents: string, type: string, filename: string): voi } type Translator = ReturnType; +type WorkspaceLocale = ReturnType; /** Documented. */ function preventUnavailableAction(event: MouseEvent): void { @@ -72,7 +74,7 @@ function safeProjectBootstrapSummary(value: ProjectBootstrapSummary | null): Pro } /** Documented. */ -const SongStructure = memo(function SongStructure({ sections, t }: { sections: RehearsalSong["sections"]; t: Translator }) { +const SongStructure = memo(function SongStructure({ sections, t, locale }: { sections: RehearsalSong["sections"]; t: Translator; locale: WorkspaceLocale }) { return (
@@ -91,10 +93,14 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R data-testid="song-structure-grid" style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > - {sections.map((section) => ( -
+ {sections.map((section, sectionIndex) => ( +

- {section.label} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)} + {translateSectionFormLabel(locale, section.label)} · {formatTimelineTime(section.timeRange.start)}–{formatTimelineTime(section.timeRange.end)}

{section.groove}

@@ -121,7 +127,17 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); - const t = useMemo(() => createTranslator(detectPreferredLocale()), []); + const locale = useMemo(() => detectPreferredLocale(), []); + const t = useMemo(() => createTranslator(locale), [locale]); + // Rehearsal priorities focus copy localizes section form labels like every + // other surface; unknown focus strings fall back to their raw value. + const focusSummary = song.exportSummary?.focusSections?.length + ? song.exportSummary.focusSections + .map((label) => translateSectionFormLabel(locale, label as SectionFormLabel)) + .join(", ") + : song.sections[0] + ? translateSectionFormLabel(locale, song.sections[0].label) + : t("workspaceFocusFallback"); // Extract all unique roles from the song's sections const roleMap = useMemo(() => { @@ -157,9 +173,9 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp t(firstRange.overlapWarning ? "workspaceFirstRangeClash" : "workspaceFirstRangeCheck"), { roleName: firstRange.roleName, - lowestNote: firstRange.lowestNote, - highestNote: firstRange.highestNote, - sectionLabel: firstRange.sectionLabel + lowestNote: firstRange.lowestNote, + highestNote: firstRange.highestNote, + sectionLabel: translateSectionFormLabel(locale, firstRange.sectionLabel) } ) : t("workspaceFirstRangeMissing"); @@ -348,12 +364,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspaceRehearsalPrioritiesLabel")}

- Focus: {song.exportSummary?.focusSections?.join(", ") || song.sections[0]?.label || "first pass"}. + {t("workspaceRehearsalPrioritiesFocusPrefix")} {focusSummary}.

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