diff --git a/CHANGELOG.md b/CHANGELOG.md index eea696893..6c6e59c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,18 @@ ### Added +- Surface high-priority role and section pairs in the workspace so players can see what to lock in first before rehearsal. +- Let players open a named lock-in pair to select that role and section on the roadmap. +- Let players open a fallback focus-section label to highlight that section on the roadmap. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. +### Fixed + +- Deduplicate normalized focus-section fallback labels so repeated analysis evidence does not consume all three rehearsal-priority slots or render duplicate list keys. +- Deduplicate lock-in role and section display pairs so a repeated verse label cannot consume the third rehearsal-priority slot. +- Replace empty rehearsal-priority copy that incorrectly told players a role click would name lock-in parts. + ## [0.1.3] - 2026-04-29 ### Fixed diff --git a/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx b/apps/desktop/src/features/workspace/SectionRoadmap.test.tsx index 75a199246..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.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(); + }); +}); 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 a3da5ffe6..5e8b7d0a3 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"; @@ -268,6 +268,210 @@ describe("Workspace", () => { expect(screen.getByText("협업")).toBeTruthy(); 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(); }); + + it("names high-priority role and section pairs to lock in first", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.textContent).toContain("Bass Guitar · verse"); + expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse"); + expect(priorities.textContent).not.toContain("Lead Vocal · verse"); + expect(priorities.textContent).not.toContain("Focus:"); + }); + + it("falls back to medium-priority parts when no high-priority role exists", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = role.rehearsalPriority === "high" ? "low" : role.rehearsalPriority; + } + } + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.textContent).toContain("Lead Vocal · verse"); + expect(priorities.textContent).not.toContain("Bass Guitar · verse"); + }); + + it("falls back to focus sections when every role is low priority", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + for (const section of song.sections) { + for (const role of section.roles) { + role.rehearsalPriority = "low"; + } + } + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Start with this section"); + expect(priorities.textContent).toContain("verse"); + expect(priorities.textContent).not.toContain("Lock in first"); + }); + + it("does not turn blank or none sentinels into lock-in instructions", () => { + setNavigatorLanguage("en-US"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: ["NONE", " ", "none"] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain( + "No named parts to lock in yet. Pick the first entrance on the section roadmap." + ); + expect(priorities.textContent).not.toContain("Open a role on the roadmap"); + expect(priorities.textContent).not.toMatch(/NONE/i); + }); + + it("localizes the empty lock-in copy without promising a role click will fill the card", () => { + setNavigatorLanguage("ko-KR"); + const song = createDemoRehearsalSong(); + song.sections = []; + song.exportSummary = { + ...song.exportSummary, + focusSections: [] + }; + + render(); + + const priorities = screen.getByRole("region", { name: "합주 우선순위" }); + expect(priorities.textContent).toContain("아직 먼저 맞출 파트가 없습니다. 구간 로드맵에서 첫 입구를 고르세요."); + expect(priorities.textContent).not.toContain("로드맵에서 역할을 열면"); + }); + + it("keeps repeated verse labels from consuming a third lock-in slot", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + expect(priorities.textContent).toContain("Lock in first"); + expect(priorities.querySelectorAll("li")).toHaveLength(3); + expect(priorities.textContent).toContain("Bass Guitar · verse"); + expect(priorities.textContent).toContain("Keyboard 1 Right Hand · verse"); + expect(priorities.textContent).toContain("Lead Vocal · chorus"); + expect(priorities.textContent?.match(/Bass Guitar · verse/g)).toHaveLength(1); + }); + + it("selects the named role and section when a lock-in pair is activated", () => { + setNavigatorLanguage("en-US"); + const song = createLateNightSetWithRepeatedVerse(); + + render(); + + const priorities = screen.getByRole("region", { name: "Rehearsal Priorities" }); + fireEvent.click( + within(priorities).getByRole("button", { name: "Show Lead Vocal in chorus 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("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(); + 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 71546b524..568702dae 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,150 @@ function downloadTextFile(contents: string, type: string, filename: string): voi type Translator = ReturnType; -/** Documented. */ +/** One role-and-section pair a player should lock in before the room starts. */ +type LockInFirstItem = { + id: string; + roleId: string; + sectionId: string; + roleName: string; + sectionLabel: string; +}; + +const MAX_LOCK_IN_FIRST_ITEMS = 3; + +/** + * Return whether a role priority belongs in the preferred lock-in list. + * + * High-priority parts are shown first. Medium is used only when no high + * priority exists so the card still names a concrete part instead of a + * section label alone. + */ +function matchesPreferredLockInPriority( + priority: RehearsalPriority, + preferred: Exclude +): boolean { + switch (priority) { + case "high": + return preferred === "high"; + case "medium": + return preferred === "medium"; + case "low": + return false; + default: { + const exhaustive: never = priority; + return exhaustive; + } + } +} + +/** + * Collect the first role-and-section pairs a player should lock in. + * + * Prefers `high` rehearsal priority, then `medium`. Blank names and `none` + * sentinels are skipped so the card never turns missing evidence into an + * instruction. Equivalent display pairs are de-duplicated case-insensitively + * so repeated verse or chorus labels cannot consume every lock-in slot. + */ +function collectLockInFirstItems(song: RehearsalSong): LockInFirstItem[] { + /** Collect lock-in pairs for one preferred priority without repeating display text. */ + const collectFor = (preferred: Exclude): LockInFirstItem[] => { + const items: LockInFirstItem[] = []; + const seenIds = new Set(); + const seenDisplayPairs = new Set(); + + for (const section of song.sections) { + const sectionLabel = nonBlankText(section.label); + if (!sectionLabel || sectionLabel.toLowerCase() === "none") { + continue; + } + + for (const role of section.roles) { + if (!matchesPreferredLockInPriority(role.rehearsalPriority, preferred)) { + continue; + } + + const roleName = nonBlankText(role.name); + if (!roleName || roleName.toLowerCase() === "none") { + continue; + } + + const id = `${role.id}:${section.id}`; + const displayKey = `${roleName.toLowerCase()}|${sectionLabel.toLowerCase()}`; + if (seenIds.has(id) || seenDisplayPairs.has(displayKey)) { + continue; + } + + seenIds.add(id); + seenDisplayPairs.add(displayKey); + items.push({ id, roleId: role.id, sectionId: section.id, roleName, sectionLabel }); + 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. 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(); + 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; + } + } + + const firstSectionLabel = nonBlankText(song.sections[0]?.label); + if (focusLabels.length === 0 && firstSectionLabel && firstSectionLabel.toLowerCase() !== "none") { + return [firstSectionLabel]; + } + + 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(); } @@ -120,6 +263,7 @@ const SongStructure = memo(function SongStructure({ sections, t }: { sections: R /** Documented. */ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: WorkspaceProps) { const [activeRole, setActiveRole] = useState(null); + const [focusedSectionId, setFocusedSectionId] = useState(null); const t = useMemo(() => createTranslator(detectPreferredLocale()), []); // Extract all unique roles from the song's sections @@ -212,6 +356,39 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp const roleTranspositionPlan = nonBlankText(activeRoleDetails?.transpositionPlan) ?? nonBlankText(activeRoleDetails?.simplification); + const lockInFirstItems = useMemo(() => collectLockInFirstItems(song), [song]); + const focusSectionLabels = useMemo(() => collectFocusSectionLabels(song), [song]); + + /** + * Select the named role and section so the roadmap shows the part to lock in. + */ + const handleLockInPairActivate = (item: LockInFirstItem): void => { + setActiveRole(item.roleId); + setFocusedSectionId(item.sectionId); + }; + + /** + * Build the action label that tells a player which roadmap part will open. + */ + const lockInPairActionLabel = (item: LockInFirstItem): string => { + return t("workspaceLockInPairAction") + .replace("{roleName}", item.roleName) + .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 = () => { @@ -323,11 +500,50 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp

Stem lanes will appear when separation results are available.

-
+

{t("workspaceRehearsalPrioritiesLabel")}

-

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

+ {lockInFirstItems.length > 0 ? ( +
+

{t("workspaceLockInFirstLabel")}

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

{t("workspacePriorityFocusLead")}

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

{t("workspacePriorityEmpty")}

+ )}
@@ -483,6 +699,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 39f716d50..052aa2aed 100644 --- a/apps/desktop/src/locales/en/common.json +++ b/apps/desktop/src/locales/en/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "Transpose / simplify", "workspaceStemsLabel": "Stems", "workspaceRehearsalPrioritiesLabel": "Rehearsal Priorities", + "workspaceLockInFirstLabel": "Lock in first", + "workspaceLockInPairAction": "Show {roleName} in {sectionLabel} 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", "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..7b41398d9 100644 --- a/apps/desktop/src/locales/ko/common.json +++ b/apps/desktop/src/locales/ko/common.json @@ -49,6 +49,11 @@ "workspaceTranspositionLabel": "전조 / 단순화", "workspaceStemsLabel": "스템", "workspaceRehearsalPrioritiesLabel": "합주 우선순위", + "workspaceLockInFirstLabel": "먼저 맞춰 볼 것", + "workspaceLockInPairAction": "로드맵에서 {sectionLabel}의 {roleName} 보기", + "workspacePriorityFocusLead": "이 구간부터 맞춰 보세요", + "workspacePriorityFocusAction": "로드맵에서 {sectionLabel} 보기", + "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..6d1519eef --- /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 + +- A focus label with no matching section id is a no-op. That is safe failure, not a missing 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/