From 7520c4aa2fc4bd110f4ccdb2ee037990a6504e09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:25:56 +0000 Subject: [PATCH 01/10] feat(workspace): name the parts to lock in first Replace the stub rehearsal-priorities card with role-and-section pairs so a player can see what to lock in before the room starts. Prefer high priority, then medium, then focus sections, and never turn blank or none sentinels into instructions. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/features/workspace/Workspace.test.tsx | 65 ++++++++ .../src/features/workspace/Workspace.tsx | 146 +++++++++++++++++- apps/desktop/src/locales/en/common.json | 3 + apps/desktop/src/locales/ko/common.json | 3 + 5 files changed, 212 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..baccc8be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Surface high-priority role and section pairs in the workspace so players can see what to lock in first before rehearsal. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index a3da5ffe6..0a3fc4153 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -268,6 +268,71 @@ describe("Workspace", () => { expect(screen.getByText("협업")).toBeTruthy(); expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); + expect(screen.getByText("먼저 맞춰 볼 것")).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("Open a role on the roadmap to see what to lock in first."); + expect(priorities.textContent).not.toMatch(/NONE/i); + }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 71546b524..2682f3599 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -1,5 +1,5 @@ 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"; @@ -41,7 +41,116 @@ 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; + roleName: string; + sectionLabel: string; +}; + +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. + */ +function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { + const collectFor = (preferred: Exclude): LockInFirstItem[] => { + const items: LockInFirstItem[] = []; + const seen = 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}`; + if (seen.has(id)) { + continue; + } + + seen.add(id); + items.push({ id, roleName, sectionLabel }); + 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 so a missing focus + * list cannot become a rehearsal action. + */ +function collectFocusSectionLabels(song: RehearsalSong): string[] { + const focusLabels: string[] = []; + for (const label of song.exportSummary?.focusSections ?? []) { + const trimmed = label.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + continue; + } + focusLabels.push(trimmed); + if (focusLabels.length === MAX_LOCK_IN_FIRST_ITEMS) { + return focusLabels; + } + } + + const firstSectionLabel = nonBlankText(song.sections[0]?.label); + if (focusLabels.length === 0 && firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { + return [firstSectionLabel]; + } + + return focusLabels; +} + +/** Prevent clicks on rehearsal controls that are not available yet. */ function preventUnavailableAction(event: MouseEvent): void { event.preventDefault(); } @@ -212,6 +321,8 @@ 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]); /** Documented. */ const handleExportCueSheet = () => { @@ -323,11 +434,34 @@ 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) => ( +
  • + {item.roleName} · {item.sectionLabel} +
  • + ))} +
+
+ ) : focusSectionLabels.length > 0 ? ( +
+

{t("workspacePriorityFocusLead")}

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

{t("workspacePriorityEmpty")}

+ )}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 39f716d50..2476a305f 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -49,6 +49,9 @@ "workspaceTranspositionLabel": "Transpose / simplify", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", + "workspaceLockInFirstLabel": "Lock in first", + "workspacePriorityFocusLead": "Start with this section", + "workspacePriorityEmpty": "Open a role on the roadmap to see what to lock in first.", "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..ccb66d44b 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,9 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", + "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", + "workspacePriorityEmpty": "로드맵에서 역할을 열면 먼저 맞출 파트가 보입니다.", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", From 65ec4d42d28af4f172795bf72b2394979f8fca13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:41:34 +0900 Subject: [PATCH 02/10] test(workspace): dedupe fallback focus sections before rendering --- .../Workspace.priority-focus-dedup.test.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx 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..2afbf36bb --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx @@ -0,0 +1,40 @@ +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): 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 = createDemoRehearsalSong(); + 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" }); + expect(within(priorities).getAllByText(/^verse$/i)).toHaveLength(1); + expect(within(priorities).getByText("bridge")).toBeTruthy(); + expect(within(priorities).getByText("chorus")).toBeTruthy(); + }); +}); From 50cc1d9ade9d3a70071587dd8c289adcdd973303 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:44:05 +0900 Subject: [PATCH 03/10] fix(workspace): dedupe normalized fallback focus sections --- apps/desktop/src/features/workspace/Workspace.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 2682f3599..610d07a53 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -126,16 +126,21 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { /** * Collect focus-section labels when no role-level lock-in pair exists. * - * Blank and case-insensitive `none` values are dropped so a missing focus - * list cannot become a rehearsal action. + * Blank and case-insensitive `none` values are dropped. 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. */ function collectFocusSectionLabels(song: RehearsalSong): string[] { const focusLabels: string[] = []; + const seenLabels = new Set(); for (const label of song.exportSummary?.focusSections ?? []) { const trimmed = label.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { + const normalized = trimmed.toLowerCase(); + if (!trimmed || normalized === "none" || seenLabels.has(normalized)) { continue; } + seenLabels.add(normalized); focusLabels.push(trimmed); if (focusLabels.length === MAX_LOCK_IN_FIRST_ITEMS) { return focusLabels; From 5cd1afd6ce7bf4805bf74fecff35d0479ace9fa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:44:41 +0900 Subject: [PATCH 04/10] docs(changelog): record deduplicated priority fallback --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index baccc8be6..910f48a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - 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. + ## [0.1.3] - 2026-04-29 ### Fixed From df023052bf37e598cff358f71245eacbabff394d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:57:50 +0000 Subject: [PATCH 05/10] fix(workspace): keep lock-in pairs display-unique and honest Repeated verse labels no longer consume the third rehearsal-priority slot, and empty copy now points at the section roadmap instead of a no-op role click. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 + .../features/workspace/Workspace.stories.tsx | 63 +++++++++++++++ .../src/features/workspace/Workspace.test.tsx | 80 ++++++++++++++++++- .../src/features/workspace/Workspace.tsx | 13 ++- apps/desktop/src/locales/en/common.json | 2 +- apps/desktop/src/locales/ko/common.json | 2 +- .../2026-08-16-workspace-lock-in-first.md | 48 +++++++++++ 7 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/features/workspace/Workspace.stories.tsx create mode 100644 docs/plans/2026-08-16-workspace-lock-in-first.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 910f48a3e..236054f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### 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. ## [0.1.3] - 2026-04-29 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..8c6fd2ed7 --- /dev/null +++ b/apps/desktop/src/features/workspace/Workspace.stories.tsx @@ -0,0 +1,63 @@ +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. */ +export const LockInHighPriorityParts: Story = { + args: { song: createDemoRehearsalSong() } +}; + +/** Repeated verse plus chorus: the third slot is Lead Vocal · chorus. */ +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() } +}; diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 0a3fc4153..83532b126 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -332,7 +332,85 @@ describe("Workspace", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - expect(priorities.textContent).toContain("Open a role on the roadmap to see what to lock in first."); + 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("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"); + }); }); + +/** + * 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 610d07a53..aaf699d44 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -80,12 +80,15 @@ function matchesPreferredLockInPriority( * * Prefers `high` rehearsal priority, then `medium`. Blank names and `none` * sentinels are skipped so the card never turns missing evidence into an - * instruction. + * 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 seen = new Set(); + const seenIds = new Set(); + const seenDisplayPairs = new Set(); for (const section of song.sections) { const sectionLabel = nonBlankText(section.label); @@ -104,11 +107,13 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { } const id = `${role.id}:${section.id}`; - if (seen.has(id)) { + const displayKey = `${roleName.toLowerCase()}|${sectionLabel.toLowerCase()}`; + if (seenIds.has(id) || seenDisplayPairs.has(displayKey)) { continue; } - seen.add(id); + seenIds.add(id); + seenDisplayPairs.add(displayKey); items.push({ id, roleName, sectionLabel }); if (items.length === MAX_LOCK_IN_FIRST_ITEMS) { return items; diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 2476a305f..10b6ce65a 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -51,7 +51,7 @@ "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceLockInFirstLabel": "Lock in first", "workspacePriorityFocusLead": "Start with this section", - "workspacePriorityEmpty": "Open a role on the roadmap to see what to lock in first.", + "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 ccb66d44b..b1919809a 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -51,7 +51,7 @@ "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", - "workspacePriorityEmpty": "로드맵에서 역할을 열면 먼저 맞출 파트가 보입니다.", + "workspacePriorityEmpty": "아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", "sectionRoadmapScrollHint": "더 많은 구간은 옆으로 스크롤하세요 →", 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..1c18830b7 --- /dev/null +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -0,0 +1,48 @@ +# 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 and the first 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. + +**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. + +### 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. + +### Remaining risk + +- Clicking a named pair does not yet select that role and section on the roadmap. That is the next buyer-visible slice, not a security control. + +## 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/ From 5fa854a6231304dde384f13a5c3bda5abd56e445 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:59:52 +0000 Subject: [PATCH 06/10] feat(workspace): open a lock-in pair on the roadmap Clicking a named role-and-section pair selects that role and focuses the matching section so players can jump from the priority card into the part they need to lock. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../workspace/SectionRoadmap.test.tsx | 8 +++++ .../src/features/workspace/SectionRoadmap.tsx | 9 +++++- .../src/features/workspace/Workspace.test.tsx | 19 ++++++++++- .../src/features/workspace/Workspace.tsx | 32 +++++++++++++++++-- apps/desktop/src/locales/en/common.json | 1 + apps/desktop/src/locales/ko/common.json | 1 + .../2026-08-16-workspace-lock-in-first.md | 2 +- 8 files changed, 68 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 236054f97..e3aebf0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..fbb973c73 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -48,6 +48,14 @@ describe("SectionRoadmap", () => { expect(onSongUpdate).toHaveBeenCalledTimes(1); }); + it("marks the focused section for the lock-in handoff", () => { + const song = createDemoRehearsalSong(); + + render(); + + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + }); + 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..87d5f400b 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -10,11 +10,12 @@ 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) { +export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSongUpdate }: SectionRoadmapProps) { const sectionRoadmapTitleId = useId(); const locale = useMemo(() => detectPreferredLocale(), []); const t = useMemo(() => createTranslator(locale), [locale]); @@ -106,7 +107,13 @@ export function SectionRoadmap({ song, activeRole, onSongUpdate }: SectionRoadma {song.sections.map((section) => ( diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 83532b126..f4f87adda 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"; @@ -269,6 +269,7 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("먼저 맞춰 볼 것")).toBeTruthy(); + expect(screen.getByRole("button", { name: "로드맵에서 verse의 Bass Guitar 보기" })).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); @@ -370,6 +371,22 @@ describe("Workspace", () => { 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 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"); + }); + it("falls back to the first section label when every role is low and focus sections are empty", () => { setNavigatorLanguage("en-US"); const song = createLateNightSetWithRepeatedVerse(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index aaf699d44..92932dab5 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -44,6 +44,8 @@ type Translator = ReturnType; /** 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; }; @@ -114,7 +116,7 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { seenIds.add(id); seenDisplayPairs.add(displayKey); - items.push({ id, roleName, sectionLabel }); + items.push({ id, roleId: role.id, sectionId: section.id, roleName, sectionLabel }); if (items.length === MAX_LOCK_IN_FIRST_ITEMS) { return items; } @@ -239,6 +241,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 @@ -334,6 +337,23 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp 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 t("workspaceLockInPairAction") + .replace("{roleName}", item.roleName) + .replace("{sectionLabel}", item.sectionLabel); + }; + /** Documented. */ const handleExportCueSheet = () => { const csv = generateCueSheetCsv(song); @@ -455,7 +475,14 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
    {lockInFirstItems.map((item) => (
  • - {item.roleName} · {item.sectionLabel} +
  • ))}
@@ -627,6 +654,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index 10b6ce65a..f6b9531a7 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,6 +50,7 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceLockInFirstLabel": "Lock in first", + "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} on the roadmap", "workspacePriorityFocusLead": "Start with this section", "workspacePriorityEmpty": "No named parts to lock in yet. Pick the first entrance on the section roadmap.", "workspaceRolesHarmonyLabel": "Roles & Harmony", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index b1919809a..a2cbf7613 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,6 +50,7 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", + "workspaceLockInPairAction": "로드맵에서 {sectionLabel}의 {roleName} 보기", "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", "workspacePriorityEmpty": "아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.", "workspaceRolesHarmonyLabel": "역할과 화성", diff --git a/docs/plans/2026-08-16-workspace-lock-in-first.md b/docs/plans/2026-08-16-workspace-lock-in-first.md index 1c18830b7..036ef5d32 100644 --- a/docs/plans/2026-08-16-workspace-lock-in-first.md +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -37,7 +37,7 @@ ### Remaining risk -- Clicking a named pair does not yet select that role and section on the roadmap. That is the next buyer-visible slice, not a security control. +- Focus-section fallback labels are still not clickable. A later slice can map a focus label to the first matching section id. ## References From c646037d390ed92dfe12c4416e8eb9874cc03b6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:01:15 +0000 Subject: [PATCH 07/10] feat(workspace): open a focus-section label on the roadmap Fallback focus labels now jump to the first matching section so players can still act when analysis only names a section, not a role pair. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/features/workspace/Workspace.test.tsx | 44 +++++++++++++++++ .../src/features/workspace/Workspace.tsx | 47 ++++++++++++++++++- apps/desktop/src/locales/en/common.json | 1 + apps/desktop/src/locales/ko/common.json | 1 + .../2026-08-16-workspace-lock-in-first.md | 2 +- 6 files changed, 94 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3aebf0bb..6c6e59c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index f4f87adda..5e8b7d0a3 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -387,6 +387,50 @@ describe("Workspace", () => { expect(screen.getByTestId("section-roadmap-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 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("does not focus a section when the fallback label has no matching roadmap card", () => { + 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" }); + fireEvent.click(within(priorities).getByRole("button", { name: "Show bridge on the roadmap" })); + + expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true"); + expect(screen.getByTestId("section-roadmap-chorus-1")).not.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(); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 92932dab5..568702dae 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -162,6 +162,28 @@ function collectFocusSectionLabels(song: RehearsalSong): string[] { return focusLabels; } +/** + * 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 findSectionIdForFocusLabel(song: RehearsalSong, focusLabel: string): string | 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.id; + } + } + + return null; +} + /** Prevent clicks on rehearsal controls that are not available yet. */ function preventUnavailableAction(event: MouseEvent): void { event.preventDefault(); @@ -354,6 +376,20 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp .replace("{sectionLabel}", item.sectionLabel); }; + /** + * Focus the first roadmap section that matches a fallback focus label. + */ + const handleFocusSectionActivate = (label: string): void => { + setFocusedSectionId(findSectionIdForFocusLabel(song, label)); + }; + + /** + * Build the action label that opens a focus section on the roadmap. + */ + const focusSectionActionLabel = (label: string): string => { + return t("workspacePriorityFocusAction").replace("{sectionLabel}", label); + }; + /** Documented. */ const handleExportCueSheet = () => { const csv = generateCueSheetCsv(song); @@ -492,7 +528,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspacePriorityFocusLead")}

    {focusSectionLabels.map((label) => ( -
  • {label}
  • +
  • + +
  • ))}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json index f6b9531a7..052aa2aed 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -52,6 +52,7 @@ "workspaceLockInFirstLabel": "Lock in first", "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} on the roadmap", "workspacePriorityFocusLead": "Start with this section", + "workspacePriorityFocusAction": "Show {sectionLabel} 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", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index a2cbf7613..7b41398d9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -52,6 +52,7 @@ "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", "workspaceLockInPairAction": "로드맵에서 {sectionLabel}의 {roleName} 보기", "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", + "workspacePriorityFocusAction": "로드맵에서 {sectionLabel} 보기", "workspacePriorityEmpty": "아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", diff --git a/docs/plans/2026-08-16-workspace-lock-in-first.md b/docs/plans/2026-08-16-workspace-lock-in-first.md index 036ef5d32..6d1519eef 100644 --- a/docs/plans/2026-08-16-workspace-lock-in-first.md +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -37,7 +37,7 @@ ### Remaining risk -- Focus-section fallback labels are still not clickable. A later slice can map a focus label to the first matching section id. +- A focus label with no matching section id is a no-op. That is safe failure, not a missing control. ## References From 43cb9438d6338890a218f2d3b7c4ba74199adb00 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:13:40 +0000 Subject: [PATCH 08/10] fix(workspace): keep unmatched focus labels from selling a no-op Omit focus labels that do not match a roadmap card so a missing bridge cannot clear verse focus. Scroll the named section into view when a lock-in pair or fallback label is opened. Co-authored-by: Seongho Bae --- CHANGELOG.md | 2 + .../workspace/SectionRoadmap.test.tsx | 9 ++- .../src/features/workspace/SectionRoadmap.tsx | 23 +++++- .../Workspace.priority-focus-dedup.test.tsx | 24 ++++-- .../features/workspace/Workspace.stories.tsx | 23 ++++++ .../src/features/workspace/Workspace.test.tsx | 77 +++++++++++++++++-- .../src/features/workspace/Workspace.tsx | 29 +++++-- .../2026-08-16-workspace-lock-in-first.md | 7 +- 8 files changed, 171 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6e59c96..2db13f91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -15,6 +16,7 @@ - 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. ## [0.1.3] - 2026-04-29 diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index fbb973c73..96542109f 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { SectionRoadmap } from "./SectionRoadmap"; const originalLanguage = window.navigator.language; +const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; function setNavigatorLanguage(language: string) { Object.defineProperty(window.navigator, "language", { @@ -16,6 +17,7 @@ describe("SectionRoadmap", () => { afterEach(() => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; }); it("localizes roadmap controls and provenance badges", () => { @@ -50,10 +52,15 @@ describe("SectionRoadmap", () => { it("marks the focused section for the lock-in handoff", () => { const song = createDemoRehearsalSong(); + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; render(); - expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + const focusedCard = screen.getByTestId("section-roadmap-verse-1"); + expect(focusedCard).toHaveAttribute("data-focused-section", "true"); + expect(focusedCard).toHaveAttribute("aria-current", "true"); + expect(scrollIntoView).toHaveBeenCalled(); }); it("does not update when the trimmed chord is unchanged", () => { diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 87d5f400b..083ed1042 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.tsx @@ -1,5 +1,5 @@ import type { RehearsalSong, RehearsalRole } from "@bandscope/shared-types"; -import { useId, useMemo } from "react"; +import { useEffect, useId, useMemo, useRef } from "react"; import { createTranslator, detectPreferredLocale } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -14,12 +14,29 @@ interface SectionRoadmapProps { onSongUpdate?: (song: RehearsalSong) => void; } -/** Documented. */ +/** + * 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: "smooth", + inline: "start", + block: "nearest" + }); + } + }, [focusedSectionId]); + /** Documented. */ const editChordLabel = (role: RehearsalRole, sectionLabel: string): string => { return t("chordEditAriaLabel") @@ -107,8 +124,10 @@ export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSo {song.sections.map((section) => ( { it("deduplicates normalized focus labels while preserving first-occurrence order", () => { setNavigatorLanguage("en-US"); - const song = createDemoRehearsalSong(); + const song = createLateNightSetWithChorus(); for (const section of song.sections) { for (const role of section.roles) { role.rehearsalPriority = "low"; @@ -33,8 +33,22 @@ describe("Workspace rehearsal-priority focus fallback", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - expect(within(priorities).getAllByText(/^verse$/i)).toHaveLength(1); - expect(within(priorities).getByText("bridge")).toBeTruthy(); - expect(within(priorities).getByText("chorus")).toBeTruthy(); + const buttons = within(priorities).getAllByRole("button"); + expect(buttons.map((button) => button.textContent)).toEqual(["verse", "chorus"]); + 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 index 8c6fd2ed7..3661b8a83 100644 --- a/apps/desktop/src/features/workspace/Workspace.stories.tsx +++ b/apps/desktop/src/features/workspace/Workspace.stories.tsx @@ -61,3 +61,26 @@ export const DedupedRepeatedVerse: Story = { 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 5e8b7d0a3..43f4407cb 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -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", () => { @@ -409,7 +411,7 @@ describe("Workspace", () => { expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true"); }); - it("does not focus a section when the fallback label has no matching roadmap card", () => { + it("omits unmatched focus labels so they cannot clear a real section", () => { setNavigatorLanguage("en-US"); const song = createLateNightSetWithRepeatedVerse(); for (const section of song.sections) { @@ -419,16 +421,37 @@ describe("Workspace", () => { } song.exportSummary = { ...song.exportSummary, - focusSections: ["bridge"] + focusSections: ["verse", "bridge"] }; render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - fireEvent.click(within(priorities).getByRole("button", { name: "Show bridge on the roadmap" })); + fireEvent.click(within(priorities).getByRole("button", { name: "Show verse on the roadmap" })); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + expect(within(priorities).queryByRole("button", { name: "Show bridge on the roadmap" })).toBeNull(); + expect(screen.getByTestId("section-roadmap-verse-1")).toHaveAttribute("data-focused-section", "true"); + }); - expect(screen.getByTestId("section-roadmap-verse-1")).not.toHaveAttribute("data-focused-section", "true"); - expect(screen.getByTestId("section-roadmap-chorus-1")).not.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 on the roadmap" })).toBeNull(); + fireEvent.click(within(priorities).getByRole("button", { name: "Show verse 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", () => { @@ -451,6 +474,50 @@ describe("Workspace", () => { 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 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 on the roadmap" })).toBeNull(); + fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus on the roadmap" })); + expect(screen.getByTestId("section-roadmap-chorus-1")).toHaveAttribute("data-focused-section", "true"); + }); + + 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 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(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 568702dae..c1b2ae37b 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -133,10 +133,13 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { /** * Collect focus-section labels when no role-level lock-in pair exists. * - * Blank and case-insensitive `none` values are dropped. 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. + * 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. 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): string[] { const focusLabels: string[] = []; @@ -147,6 +150,9 @@ function collectFocusSectionLabels(song: RehearsalSong): string[] { if (!trimmed || normalized === "none" || seenLabels.has(normalized)) { continue; } + if (!findSectionIdForFocusLabel(song, trimmed)) { + continue; + } seenLabels.add(normalized); focusLabels.push(trimmed); if (focusLabels.length === MAX_LOCK_IN_FIRST_ITEMS) { @@ -154,9 +160,13 @@ function collectFocusSectionLabels(song: RehearsalSong): string[] { } } - const firstSectionLabel = nonBlankText(song.sections[0]?.label); - if (focusLabels.length === 0 && firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { - return [firstSectionLabel]; + if (focusLabels.length === 0) { + for (const section of song.sections) { + const firstSectionLabel = nonBlankText(section.label); + if (firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { + return [firstSectionLabel]; + } + } } return focusLabels; @@ -380,7 +390,10 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp * Focus the first roadmap section that matches a fallback focus label. */ const handleFocusSectionActivate = (label: string): void => { - setFocusedSectionId(findSectionIdForFocusLabel(song, label)); + const sectionId = findSectionIdForFocusLabel(song, label); + if (sectionId) { + setFocusedSectionId(sectionId); + } }; /** diff --git a/docs/plans/2026-08-16-workspace-lock-in-first.md b/docs/plans/2026-08-16-workspace-lock-in-first.md index 6d1519eef..f8f2dbbdd 100644 --- a/docs/plans/2026-08-16-workspace-lock-in-first.md +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -2,7 +2,7 @@ **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 and the first 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. +**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. Opening a pair or label selects that role and section and scrolls the matching roadmap card into view. **Tech Stack:** React 19 Workspace card, shared `RehearsalSong` contract, Vitest fixtures from the Late Night Set demo, Storybook inventory under `Workspace/Rehearsal Priorities`. @@ -29,15 +29,18 @@ - 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. ### 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 -- A focus label with no matching section id is a no-op. That is safe failure, not a missing control. +- Role names and `focusSections` remain free-form after parse and enter `aria-label` through sequential `.replace`. Workspace does not re-parse `jobResult`. Visible text stays React text nodes. ## References From db2edf001d231c5d3679b738da4bd9060089164b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:42:16 +0000 Subject: [PATCH 09/10] feat(workspace): name the first entrance time on lock-in pairs Show when each lock-in part starts, mark the activated pair as current, keep aria-label tokens from rewriting each other, and skip smooth roadmap scrolling when the player prefers reduced motion. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 4 + .../workspace/SectionRoadmap.test.tsx | 33 +++++- .../src/features/workspace/SectionRoadmap.tsx | 26 +++- .../Workspace.priority-focus-dedup.test.tsx | 2 +- .../features/workspace/Workspace.stories.tsx | 4 +- .../src/features/workspace/Workspace.test.tsx | 68 +++++++++-- .../src/features/workspace/Workspace.tsx | 112 +++++++++++++----- apps/desktop/src/i18n/index.test.ts | 20 +++- apps/desktop/src/i18n/index.ts | 19 +++ apps/desktop/src/locales/en/common.json | 4 +- apps/desktop/src/locales/ko/common.json | 4 +- docs/design-system/product-design-handoff.md | 2 +- .../2026-08-16-workspace-lock-in-first.md | 12 +- 14 files changed, 252 insertions(+), 62 deletions(-) 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 2db13f91d..0d6c5e437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - 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. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -17,6 +19,8 @@ - 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 diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 96542109f..799751a50 100644 --- a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx +++ b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx @@ -5,6 +5,7 @@ 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", { @@ -18,6 +19,7 @@ describe("SectionRoadmap", () => { setNavigatorLanguage(originalLanguage); vi.restoreAllMocks(); HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + window.matchMedia = originalMatchMedia; }); it("localizes roadmap controls and provenance badges", () => { @@ -60,7 +62,36 @@ describe("SectionRoadmap", () => { const focusedCard = screen.getByTestId("section-roadmap-verse-1"); expect(focusedCard).toHaveAttribute("data-focused-section", "true"); expect(focusedCard).toHaveAttribute("aria-current", "true"); - expect(scrollIntoView).toHaveBeenCalled(); + 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", () => { diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.tsx index 083ed1042..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 { useEffect, useId, useMemo, useRef } from "react"; -import { createTranslator, detectPreferredLocale } from "../../i18n"; +import { createTranslator, detectPreferredLocale, interpolateTemplate } from "../../i18n"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -14,6 +14,19 @@ interface SectionRoadmapProps { onSongUpdate?: (song: RehearsalSong) => void; } +/** + * 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. */ @@ -30,7 +43,7 @@ export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSo const focusedCard = focusedCardRef.current; if (typeof focusedCard?.scrollIntoView === "function") { focusedCard.scrollIntoView({ - behavior: "smooth", + behavior: prefersReducedMotion() ? "auto" : "smooth", inline: "start", block: "nearest" }); @@ -39,10 +52,11 @@ export function SectionRoadmap({ song, activeRole, focusedSectionId = null, onSo /** 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. */ 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 index a9bbed26d..410a8dbc3 100644 --- a/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.priority-focus-dedup.test.tsx @@ -34,7 +34,7 @@ describe("Workspace rehearsal-priority focus fallback", () => { const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); const buttons = within(priorities).getAllByRole("button"); - expect(buttons.map((button) => button.textContent)).toEqual(["verse", "chorus"]); + expect(buttons.map((button) => button.textContent)).toEqual(["verse · 0:10", "chorus · 0:30"]); expect(within(priorities).queryByText("bridge")).toBeNull(); }); }); diff --git a/apps/desktop/src/features/workspace/Workspace.stories.tsx b/apps/desktop/src/features/workspace/Workspace.stories.tsx index 3661b8a83..adc82f637 100644 --- a/apps/desktop/src/features/workspace/Workspace.stories.tsx +++ b/apps/desktop/src/features/workspace/Workspace.stories.tsx @@ -47,12 +47,12 @@ const meta = { export default meta; type Story = StoryObj; -/** Demo song: names Bass Guitar and Keyboard 1 Right Hand on verse. */ +/** 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. */ +/** Repeated verse plus chorus: the third slot is Lead Vocal · chorus · 0:30. */ export const DedupedRepeatedVerse: Story = { args: { song: createLateNightSetWithRepeatedVerse() } }; diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 43f4407cb..21e78f7b0 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -271,7 +271,7 @@ describe("Workspace", () => { expect(screen.getByText("스템")).toBeTruthy(); expect(screen.getByText("합주 우선순위")).toBeTruthy(); expect(screen.getByText("먼저 맞춰 볼 것")).toBeTruthy(); - expect(screen.getByRole("button", { name: "로드맵에서 verse의 Bass Guitar 보기" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "로드맵에서 0:10 verse의 Bass Guitar 보기" })).toBeTruthy(); expect(screen.getByText("역할과 화성")).toBeTruthy(); }); @@ -381,7 +381,7 @@ describe("Workspace", () => { const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); fireEvent.click( - within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus on the roadmap" }) + 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"); @@ -405,7 +405,7 @@ describe("Workspace", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus on the roadmap" })); + 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"); @@ -427,9 +427,9 @@ describe("Workspace", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - fireEvent.click(within(priorities).getByRole("button", { name: "Show verse on the roadmap" })); + 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 on the roadmap" })).toBeNull(); + 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"); }); @@ -449,8 +449,8 @@ describe("Workspace", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - expect(within(priorities).queryByRole("button", { name: "Show bridge on the roadmap" })).toBeNull(); - fireEvent.click(within(priorities).getByRole("button", { name: "Show verse on the roadmap" })); + 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"); }); @@ -474,7 +474,7 @@ describe("Workspace", () => { 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 on the roadmap" })); + 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"); }); @@ -496,11 +496,57 @@ describe("Workspace", () => { render(); const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); - expect(within(priorities).queryByRole("button", { name: "Show none on the roadmap" })).toBeNull(); - fireEvent.click(within(priorities).getByRole("button", { name: "Show chorus on the roadmap" })); + 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(); @@ -511,7 +557,7 @@ describe("Workspace", () => { const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); fireEvent.click( - within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus on the roadmap" }) + within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus at 0:30 on the roadmap" }) ); const chorusCard = screen.getByTestId("section-roadmap-chorus-1"); diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index c1b2ae37b..772564aa5 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -4,7 +4,7 @@ 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"; @@ -48,6 +48,14 @@ type LockInFirstItem = { 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; @@ -116,7 +124,14 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { seenIds.add(id); seenDisplayPairs.add(displayKey); - items.push({ id, roleId: role.id, sectionId: section.id, roleName, sectionLabel }); + 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; } @@ -135,14 +150,15 @@ function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { * * 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. Equivalent labels are de-duplicated + * 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): string[] { - const focusLabels: string[] = []; +function collectFocusSectionLabels(song: RehearsalSong): FocusSectionItem[] { + const focusItems: FocusSectionItem[] = []; const seenLabels = new Set(); for (const label of song.exportSummary?.focusSections ?? []) { const trimmed = label.trim(); @@ -150,26 +166,35 @@ function collectFocusSectionLabels(song: RehearsalSong): string[] { if (!trimmed || normalized === "none" || seenLabels.has(normalized)) { continue; } - if (!findSectionIdForFocusLabel(song, trimmed)) { + const matchedSection = findSectionForFocusLabel(song, trimmed); + if (!matchedSection) { continue; } seenLabels.add(normalized); - focusLabels.push(trimmed); - if (focusLabels.length === MAX_LOCK_IN_FIRST_ITEMS) { - return focusLabels; + focusItems.push({ + sectionId: matchedSection.id, + sectionLabel: trimmed, + sectionStartSeconds: matchedSection.timeRange.start + }); + if (focusItems.length === MAX_LOCK_IN_FIRST_ITEMS) { + return focusItems; } } - if (focusLabels.length === 0) { + if (focusItems.length === 0) { for (const section of song.sections) { const firstSectionLabel = nonBlankText(section.label); if (firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { - return [firstSectionLabel]; + return [{ + sectionId: section.id, + sectionLabel: firstSectionLabel, + sectionStartSeconds: section.timeRange.start + }]; } } } - return focusLabels; + return focusItems; } /** @@ -178,7 +203,10 @@ function collectFocusSectionLabels(song: RehearsalSong): string[] { * Matching is case-insensitive after trim so analysis spelling variants still * open the same roadmap card. */ -function findSectionIdForFocusLabel(song: RehearsalSong, focusLabel: string): string | null { +function findSectionForFocusLabel( + song: RehearsalSong, + focusLabel: string +): RehearsalSong["sections"][number] | null { const normalized = focusLabel.trim().toLowerCase(); if (!normalized) { return null; @@ -187,7 +215,7 @@ function findSectionIdForFocusLabel(song: RehearsalSong, focusLabel: string): st for (const section of song.sections) { const sectionLabel = nonBlankText(section.label); if (sectionLabel && sectionLabel.toLowerCase() === normalized) { - return section.id; + return section; } } @@ -381,26 +409,42 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp * Build the action label that tells a player which roadmap part will open. */ const lockInPairActionLabel = (item: LockInFirstItem): string => { - return t("workspaceLockInPairAction") - .replace("{roleName}", item.roleName) - .replace("{sectionLabel}", item.sectionLabel); + return interpolateTemplate(t("workspaceLockInPairAction"), { + roleName: item.roleName, + sectionLabel: item.sectionLabel, + startTime: formatTimelineTime(item.sectionStartSeconds) + }); }; /** - * Focus the first roadmap section that matches a fallback focus label. + * Focus the matching roadmap section for a fallback focus label. */ - const handleFocusSectionActivate = (label: string): void => { - const sectionId = findSectionIdForFocusLabel(song, label); - if (sectionId) { - setFocusedSectionId(sectionId); - } + 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 = (label: string): string => { - return t("workspacePriorityFocusAction").replace("{sectionLabel}", label); + const focusSectionActionLabel = (item: FocusSectionItem): string => { + return interpolateTemplate(t("workspacePriorityFocusAction"), { + sectionLabel: item.sectionLabel, + startTime: formatTimelineTime(item.sectionStartSeconds) + }); }; /** Documented. */ @@ -529,8 +573,13 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp className="rounded text-left font-semibold text-slate-100 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300" onClick={() => handleLockInPairActivate(item)} aria-label={lockInPairActionLabel(item)} + aria-current={ + focusedSectionId === item.sectionId && activeRole === item.roleId + ? "true" + : undefined + } > - {item.roleName} · {item.sectionLabel} + {lockInPairVisibleLabel(item)} ))} @@ -540,15 +589,16 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

{t("workspacePriorityFocusLead")}

    - {focusSectionLabels.map((label) => ( -
  • + {focusSectionLabels.map((item) => ( +
  • ))} 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 052aa2aed..6435b9f37 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -50,9 +50,9 @@ "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", "workspaceLockInFirstLabel": "Lock in first", - "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} on the roadmap", + "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} at {startTime} on the roadmap", "workspacePriorityFocusLead": "Start with this section", - "workspacePriorityFocusAction": "Show {sectionLabel} on the roadmap", + "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", diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json index 7b41398d9..758d0262d 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -50,9 +50,9 @@ "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", - "workspaceLockInPairAction": "로드맵에서 {sectionLabel}의 {roleName} 보기", + "workspaceLockInPairAction": "로드맵에서 {startTime} {sectionLabel}의 {roleName} 보기", "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", - "workspacePriorityFocusAction": "로드맵에서 {sectionLabel} 보기", + "workspacePriorityFocusAction": "로드맵에서 {startTime} {sectionLabel} 보기", "workspacePriorityEmpty": "아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요.", "workspaceRolesHarmonyLabel": "역할과 화성", "sectionRoadmapTitle": "구간 흐름", 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 index f8f2dbbdd..b949cbae0 100644 --- a/docs/plans/2026-08-16-workspace-lock-in-first.md +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -2,7 +2,7 @@ **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. Opening a pair or label selects that role and section and scrolls the matching roadmap card into view. +**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`. @@ -31,6 +31,10 @@ - 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"`. +- Role names that contain `{sectionLabel}` cannot rewrite later aria-label tokens. +- `prefers-reduced-motion: reduce` uses instant `scrollIntoView` instead of smooth animation. ### Realistic threats @@ -40,7 +44,7 @@ ### Remaining risk -- Role names and `focusSections` remain free-form after parse and enter `aria-label` through sequential `.replace`. Workspace does not re-parse `jobResult`. Visible text stays React text nodes. +- 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 @@ -49,3 +53,7 @@ International Organization for Standardization. (2020). *Ergonomics of human-sys 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 From 05935e02f29366b2bd240d6fb4d1bae784cb992b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:44:12 +0000 Subject: [PATCH 10/10] feat(workspace): highlight the focused section on the song timeline A lock-in click now marks the same section on the song-structure timeline so players can see the entrance on both the form strip and the roadmap. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + .../src/features/workspace/Workspace.test.tsx | 2 ++ .../src/features/workspace/Workspace.tsx | 23 ++++++++++++++++--- .../2026-08-16-workspace-lock-in-first.md | 1 + 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6c5e437..ba0cfafee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx index 21e78f7b0..0400c8814 100644 --- a/apps/desktop/src/features/workspace/Workspace.test.tsx +++ b/apps/desktop/src/features/workspace/Workspace.test.tsx @@ -387,6 +387,8 @@ describe("Workspace", () => { 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", () => { diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx index 772564aa5..16b12cbc8 100644 --- a/apps/desktop/src/features/workspace/Workspace.tsx +++ b/apps/desktop/src/features/workspace/Workspace.tsx @@ -252,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 (
    @@ -272,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)}

    @@ -610,7 +627,7 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
- +
diff --git a/docs/plans/2026-08-16-workspace-lock-in-first.md b/docs/plans/2026-08-16-workspace-lock-in-first.md index b949cbae0..40ca4e774 100644 --- a/docs/plans/2026-08-16-workspace-lock-in-first.md +++ b/docs/plans/2026-08-16-workspace-lock-in-first.md @@ -33,6 +33,7 @@ - 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.