ZenNotes 2.53.0: when two devices change a vault's settings, sync now asks right away, shows each setting that differs with this device's value beside the cloud's, and lets you pick a side per setting; an update check that launches without a network now waits for the connection and tries again on its own; a sync that fails midway keeps what already landed instead of replaying it on every retry; a task added to a past daily note after today's note was opened now rolls over on the next open; :version in Vim mode prints the ZenNotes version, OS, engine and install format a bug report needs, with a copy of the same block in Settings → About; and zn open -n opens a second window on a vault or folder you already have open, with its own tabs, instead of raising the one you were working in
Released on September 18, 2026 as v2.53.0, at 3a622639. The release branch v2.53.0 was fast-forwarded into main through PR #818. Three reports came from @uNyanda (#816, #812, #813), one each from @naingyeminn (#817), @vlcinsky (#814), @radiorambo (#815) and @saran-ncsu (#810); two more came in through the Android repo and are fixed in the shared core here: zennotesandroid#74 from @Alastor1991 and zennotesandroid#78 from @uNyanda, which reach phones with the iOS and Android releases that adopt this version. Every issue is closed with a commit-linked comment. The Go zn half of #815 shipped the same day as ZenNotes CLI 0.4.0, and the zn bundled with the desktop app moves from 0.2.0 to 0.4.0 in this release. Channel and validation details are in the sections at the end.
✨ Features
- The vault settings question is asked where you are, per setting (#816). When this device and another one both changed a vault's settings (its favorites, folder icons and colors, daily and weekly notes, where new notes go, system folder paths, Tasks exclusions, Typst and Harper data) since the last sync, sync parks the cloud's copy and keeps yours in use. Until now the only place that said so was a card in Settings → Cloud with two whole-file buttons, so the question sat there until you happened to look, and answering it meant choosing between "my favorites" and "their folder icons" when you wanted both. Now the question opens by itself right after the sync that found it. It lists each section that differs, with this device's value beside the cloud's, down to the field that changed (a directory, an icon, a pinned note), and offers This device or Cloud for each section, plus This device for all and Cloud for all. Settings the cloud's file carries that this build has no setting for (another app, a newer version) are listed under Not used on this device; they are never applied. Decide later applies nothing: your settings stay in use, the status bar says Settings need review until you answer, and
Space r(Vim mode), Review Cloud conflicts in the command palette, or Review in the status bar brings the question back. The card in Settings → Cloud remains, now with a Compare and choose… button that opens the same dialog. The plain answers still work as before: keeping this device's settings only drops the cloud's copy, and taking the cloud's writes them through the vault's own normalizer. :versionprints what a bug report needs (#814). In Vim mode,:versionprinted "Codemirror-vim version: 6.3.0" in red and nothing else: the stock ex command of the Vim library ZenNotes embeds, which knows nothing about the ZenNotes build around it.:version(and:ve) is now ZenNotes' own. It prints the ZenNotes version and host, the operating system and architecture (macOS 27.0.0, arm64,Ubuntu 24.04.1 LTS (kernel 6.8.0), x64,Windows 10.0.26100, x64), the engine (Electron 41.10.7 (Chromium 146.0.7680.216, Node 24.18.0), or the browser's user agent on the web client), how this copy was installed (macOS app bundle, Mac App Store, NSIS installer, portable exe, AppImage, deb, rpm, pacman, or "package manager or tarball" for AUR, Homebrew, Nix and the like), and, when the window is connected to a remote workspace, the server's version and address.:version copy(or:version!) also puts the lines on the clipboard and says so. The text is shown in the editor's own color for 15 seconds, not red, since nothing failed. Settings → About shows the same lines under the version as a Version details block with a Copy details button, so a user who never enters Vim mode has the same report one click away. Both surfaces build their lines from one function, so a report pasted from either reads the same. The codemirror-vim version line is gone on purpose: the library does not expose it at runtime, and the ZenNotes version pins it anyway. How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/demo-814-version.mjs(scratch vault, isolated stores; asserts the printed lines, the clipboard after:version copy, that:veresolves to this command, and that the About block matches). By hand: Vim mode on, Escape, type:version, Enter. Before: one red line naming the Vim library. After: four lines starting withZenNotes 2.53.0 (desktop).zn open -nopens a second window instead of raising the first (#815). The report:zn open path/to/folderbound to a key, a window on that folder already up in workspace 1 with tabs mid-task, and running it again from workspace 2 pulled you back to workspace 1 instead of giving workspace 2 its own window.zn open -n <path>(or--new-window), spelled like Chrome's flag, now opens a fresh window on the same notes with its own tabs and leaves the first window exactly where it was; closing the second later leaves the first intact. Without the flagzn openstill raises the window that already shows the path. What the second window is depends on the path: a vault a window has open for real gets a second real window on that vault (what Open Vault in New Window does); a plain folder gets a second focused session; a note inside a vault opens in a new window on its vault. A markdown file outside every vault keeps reusing its editor window even with-n, on purpose, since nothing keeps two standalone editors of one file in sync. Both CLIs carry the flag: theznbundled with the desktop app (this release pins ZenNotes CLI 0.4.0, up from 0.2.0), and the Gozn(ZenNotes/tui) the reporter uses, released as 0.4.0 on 2026-09-18. A parser fix came with it: a switch written before the path used to swallow it (zn open --new-window ~/notesread the path as the flag's value, andzn delete --yes inbox/a.mdhad the same problem), so both CLIs now know which flags never take a value.zn --helpnames the flag in the OPEN row and the in-app manual's CLI section gains a card for it. How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/demo-815-new-window.mjs(scratch vault, isolated stores, spawns the second launches the wayzn opendoes; asserts one window without the flag, a second real vault window with it, the note and folder cases, and the first window still on its note). By hand: open a vault, note its tabs, thenzn open -n <that vault>. Before: the existing window comes to the front. After: a second window on the vault; the first keeps its tabs.- Favorites on the home view, and a way to favorite a note from anywhere (#810). The request: a list of pinned or favorite notes to open from, without keeping them as tabs, and on the phones, which open Recent from the home screen, that same list. ZenNotes already had Favorites (the sidebar section that
Space l sand the row's context menu fill, stored invault.jsonso it travels with the vault), so the home view now shows them: a Favorites section right after Recent, in the order you arranged them, with the same icon and color the sidebar gives each entry and, for a note, where it lives (Projects / Alpha). A favorited note opens in place; a favorited folder opens that folder in the note list, brings the sidebar back if it was hidden and expands the folder in the tree so its notes are visible;j/k(Vim mode) or the arrows move through Recent into Favorites andEnteropens. The section stays out of the way until you favorite something, and a favorite whose note is gone (trashed, moved away) is skipped, as in the sidebar. Favoriting itself gains two routes: the command palette's Add Note to Favorites / Remove Note from Favorites (hidden for trashed notes), and on the phones, an Add to Favorites row in the ••• sheet and in the long-press note menu, next to Pin (Pin stays the phone's own list order; Favorites is the vault's list). Pinned tabs are unchanged. How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/demo-810-home-favorites.mjs(scratch vault with a note favorite, a folder favorite and a favorite whose note was deleted; 12 checks). By hand: with no note open, favorite a note withSpace l sor the palette. Before: it appears in the sidebar only. After: a Favorites section appears on the home view under Recent; unfavorite and the row disappears at once.
🐛 Fixes
- An update check that finds no network waits for it and tries again by itself (#812). The startup check runs once, 8 seconds after launch. Open the laptop before Wi-Fi is up and that one check failed, Settings → About read Update error, the "ZenNotes update needs attention" toast fired, and nothing ever tried again until you pressed Check for Updates yourself. On AUR and other package-manager installs the message was just "fetch failed", the real cause hidden inside the error. Now a check that cannot reach GitHub (DNS not resolving, no route, connection refused or reset, a timeout) puts the updater in a Waiting for network state instead: an amber badge in Settings → About, the message names the cause and says the app will check again on its own, and no toast. While waiting, ZenNotes watches the connection every 15 seconds without making a request and checks again the moment the link comes back. If the link is up but GitHub still cannot be reached (a captive portal, a router with no WAN), it retries 30 seconds after the failure, then 1, 2, 4 minutes, up to every 15 minutes, and it makes a real attempt at least every 15 minutes even when the system claims to be offline, so a wrong answer from the OS cannot silence it. Check for Updates from the app menu, the command palette or Settings → About still works at any time. Errors that are not about the network (a 404 on the feed, a bad signature) still show as errors, now with the most specific cause the error chain carries.
- A sync that fails midway keeps what already landed instead of replaying it on every retry (#813). The report: delete
Untitled.mdon the desktop, and the phone's sync fails forever withrename failed: Already exists /storage/emulated/0/Mind/trash/Untitled.md, the deleted note coming back on the phone with every Sync now. Two causes, one on each side. The desktop's Trash already held an olderUntitled.md, so the note was trashed astrash/Untitled 2.md, a move that changes folder and name in one step; Trash is synced, so the phone had the oldertrash/Untitled.mdtoo. The Android plugin did such a move as "move the document, then rename it", and the Storage Access Framework's move step keeps the source name, so it tried to land a secondUntitled.mdintrash/and Android refused with that message. That half is fixed in the Android repo (rename first, in the source folder, then move; a hidden temporary name when the target name is taken there; rollback per hop) and ships with the next Android release. The half that made it come back lives in the shared sync engine and lands here:pullChangessaved its cursor only after a whole batch had been applied, so the earlier change in the batch (the last saved revision ofUntitled.md) was written to disk on every retry and the batch failed again on the same move. Now, when a change throws, the engine first saves the state of everything that already landed and then lets the error out, so a retry resumes at the failing change. Nothing is saved past a coalesced revision whose landing change has not run yet, so the #661 catch-up behaviour is unchanged. For the reporter this alone means: deleteUntitled.mdon the phone once more, press Sync now, and the move and delete become no-ops instead of a loop. How to test:cd packages/shared-domain && npx vitest run src/cloud-sync-coordinator.test.ts -t 813(the first case fails on the old code with cursor 1 instead of 2 and a re-createdUntitled.md). - A task added to a past daily note after today's note was already opened rolls over on the next open (#817). The report: with Roll over unfinished tasks to today on, open today's note with
Space d, add a task to yesterday's note, open today's again, and nothing moves. The rollover kept a once-per-day marker in localStorage and wrote it after every run, including runs that found nothing to move, so the first open of the day locked the feature until tomorrow. Open today's note first, as the reporter does, and the setting looks like it does nothing at all; the marker had been there since the feature shipped, so this was not a 2.52 regression. The marker is gone. In its place the app keeps, per vault, the list of past daily notes it already read and found free of open tasks, each with the file's modification time and size at that moment. A note changed since (a task typed in ZenNotes, a file changed by sync or another editor) no longer matches and is read again the next time today's note opens; notes open in a tab are always read from their buffer, which costs nothing; a note the rollover just trimmed is read once more before it is trusted. Years of untouched daily notes are still skipped, which is what the marker was for. The command palette's Roll over unfinished tasks reads every past note, as before. Two smaller things landed with it: today's note now receives the tasks before the source notes are trimmed, so a failure midway leaves a task in two notes instead of in none; and a Windows daily note with CRLF line endings now rolls its tasks too (the trailing\rdefeated the task line match, so those tasks showed in the Tasks view, whose scanner normalizes endings, but never moved). How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/repro-817-rollover.mjs --typed --marker(scratch vault, isolated stores, the reporter's keystrokes; exits 1 when the task did not land in today's note). By hand: daily notes on with the rollover setting on,Space d,Cmd+Pto yesterday's note, type- [ ] Call the bank, Escape,Space d. Before: today's note unchanged. After: the task is in today's note and gone from yesterday's. - A heading link followed in reading mode stays in reading mode (android#74). Reported from the Android app, where the default view is often Reading: tap
[[Note#Heading]]and the target note opened in edit mode with the cursor on the heading. The desktop did the same. Every jump inside a note ([[Note#Heading]],[[Note#^block]], a same-note[[#Heading]], a search hit,Ctrl+O/Ctrl+I) went through one path written for the editor, which set the cursor and switched the pane to edit so the cursor could show. A pane in reading mode now plans the jump itself: a heading or block link, or a search hit, scrolls the rendered block for that source line to the top of the reading view;Ctrl+O/Ctrl+I(the phone's Back button) put the reading view back where it was scrolled when you left, rather than jumping to a line; a task opened from the Tasks view still switches to edit, on purpose, since it paints a highlight on the source line that only the editor can show. A note you last left in edit mode still opens in edit mode with the cursor on the heading. How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/demo-android-74-reading-links.mjs(scratch vault, default mode Reading; 11 checks). By hand: Settings → Editor → default mode Reading, open a note with[[Other#Heading]]and click it. Before: Other opens in edit mode. After: Other opens in reading mode with the heading at the top;Ctrl+Oreturns to the first note at the scroll position you left. - The template editor stacks its panes on narrow screens (android#78). Settings → Templates → edit showed the editor and the preview side by side at any width, which on a phone left each about half a screen wide. The two panes now follow the viewport: side by side from 768 px up (the app's
mdbreakpoint), each 60vh tall as before; below it the editor sits on top at 38vh and the preview under it at 22vh, each scrolling on its own, and the row of variable chips scrolls sideways instead of wrapping into several lines. The dialog itself, its header, name field and buttons are unchanged, so a narrow desktop window gets the stacked layout too. How to test:cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/check-android-78-template-editor.mjs(opens the dialog at 1280 px and at 390 px and reads the layout from the DOM; 11 checks). - Per-vault asset sort, board root and board statuses survive a settings save. The main process rewrites
vault.jsonthrough a normalizer that lists the view keys it keeps, and three keys added later were missing from that list:assetSortOrder,kanbanFolderRootandkanbanStatuses. Any settings save from the desktop (a new favorite, a folder icon) silently dropped them from the file. They are now carried through. Found while writing the merge for #816, which saves settings through the same path.
🧰 For contributors
- #816:
packages/shared-domain/src/vault-settings-conflict.tsis the pure core, shared so every runtime asks the same question of the same bytes:diffVaultSettings(per top-level section, leaf fields, ordered lists as one leaf, the daily/weekly/monthlylegacyPatternsbookkeeping ignored),mergeVaultSettings(local, cloud, choices)andunknownVaultSettingsKeys.SECTIONSis aRecord<keyof VaultSettings, true>, so a new settings key fails to typecheck until it is added to the prompt or knowingly left out. The desktop'ssettingsConflict()now includes the parsed parked file ascloud_settingsonCloudSyncSettingsConflict(optional in the bridge contract; a parked file that does not parse is still reported, and the dialog falls back to the whole-file choice). In app-core,lib/cloud-auto-sync.tscarriessettingsConflictandsettingsConflictPromptOpenin the status store, refreshes after every sync (success, failure, sibling window) and opens the prompt once per new cloud copy;hasPendingCloudReview/openPendingCloudReviewserveSpace r, the palette command and the status bar, file queue first.components/CloudSettingsConflictDialog.tsxrenders onui/Modalwith the shareddata-cloud-conflict-dialogmarker so the existing Vim and palette guards apply.store.setVaultSettingsnow resolves to a boolean so callers can tell a refused save from a completed one.tooling/scripts/cloud-conflict-demo-fixture.mjs:POST /demo/armalso bumps the cloud'svault.json(daily notes on intoJournal, amapicon on Plans, a different pinned note, and anexperimentalSpellcheckkey this build does not know), so the settings question can be reproduced against the local fixture. - #812:
AppUpdatePhasein the bridge contract gainsoffline; a host that does not know it shows no badge, notice or action (the renderer defaults return null for an unknown phase, tested inapp-update-state.test.ts). Inapps/desktop/src/main/updater.ts,isNetworkUnreachableErrorwalks the error chain (message,code,cause, five levels deep) for the Node, undici and Chromiumnet::ERR_*spellings of "no route to the host", including undici's bareTypeError: fetch failed;describeErrorpicks the most specific line of that chain for the message. Both check paths (electron-updater and the managed, notify-only path) callwaitForNetworkafter the existing three immediate retries, which setsofflineand starts onesetIntervalatOFFLINE_POLL_MS(15 s). The poll readsnet.isOnline()only (no request) and runscheckForAppUpdates()on a down-to-up transition, when the backoff (offlineRetryDelayMs: 30 s doubling, capped atOFFLINE_RETRY_MAX_MS= 15 min) is due with the link up, or when 15 minutes passed since the last attempt whateverisOnline()says. Any state other thanofflineorcheckingstops the wait, so a manual check owns its result. electron-updater's ownerrorevent is ignored for network-class errors so the state never flasheserrorbefore the check's catch decides. Tests inupdater.test.tsdrive the wait with fake timers and a mockednet.isOnline. - #813, shared engine:
CloudSyncCoordinator.pullChanges(packages/shared-domain/src/cloud-sync-coordinator.ts) keeps alandedstate next to the livestate. After each change is reduced,landedadvances only whenonDisk.size === 0, that is, no coalesced upsert is still waiting for the change that lands it (the #661 rule: a skipped revision is reduced intostatebut never written, so persisting that state would make the device vouch for bytes it does not have). The loop sits in atry; thecatchsaveslandedwhen it moved pastinitialState, then rethrows. Nothing else changed: the success path still saves the final state once, the local-conflict and pending-conflict branches are untouched. Two cases incloud-sync-coordinator.test.tsunder "catching up on a file this device never touched": the #813 replay (anupsertthen a failingmove; cursor must be 2, not 1; after the user deletes the note locally the retry succeeds with cursor 4 and re-creates nothing) and a guard that progress is never persisted past a coalesced revision whose landing change has not run (fails against a naive save-after-every-change implementation). - #813, Android side (separate repo
zennotesandroid,android/app/src/main/java/md/zennotes/SafFsPlugin.java, uncommitted at the time of writing):rename()splits into same-directory (renameExactly), cross-directory same name (moveDocument) and cross-directory new name (moveRenamed: rename in the source directory first, to the target name if free there by a case-insensitive check against the cached listing, else a hidden.zn-move-<hex>-<name>temporary, then move, then the final rename; each hop rolled back on failure,FOLDER_STATE_UNCERTAIN:when the rollback itself fails).renameExactlyreadsCOLUMN_DISPLAY_NAMEback after the rename and undoes a provider that redirected to a "name (1)" variant, which was silently accepted before. Thevault-fs.tscallers already pre-check names withuniqueFilename, so exact semantics match what they expect. Known residual: a case-only rename on case-insensitive storage now errors "already exists" instead of quietly becoming "name (1)". Compiles (./gradlew :app:compileDebugJavaWithJavac --offline); not exercised on an emulator. - #817:
rolloverUnfinishedTasksIntoTodayinpackages/app-core/src/store.tsreads and writeszen.tasks.rolloverClean.<root>(JSONRecord<path, "updatedAt:size">, garbage parses to{}; the oldzen.tasks.rollover.<root>marker is removed on the first write). Per past daily note, oldest first: no open buffer and a matching signature skips the read and carries the entry forward; otherwise the buffer or disk body goes throughextractOpenTaskBlocks, and a disk read that moved nothing records the signature (a buffer never vouches, since it can be ahead of the listing). Trimmed notes are left out of the record on purpose.forcestarts from an empty record. Today's note is written before the sources are trimmed, and thetrimmedlist carries each source'srestand whether it was buffered so the existingupdateNoteBodyversuswriteNoteplusrescanTasksForPathsplit is unchanged.extractOpenTaskBlocks(packages/shared-domain/src/tasklists.ts) matches on\r-stripped copies and rebuildsrestfrom the raw lines; moved lines are bare, sinceinsertTasksUnderTasksHeadingjoins on\n. Tests:store.test.ts"rolls a task added to a past daily note after today was already opened" (four runs: nothing to roll, unchanged listing reads nothing, new signature rolls the task, the trimmed note is read once more) and the CRLF case intasks-rollover.test.ts, both red on the old code. - #814:
ZenAppInfoin the bridge contract gains optionalarch,os,engineandinstall; a host that fills none of them (the mobile shells today) gets a one-line report, sincebuildVersionReport(packages/app-core/src/lib/version-report.ts, pure, tested) leaves an unfilled field out rather than printing "unknown". The desktop preload fillsarchandenginefromprocess, andosandinstallthrough one new synchronous IPC,APP_INSTALL_INFO_SYNC, answered in main bydescribeInstall()inupdater.tsand cached in the preload after the firstgetAppInfo()call (that call runs many times at boot forruntimealone, so it must not pay a sync round trip each time).describeInstall()never throws;installLabel()is the pure mapping (isPackaged, platform,process.mas,process.windowsStore,PORTABLE_EXECUTABLE_DIR, and on Linux the updater's ownlinuxUpdaterFormatso the label agrees with what the updater will do),osReleasePrettyName()readsPRETTY_NAMEout of os-release. The web bridge fillsenginewithnavigator.userAgent. In the editor,alertEditorErroris now a thin wrapper overshowEditorNotification(message, { color?, duration }), which is what:versionuses with no color and 15 s;Vim.defineEx("version", "ve", …)shadows the library's built-in, andparams.argStringofcopyor!routes throughwriteClipboardText.SettingsModalgains aVersionDetailscomponent that renders the same lines in a<pre>with a ghost Copy details button (label flips to Copied for 1.5 s). New unit cases:installLabelandosReleasePrettyNamein desktopupdater.test.ts,version-report.test.tsin app-core (5). - #815: the app side is one argv switch,
NEW_WINDOW_SWITCH(--new-window) andargvRequestsNewWindowinapps/desktop/src/main/file-open.ts. Chromium ignores switches it does not know and Electron does not define this one, so it reachessecond-instanceuntouched, andcandidatePathsFromArgvalready skipped--prefixed entries, so an older app raises the existing window as before. Inmain/index.ts,pendingFileOpensitems carrynewWindow,handleStartupMarkdownArgsreads it once per launch (so it covers every path of that launch and none of a later one), andopenMarkdownFileFromOS/openTemporaryFolderskip the find-and-focus step when it is set. The one non-obvious branch:-non a root a window has open as a REAL vault opens a second real vault window throughcreateWindow({ initialVaultRoot, persistInitialVault: true }), the same call as Open Vault in New Window, never an ephemeral session, because the ephemeral registry (ephemeral-vaults.ts) is keyed by root and never unregistered, so a session on a live vault root would switch the first window's workspace-state and settings writes off and send its deletes to the system Trash.-non a plain folder opens a second session window; akind: 'external'file ignores the flag. CLI:VALUELESS_FLAGSincli/args.ts(all, include-excluded, json, meta, new-window, page, reopen, unchecked, yes; each verified to be read only throughgetBool) stops--switch <positional>from swallowing the positional;--flag=valuestill parses.commands/open.tsreads-n/--new-windowand spawns[NEW_WINDOW_SWITCH, ...absPaths]. Go CLI (ZenNotes/tui6481d17):valuelessFlagsininternal/cli/args.go(the Node list plus no-default, no-edit, path, pretty, which only that CLI has) honored inParse;commands_open.gogetsnewWindowSwitch,launchArgs(newWindow, paths)andopenMessage; newargs_test.go. The two lists must stay in sync; both comments say so. Tests:file-open.test.ts(switch detection, no leak into candidate paths),cli/args.test.ts(switch before positional for every valueless flag,--json=false,-n),cli/commands/open.test.ts(the spawned argv, both messages). - #810:
resolveFavoriteItems(favorites, notes, folders)moved out ofSidebar.tsxintopackages/app-core/src/lib/vault-layout.ts(pure, tested invault-layout.test.ts) and both the sidebar andHomeView.tsxread the same list through it, so a stale key can never show on one surface and not the other. Home stampsdata-home-section(recent,favorites,today) on its sections anddata-home-favorite(noteorfolder) plusdata-home-note-pathon rows; the mobile shells' CSS and their row-swipe resolver key off these (before, they countedsection.mb-9 ~ sectionand resolved Recent rows by position). The favorite-folder click reuses the sidebar'ssetView({ kind: 'folder' }), restores the sidebar and prunes the folder chain out ofcollapsedFoldersfirst, since a collapsed tree row would look like nothing happened. New palette commandnote.favoriteinlib/commands.ts(title flips on the active note's state,whenhides it for trashed notes, shortcut resolved from thevim.leaderToggleFavoritechord). For hosts,requestToggleNoteFavorite(host, path)innotes.tswrapsstore.toggleFavoritein the usualrequestNoteActionstaleness guard, andShellSnapshot.favorites(a frozen array, rebuilt only when the contents change so a folder-color save does not wake subscribers) gives shells a read. Both phone shells add the row to the ••• sheet (noteRowsFor(folder, favorite)) and the long-press menu (note-actions.tsx, after Pin), and hide the Today section and folder favorites on phones (folder favorites open the desktop tree, which phones replace with the drawer; a Favorites section left with only folders is dropped). - android#74:
planPreviewJump(jump, body)inlib/preview-outline-jump.tsis the pure decision (highlightLine→edit;editorScrollModepreserve, the Ctrl+O / Ctrl+I shape, →restoretopreviewScrollTop; otherwise →linefromeditorSelectionAnchor), andpreviewShowsNote(el, path)guards against scrolling the previous note's blocks:Preview.tsxstampsdata-note-pathon the article in the same step as the DOM swap, since the render is asynchronous.EditorPane's pending-jump effect calls the planner when the pane is in reading mode instead ofapplyPaneMode('edit'); alineplan whose blocks are not rendered yet is parked in the renamedpendingPreviewLineRef(formerly the #543 cursor-line ref, sameonRenderedconsumer), reset on note change before the effect runs so a stale line never fires on a later visit. 14 cases inpreview-outline-jump.test.ts, including search hit, task jump and the restore shape. - android#78:
TemplateEditorModal.tsxonly:grid-cols-1 md:grid-cols-2, editorh-[38vh] md:h-[60vh], previewh-[22vh] md:h-[60vh], and the variables rowflex-nowrap overflow-x-auto md:flex-wrap md:overflow-visible. Viewport heights are the accepted exception to the no-arbitrary-values rule; no new tokens. The dialog stays on the popover layer as a centeredui/Modal.
Demos
- 816-settings-conflict.mp4: 23 s, 1920 × 1080 at 30 fps with a
.vttsidecar, recorded from the built app over CDP against the local Cloud fixture with real key events.Space l spins a favorite while the "other device" changes the settings; the question opens after the next sync; Decide later, the status bar shows Settings need review;Space rreopens it; Cloud for Daily notes and Folder icons, this device's favorites kept; Apply choices. - 812-offline-update-check.mp4: 16 s, 1920 × 1080 at 30 fps with a
.vttsidecar, recorded from the built app over CDP with the release feed pointed at a port nothing listens on. Settings → About shows Waiting for network with the cause (connect ECONNREFUSED) and no toast; the feed comes up; nobody presses anything; the check runs again by itself and About switches to Update available, 2.99.0. The 25 s wait for the retry is squeezed to 2.5 s in the clip, and the caption says so.
Verification
- Unit:
npm run typecheck7 of 7;npm run test:rungreen on every package (shared-domain 1682, app-core 2523 passed and 1 skipped, desktop 869 passed and 4 skipped). For #813, the two newcloud-sync-coordinator.test.tscases (the replay and the coalesced-revision guard); the first was confirmed red on the old code, the second by temporarily making the engine save after every change. New suites for #816: shared-domainvault-settings-conflict(14), app-corevault-settings-conflict(11),CloudSettingsConflictDialog(7), the "vault settings question (#816)" block incloud-auto-sync.test.ts(5), aCloudSettingscase for the card, and a desktopvault.test.tscase for the three view keys. For #812, in desktopupdater.test.ts(35 in the file):isNetworkUnreachableErrorover Node, undici and Chromium spellings plus non-network errors,offlineRetryDelayMs, and five fake-timer runs of the wait (link down: no request until the link comes back, then one check; link up but GitHub unreachable: retries at 30 s, 60 s, 120 s; the 15-minute attempt withisOnline()stuck on false; a 404 does not wait; a manual check during the wait keeps the wait), and anapp-update-state.test.tscase thatofflineyields no badge, notice or action in the renderer. - Live for #812, in the built desktop app over CDP with both stores isolated,
ZENNOTES_UPDATER_FORMAT=managedandZENNOTES_UPDATE_FEED_URLpointed at a closed local port: 10 s after launch About read Waiting for network withconnect ECONNREFUSED 127.0.0.1:43191in the message and no attention toast; a feed servingversion: 2.99.0was then started on that port; 25 s later, with nobody touching the app, the state wasavailablewithavailableVersion: 2.99.0and About read Update available. Driver:tools/demo-812-offline-updates.mjs. Not exercised live: the electron-updater path (AppImage, dmg, exe), which shareswaitForNetworkand is covered by the unit tests, and a real down-to-up link transition (the unit tests cover it through the mockednet.isOnline). - Live, in the built desktop app over CDP against
tooling/scripts/cloud-conflict-demo-fixture.mjs, both stores isolated: connect, link, first sync (3 pushed, 1 pulled);Space l sthen/demo/arminside the auto sync debounce, so both sides changedvault.json; the prompt opened on its own listing Daily notes, Folder icons, Favorites and the unknownexperimentalSpellcheckkey; Decide later leftvault.jsonand the parked.zennotes/vault.cloud-conflict.jsonuntouched and the status bar read Settings need review;Space rreopened it; Cloud for Daily notes and Folder icons plus Apply choices produceddailyNotes.enabled = trueinJournal,folderIcons["inbox:Plans"] = "map", this device's favorites unchanged, noexperimentalSpellcheckkey, the parked copy gone, status back to Synced. Driver:tools/demo-816-settings-conflict.mjs. - How to test locally:
cd apps/desktop && npm run build, thennode docs/releases/v2.53.0/tools/demo-816-settings-conflict.mjs(starts the fixture on 43183, a scratch vault and isolated stores, drives the whole flow and asserts the results;--no-recordskips the clip). By hand: launch withZEN_PERF=1 ZENNOTES_USER_DATA_PATH=<scratch> ZENNOTES_CONFIG_DIR=<scratch>/config ZENNOTES_CLOUD_BASE_URL=http://127.0.0.1:43183, connect and open the fixture's vault in Settings → Cloud, pin a favorite withSpace l s,curl -X POST http://127.0.0.1:43183/demo/armwithin two seconds, and wait for the sync. - Live for #817, in the built desktop app over CDP with both stores isolated (
docs/releases/v2.53.0/tools/repro-817-rollover.mjs), five variants: yesterday's task already on disk andSpace dcreates today (rolled); the reporter's steps, yesterday opened withCmd+Pand the task typed in vim insert mode, Escape,Space d(rolled); the same with today's note opened once first with nothing to roll, the reported case (NOT rolled before the fix, rolled after; the record shows yesterday'supdatedAt:sizeentry after the first open and the task in today's note on disk after the second); today opened first, then the task appended to yesterday's file on disk by the driver, the watcher re-lists,Space d(rolled, the signature path); the builtin daily template (rolled under## Tasks). After the fixnpm run typecheck7 of 7, shared-domain 1683, app-corestore.test.ts104, desktoptasklists.test.ts50. - Live for #814, in the built desktop app over CDP with both stores isolated (
docs/releases/v2.53.0/tools/demo-814-version.mjs, key events carrying realkeyCodes since the Vim prompt readse.keyCode)::versionprintedZenNotes 2.52.0 (desktop),OS: macOS 27.0.0, arm64,Engine: Electron 41.10.7 (Chromium 146.0.7680.216, Node 24.18.0),Install: development buildin the editor color (rgb(221, 199, 161)), not red;:version copyprinted the same plus "Copied to the clipboard" and the clipboard held exactly the four lines;:veproduced the same output, so the abbreviation resolves to this command and not the library's; Settings → About showed the identical block and Copy details put it on the clipboard. Unit after the change:npm run typecheck7 of 7, desktopupdater.test.ts42, app-coreversion-report.test.ts5, and the app-coreSettingsModal,TitleBar,public-host-apiandcommandssuites (54) still green. The Linux and Windows install labels and OS lines are unit-tested only; the packaged artifact was not launched for this feature (theisPackagedbranch is covered byinstallLabel's tests). - Live for #815, in the built desktop app over CDP with both stores isolated (
docs/releases/v2.53.0/tools/demo-815-new-window.mjs, which spawns secondelectron out/main/index.js [--new-window] <path>processes on the same userData, the hand-offzn openperforms), six checks: a second launch on the open vault without the flag kept one window;--new-window <vault>gave a second window that is a real vault window (temporary: false, same root); the first window still showed its Reading note while the second opened Ideas;--new-window <vault>/Ideas.mdopened the note in a third vault window with the first untouched; a plain folder reused its window without the flag and got a second session window with it; closing the second window left the first intact. Screenshots inspected at each step. The Goznwas then built from6481d17and run against the same app withZENNOTES_APP_PATHpointed at a wrapper for the unpackaged build:zn open <vault>kept 1 page target,zn open -n <vault>gave 2,zn open --new-window Ideas.md(vault-relative) gave 3, andzn --helpshowed the flag. Unit after the change:npm run typecheck7 of 7; desktopsrc/cli+src/mainsuites 852 passed, 4 skipped; Gogo vet ./... && go test ./...clean. Not exercised:-non a note that is not inside any vault (the flag is ignored there by design, and that path is unchanged code). - Live for #810, in the built desktop app over CDP with both stores isolated (
docs/releases/v2.53.0/tools/demo-810-home-favorites.mjs, 12 checks): Favorites sits between Recent and Today; a note favorite, a folder favorite and a deleted note's key resolve to two rows with the vault path stamped on the note row and the deleted one skipped;jmoves past the Recent rows onto the first favorite andEnteropens it; the folder row restores the hidden sidebar and expands the collapsed Projects folder so Beta shows, with Home still on screen; unfavoriting removes the row at once; a vault without favorites shows no section. Phone CSS (tools/check-810-phone-css.mjs, 9 checks, the phone stylesheet applied to the desktop build): Recent and Favorites stay, Today hides, folder favorites hide, note favorites keep their rows, a Favorites section left with only folders hides, and against a 2.52 core with no section markers every section after Recent still hides. Android, live on the emulator over WebView CDP (tools/check-810-android-favorite.mjs, 4 checks): the long-press menu offers the Favorites row, toggling it moves the note in and out of Home's Favorites, the ••• sheet reads the flipped label and toggles it back. iPhone,FavoriteUITests(XCUITest, iPhone 17 Pro Max simulator): a new note, Add from the ••• sheet, the label flips to Remove, the Browse drawer's long-press row removes and re-adds it, the ••• sheet reads Add again. That test caught a real iPhone shell bug on the way: the editor toolbar's Dismiss keyboard left DOM focus in CodeMirror, so the next tap (the ensō button) brought the keyboard straight back; the shell now blurs beforeKeyboard.hide(), as its drawer and sheets already did, and the test asserts the keyboard is down before the tap. Unit after the change: app-corevault-layout,commands,note-actions,public-host-apiandpreview-outline-jumpsuites 167 passed;npm run typecheck7 of 7. Screenshots from these runs were captured but not inspected (the image-inspection tool was unavailable in this session); every assertion above is a DOM or accessibility read. - Live for android#74, in the built desktop app over CDP with both stores isolated and default mode Reading (
docs/releases/v2.53.0/tools/demo-android-74-reading-links.mjs, 11 checks): Guide opens in reading mode; scrolled to the link paragraph, clicking[[Reference#Deploy]]keeps reading mode with the Deploy heading at the top;Ctrl+Oreturns to Guide in reading mode within 40 px of the scroll position; a same-note[[#Appendix]]link and a[[Reference#^gotcha]]block link both keep reading mode with the target at the top; a note remembered in edit mode still lands the cursor on the heading. Two driver fixes worth knowing: a wikilink scrolled under the resting pointer opened the hover preview, which ate the click (the driver parks the pointer first), and the active-line read had to strip the fold arrows. The Android shell was not driven for this one; the change is all core, and the Android repo's release adopts the same core. - Live for android#78 (
docs/releases/v2.53.0/tools/check-android-78-template-editor.mjs, 11 checks): at 1280 px the two panes share a row (two grid columns, 60vh each, chips wrapping); at 390 px they stack (one column, editor 38vh over preview 22vh, chips in one scrolling row); the dialog stays centered. - Not exercised live: the web client and the mobile shells for #816 (same core; the bridge field is optional, so a host without it keeps the whole-file question; both phone shells now fill
cloud_settings, but the dialog itself was not driven on a phone). For #813, the engine change was not driven against a live phone; the failing sequence (upsert, then a move that throws) is reproduced in the unit tests, and the Android plugin change compiles but has not run on an emulator.
Distribution channels
- Release workflow: the tag's run 35413541851 built every platform and finished green on the first attempt, 42 minutes from tag to the last macOS upload (01:43 to 02:25 UTC on September 19). 25 installer assets, the same set as 2.52.0, plus the 2 demo clips. macOS is Developer ID signed and notarized. Both macOS apps carry ZenNotes CLI 0.4.0 as the bundled
zn(read fromContents/Resources/terminal/manifest.jsoninside each DMG); the Linux and Windows installers stage it through the same packaging step, checked before the cut withnpm run terminal:stagefor all four platform pairs. - AUR:
2720a23in the separatezennotes-binclone (zennotes-bin 2.53.0-1), mirrored on desktopmainas57f88522;aur-check.ymlpassed (runs 35414358979 and 35414361708). The tarball downloaded from the release has SHA-256cf223841b82ea5e6073618dcd0895f274fbba6d33ae9a525e844b820c151c1f4, equal to GitHub's asset digest and to the pinned value. - Nix:
nix-update.ymlrun 35414372445 opened bot PR #819, lifted byte-for-byte ontomainascf199243and closed;nix-build.ymlrun 35414549580 passed onmainfirst try.desktopHash(sha256-zyI4QbgupeYHNhjc0IlfJ0+7ptM66aUl6ES4IMFRwfQ=) is the same tarball digest as the AUR pin, in SRI form. - nixpkgs: PR #561418 restacked onto current master as
zennotes-desktop: 2.45.0 -> 2.53.0(fork commit34da87eb, body innixpkgs-pr-body.md). Onlyversion,npmDepsHashand the sourcehashchange. - Homebrew:
bb6e508in the tap (zennotes 2.53.0), mirrored on desktopmainascfff7df1. The cask pins both DMG digests from the GitHub API (arm64d8eabf574b1542a135daae19433f67098e8313412a997dd098dec60bcc3662ef, x644504f90101c1faf7ea40850b6b5cd7c5b1d3673909f8b7c482cd29763499b45c), and both equal a localshasumof the downloaded DMGs. - Website: the 2.53 docs (the per-setting vault settings question and
Space r, the update check that waits for the network,:versionand the Version details block,zn open -n, Favorites on the home view) and the 2.53.0 releases entry with 2 captioned clips merged as website PR #32 (853bc0d). - Docker and the server: unchanged by this release.
npm run verify:channels -- 2.53.0passed at 02:43 UTC: Homebrew tap, AUR, Nix, and all seven website download redirects serve 2.53.0.releases/lateston GitHub answersv2.53.0. The live release page leads with 2.53.0 and serves both clips with captions.
Release validation
- Release PR #818's CI passed on macOS, Ubuntu, Ubuntu arm64 and Windows plus CodeQL, the Arch package check and the production dependency audit on the first run, with nothing to fix, before the fast-forward. Before the cut, on the release commit:
npm run typecheck7 of 7 andnpm run test:rungreen with the turbo cache cold (shared-domain 1683, app-core 2542 passed and 1 skipped, desktop 886 passed and 4 skipped),apps/desktopbuild:prod,npm run pack, the packaged launch check on the local pack (page target in 1.5 s, version 2.53.0, bundledzn0.4.0), and thetest:vim-editor,test:sidebar-vimandtest:editor-improvementssmoke suites against the built app. - The release workflow's tag-push path, hardened in
62da9ee4after the 2.52.0 upload outage, ran for real for the first time and behaved as designed: each platform's installers went up one at a time and its update manifest last (Windows: exe 02:19:42, blockmap 02:19:49,latest.yml02:19:59 UTC; macOS: both DMGs and zips 02:24:48 to 02:25:45,latest-mac.yml02:25:46). No asset was uploaded twice and nothing had to be rebuilt. - Verified by hand after the uploads: all 13 installers named by the four manifests (
latest-mac.yml,latest.yml,latest-linux.yml,latest-linux-arm64.yml) were downloaded in full and match the SHA-512 in their manifest; both macOS apps are accepted by Gatekeeper asNotarized Developer ID(Lumary Labs LLC) with a valid stapled ticket and read version 2.53.0; the released arm64 app, copied out of its DMG, launches with both stores isolated (CDP page target in 2.4 s, Welcome screen rendered), so the artifact users download was launch-checked, not only the local pack. - One expected red mark:
nix-build.ymlrun 35414458246 on the bot branchnix/update-v2.53.0shows "failure" with zero jobs, because the branch was deleted when PR #819 was closed while that run was queued. The same happened on 2.52.0 and 2.51.1. The run that counts, onmain, passed.
Local-first and keyboard-first, as always.