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