diff --git a/apps/mobile/src/mobileEditorDraftSave.test.ts b/apps/mobile/src/mobileEditorDraftSave.test.ts
new file mode 100644
index 00000000..2f9f4c56
--- /dev/null
+++ b/apps/mobile/src/mobileEditorDraftSave.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest'
+import { createMobileEditorDraft } from './mobileEditorDraft'
+import { saveMobileEditorDraft } from './mobileEditorDraftSave'
+import { createMobileVaultConfig } from './mobileVaultConfig'
+import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
+
+const vault = createVault()
+
+describe('mobile editor draft save', () => {
+ it('writes persistable editor drafts as canonical Markdown', async () => {
+ const storage = createMemoryMobileVaultStorage([])
+ const draft = createMobileEditorDraft({
+ note: { id: 'notes/workflow', title: 'Workflow', content: '# Workflow' },
+ editorHtml: '
Workflow
Edited
',
+ })
+
+ await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
+ status: 'saved',
+ path: 'notes/workflow.md',
+ })
+
+ await expect(storage.readMarkdownFile(vault, 'notes/workflow.md')).resolves.toBe('# Workflow\n\nEdited')
+ })
+
+ it('does not write blocked editor drafts', async () => {
+ const storage = createMemoryMobileVaultStorage([])
+ const draft = createMobileEditorDraft({
+ note: { id: 'workflow', title: 'Workflow', content: '# Workflow' },
+ editorHtml: 'Unsupported
',
+ })
+
+ await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
+ status: 'blocked',
+ reason: 'unsupportedEditorHtml',
+ })
+
+ await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([])
+ })
+})
+
+function createVault() {
+ const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+
+ return result.config
+}
diff --git a/apps/mobile/src/mobileEditorDraftSave.ts b/apps/mobile/src/mobileEditorDraftSave.ts
new file mode 100644
index 00000000..1fe97708
--- /dev/null
+++ b/apps/mobile/src/mobileEditorDraftSave.ts
@@ -0,0 +1,36 @@
+import type { MobileEditorDraft } from './mobileEditorDraft'
+import type { MobileVaultConfig } from './mobileVaultConfig'
+import type { MobileVaultStorageDriver } from './mobileVaultStorage'
+
+export type MobileEditorDraftSaveResult =
+ | {
+ status: 'saved'
+ path: string
+ }
+ | {
+ status: 'blocked'
+ reason: 'unsupportedEditorHtml'
+ }
+
+export async function saveMobileEditorDraft({
+ draft,
+ storage,
+ vault,
+}: {
+ draft: MobileEditorDraft
+ storage: MobileVaultStorageDriver
+ vault: MobileVaultConfig
+}): Promise {
+ if (!draft.persistable) {
+ return { status: 'blocked', reason: draft.blockedReason }
+ }
+
+ const path = draftPath(draft)
+ await storage.writeMarkdownFile(vault, path, draft.canonicalMarkdown)
+
+ return { status: 'saved', path }
+}
+
+function draftPath(draft: MobileEditorDraft) {
+ return `${draft.noteId}.md`
+}
diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md
index e7bd52c9..3bcea1ef 100644
--- a/docs/MOBILE_PROGRESS.md
+++ b/docs/MOBILE_PROGRESS.md
@@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Branch: `codex/mobile`
- Active phase: Phase 2 - Mobile Shell
-- Active slice: Expand TenTap Markdown serialization
+- Active slice: Add mobile editor draft save boundary
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -57,6 +57,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Wired the mobile shell to load its starter notes through the native Expo FileSystem storage driver and stored repository path, with fixture notes retained as the fallback while storage initializes.
- Expanded supported TenTap HTML serialization to include H1-H6 headings, ordered lists, task-list markers, inline strong/emphasis/code, and links while continuing to block unsupported block HTML.
- Split the mobile editor HTML serializer into its own CodeScene-10 module so the draft boundary stays small.
+- Added a mobile editor draft save boundary that writes persistable canonical Markdown drafts to vault storage and refuses blocked drafts.
## Next Action
@@ -64,7 +65,7 @@ Continue Phase 2 with the next mobile shell slice:
1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay.
2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists.
-3. Add a save command boundary that writes persistable TenTap Markdown drafts back to mobile vault storage.
+3. Wire `MobileEditorAdapter` draft callbacks to the save boundary with explicit dirty/saved/blocked UI state.
## Verification Log
@@ -176,6 +177,11 @@ Continue Phase 2 with the next mobile shell slice:
- `pnpm --filter @tolaria/mobile typecheck` passed after expanded TenTap serialization.
- CodeScene after expanded TenTap serialization: `apps/mobile/src/mobileEditorDraft.ts`, `apps/mobile/src/mobileEditorDraft.test.ts`, and `apps/mobile/src/mobileEditorHtmlMarkdown.ts` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after expanded TenTap serialization.
+- `pnpm --filter @tolaria/mobile test -- src/mobileEditorDraftSave.test.ts` passed after editor draft save boundary: 13 files / 44 tests.
+- `pnpm --filter @tolaria/mobile test` passed after editor draft save boundary: 13 files / 44 tests.
+- `pnpm --filter @tolaria/mobile typecheck` passed after editor draft save boundary.
+- CodeScene after editor draft save boundary: `apps/mobile/src/mobileEditorDraftSave.ts` and `apps/mobile/src/mobileEditorDraftSave.test.ts` scored `10`.
+- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after editor draft save boundary.
## Risks / Watch Items