diff --git a/AGENTS.md b/AGENTS.md
index b9a67ce17..05b66797b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working
- Keep UI and analysis engine decoupled through shared contracts.
- Prefer minimal, test-first changes for production code.
- Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language.
-- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers.
+- Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, setup cues, and unlogged practice passes are the real rehearsal blockers.
- Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy.
## Safety
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ca0df5ac4..6cff2e4ef 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -83,6 +83,7 @@ Last updated: 2026-03-11
- section roadmap with entries, dropouts, pickups, stops, tags, and handoffs
- groove and timing cues relevant to locking the band together
- playable ranges and density or overlap warnings, with the ready workspace naming tonight's first span and the next instrument check
+ - the first named part that still has no practice mark, with a record-tonight's-first-pass next action
- simplification, transposition, capo, tuning, or setup cues where applicable
- role-specific rehearsal priorities and confidence flags
- cue-sheet or chart-style exports that summarize the analysis in rehearsal-friendly form
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6f7e784..6c2facd0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Added
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
+- Name tonight's first unlogged practice pass on the ready rehearsal map and tell the player to select that part and record it.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
diff --git a/CLAUDE.md b/CLAUDE.md
index b5a34c1fa..8147db023 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -51,7 +51,7 @@ BandScope is a local-first desktop app for rehearsal prep: it turns a song into
Three layers, decoupled through shared contracts:
-- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range and the next instrument check. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
+- `apps/desktop` — Tauri 2 + Vite + React 19 shell (Tailwind 4, Base UI, Storybook). Feature screens live in `src/features/` (home, workspace, chords, ranges, player, settings). The ready workspace names tonight's first playable range, the next instrument check, and the first unlogged practice pass to record. `src/lib/analysis.ts` and `src/lib/job_runner.ts` call typed Tauri IPC commands, with a browser fallback that serves demo data when not running inside Tauri.
- `apps/desktop/src-tauri/src/main.rs` — the Rust orchestration boundary. Tauri commands (`start_analysis_job`, `get_analysis_job_status`, `select_local_audio_source`, `import_youtube_url`) validate untrusted input (project IDs, file paths, URLs) and spawn the Python engine as a subprocess. There is no loopback HTTP listener and no network path for local analysis.
- `services/analysis-engine` — Python package `bandscope_analysis` (librosa/numpy). Entry point `cli.py` reads a JSON job request on stdin and prints a structured job-status JSON envelope on stdout (`--progress-jsonl` streams progress lines). `api.py` orchestrates the pipeline across the `separation`, `sections`, `roles`, `chords`, `ranges`, `temporal`, `transcription`, and `youtube` modules.
diff --git a/apps/desktop/src/features/workspace/Workspace.test.tsx b/apps/desktop/src/features/workspace/Workspace.test.tsx
index 7837bf80e..fd8554f0f 100644
--- a/apps/desktop/src/features/workspace/Workspace.test.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.test.tsx
@@ -196,6 +196,53 @@ describe("Workspace", () => {
);
});
+ it("names tonight's first unlogged practice pass and tells the player to record it", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+
+ render();
+
+ const callout = screen.getByTestId("first-unlogged-practice");
+ expect(callout).toHaveTextContent("Tonight's first unlogged pass");
+ expect(callout).toHaveTextContent(
+ "Bass Guitar in verse has no practice logged yet. Select that part and record tonight's first pass."
+ );
+ });
+
+ it("asks the selected part to switch when it already has a practice mark", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[2] = {
+ ...song.sections[0]!.roles[2]!,
+ practiceProgress: 80
+ };
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ expect(screen.getByTestId("first-unlogged-practice")).toHaveTextContent(
+ "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass."
+ );
+ });
+
+ it("does not call malformed selected progress a completed practice mark", () => {
+ setNavigatorLanguage("en-US");
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[2] = {
+ ...song.sections[0]!.roles[2]!,
+ practiceProgress: 150 as unknown as number
+ };
+
+ render();
+ fireEvent.click(screen.getByRole("tab", { name: "Lead Vocal" }));
+
+ const callout = screen.getByTestId("first-unlogged-practice");
+ expect(callout).toHaveTextContent(
+ "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged."
+ );
+ expect(callout).not.toHaveTextContent("already has a practice mark");
+ });
+
it("falls back from blank planning copy and tolerates partial collaboration payloads", () => {
setNavigatorLanguage("en-US");
const song = createDemoRehearsalSong();
diff --git a/apps/desktop/src/features/workspace/Workspace.tsx b/apps/desktop/src/features/workspace/Workspace.tsx
index d44e20777..180210a7f 100644
--- a/apps/desktop/src/features/workspace/Workspace.tsx
+++ b/apps/desktop/src/features/workspace/Workspace.tsx
@@ -5,6 +5,7 @@ import { SectionRoadmap } from "./SectionRoadmap";
import { GrooveMap } from "./GrooveMap";
import { PracticeProgress } from "./PracticeProgress";
import { fillRangeCopy, firstRangeSqueeze } from "./firstRangeSqueeze";
+import { fillUnloggedPracticeCopy, firstUnloggedPractice } from "./firstUnloggedPractice";
import { createTranslator, detectPreferredLocale } from "../../i18n";
import { generateCueSheetCsv, generateChartSummaryJson, generateMetadataHandoffJson, sanitizeFilename } from "../../lib/export";
import { Button } from "@/components/ui/button";
@@ -163,6 +164,18 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
}
)
: t("workspaceFirstRangeMissing");
+ const firstUnlogged = useMemo(() => firstUnloggedPractice(song, activeRole), [activeRole, song]);
+ const firstUnloggedCopy =
+ firstUnlogged.kind === "unlogged"
+ ? fillUnloggedPracticeCopy(t("workspaceFirstUnloggedPracticeCheck"), {
+ roleName: firstUnlogged.roleName,
+ sectionLabel: firstUnlogged.sectionLabel
+ })
+ : firstUnlogged.kind === "selected-logged"
+ ? t("workspaceFirstUnloggedPracticeSelectedReady")
+ : firstUnlogged.kind === "all-logged"
+ ? t("workspaceFirstUnloggedPracticeMissing")
+ : t("workspaceFirstUnloggedPracticeUnavailable");
/** Handle the practice progress change internally by immutably updating the song state. */
const handlePracticeProgressChange = (newProgress: number) => {
@@ -310,6 +323,15 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
{firstRangeCopy}
+
+ {t("workspaceFirstUnloggedPracticeTitle")}
+ {firstUnloggedCopy}
+
+
{t("workspaceSongTimelineLabel")}
@@ -512,4 +534,4 @@ export function Workspace({ song, sourceBootstrap = null, onSongUpdate }: Worksp
);
-}
+}
\ No newline at end of file
diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts
new file mode 100644
index 000000000..f97e306a2
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.test.ts
@@ -0,0 +1,181 @@
+import { createDemoRehearsalSong, type RehearsalSong } from "@bandscope/shared-types";
+import { describe, expect, it } from "vitest";
+import {
+ fillUnloggedPracticeCopy,
+ firstUnloggedPractice,
+ hasLoggedPracticeProgress
+} from "./firstUnloggedPractice";
+
+describe("hasLoggedPracticeProgress", () => {
+ it("admits only 0–100 integers", () => {
+ expect(hasLoggedPracticeProgress(0)).toBe(true);
+ expect(hasLoggedPracticeProgress(100)).toBe(true);
+ expect(hasLoggedPracticeProgress(40)).toBe(true);
+ expect(hasLoggedPracticeProgress(undefined)).toBe(false);
+ expect(hasLoggedPracticeProgress(40.5)).toBe(false);
+ expect(hasLoggedPracticeProgress(-1)).toBe(false);
+ expect(hasLoggedPracticeProgress(101)).toBe(false);
+ expect(hasLoggedPracticeProgress("40")).toBe(false);
+ });
+});
+
+describe("firstUnloggedPractice", () => {
+ it("names the first demo part that still has no practice mark", () => {
+ expect(firstUnloggedPractice(createDemoRehearsalSong())).toEqual({
+ kind: "unlogged",
+ sectionLabel: "verse",
+ roleName: "Bass Guitar"
+ });
+ });
+
+ it("skips parts that already own a 0–100 mark", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ practiceProgress: 40
+ };
+
+ expect(firstUnloggedPractice(song)).toEqual({
+ kind: "unlogged",
+ sectionLabel: "verse",
+ roleName: "Keyboard 1 Right Hand"
+ });
+ });
+
+ it("limits the callout to the selected unlogged part", () => {
+ expect(firstUnloggedPractice(createDemoRehearsalSong(), "lead-vocal")).toEqual({
+ kind: "unlogged",
+ sectionLabel: "verse",
+ roleName: "Lead Vocal"
+ });
+ });
+
+ it("distinguishes a selected part with a trustworthy practice mark", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[2] = {
+ ...song.sections[0]!.roles[2]!,
+ practiceProgress: 0
+ };
+
+ expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "selected-logged" });
+ });
+
+ it("skips malformed marks and duplicate role ids inside one section", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[0] = {
+ ...song.sections[0]!.roles[0]!,
+ practiceProgress: 150 as unknown as number
+ };
+ song.sections[0]!.roles[1] = {
+ ...song.sections[0]!.roles[1]!,
+ id: "bass-guitar"
+ };
+
+ expect(firstUnloggedPractice(song)).toEqual({
+ kind: "unlogged",
+ sectionLabel: "verse",
+ roleName: "Lead Vocal"
+ });
+ });
+
+ it("treats the same named role across sections as one part", () => {
+ const song = createDemoRehearsalSong();
+ const verse = song.sections[0]!;
+ song.sections.push({
+ ...structuredClone(verse),
+ id: "chorus-1",
+ label: "chorus",
+ timeRange: { start: 30, end: 50 }
+ });
+
+ expect(firstUnloggedPractice(song)).toEqual({
+ kind: "unlogged",
+ sectionLabel: "verse",
+ roleName: "Bass Guitar"
+ });
+ });
+
+ it("reports unavailable when repeated section copies disagree about selected practice state", () => {
+ 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[0] = {
+ ...chorus.roles[0]!,
+ practiceProgress: 40
+ };
+ song.sections.push(chorus);
+
+ expect(firstUnloggedPractice(song, "bass-guitar")).toEqual({ kind: "unavailable" });
+ });
+
+ it("reports unavailable for a selected malformed practice mark instead of claiming it is logged", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles[2] = {
+ ...song.sections[0]!.roles[2]!,
+ practiceProgress: 150 as unknown as number
+ };
+
+ expect(firstUnloggedPractice(song, "lead-vocal")).toEqual({ kind: "unavailable" });
+ });
+
+ it("returns all-logged only when every named role has trustworthy consistent marks", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({
+ ...role,
+ practiceProgress: index * 40
+ }));
+
+ expect(firstUnloggedPractice(song)).toEqual({ kind: "all-logged" });
+ });
+
+ it("reports unavailable rather than all-logged when the remaining evidence is malformed", () => {
+ const song = createDemoRehearsalSong();
+ song.sections[0]!.roles = song.sections[0]!.roles.map((role, index) => ({
+ ...role,
+ practiceProgress: index === 2 ? (150 as unknown as number) : index * 40
+ }));
+
+ expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" });
+ });
+
+ it("rejects inherited identity and practice evidence", () => {
+ const inheritedRole = Object.create({
+ id: "ghost-role",
+ name: "Ghost Role",
+ practiceProgress: 40
+ }) as Record;
+ const song = {
+ sections: [
+ {
+ label: "verse",
+ roles: [inheritedRole]
+ }
+ ]
+ } as unknown as RehearsalSong;
+
+ expect(firstUnloggedPractice(song)).toEqual({ kind: "unavailable" });
+ });
+
+ it("fails closed on malformed runtime roots and collections", () => {
+ for (const malformed of [null, {}, { sections: null }, { sections: [null] }]) {
+ expect(firstUnloggedPractice(malformed as unknown as RehearsalSong)).toEqual({ kind: "unavailable" });
+ }
+ });
+});
+
+describe("fillUnloggedPracticeCopy", () => {
+ it("replaces tokens without inheriting object members", () => {
+ expect(
+ fillUnloggedPracticeCopy("{roleName} in {sectionLabel} before {sectionLabel}.", {
+ roleName: "Bass Guitar",
+ sectionLabel: "verse"
+ })
+ ).toBe("Bass Guitar in verse before verse.");
+ expect(fillUnloggedPracticeCopy("Check {toString} before {missingToken}.", { roleName: "Bass Guitar" })).toBe(
+ "Check {toString} before {missingToken}."
+ );
+ });
+});
diff --git a/apps/desktop/src/features/workspace/firstUnloggedPractice.ts b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts
new file mode 100644
index 000000000..37c6e47fc
--- /dev/null
+++ b/apps/desktop/src/features/workspace/firstUnloggedPractice.ts
@@ -0,0 +1,205 @@
+import type { RehearsalSong } from "@bandscope/shared-types";
+import { fillRangeCopy, meaningfulRangeText } from "./firstRangeSqueeze";
+
+/** Trustworthy state of tonight's first unlogged-practice decision. */
+export type FirstUnloggedPractice =
+ | {
+ kind: "unlogged";
+ sectionLabel: string;
+ roleName: string;
+ }
+ | { kind: "selected-logged" }
+ | { kind: "all-logged" }
+ | { kind: "unavailable" };
+
+type PracticeMark =
+ | { kind: "unlogged" }
+ | { kind: "logged"; value: number }
+ | { kind: "invalid" };
+
+type RoleEvidence = {
+ roleName: string;
+ firstSectionLabel: string;
+ marks: PracticeMark[];
+};
+
+/** Return whether an untrusted runtime value is a plain object record. */
+function isRuntimeObject(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Return whether a record owns a field rather than inheriting it. */
+function owns(record: Record, field: string): boolean {
+ return Object.prototype.hasOwnProperty.call(record, field);
+}
+
+/** Return whether a role already owns a 0–100 integer practice mark. */
+export function hasLoggedPracticeProgress(value: unknown): boolean {
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100;
+}
+
+/** Admit one role-copy practice mark without granting inherited values authority. */
+function practiceMark(roleValue: Record): PracticeMark {
+ if (!owns(roleValue, "practiceProgress")) {
+ return { kind: "unlogged" };
+ }
+ const value = roleValue.practiceProgress;
+ if (!hasLoggedPracticeProgress(value)) {
+ return { kind: "invalid" };
+ }
+ return { kind: "logged", value };
+}
+
+/** Return whether every section copy agrees that the named part is still unlogged. */
+function isConsistentlyUnlogged(marks: PracticeMark[]): boolean {
+ return marks.length > 0 && marks.every((mark) => mark.kind === "unlogged");
+}
+
+/** Return whether every section copy owns the same trustworthy practice mark. */
+function isConsistentlyLogged(marks: PracticeMark[]): boolean {
+ if (marks.length === 0 || marks.some((mark) => mark.kind !== "logged")) {
+ return false;
+ }
+ const expected = (marks[0] as Extract).value;
+ return marks.every(
+ (mark) => mark.kind === "logged" && mark.value === expected
+ );
+}
+
+/**
+ * Resolve tonight's first trustworthy unlogged-practice state.
+ *
+ * The same role id may legitimately appear in several song sections. Those
+ * copies are one rehearsal part only when their display name agrees and their
+ * practice evidence is role-wide consistent. Duplicate ids inside one
+ * section, conflicting names, mixed logged/unlogged copies, malformed marks,
+ * inherited identity, or malformed collection evidence never become proof
+ * that a part—or the whole rehearsal—has already been logged.
+ */
+export function firstUnloggedPractice(
+ song: RehearsalSong,
+ activeRole: string | null = null
+): FirstUnloggedPractice {
+ const runtimeSong: unknown = song;
+ if (!isRuntimeObject(runtimeSong) || !owns(runtimeSong, "sections") || !Array.isArray(runtimeSong.sections)) {
+ return { kind: "unavailable" };
+ }
+
+ const evidenceByRole = new Map();
+ const roleOrder: string[] = [];
+ const invalidRoleIds = new Set();
+ let hasInvalidEvidence = false;
+
+ for (const sectionValue of runtimeSong.sections) {
+ if (
+ !isRuntimeObject(sectionValue) ||
+ !owns(sectionValue, "label") ||
+ !owns(sectionValue, "roles") ||
+ !Array.isArray(sectionValue.roles)
+ ) {
+ return { kind: "unavailable" };
+ }
+
+ const sectionLabel = meaningfulRangeText(sectionValue.label);
+ if (!sectionLabel) {
+ return { kind: "unavailable" };
+ }
+
+ const sectionRoleIds = new Set();
+ for (const roleValue of sectionValue.roles) {
+ if (
+ !isRuntimeObject(roleValue) ||
+ !owns(roleValue, "id") ||
+ !owns(roleValue, "name")
+ ) {
+ hasInvalidEvidence = true;
+ continue;
+ }
+
+ const roleId = meaningfulRangeText(roleValue.id);
+ const roleName = meaningfulRangeText(roleValue.name);
+ if (!roleId || !roleName) {
+ hasInvalidEvidence = true;
+ continue;
+ }
+
+ if (sectionRoleIds.has(roleId)) {
+ invalidRoleIds.add(roleId);
+ hasInvalidEvidence = true;
+ continue;
+ }
+ sectionRoleIds.add(roleId);
+
+ const mark = practiceMark(roleValue);
+ if (mark.kind === "invalid") {
+ hasInvalidEvidence = true;
+ }
+
+ const existing = evidenceByRole.get(roleId);
+ if (!existing) {
+ evidenceByRole.set(roleId, {
+ roleName,
+ firstSectionLabel: sectionLabel,
+ marks: [mark]
+ });
+ roleOrder.push(roleId);
+ continue;
+ }
+
+ if (existing.roleName !== roleName) {
+ invalidRoleIds.add(roleId);
+ hasInvalidEvidence = true;
+ }
+ existing.marks.push(mark);
+ }
+ }
+
+ if (activeRole) {
+ const evidence = evidenceByRole.get(activeRole);
+ if (!evidence || invalidRoleIds.has(activeRole)) {
+ return { kind: "unavailable" };
+ }
+ if (isConsistentlyUnlogged(evidence.marks)) {
+ return {
+ kind: "unlogged",
+ sectionLabel: evidence.firstSectionLabel,
+ roleName: evidence.roleName
+ };
+ }
+ if (isConsistentlyLogged(evidence.marks)) {
+ return { kind: "selected-logged" };
+ }
+ return { kind: "unavailable" };
+ }
+
+ for (const roleId of roleOrder) {
+ if (invalidRoleIds.has(roleId)) {
+ continue;
+ }
+ const evidence = evidenceByRole.get(roleId);
+ if (!evidence) {
+ hasInvalidEvidence = true;
+ continue;
+ }
+ if (isConsistentlyUnlogged(evidence.marks)) {
+ return {
+ kind: "unlogged",
+ sectionLabel: evidence.firstSectionLabel,
+ roleName: evidence.roleName
+ };
+ }
+ if (!isConsistentlyLogged(evidence.marks)) {
+ hasInvalidEvidence = true;
+ }
+ }
+
+ if (roleOrder.length === 0 || invalidRoleIds.size > 0 || hasInvalidEvidence) {
+ return { kind: "unavailable" };
+ }
+ return { kind: "all-logged" };
+}
+
+/** Fill trusted `{token}` placeholders for unlogged-practice copy. */
+export function fillUnloggedPracticeCopy(template: string, values: Record): string {
+ return fillRangeCopy(template, values);
+}
diff --git a/apps/desktop/src/locales/en/common.json b/apps/desktop/src/locales/en/common.json
index d803a765e..5cb997e4d 100644
--- a/apps/desktop/src/locales/en/common.json
+++ b/apps/desktop/src/locales/en/common.json
@@ -153,6 +153,11 @@
"workspaceFirstRangeCheck": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Check that span on your instrument before the {sectionLabel}.",
"workspaceFirstRangeClash": "{roleName} sits {lowestNote}–{highestNote} in {sectionLabel}. Hear that clash on your instrument before the {sectionLabel}.",
"workspaceFirstRangeMissing": "Tonight's first range still needs an ear check. Confirm the high and low notes on the selected part before the first section.",
+ "workspaceFirstUnloggedPracticeTitle": "Tonight's first unlogged pass",
+ "workspaceFirstUnloggedPracticeCheck": "{roleName} in {sectionLabel} has no practice logged yet. Select that part and record tonight's first pass.",
+ "workspaceFirstUnloggedPracticeMissing": "Every named part already has a practice mark. Keep going until the room is ready.",
+ "workspaceFirstUnloggedPracticeSelectedReady": "This part already has a practice mark. Switch to the next unlogged part and record tonight's first pass.",
+ "workspaceFirstUnloggedPracticeUnavailable": "Practice progress is inconsistent or invalid for this part. Check its practice mark before treating it as logged.",
"sectionRangeLabel": "Range",
"sectionRangeNextAction": "Check this span on your instrument before {sectionLabel}."
}
diff --git a/apps/desktop/src/locales/ko/common.json b/apps/desktop/src/locales/ko/common.json
index 0f6c6c66d..59d80fc80 100644
--- a/apps/desktop/src/locales/ko/common.json
+++ b/apps/desktop/src/locales/ko/common.json
@@ -153,6 +153,11 @@
"workspaceFirstRangeCheck": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}입니다. {sectionLabel} 들어가기 전에 그 음역을 악기로 확인해 보세요.",
"workspaceFirstRangeClash": "{sectionLabel}의 {roleName}은 {lowestNote}–{highestNote}이고 다른 파트와 겹칩니다. {sectionLabel} 들어가기 전에 그 충돌을 악기로 들어 보세요.",
"workspaceFirstRangeMissing": "오늘 먼저 볼 음역은 아직 귀로 확인이 필요합니다. 선택한 파트의 최저·최고음을 첫 구간 전에 확인해 보세요.",
+ "workspaceFirstUnloggedPracticeTitle": "오늘 아직 기록 안 된 첫 연습",
+ "workspaceFirstUnloggedPracticeCheck": "{sectionLabel}의 {roleName}은 아직 연습 기록이 없습니다. 그 파트를 선택하고 오늘 첫 패스를 기록하세요.",
+ "workspaceFirstUnloggedPracticeMissing": "이름 있는 파트는 모두 연습 기록이 있습니다. 방이 준비될 때까지 이어서 연습하세요.",
+ "workspaceFirstUnloggedPracticeSelectedReady": "이 파트는 이미 연습 기록이 있습니다. 아직 기록 안 된 다음 파트로 바꿔 오늘 첫 패스를 기록하세요.",
+ "workspaceFirstUnloggedPracticeUnavailable": "이 파트의 연습 진행 정보가 서로 맞지 않거나 유효하지 않습니다. 기록된 것으로 보기 전에 연습 표시를 확인하세요.",
"sectionRangeLabel": "음역",
"sectionRangeNextAction": "{sectionLabel} 들어가기 전에 이 음역을 악기로 확인해 보세요."
}
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 3cf5261b9..b51cef266 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -12,6 +12,7 @@ It is technically defined as a rehearsal-analysis product, not a single-output c
- section roadmap with entries, dropouts, pickups, stops, and handoffs
- groove and timing cues
- role ranges, overlap warnings, and simplification guidance
+- the first named part that still has no practice mark, with a record-tonight's-first-pass next action
- transposition, capo, tuning, or setup cues where relevant
- role-specific confidence and rehearsal priority
diff --git a/docs/doctoring/first-unlogged-practice.md b/docs/doctoring/first-unlogged-practice.md
new file mode 100644
index 000000000..bf4695c96
--- /dev/null
+++ b/docs/doctoring/first-unlogged-practice.md
@@ -0,0 +1,15 @@
+# First unlogged practice pass
+
+The ready workspace names the first part that still has no stored `practiceProgress` so a player can select it and record tonight's first pass.
+
+## Authority
+
+`firstUnloggedPractice` is the only unlogged-pass helper. It walks sections and roles in song order, requires owned role/section identity, allows the same role id to recur across sections only when its display name remains consistent, treats a role as unlogged only when every admitted section copy omits `practiceProgress`, and rejects duplicate ids inside one section or malformed/conflicting practice evidence instead of inventing a pass.
+
+Issue `#1107` still owns the selected-part tracker copy. This slice does not start playback (`#961`) or change MIR (`#828` / `#770`).
+
+## Security notes
+
+- Untrusted input: `practiceProgress` and role/section identity inside a loaded project payload.
+- Trust boundary: project JSON → lexical admission → React copy.
+- Safe failure: inherited identity, malformed collections, conflicting section copies, and malformed marks never become rehearsal authority; no filesystem, URL, subprocess, IPC, or network dereference is added.