From 7520c4aa2fc4bd110f4ccdb2ee037990a6504e09 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:25:56 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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