@@ -25,6 +28,7 @@ globalThis.webViewComponent = function InterlinearizerWebView({
projectId={projectId}
useWebViewScrollGroupScrRef={useWebViewScrollGroupScrRef}
useWebViewState={useWebViewState}
+ updateWebViewDefinition={updateWebViewDefinition}
/>
) : (
diff --git a/src/main.ts b/src/main.ts
index 4c2beb5d..00bcbf44 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -10,7 +10,7 @@ import type {
import interlinearizerReact from './interlinearizer.web-view?inline';
import interlinearizerStyles from './interlinearizer.web-view.scss?inline';
import * as projectStorage from './services/projectStorage';
-import { isTextAnalysis } from './types/type-guards';
+import { isDraftProject, isTextAnalysis } from './types/type-guards';
// #region WebView provider
@@ -291,6 +291,56 @@ async function saveInterlinearAnalysis(
}
}
+/**
+ * Returns the draft working buffer for a source project as a JSON string, creating a fresh empty
+ * draft when none has been written. The WebView loads this on mount to seed the editor.
+ *
+ * @param sourceProjectId - Platform.Bible source project ID whose draft to fetch.
+ * @returns JSON-stringified `DraftProject`.
+ * @throws {SyntaxError} If the draft's storage value contains invalid JSON.
+ * @throws If `papi.storage.readUserData` rejects for a non-ENOENT reason. The error is logged
+ * before rethrowing.
+ */
+async function getInterlinearDraft(sourceProjectId: string): Promise {
+ try {
+ const draft = await projectStorage.getDraft(executionToken, sourceProjectId);
+ return JSON.stringify(draft);
+ } catch (e) {
+ logger.error('Interlinearizer: failed to get draft', e);
+ throw e;
+ }
+}
+
+/**
+ * Persists the draft working buffer for a source project. Called from the WebView after every edit
+ * (auto-save) and whenever the draft is reset (New), opened from a project, or wiped.
+ *
+ * @param sourceProjectId - Platform.Bible source project ID whose draft to write.
+ * @param draftJson - JSON-stringified `DraftProject` to persist.
+ * @returns A promise that resolves when the draft has been written to storage.
+ * @throws If JSON parsing, validation, or storage fails. The error is logged and an error
+ * notification is sent before rethrowing so the frontend `catch` block can suppress it without a
+ * second notification.
+ */
+async function saveInterlinearDraft(sourceProjectId: string, draftJson: string): Promise {
+ try {
+ const draft = JSON.parse(draftJson);
+ if (!isDraftProject(draft)) {
+ throw new TypeError('saveInterlinearDraft: draftJson does not conform to DraftProject');
+ }
+ await projectStorage.saveDraft(executionToken, sourceProjectId, draft);
+ } catch (e) {
+ logger.error('Interlinearizer: failed to save draft', e);
+ await papi.notifications
+ .send({
+ message: '%interlinearizer_error_save_draft_failed%',
+ severity: 'error',
+ })
+ .catch(() => {});
+ throw e;
+ }
+}
+
/**
* Returns all interlinearizer projects for the given source project as a JSON string. The WebView
* deserializes this to populate its project picker and to decide whether to prompt "create new" or
@@ -519,6 +569,54 @@ export async function activate(context: ExecutionActivationContext): Promise {},
+ {
+ method: {
+ summary:
+ 'Save the current draft to the active project (handled in the Interlinearizer WebView)',
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
+ const openSaveAsModalCommandRegistration = await papi.commands.registerCommand(
+ 'interlinearizer.openSaveAsModal',
+ // Handled entirely in the WebView; backend registration makes the command known to the platform.
+ /* v8 ignore next */ async () => {},
+ {
+ method: {
+ summary: 'Open the Save As modal in the Interlinearizer WebView',
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
+ const wipeBookCommandRegistration = await papi.commands.registerCommand(
+ 'interlinearizer.wipeBook',
+ // Handled entirely in the WebView; backend registration makes the command known to the platform.
+ /* v8 ignore next */ async () => {},
+ {
+ method: {
+ summary: "Wipe the current book's analysis from the draft (handled in the WebView)",
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
+ const wipeDraftCommandRegistration = await papi.commands.registerCommand(
+ 'interlinearizer.wipeDraft',
+ // Handled entirely in the WebView; backend registration makes the command known to the platform.
+ /* v8 ignore next */ async () => {},
+ {
+ method: {
+ summary: "Wipe the entire draft's analysis (handled in the WebView)",
+ params: [],
+ result: { name: 'return value', summary: 'void', schema: { type: 'null' } },
+ },
+ },
+ );
+
const webViewOpenUnsubscriber = papi.webViews.onDidOpenWebView(({ webView }) => {
if (webView.webViewType !== mainWebViewType || !webView.projectId) return;
openWebViewsByProject.set(webView.projectId, webView.id);
@@ -651,11 +802,17 @@ export async function activate(context: ExecutionActivationContext): Promise = Promise.resolve();
*/
const projectQueues = new Map>();
+/**
+ * Per-source draft serialization queues. Keyed by `sourceProjectId`; serializes writes to a single
+ * draft record so a WebView's rapid auto-saves cannot interleave at await boundaries and persist
+ * out of order.
+ */
+const draftQueues = new Map>();
+
/**
* Enqueues `fn` on the index serialization queue and returns a promise that resolves or rejects
* with `fn`'s result. The queue always advances regardless of whether `fn` throws.
@@ -35,27 +42,47 @@ function enqueueIndexOp(fn: () => Promise): Promise {
}
/**
- * Enqueues `fn` on the per-project serialization queue for `id` and returns a promise that resolves
- * or rejects with `fn`'s result. Cleans up the queue entry when the operation settles.
+ * Enqueues `fn` on the serialization queue identified by `key` within `queues` and returns a
+ * promise that resolves or rejects with `fn`'s result. Cleans up the queue entry when the operation
+ * settles.
*
- * @param id - The project UUID whose queue `fn` should join.
+ * @param queues - The queue map to serialize on; one chain per `key`.
+ * @param key - The key whose queue `fn` should join.
* @param fn - The async function to serialize.
* @returns A promise that resolves or rejects with the return value of `fn`.
* @throws Whatever `fn` throws; the queue entry is removed and the rejection propagates to the
* caller.
*/
-function enqueueProjectOp(id: string, fn: () => Promise): Promise {
- const previous = projectQueues.get(id) ?? Promise.resolve();
+function enqueueSerialized(
+ queues: Map>,
+ key: string,
+ fn: () => Promise,
+): Promise {
+ const previous = queues.get(key) ?? Promise.resolve();
const result = previous.then(fn);
let settled: Promise;
const cleanup = () => {
- if (projectQueues.get(id) === settled) projectQueues.delete(id);
+ if (queues.get(key) === settled) queues.delete(key);
};
settled = result.then(cleanup, cleanup);
- projectQueues.set(id, settled);
+ queues.set(key, settled);
return result;
}
+/**
+ * Enqueues `fn` on the per-project serialization queue for `id`. Thin wrapper over
+ * {@link enqueueSerialized} bound to {@link projectQueues}.
+ *
+ * @param id - The project UUID whose queue `fn` should join.
+ * @param fn - The async function to serialize.
+ * @returns A promise that resolves or rejects with the return value of `fn`.
+ * @throws Whatever `fn` throws; the queue entry is removed and the rejection propagates to the
+ * caller.
+ */
+function enqueueProjectOp(id: string, fn: () => Promise): Promise {
+ return enqueueSerialized(projectQueues, id, fn);
+}
+
/**
* Returns the storage key for a project by ID.
*
@@ -66,6 +93,16 @@ function projectKey(id: string): string {
return `project:${id}`;
}
+/**
+ * Returns the storage key for a source project's draft.
+ *
+ * @param sourceProjectId - The Platform.Bible source project ID the draft belongs to.
+ * @returns The storage key string used to read and write the draft record.
+ */
+function draftKey(sourceProjectId: string): string {
+ return `draft:${sourceProjectId}`;
+}
+
/**
* Returns true when `e` is a file-not-found error (ENOENT) from the Node.js file system, which is
* what `papi.storage.readUserData` throws when the requested key has never been written.
@@ -319,12 +356,58 @@ export async function deleteProject(token: ExecutionToken, id: string): Promise<
});
}
+/**
+ * Reads the draft working buffer for a source project, returning a fresh empty draft when none has
+ * been written yet (ENOENT). Drafts are never added to the `projectIds` index, so they stay out of
+ * {@link listProjects} and {@link getProjectsForSource} and never appear in the project picker.
+ *
+ * @param token - The execution token for storage access.
+ * @param sourceProjectId - The Platform.Bible source project ID whose draft to read.
+ * @returns The stored {@link DraftProject}, or a fresh empty draft when none exists.
+ * @throws {SyntaxError} If the draft's storage value contains invalid JSON.
+ * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason.
+ */
+export async function getDraft(
+ token: ExecutionToken,
+ sourceProjectId: string,
+): Promise {
+ try {
+ return JSON.parse(await papi.storage.readUserData(token, draftKey(sourceProjectId)));
+ } catch (e) {
+ if (isNotFound(e)) return emptyDraft(sourceProjectId);
+ throw e;
+ }
+}
+
+/**
+ * Writes the draft working buffer for a source project, replacing any existing draft. Writes are
+ * serialized per source (via {@link draftQueues}) so a WebView's rapid auto-saves cannot persist out
+ * of order. The caller owns the whole envelope — including the `dirty` flag — so this function is a
+ * plain write with no read-modify-merge.
+ *
+ * @param token - The execution token for storage access.
+ * @param sourceProjectId - The Platform.Bible source project ID whose draft to write.
+ * @param draft - The full {@link DraftProject} envelope to persist.
+ * @returns A promise that resolves once the draft has been written.
+ * @throws If `papi.storage.writeUserData` rejects.
+ */
+export async function saveDraft(
+ token: ExecutionToken,
+ sourceProjectId: string,
+ draft: DraftProject,
+): Promise {
+ await enqueueSerialized(draftQueues, sourceProjectId, () =>
+ papi.storage.writeUserData(token, draftKey(sourceProjectId), JSON.stringify(draft)),
+ );
+}
+
/**
* Resets module-level queue state between tests. Jest's `resetMocks` resets mock implementations
- * but does not re-execute modules, so `indexQueue` and `projectQueues` would otherwise persist
- * across tests and allow promise chains from one test to bleed into the next.
+ * but does not re-execute modules, so `indexQueue`, `projectQueues`, and `draftQueues` would
+ * otherwise persist across tests and allow promise chains from one test to bleed into the next.
*/
export function resetQueuesForTesting(): void {
indexQueue = Promise.resolve();
projectQueues.clear();
+ draftQueues.clear();
}
diff --git a/src/types/empty-factories.ts b/src/types/empty-factories.ts
index e385d5f6..70b1c6b1 100644
--- a/src/types/empty-factories.ts
+++ b/src/types/empty-factories.ts
@@ -2,7 +2,7 @@
* @file Factory functions that return zero-value instances of core types, giving each caller a
* fresh independent object.
*/
-import type { TextAnalysis } from 'interlinearizer';
+import type { DraftProject, TextAnalysis } from 'interlinearizer';
import type { FocusContext } from './token-layout';
/**
@@ -22,6 +22,23 @@ export function emptyAnalysis(): TextAnalysis {
};
}
+/**
+ * Returns a fresh, empty {@link DraftProject} for a source project: empty analysis, no analysis
+ * languages yet, and `dirty: false`. Used by the storage layer when no draft has been written and
+ * as the seed for the "New" (reset) flow. Each call produces a fresh object with its own analysis.
+ *
+ * @param sourceProjectId - The Platform.Bible source project ID the draft belongs to.
+ * @returns A new, empty `DraftProject` for the given source.
+ */
+export function emptyDraft(sourceProjectId: string): DraftProject {
+ return {
+ sourceProjectId,
+ analysisLanguages: [],
+ analysis: emptyAnalysis(),
+ dirty: false,
+ };
+}
+
/**
* Returns a `FocusContext` with all fields set to `undefined`, representing the state where nothing
* is focused. Each call produces a fresh object so callers can use it without sharing references.
diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts
index 9a490996..ef6f9444 100644
--- a/src/types/interlinearizer.d.ts
+++ b/src/types/interlinearizer.d.ts
@@ -123,6 +123,34 @@ declare module 'papi-shared-types' {
*/
'interlinearizer.openProjectInfoModal': () => Promise;
+ /**
+ * Saves the current draft's analysis to the active project (the Save target), or opens the Save
+ * As modal when there is no active project. The backend registers this command to make it
+ * visible to the platform menu system; all logic executes in the WebView.
+ */
+ 'interlinearizer.save': () => Promise;
+
+ /**
+ * Opens the Save As modal in the Interlinearizer WebView, where the user can save the draft to
+ * a new project or overwrite an existing one. The backend registers this command to make it
+ * visible to the platform menu system; all logic executes in the WebView.
+ */
+ 'interlinearizer.openSaveAsModal': () => Promise;
+
+ /**
+ * Removes the currently viewed book's analysis from the draft (after an in-WebView
+ * confirmation). The backend registers this command to make it visible to the platform menu
+ * system; all logic executes in the WebView.
+ */
+ 'interlinearizer.wipeBook': () => Promise;
+
+ /**
+ * Removes the entire draft's analysis (after an in-WebView confirmation). The backend registers
+ * this command to make it visible to the platform menu system; all logic executes in the
+ * WebView.
+ */
+ 'interlinearizer.wipeDraft': () => Promise;
+
/**
* Returns the interlinearizer project with the given UUID as a JSON string, including its full
* `TextAnalysis`. The WebView calls this when the active project changes to load the stored
@@ -149,6 +177,34 @@ declare module 'papi-shared-types' {
analysisJson: string,
) => Promise;
+ /**
+ * Returns the draft working buffer for the given source project, serialized as a JSON string.
+ * Creates and returns a fresh empty draft when none has been written. The WebView loads this on
+ * mount to seed the editor. The draft is decoupled from saved projects and never appears in the
+ * project picker.
+ *
+ * @param sourceProjectId Platform.Bible source project ID whose draft to fetch.
+ * @returns JSON-stringified `DraftProject`.
+ * @throws {SyntaxError} If the stored draft contains invalid JSON.
+ * @throws If `papi.storage.readUserData` rejects for a reason other than the draft not
+ * existing.
+ */
+ 'interlinearizer.getDraft': (sourceProjectId: string) => Promise;
+
+ /**
+ * Persists the draft working buffer for the given source project. Called from the WebView after
+ * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or
+ * wiped.
+ *
+ * @param sourceProjectId Platform.Bible source project ID whose draft to write.
+ * @param draftJson JSON-stringified `DraftProject` to persist.
+ * @returns Promise that resolves to void once the draft has been written to storage.
+ * @throws If JSON parsing, validation, or storage fails. The error is logged and an error
+ * notification is sent before rethrowing so callers do not need to send a second
+ * notification.
+ */
+ 'interlinearizer.saveDraft': (sourceProjectId: string, draftJson: string) => Promise;
+
/**
* Updates the metadata of an existing interlinearizer project. Returns the updated project as a
* JSON string, or `undefined` if no project with the given ID exists.
@@ -1109,6 +1165,58 @@ declare module 'interlinearizer' {
links?: AlignmentLink[];
}
+ /**
+ * The always-present, auto-saved working buffer for a single source project. Decoupled from the
+ * user's saved {@link InterlinearProject}s: it is stored under its own `draft:{sourceProjectId}`
+ * key, is never added to the `projectIds` index, and is never shown in the project picker.
+ *
+ * The draft is the runtime source of truth for the analysis being edited. Every gloss / phrase
+ * write is persisted here (so work is never lost), independent of the active project. The active
+ * project — the **Save target** — is tracked separately in WebView state; `Save` copies the
+ * draft's analysis into it, and `Save As` copies it into a new or existing project.
+ *
+ * As with {@link InterlinearProject}, the `Book` hierarchy is not stored — it is rebuilt from USJ
+ * on load. Alignment `links` are intentionally not carried here yet (there is no link-editing
+ * feature); `Save` preserves a target project's existing links via `updateAnalysis`.
+ */
+ export interface DraftProject {
+ /** Platform.Bible source project ID this draft belongs to; equals the storage key suffix. */
+ sourceProjectId: string;
+
+ /**
+ * BCP 47 tags for the gloss / annotation languages used while editing this draft. Seeded from
+ * the platform UI language for a fresh source, or copied from a project when one is opened into
+ * the draft. Used as the new project's `analysisLanguages` on Save As.
+ */
+ analysisLanguages: string[];
+
+ /**
+ * Platform.Bible target-text project ID, present only when the draft was opened from a
+ * bilateral alignment project. Carried so Save As can recreate the bilateral binding.
+ */
+ targetProjectId?: string;
+
+ /**
+ * Name typed in the "New" dialog, retained only to prefill the Save As dialog. A draft has no
+ * project name of its own; this is never shown as one until the draft is saved to a project.
+ */
+ suggestedName?: string;
+
+ /** Description typed in the "New" dialog, retained only to prefill the Save As dialog. */
+ suggestedDescription?: string;
+
+ /** The live analysis being edited and auto-saved. Empty for a fresh draft. */
+ analysis: TextAnalysis;
+
+ /**
+ * Whether the draft has diverged from its active project (the Save target) since the last Save
+ * / Save As / Open / New. Drives the discard confirmation and the tab's unsaved-changes
+ * indicator. `true` after any edit; reset to `false` whenever the draft is synced to a
+ * project.
+ */
+ dirty: boolean;
+ }
+
// ---------------------------------------------------------------------------
// §7 ActiveProject — runtime pairing of project envelope and text layers
// ---------------------------------------------------------------------------
diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts
index 59b3a350..00dddc1f 100644
--- a/src/types/type-guards.ts
+++ b/src/types/type-guards.ts
@@ -1,5 +1,5 @@
/** @file Type guards for narrowing interlinearizer types and validating parsed JSON payloads. */
-import type { AssignmentStatus, TextAnalysis, Token } from 'interlinearizer';
+import type { AssignmentStatus, DraftProject, TextAnalysis, Token } from 'interlinearizer';
import type { InterlinearProjectSummary } from './interlinear-project-summary';
/**
@@ -233,3 +233,31 @@ export function isTextAnalysis(value: unknown): value is TextAnalysis {
value.phraseAnalysisLinks.every(isPhraseAnalysisLink)
);
}
+
+/**
+ * Type guard for {@link DraftProject} parsed from unknown JSON. Validates the envelope fields and
+ * delegates the `analysis` to {@link isTextAnalysis}, so malformed drafts are rejected before
+ * persisting.
+ *
+ * @param value - The value to test, typically a parsed JSON object of unknown shape.
+ * @returns `true` if `value` satisfies the {@link DraftProject} shape, narrowing its type
+ * accordingly.
+ */
+export function isDraftProject(value: unknown): value is DraftProject {
+ return (
+ !!value &&
+ typeof value === 'object' &&
+ 'sourceProjectId' in value &&
+ typeof value.sourceProjectId === 'string' &&
+ 'analysisLanguages' in value &&
+ Array.isArray(value.analysisLanguages) &&
+ value.analysisLanguages.every((l) => typeof l === 'string') &&
+ 'dirty' in value &&
+ typeof value.dirty === 'boolean' &&
+ (!('targetProjectId' in value) || typeof value.targetProjectId === 'string') &&
+ (!('suggestedName' in value) || typeof value.suggestedName === 'string') &&
+ (!('suggestedDescription' in value) || typeof value.suggestedDescription === 'string') &&
+ 'analysis' in value &&
+ isTextAnalysis(value.analysis)
+ );
+}
diff --git a/src/utils/analysis-book.ts b/src/utils/analysis-book.ts
new file mode 100644
index 00000000..35d15ca1
--- /dev/null
+++ b/src/utils/analysis-book.ts
@@ -0,0 +1,51 @@
+/** @file Pure helpers for filtering a `TextAnalysis` by the book a record belongs to. */
+import type { TextAnalysis } from 'interlinearizer';
+
+/**
+ * Returns the 3-letter book code embedded at the start of a segment id or token ref. Both are
+ * formatted `" :[:]"` (e.g. `"GEN 1:1"`, `"1JN 2:3:5"`), so the
+ * book code is the substring before the first space.
+ *
+ * @param ref - A `Segment.id` or `Token.ref` / `TokenSnapshot.tokenRef` value.
+ * @returns The leading book code, or the whole string when it contains no space.
+ */
+export function bookOfRef(ref: string): string {
+ const spaceIndex = ref.indexOf(' ');
+ return spaceIndex === -1 ? ref : ref.slice(0, spaceIndex);
+}
+
+/**
+ * Returns a copy of `analysis` with every record belonging to `bookCode` removed. A token- or
+ * segment-level record is dropped when its referenced token/segment is in the book; a phrase is
+ * dropped when **any** of its member tokens is in the book (so a rare cross-book phrase is removed
+ * when wiping either side). Analysis payloads left unreferenced by a surviving link are also
+ * dropped, so no orphans remain.
+ *
+ * @param analysis - The analysis to filter. Not mutated.
+ * @param bookCode - The 3-letter book code (e.g. `"GEN"`) whose records to remove.
+ * @returns A new `TextAnalysis` with the book's records (and any orphaned payloads) removed.
+ */
+export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string): TextAnalysis {
+ const tokenAnalysisLinks = analysis.tokenAnalysisLinks.filter(
+ (link) => bookOfRef(link.token.tokenRef) !== bookCode,
+ );
+ const segmentAnalysisLinks = analysis.segmentAnalysisLinks.filter(
+ (link) => bookOfRef(link.segmentId) !== bookCode,
+ );
+ const phraseAnalysisLinks = analysis.phraseAnalysisLinks.filter(
+ (link) => !link.tokens.some((token) => bookOfRef(token.tokenRef) === bookCode),
+ );
+
+ const survivingTokenAnalysisIds = new Set(tokenAnalysisLinks.map((link) => link.analysisId));
+ const survivingSegmentAnalysisIds = new Set(segmentAnalysisLinks.map((link) => link.analysisId));
+ const survivingPhraseAnalysisIds = new Set(phraseAnalysisLinks.map((link) => link.analysisId));
+
+ return {
+ tokenAnalyses: analysis.tokenAnalyses.filter((a) => survivingTokenAnalysisIds.has(a.id)),
+ tokenAnalysisLinks,
+ segmentAnalyses: analysis.segmentAnalyses.filter((a) => survivingSegmentAnalysisIds.has(a.id)),
+ segmentAnalysisLinks,
+ phraseAnalyses: analysis.phraseAnalyses.filter((a) => survivingPhraseAnalysisIds.has(a.id)),
+ phraseAnalysisLinks,
+ };
+}
diff --git a/user-questions.md b/user-questions.md
index 61151c61..7fec0513 100644
--- a/user-questions.md
+++ b/user-questions.md
@@ -11,3 +11,69 @@ Description of thing needing decisions.
Explicit questions and/or clear list of options. Use multiple questions as needed: combining distinct factors into a single question leads to incomplete or confusing answers.
List of relevant GitHub issues/prs/commits.
+
+## Draft project, Save / Save As, and Wipe
+
+The Interlinearizer now keeps an always-present **draft** per source project that auto-saves every
+edit (so work is never lost), decoupled from the user's saved projects. Editing no longer writes to a
+project automatically; instead the user explicitly **Save**s (writes the draft to the active project)
+or **Save As**es (new project, or overwrite an existing one). **New** starts an empty draft (a project
+is only created on Save As), and **Open** loads a project into the draft as a working copy. Users can
+**Wipe** the draft — the whole thing or just the current book. The tab title shows a `●` marker while
+the draft has unsaved changes (Platform.Bible exposes no native "unsaved" tab indicator).
+
+Decisions made during development that we'd like reviewed:
+
+1. **Save with no active project.** When nothing has been opened/saved yet, the "Save" menu item opens
+ "Save As" (there is no target). Alternative: hide or disable "Save" until there is an active
+ project. Current choice: route to Save As.
+
+2. **Discarding unsaved draft changes.** Switching projects (New / Open) while the draft has unsaved
+ changes shows a two-button confirm (Discard / Cancel). Should it instead offer a three-way choice
+ ("Save As first" / Discard / Cancel) so the in-progress draft can be kept?
+
+3. **"Wipe book" scope.** "Wipe Current Book" targets the book currently in view. Alternative: present
+ a picker of books that have draft analysis. Current choice: current book only.
+
+4. **Unsaved indicator + Save feedback.** The unsaved state is shown as a `●` appended to the tab
+ title. Options: a different glyph, swapping the tab icon to a "modified" badge, and/or whether a
+ success toast should appear on Save (currently the only feedback is the marker disappearing).
+
+5. **New dialog fields.** The "New" dialog still collects name/description (retained on the draft to
+ prefill Save As) even though no project is created until Save As. Is collecting them at "New" time
+ useful, or should they be collected only at Save As?
+
+6. **Active-project indication.** It was previously impossible to tell which saved project the draft
+ was working against. The "Select Interlinear Project" list now highlights the active project's row
+ (accent border/background) and shows an "Active" badge on it. This is currently the _only_ place
+ the active project is surfaced. Alternatives considered: showing the project name in the tab title
+ and/or a persistent toolbar label/badge. Should the active project also be shown outside the
+ select modal?
+
+7. **Unsaved indicator timing.** The `●` marker now appears as soon as the user starts typing in a
+ gloss field, not only after the field loses focus (gloss values are still committed/persisted on
+ blur — only the indicator is eager). Is reflecting in-progress typing as "unsaved" the right
+ behavior, or should the marker wait until an edit is actually committed?
+
+8. **Wipe and the unsaved indicator.** "Wipe Entire Draft" is treated as a clean baseline: it clears
+ the `●` marker (the empty draft is not flagged as unsaved) while keeping the active project as the
+ Save target, so a subsequent Save still writes the (now empty) draft to it. "Wipe Current Book"
+ stays flagged as unsaved, since it is a partial edit the user will usually want to save. Is this
+ split right, or should both wipes behave the same?
+
+9. **Save As → Overwrite and the target's metadata.** "Save As → Overwrite an existing project"
+ replaces that project's analysis with the draft's. The draft's _config_ (analysis languages and
+ alignment target) can differ from the chosen project's — e.g. you Open project A (languages
+ `[en]`), then Save As → Overwrite project B (languages `[fr]`). We currently push the draft's
+ analysis languages and alignment target onto the overwritten project so its declared metadata
+ matches the glosses now stored in it, while keeping the project's existing **name and
+ description** (overwriting an existing named project keeps its identity). This mirrors how
+ Save As → New carries the draft's config into the newly created project. Is this the right split?
+ Options to review:
+ - Should overwrite also adopt the draft's **name/description** (i.e. fully replace the target),
+ or keep the target's identity as it does now?
+ - Should overwrite instead be **analysis-only**, leaving the target's languages/target untouched
+ (accepting that the stored glosses may then be tagged with languages the project doesn't
+ declare)?
+ - Should the Overwrite confirmation surface when the draft's languages differ from the target's,
+ so the user is aware their language tags are about to change?