diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3302a6fc3..9d2d98d46 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-16 ## Brand source @@ -84,7 +84,7 @@ Last updated: 2026-03-11 - groove and timing cues relevant to locking the band together - playable ranges and density or overlap warnings - simplification, transposition, capo, tuning, or setup cues where applicable - - role-specific rehearsal priorities and confidence flags + - role-specific rehearsal priorities and confidence flags, including named lock-in pairs with first entrance times - cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form ## Confidence, edits, and provenance diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..ba0cfafee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,25 @@ ### Added +- Surface high-priority role and section pairs in the workspace so players can see what to lock in first before rehearsal. +- Let players open a named lock-in pair to select that role and section on the roadmap. +- Let players open a fallback focus-section label to highlight that section on the roadmap. +- Scroll the named roadmap card into view when a lock-in pair or focus label is opened. +- Name the first entrance time on each lock-in pair and focus label so players know when to start. +- Mark the activated lock-in pair as the current rehearsal action. +- Highlight the same focused section on the song-structure timeline so the lock-in click has one visible destination. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Deduplicate normalized focus-section fallback labels so repeated analysis evidence does not consume all three rehearsal-priority slots or render duplicate list keys. +- Deduplicate lock-in role and section display pairs so a repeated verse label cannot consume the third rehearsal-priority slot. +- Replace empty rehearsal-priority copy that incorrectly told players a role click would name lock-in parts. +- Omit unmatched focus-section labels so a missing bridge cannot be sold as a roadmap action or clear verse focus. +- Keep lock-in aria-labels from rewriting later tokens when a role name contains `{sectionLabel}`. +- Skip smooth roadmap scrolling when the player prefers reduced motion. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..799751a50 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { SectionRoadmap } from "./SectionRoadmap"; const originalLanguage = window.navigator.language; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; +const originalMatchMedia = window.matchMedia; function setNavigatorLanguage(language: string) { Object.defineProperty(window.navigator, "language", { @@ -16,6 +18,8 @@ describe("SectionRoadmap", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + window.matchMedia = originalMatchMedia; }); it("localizes roadmap controls and provenance badges", () => { @@ -48,6 +52,48 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).toHaveBeenCalledTimes(1); }); + it("marks the focused section for the lock-in handoff", () => { + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + + const focusedCard = screen.getByTestId("section-roadmap-verse-1"); + expect(focusedCard).toHaveAttribute("data-focused-section", "true"); + expect(focusedCard).toHaveAttribute("aria-current", "true"); + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + inline: "start", + block: "nearest" + }); + }); + + it("skips smooth scrolling when the player prefers reduced motion", () => { + const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + const matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })); + window.matchMedia = matchMedia; + + render(); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: "auto", + inline: "start", + block: "nearest" + }); + }); + it("does not update when the trimmed chord is unchanged", () => { setNavigatorLanguage("en-US"); const song = createDemoRehearsalSong(); diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 6f27c2509..0bf17f424 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 { useEffect, useId, useMemo, useRef } from "react"; +import { createTranslator, detectPreferredLocale, interpolateTemplate } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -10,21 +10,53 @@ import { AlertCircle, CheckCircle2, Music2, Wand2, Lightbulb, Info } from "lucid interface SectionRoadmapProps { song: RehearsalSong; activeRole: string | null; // null means all roles + focusedSectionId?: string | null; onSongUpdate?: (song: RehearsalSong) => void; } -/** Documented. */ -export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadmapProps) { +/** + * Return whether the player asked the OS to reduce motion. + * + * Instant scroll keeps the named card in view without a horizontal animation + * that can hide the first entrance on a long Late Night Set roadmap. + */ +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return false; + } + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** + * Render the horizontal section roadmap and keep the focused card in view. + */ +export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSongUpdate }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); + const focusedCardRef = useRef(null); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); + useEffect(() => { + if (!focusedSectionId) { + return; + } + const focusedCard = focusedCardRef.current; + if (typeof focusedCard?.scrollIntoView === "function") { + focusedCard.scrollIntoView({ + behavior: prefersReducedMotion() ? "auto" : "smooth", + inline: "start", + block: "nearest" + }); + } + }, [focusedSectionId]); + /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { - return t("chordEditAriaLabel") - .replace("{roleName}", role.name) - .replace("{sectionLabel}", sectionLabel) - .replace("{chord}", role.harmony.chord); + return interpolateTemplate(t("chordEditAriaLabel"), { + roleName: role.name, + sectionLabel, + chord: role.harmony.chord + }); }; /** Documented. */ @@ -106,7 +138,15 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( diff --git a/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx b/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx new file mode 100644 index 000000000..410a8dbc3 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, within } from "@testing-library/react"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { afterEach, describe, expect, it } from "vitest"; +import { Workspace } from "./Workspace"; + +const originalLanguage = navigator.language; + +function setNavigatorLanguage(language: string): void { + Object.defineProperty(navigator, "language", { + configurable: true, + value: language + }); +} + +describe("Workspace rehearsal-priority focus fallback", () => { + afterEach(() => { + setNavigatorLanguage(originalLanguage); + }); + + it("deduplicates normalized focus labels while preserving first-occurrence order", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithChorus(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: [" verse ", "verse", "VERSE", "bridge", "chorus"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + const buttons = within(priorities).getAllByRole("button"); + expect(buttons.map((button) => button.textContent)).toEqual(["verse · 0:10", "chorus · 0:30"]); + expect(within(priorities).queryByText("bridge")).toBeNull(); + }); +}); + +/** + * Build a Late Night Set with verse and chorus so unmatched bridge labels can + * be dropped while still proving first-occurrence order for real sections. + */ +function createLateNightSetWithChorus(): RehearsalSong { + const song = createDemoRehearsalSong(); + const chorus = structuredClone(song.sections[0]!); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + song.sections = [song.sections[0]!, chorus]; + return song; +} diff --git a/apps/desktop/src/features/workspace/Workspace.stories.tsx b/apps/desktop/src/features/workspace/Workspace.stories.tsx new file mode 100644 index 000000000..adc82f637 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types"; +import { Workspace } from "./Workspace"; + +/** + * Build a Late Night Set with a repeated verse before the chorus so Storybook + * can show display-unique lock-in pairs instead of two identical verse lines. + */ +function createLateNightSetWithRepeatedVerse(): RehearsalSong { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + chorus.roles = chorus.roles.map((role) => ({ + ...role, + rehearsalPriority: role.id === "lead-vocal" ? "high" : "low" + })); + const verseRepeat = structuredClone(verse); + verseRepeat.id = "verse-2"; + verseRepeat.timeRange = { start: 50, end: 70 }; + song.sections = [verse, verseRepeat, chorus]; + return song; +} + +/** + * Build a song with no priority roles and no focus sections so the empty + * rehearsal-priority card can be inspected in Storybook. + */ +function createEmptyPrioritySong(): RehearsalSong { + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + return song; +} + +const meta = { + title: "Workspace/Rehearsal Priorities", + component: Workspace, + parameters: { layout: "fullscreen" } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Demo song: names Bass Guitar and Keyboard 1 Right Hand on verse at 0:10. */ +export const LockInHighPriorityParts: Story = { + args: { song: createDemoRehearsalSong() } +}; + +/** Repeated verse plus chorus: the third slot is Lead Vocal · chorus · 0:30. */ +export const DedupedRepeatedVerse: Story = { + args: { song: createLateNightSetWithRepeatedVerse() } +}; + +/** No priority evidence: honest empty copy that points at the roadmap. */ +export const EmptyPriorityCard: Story = { + args: { song: createEmptyPrioritySong() } +}; + +/** + * Build a low-priority Late Night Set whose focus list names a missing + * bridge so Storybook can show only the matching verse action. + */ +function createUnmatchedFocusSong(): RehearsalSong { + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: ["verse", "bridge"] + }; + return song; +} + +/** Unmatched bridge is omitted; verse remains the only clickable focus. */ +export const ActionableFocusLabelsOnly: Story = { + args: { song: createUnmatchedFocusSong() } +}; diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..0400c8814 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { createDemoRehearsalSong, type ProjectBootstrapSummary, type RehearsalSong } from "@bandscope/shared-types"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Workspace } from "./Workspace"; @@ -8,6 +8,7 @@ import { generateMetadataHandoffJson } from "../../lib/export"; const originalLanguage = navigator.language; const originalCreateObjectUrl = URL.createObjectURL; const originalRevokeObjectUrl = URL.revokeObjectURL; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(navigator, "language", { @@ -28,6 +29,7 @@ describe("Workspace", () => { configurable: true, value: originalRevokeObjectUrl }); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); it("updates practice progress immutably through onSongUpdate", () => { @@ -268,6 +270,323 @@ describe("Workspace", () => { expect(screen.getByText("협업")).toBeTruthy(); expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); + expect(screen.getByText("먼저 맞춰 볼 것")).toBeTruthy(); + expect(screen.getByRole("button", { name: "로드맵에서 0:10 verse의 Bass Guitar 보기" })).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); + + it("names high-priority role and section pairs to lock in first", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.textContent).toContain("Bass Guitar · verse"); + expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse"); + expect(priorities.textContent).not.toContain("Lead Vocal · verse"); + expect(priorities.textContent).not.toContain("Focus:"); + }); + + it("falls back to medium-priority parts when no high-priority role exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = role.rehearsalPriority === "high" ? "low" : role.rehearsalPriority; + } + } + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.textContent).toContain("Lead Vocal · verse"); + expect(priorities.textContent).not.toContain("Bass Guitar · verse"); + }); + + it("falls back to focus sections when every role is low priority", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Start with this section"); + expect(priorities.textContent).toContain("verse"); + expect(priorities.textContent).not.toContain("Lock in first"); + }); + + it("does not turn blank or none sentinels into lock-in instructions", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: ["NONE", " ", "none"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain( + "No named parts to lock in yet. Pick the first entrance on the section roadmap." + ); + expect(priorities.textContent).not.toContain("Open a role on the roadmap"); + expect(priorities.textContent).not.toMatch(/NONE/i); + }); + + it("localizes the empty lock-in copy without promising a role click will fill the card", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "합주 우선순위" }); + expect(priorities.textContent).toContain("아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요."); + expect(priorities.textContent).not.toContain("로드맵에서 역할을 열면"); + }); + + it("keeps repeated verse labels from consuming a third lock-in slot", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.querySelectorAll("li")).toHaveLength(3); + expect(priorities.textContent).toContain("Bass Guitar · verse"); + expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse"); + expect(priorities.textContent).toContain("Lead Vocal · chorus"); + expect(priorities.textContent?.match(/Bass Guitar · verse/g)).toHaveLength(1); + }); + + it("selects the named role and section when a lock-in pair is activated", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + fireEvent.click( + within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus at 0:30 on the roadmap" }) + ); + + expect(screen.getByRole("tab", { name: "Lead Vocal" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true"); + expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true"); + expect(screen.getByTestId("song-structure-chorus-1")).toHaveAttribute("data-focused-section", "true"); + expect(screen.getByTestId("song-structure-verse-1")).not.toHaveAttribute("data-focused-section", "true"); + }); + + it("focuses the matching section when a fallback focus label is activated", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: ["chorus"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus at 0:30 on the roadmap" })); + + expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true"); + expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true"); + }); + + it("omits unmatched focus labels so they cannot clear a real section", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: ["verse", "bridge"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + fireEvent.click(within(priorities).getByRole("button", { name: "Show verse at 0:10 on the roadmap" })); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + expect(within(priorities).queryByRole("button", { name: "Show bridge at 0:10 on the roadmap" })).toBeNull(); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + }); + + it("falls back to the first valid section when unmatched focus labels are the only evidence", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: ["bridge"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(within(priorities).queryByRole("button", { name: /Show bridge/ })).toBeNull(); + fireEvent.click(within(priorities).getByRole("button", { name: "Show verse at 0:10 on the roadmap" })); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + }); + + it("falls back to the first section label when every role is low and focus sections are empty", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Start with this section"); + expect(priorities.textContent).toContain("verse"); + expect(priorities.textContent).not.toContain("chorus"); + expect(priorities.textContent).not.toContain("Lock in first"); + fireEvent.click(within(priorities).getByRole("button", { name: "Show verse at 0:10 on the roadmap" })); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + }); + + it("skips a none first-section label when falling back to the roadmap entrance", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + song.sections[0] = { ...song.sections[0]!, label: "none" }; + song.sections[1] = { ...song.sections[1]!, label: "NONE" }; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(within(priorities).queryByRole("button", { name: /Show none/ })).toBeNull(); + fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus at 0:30 on the roadmap" })); + expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true"); + }); + + it("names the first entrance time on each lock-in pair so players know when to lock in", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus at 0:30 on the roadmap" })).toBeTruthy(); + expect(priorities.textContent).toContain("Lead Vocal · chorus · 0:30"); + expect(priorities.textContent).toContain("Bass Guitar · verse · 0:10"); + }); + + it("marks the activated lock-in pair as the current rehearsal action", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + const chorusAction = within(priorities).getByRole("button", { + name: "Show Lead Vocal in chorus at 0:30 on the roadmap" + }); + fireEvent.click(chorusAction); + + expect(chorusAction).toHaveAttribute("aria-current", "true"); + expect( + within(priorities).getByRole("button", { name: "Show Bass Guitar in verse at 0:10 on the roadmap" }) + ).not.toHaveAttribute("aria-current"); + }); + + it("keeps a role name that contains a later token from rewriting the aria-label", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections[0]!.roles[0] = { + ...song.sections[0]!.roles[0]!, + name: "Lead {sectionLabel} Vocal", + rehearsalPriority: "high" + }; + + render(); + + expect( + screen.getByRole("button", { name: "Show Lead {sectionLabel} Vocal in verse at 0:10 on the roadmap" }) + ).toBeTruthy(); + }); + + it("scrolls the named section into view when a lock-in pair is activated", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + fireEvent.click( + within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus at 0:30 on the roadmap" }) + ); + + const chorusCard = screen.getByTestId("section-roadmap-chorus-1"); + expect(chorusCard).toHaveAttribute("data-focused-section", "true"); + expect(chorusCard).toHaveAttribute("aria-current", "true"); + expect(scrollIntoView).toHaveBeenCalled(); + }); }); + +/** + * Build a Late Night Set with verse, chorus, and a second verse that reuses + * the same role names and section label the analysis engine emits for repeats. + */ +function createLateNightSetWithRepeatedVerse(): RehearsalSong { + const song = createDemoRehearsalSong(); + const verse = song.sections[0]!; + const chorus = structuredClone(verse); + chorus.id = "chorus-1"; + chorus.label = "chorus"; + chorus.timeRange = { start: 30, end: 50 }; + chorus.roles = chorus.roles.map((role) => ({ + ...role, + rehearsalPriority: role.id === "lead-vocal" ? "high" : "low" + })); + const verseRepeat = structuredClone(verse); + verseRepeat.id = "verse-2"; + verseRepeat.timeRange = { start: 50, end: 70 }; + song.sections = [verse, verseRepeat, chorus]; + return song; +} diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..16b12cbc8 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,10 +1,10 @@ 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 RehearsalPriority, type RehearsalSong, type RehearsalRole } from "@bandscope/shared-types"; import { RoleSwitcher } from "./RoleSwitcher"; import { SectionRoadmap } from "./SectionRoadmap"; import { GrooveMap } from "./GrooveMap"; import { PracticeProgress } from "./PracticeProgress"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, interpolateTemplate } 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,7 +41,188 @@ function downloadTextFile(contents: string, type: string, filename: string): voi type Translator = ReturnType; -/** Documented. */ +/** One role-and-section pair a player should lock in before the room starts. */ +type LockInFirstItem = { + id: string; + roleId: string; + sectionId: string; + roleName: string; + sectionLabel: string; + sectionStartSeconds: number; +}; + +/** One matching focus-section label when no role-level lock-in pair exists. */ +type FocusSectionItem = { + sectionId: string; + sectionLabel: string; + sectionStartSeconds: number; +}; + +const MAX_LOCK_IN_FIRST_ITEMS = 3; + +/** + * Return whether a role priority belongs in the preferred lock-in list. + * + * High-priority parts are shown first. Medium is used only when no high + * priority exists so the card still names a concrete part instead of a + * section label alone. + */ +function matchesPreferredLockInPriority( + priority: RehearsalPriority, + preferred: Exclude +): boolean { + switch (priority) { + case "high": + return preferred === "high"; + case "medium": + return preferred === "medium"; + case "low": + return false; + default: { + const exhaustive: never = priority; + return exhaustive; + } + } +} + +/** + * Collect the first role-and-section pairs a player should lock in. + * + * Prefers `high` rehearsal priority, then `medium`. Blank names and `none` + * sentinels are skipped so the card never turns missing evidence into an + * instruction. Equivalent display pairs are de-duplicated case-insensitively + * so repeated verse or chorus labels cannot consume every lock-in slot. + */ +function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { + /** Collect lock-in pairs for one preferred priority without repeating display text. */ + const collectFor = (preferred: Exclude): LockInFirstItem[] => { + const items: LockInFirstItem[] = []; + const seenIds = new Set(); + const seenDisplayPairs = new Set(); + + for (const section of song.sections) { + const sectionLabel = nonBlankText(section.label); + if (!sectionLabel || sectionLabel.toLowerCase() === "none") { + continue; + } + + for (const role of section.roles) { + if (!matchesPreferredLockInPriority(role.rehearsalPriority, preferred)) { + continue; + } + + const roleName = nonBlankText(role.name); + if (!roleName || roleName.toLowerCase() === "none") { + continue; + } + + const id = `${role.id}:${section.id}`; + const displayKey = `${roleName.toLowerCase()}|${sectionLabel.toLowerCase()}`; + if (seenIds.has(id) || seenDisplayPairs.has(displayKey)) { + continue; + } + + seenIds.add(id); + seenDisplayPairs.add(displayKey); + items.push({ + id, + roleId: role.id, + sectionId: section.id, + roleName, + sectionLabel, + sectionStartSeconds: section.timeRange.start + }); + if (items.length === MAX_LOCK_IN_FIRST_ITEMS) { + return items; + } + } + } + + return items; + }; + + const highPriorityItems = collectFor("high"); + return highPriorityItems.length > 0 ? highPriorityItems : collectFor("medium"); +} + +/** + * Collect focus-section labels when no role-level lock-in pair exists. + * + * Blank and case-insensitive `none` values are dropped. Labels that do not + * match a roadmap section are omitted so the card never sells a no-op click + * or clears an earlier focus. Each kept label carries the first entrance + * time so players know when to start. Equivalent labels are de-duplicated + * case-insensitively after trimming so repeated analysis evidence cannot + * consume all three buyer-visible fallback slots or create duplicate React + * keys. First-occurrence display text and order are preserved. When every + * focus label is unmatched, the first valid section label is the fallback. + */ +function collectFocusSectionLabels(song: RehearsalSong): FocusSectionItem[] { + const focusItems: FocusSectionItem[] = []; + const seenLabels = new Set(); + for (const label of song.exportSummary?.focusSections ?? []) { + const trimmed = label.trim(); + const normalized = trimmed.toLowerCase(); + if (!trimmed || normalized === "none" || seenLabels.has(normalized)) { + continue; + } + const matchedSection = findSectionForFocusLabel(song, trimmed); + if (!matchedSection) { + continue; + } + seenLabels.add(normalized); + focusItems.push({ + sectionId: matchedSection.id, + sectionLabel: trimmed, + sectionStartSeconds: matchedSection.timeRange.start + }); + if (focusItems.length === MAX_LOCK_IN_FIRST_ITEMS) { + return focusItems; + } + } + + if (focusItems.length === 0) { + for (const section of song.sections) { + const firstSectionLabel = nonBlankText(section.label); + if (firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { + return [{ + sectionId: section.id, + sectionLabel: firstSectionLabel, + sectionStartSeconds: section.timeRange.start + }]; + } + } + } + + return focusItems; +} + +/** + * Return the first section id whose label matches a focus label. + * + * Matching is case-insensitive after trim so analysis spelling variants still + * open the same roadmap card. + */ +function findSectionForFocusLabel( + song: RehearsalSong, + focusLabel: string +): RehearsalSong["sections"][number] | null { + const normalized = focusLabel.trim().toLowerCase(); + if (!normalized) { + return null; + } + + for (const section of song.sections) { + const sectionLabel = nonBlankText(section.label); + if (sectionLabel && sectionLabel.toLowerCase() === normalized) { + return section; + } + } + + return null; +} + +/** Prevent clicks on rehearsal controls that are not available yet. */ function preventUnavailableAction(event: MouseEvent): void { event.preventDefault(); } @@ -71,7 +252,15 @@ 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, + focusedSectionId, + t +}: { + sections: RehearsalSong["sections"]; + focusedSectionId: string | null; + t: Translator; +}) { return (
@@ -91,7 +280,16 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R style={{ gridTemplateColumns: `repeat(${Math.max(1, sections.length)}, minmax(8rem, 1fr))` }} > {sections.map((section) => ( -
+

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

@@ -120,6 +318,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [focusedSectionId, setFocusedSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +411,58 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const lockInFirstItems = useMemo(() => collectLockInFirstItems(song), [song]); + const focusSectionLabels = useMemo(() => collectFocusSectionLabels(song), [song]); + + /** + * Select the named role and section so the roadmap shows the part to lock in. + */ + const handleLockInPairActivate = (item: LockInFirstItem): void => { + setActiveRole(item.roleId); + setFocusedSectionId(item.sectionId); + }; + + /** + * Build the action label that tells a player which roadmap part will open. + */ + const lockInPairActionLabel = (item: LockInFirstItem): string => { + return interpolateTemplate(t("workspaceLockInPairAction"), { + roleName: item.roleName, + sectionLabel: item.sectionLabel, + startTime: formatTimelineTime(item.sectionStartSeconds) + }); + }; + + /** + * Focus the matching roadmap section for a fallback focus label. + */ + const handleFocusSectionActivate = (item: FocusSectionItem): void => { + setFocusedSectionId(item.sectionId); + }; + + /** + * Build the visible lock-in pair that names the part and its first entrance. + */ + const lockInPairVisibleLabel = (item: LockInFirstItem): string => { + return `${item.roleName} · ${item.sectionLabel} · ${formatTimelineTime(item.sectionStartSeconds)}`; + }; + + /** + * Build the visible focus label that names the section and its first entrance. + */ + const focusSectionVisibleLabel = (item: FocusSectionItem): string => { + return `${item.sectionLabel} · ${formatTimelineTime(item.sectionStartSeconds)}`; + }; + + /** + * Build the action label that opens a focus section on the roadmap. + */ + const focusSectionActionLabel = (item: FocusSectionItem): string => { + return interpolateTemplate(t("workspacePriorityFocusAction"), { + sectionLabel: item.sectionLabel, + startTime: formatTimelineTime(item.sectionStartSeconds) + }); + }; /** Documented. */ const handleExportCueSheet = () => { @@ -323,15 +574,60 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

Stem lanes will appear when separation results are available.

-
+

{t("workspaceRehearsalPrioritiesLabel")}

-

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

+ {lockInFirstItems.length > 0 ? ( +
+

{t("workspaceLockInFirstLabel")}

+
    + {lockInFirstItems.map((item) => ( +
  • + +
  • + ))} +
+
+ ) : focusSectionLabels.length > 0 ? ( +
+

{t("workspacePriorityFocusLead")}

+
    + {focusSectionLabels.map((item) => ( +
  • + +
  • + ))} +
+
+ ) : ( +

{t("workspacePriorityEmpty")}

+ )}
- +
@@ -483,6 +779,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/i18n/index.test.ts b/apps/desktop/src/i18n/index.test.ts index dc49a0a25..f5ecf0d87 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, interpolateTemplate } from "./index"; import koCommon from "../locales/ko/common.json"; describe("i18n", () => { @@ -62,6 +62,24 @@ describe("i18n", () => { expect(t("appSubtitle")).toBe("합주 준비를 위한 로컬-퍼스트 분석 도구"); }); + it("fills named tokens in one pass so a value cannot rewrite later placeholders", () => { + expect( + interpolateTemplate("Show {roleName} in {sectionLabel} at {startTime}", { + roleName: "Lead {sectionLabel} Vocal", + sectionLabel: "chorus", + startTime: "0:30" + }) + ).toBe("Show Lead {sectionLabel} Vocal in chorus at 0:30"); + }); + + it("leaves unknown tokens in the template", () => { + expect(interpolateTemplate("Show {missing}", { sectionLabel: "verse" })).toBe("Show {missing}"); + }); + + it("keeps an empty known value instead of restoring the placeholder", () => { + expect(interpolateTemplate("Show {sectionLabel}", { sectionLabel: "" })).toBe("Show "); + }); + it("falls back to English when a Korean translation is missing", () => { const t = createTranslator("ko"); const koDictionary = koCommon as Record; diff --git a/apps/desktop/src/i18n/index.ts b/apps/desktop/src/i18n/index.ts index 1a9f471f0..7365ff455 100644 --- a/apps/desktop/src/i18n/index.ts +++ b/apps/desktop/src/i18n/index.ts @@ -18,6 +18,25 @@ export function createTranslator(locale: Locale = "en") { }; } +/** + * Fill `{token}` placeholders in one pass. + * + * Values are substituted from the original template only, so a role name that + * contains `{sectionLabel}` cannot rewrite a later placeholder. Unknown tokens + * stay in the string so missing locale keys remain visible in tests. + */ +export function interpolateTemplate( + template: string, + values: Readonly> +): string { + return template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, token: string) => { + if (Object.prototype.hasOwnProperty.call(values, token)) { + return values[token] ?? match; + } + return match; + }); +} + /** 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 39f716d50..6435b9f37 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "Transpose / simplify", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", + "workspaceLockInFirstLabel": "Lock in first", + "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} at {startTime} on the roadmap", + "workspacePriorityFocusLead": "Start with this section", + "workspacePriorityFocusAction": "Show {sectionLabel} at {startTime} on the roadmap", + "workspacePriorityEmpty": "No named parts to lock in yet. Pick the first entrance on the section roadmap.", "workspaceRolesHarmonyLabel": "Roles & Harmony", "sectionRoadmapTitle": "Section Roadmap", "sectionRoadmapScrollHint": "Scroll for more sections →", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 371884abb..758d0262d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", + "workspaceLockInPairAction": "로드맵에서 {startTime} {sectionLabel}의 {roleName} 보기", + "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", + "workspacePriorityFocusAction": "로드맵에서 {startTime} {sectionLabel} 보기", + "workspacePriorityEmpty": "아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", diff --git a/docs/design-system/product-design-handoff.md b/docs/design-system/product-design-handoff.md index 5c8f9712b..941f15013 100644 --- a/docs/design-system/product-design-handoff.md +++ b/docs/design-system/product-design-handoff.md @@ -93,7 +93,7 @@ Wireframe rules: | --- | --- | --- | | Player | As a player, I can choose a local audio file and start analysis only after the source is valid. | Start Analysis is disabled until `selectedBootstrap` exists; invalid selections show a safe source error. | | Vocalist | As a vocalist, I can filter rehearsal guidance by role without losing song structure context. | Role switcher changes role-specific guidance while timeline and section roadmap remain visible. | -| Band leader | As a band leader, I can see priority sections and confidence before rehearsal. | Metrics, focus section, confidence badges, and section roadmap are visible in the ready workspace. | +| Band leader | As a band leader, I can see which parts to lock in first and when those sections start. | The Rehearsal Priorities card names up to three role-and-section pairs with first entrance times, and opening a pair focuses that roadmap card. | | Publisher | As a publisher, I can export cue sheet, chart, and handoff files from the ready workspace. | Export buttons exist only when `jobResult` exists and produce CSV/JSON downloads. | | Privacy-conscious user | As a local-first user, I can recover from errors without exposing local paths, URLs, or secrets. | Error copy passes through `safeErrorDetail` and uses alert semantics. | diff --git a/docs/plans/2026-08-16-workspace-lock-in-first.md b/docs/plans/2026-08-16-workspace-lock-in-first.md new file mode 100644 index 000000000..40ca4e774 --- /dev/null +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -0,0 +1,60 @@ +# Workspace lock-in-first card + +**Goal:** Name up to three concrete role-and-section pairs on the Rehearsal Priorities card so players can see what to lock in before the room starts. + +**Architecture:** The desktop Workspace reads already-validated `RehearsalSong` evidence. High-priority roles are preferred, then medium. When no role-level pair exists, focus-section labels that match a roadmap card, then the first valid section label, are the fallback. Display pairs and focus labels are de-duplicated case-insensitively so repeated verse or chorus evidence cannot consume every slot. Each named action includes the first entrance time. Opening a pair or label selects that role and section, marks the action as current, and scrolls the matching roadmap card into view, using instant scroll when the player prefers reduced motion. + +**Tech Stack:** React 19 Workspace card, shared `RehearsalSong` contract, Vitest fixtures from the Late Night Set demo, Storybook inventory under `Workspace/Rehearsal Priorities`. + +## Security Notes + +### Attack surface + +- Analysis payloads already rendered in Workspace: section labels, role names, focus-section strings, and rehearsal-priority enums. + +### Trust boundary + +- The card consumes `RehearsalSong` after `parseRehearsalSong`. It does not open files, URLs, subprocesses, IPC, WebView, models, or a new persistence path. + +### Mitigations + +- Render role and section text as React text nodes only. +- Skip blank and case-insensitive `none` sentinels so missing evidence never becomes an instruction. +- De-duplicate display pairs and focus labels so untrusted repeated strings cannot hide later distinct actions. +- Keep English and Korean chrome in locale files; do not interpolate untrusted text into HTML. + +### Test points + +- High-priority and medium-priority named pairs on the Late Night Set demo. +- Repeated verse labels before chorus: third slot is the distinct chorus pair. +- Empty and `none` sentinels show honest empty copy that points at the section roadmap. +- First-section fallback when every role is low and `focusSections` is empty. +- Unmatched focus labels are omitted so they cannot clear an earlier focus. +- Clicking a named pair or fallback label scrolls that roadmap card into view. +- Each named pair and focus label shows the first entrance time from `section.timeRange.start`. +- The activated lock-in pair exposes `aria-current="true"`. +- The song-structure timeline marks the same focused section. +- Role names that contain `{sectionLabel}` cannot rewrite later aria-label tokens. +- `prefers-reduced-motion: reduce` uses instant `scrollIntoView` instead of smooth animation. + +### Realistic threats + +- A malformed analysis result could repeat the same verse label and hide the chorus action. +- Empty copy that claims a role click fills this card would send players into a no-op. +- A free-form `focusSections` string such as `bridge` could be sold as a clickable action when no matching card exists, then assign `null` and wipe verse focus. + +### Remaining risk + +- Role names and `focusSections` remain free-form after parse and enter `aria-label` through one-pass `{token}` interpolation. Workspace does not re-parse `jobResult`. Visible text stays React text nodes. + +## References + +International Organization for Standardization. (2020). *Ergonomics of human-system interaction — Part 110: Interaction principles* (ISO 9241-110:2020). https://www.iso.org/standard/75258.html + +International Organization for Standardization. (2025). *Information technology — W3C Web Content Accessibility Guidelines (WCAG) 2.2* (ISO/IEC 40500:2025). + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2024). Animation from interactions (Success Criterion 2.3.3). In *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/#animation-from-interactions + +London, J. (2012). *Hearing in time: Psychological aspects of musical meter* (2nd ed.). Oxford University Press. https://doi.org/10.1093/acprof:oso/9780199744374.001.0001