diff --git a/src/App.svelte b/src/App.svelte
index 0fa27ba..9e94c91 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -68,6 +68,7 @@
import { OPEN_THESAURUS_EVENT } from './lib/spell/thesaurus';
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
+ import SaveFormatDialog from './lib/components/SaveFormatDialog.svelte';
import { t, locale } from './lib/i18n/i18n.svelte';
import { fnv1a } from './lib/utils/hash';
import { withShortcut } from './lib/i18n/shortcut';
@@ -569,6 +570,7 @@
let fileInput: HTMLInputElement | null = $state(null);
let pdfBusy = $state(false);
let exportMenuOpen = $state(false);
+ let saveFormatOpen = $state(false);
// The editable zones as one section — section 1 of the export.
function hfSetOfState(): HfSet {
@@ -979,6 +981,9 @@
async function handleSave() {
if (!editor) return;
exportMenuOpen = false;
+ // A document with no file behind it has no format yet: ask, the way saving an
+ // untitled document does everywhere else, and let Save As do the rest.
+ if (!fileHandle && !documentHasFile) { saveFormatOpen = true; return; }
if (!(await ensurePassword())) return;
const json = editor.getJSON() as TiptapNode;
try {
@@ -1765,6 +1770,7 @@
callers only say which family to land on. -->
+
diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md
index 577f8ad..25b10ba 100644
--- a/src/lib/export/CLAUDE.md
+++ b/src/lib/export/CLAUDE.md
@@ -11,6 +11,8 @@ are built, because the download route (Brave ships with the File System Access A
read a changed name back from the browser's dialog — and Chrome's macOS save panel has no format
popup and admits only the first picker type's extensions anyway. The chosen format is the
document's from then on; a template follows the document's format (`.ott`, or `.dotx` for `.docx`).
+Save on a document with no file behind it therefore asks first (`SaveFormatDialog`, App.svelte)
+and hands over to Save As — the same beat as saving an untitled document elsewhere.
`saveFile.ts` picks the path: `showSaveFilePicker` where there is one, else a download —
Gecko has none and Brave disables the whole File System Access API by default. The first
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index f7f904e..c1eb288 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -1082,6 +1082,7 @@ const de: Messages = {
confirmReplace: 'Beim Öffnen dieser Datei wird das aktuelle Dokument ersetzt. Fortfahren?',
openedWithLimitations: (list) => `Mit Einschränkungen geöffnet:\n• ${list}`,
couldNotOpen: 'Diese Datei konnte nicht geöffnet werden.',
+ chooseFormat: 'In welchem Format speichern?',
couldNotSave: 'Diese Datei konnte nicht gespeichert werden.',
scriptBlocked: 'Ein Teil der App konnte nicht geladen werden — ein Skript-Blocker (uBlock Origin, NoScript, strenger Schutzmodus) verhindert das. Erlaube diese Seite und versuche es erneut.',
couldNotExportPdf: 'PDF-Export nicht möglich.',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index a328eb1..c2290b8 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -1088,6 +1088,7 @@ const en = {
confirmReplace: 'Opening this file will replace the current document. Continue?',
openedWithLimitations: (list: string) => `Opened with limitations:\n• ${list}`,
couldNotOpen: 'Could not open this file.',
+ chooseFormat: 'Save in which format?',
couldNotSave: 'Could not save this file.',
scriptBlocked: 'Part of the app could not be loaded — a script blocker (uBlock Origin, NoScript, strict privacy mode) is blocking it. Allow this site and try again.',
couldNotExportPdf: 'Could not export to PDF.',
diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs
index 227f22d..361ebb5 100644
--- a/tests/dom/run.mjs
+++ b/tests/dom/run.mjs
@@ -30,6 +30,19 @@ try {
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 15_000 });
+ // Save on a document with no file behind it settles the format first: the download
+ // route cannot read one back from the browser's own dialog.
+ await page.evaluate(() => { window.showSaveFilePicker = undefined; });
+ await page.click('.tiptap');
+ await page.keyboard.type('Format first');
+ await page.keyboard.press(`${MOD}+s`);
+ const [firstSave] = await Promise.all([
+ page.waitForEvent('download', { timeout: 30_000 }),
+ page.locator('dialog[open] button', { hasText: '(.docx)' }).first().click(),
+ ]);
+ check(firstSave.suggestedFilename().endsWith('.docx'),
+ `Ctrl+S on an unsaved document asks for the format (${firstSave.suggestedFilename()})`);
+
// A multi-page corpus document, through the file input (no picker in headless).
await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt'));
await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200,