github ZenNotes/zennotes v2.54.0
ZenNotes v2.54.0

latest release: v2.54.1
3 hours ago

ZenNotes 2.54.0: on a touch screen, tapping a wikilink in Reading mode no longer leaves the footer showing the link's target on the note that opened; the status bar's left slot behaves the way it does with a mouse, target while over the link, gone once the link is followed or the note changes; the words you teach Harper with zg (and the suggestions you hide with zG) survive a restart and a change of dialect, instead of coming back underlined and being dropped from vault.json by the next zg; and leaving Reading mode after scrolling through a long note opens the editor on the section you were reading instead of the line the cursor had when you left, with a double-click on any rendered paragraph as the direct route to editing it; and a note's YAML frontmatter no longer shows up in heading type with Live Preview off: the editor's grammar now keeps the block out of the markdown parse in every editor, so key: value lines above the closing --- read as the plain text they are instead of a setext heading; and in Vim mode, Ctrl+D and Ctrl+U move by half a page in visual mode too, growing or shrinking the selection with the view scrolling along, in the main editor and in the floating note, Quick Note and external-file windows, which never had the keys mapped; and zn mcp honours --vault, --server and --token, so an AI agent can be pointed at a vault other than the one the desktop app has open (or at a server) from its MCP client config alone, and a --vault that names nothing is reported on stderr at startup while the server still starts; and a custom Vim insert escape such as jk no longer rewrites the note on the way out of insert mode, so {{modified_time}} and its siblings stay put unless the text really changed (the same for a character typed and deleted again, and for an undo back to the saved text); and note search creates the note you typed: a Create row after the results, Shift+Enter (or Enter on the row) opens a small New note form inside the palette with the name filled in, a Folder field that lists every folder of the vault, and a Tags field that takes chips; Enter makes the note where you said (the Inbox by default), writes the tags under the heading, and opens it in the editor, while a name a note already carries in that folder blocks Create with the reason and Shift+Enter opens that note instead

Released on September 22, 2026 as v2.54.0, at 173c61f5. The release branch v2.54.0 was cut from main at cfff7df1 (the 2.53.0 release, after its channel commits) and fast-forwarded into main through PR #833. One feature and seven fixes. #820 was filed by the maintainer from the Android app; #829 and #828 came from @uNyanda, #822 from @saran-ncsu, #827, #825 and the feature request #826 all from @vinivinoo, and #831 from @diazkev314. Every issue is closed with a commit-linked comment. The commits, in order: 14c2b76d Fix(status-bar) #820, f0d2fd5b Fix(harper) #829, 60cc413a Fix(preview) #822, f54792af Fix(editor) #827, 450df3ee Fix(vim) #825, 5afec707 Fix(cli) #831, 4a168fbf Fix(editor) #828, 704106d1 Feat(search) #826 and its follow-up 38f9f15a (the Create row opens a New note form with name, folder and tags, after the maintainer tried the one-shot version), then the version bump d25ee730. The release commit itself, 173c61f5, is test-only: the release PR's macOS run failed one new #828 unit test because an older test in the same file left the store's 350 ms autosave timer running past its end, and the stray write landed in the #828 test on the slow runner; the older test now settles its save before it returns, and CI was green on every platform before the fast-forward. No app code changed after the bump. Channel and validation details are in the sections at the end.

✨ Features

  • Note search creates the note you typed, through a New note form (#826). Requested by @vinivinoo: in note search (Space f in Vim mode, Mod+P), a name that matched no note ended in "No matches." and a trip through New Note to type the same name again; Obsidian's quick switcher offers to create the note right there with Shift+Enter, and the request was the same move in ZenNotes. The palette now reads the free text of the query as the name of a note to create. For any such text a Create "Meeting notes"… row follows the results, with the destination named on its right (Inbox, or Inbox › projects for a typed path); the arrow keys reach it, and Enter on it, a click, or Shift+Enter from anywhere in the palette opens a small New note form inside the same palette window. The query stays put, so Escape returns to the results with the search box focused. The form has three fields. Name is prefilled from the query and selected, so retyping replaces it and Enter creates right away: the fast path is Shift+Enter, Enter, one keystroke more than the first cut in exchange for seeing where the note goes before anything is written. Folder speaks the :e dialect (empty is the Inbox root, projects/x nests under Inbox, archive/x and quick/x pick that area) and, once focused, lists every folder of the vault below the fields, roots first (Inbox, Quick Notes, Archive, then their subfolders; never the Trash, never a database folder). Typing narrows the list and preselects the first match, Enter takes it and moves on to Tags; ArrowUp above the first row keeps the typed text, which is how a folder that does not exist yet gets created. Tags takes chips: any #tag in the query arrives as a chip, the vault's tags are listed most used first with a "new tag" row last, typing narrows them, Enter picks, Space or comma commits the typed text, Backspace on an empty field removes the last chip, and text that cannot be a tag blocks Create with the reason. Before writing, the form checks the name against the live notes. A same-titled note in the chosen folder blocks Create and says so in red, "Weekly review" already exists in Inbox. Change the name, or open it., and Shift+Enter (or the Open it button) opens that note instead; a same-titled note in another folder only warns, since the folder was chosen on purpose. The check is by title, ignoring case, never counting a trashed note or a partial match. The status line under the fields always names the outcome in words, Creates "Q4 plan" in Inbox › projects with #planning #q4, and Ctrl+Enter or Cmd+Enter creates from any field. The new note opens in the editor with the focus there (Normal mode with Vim on), the same landing as :e name. Tags are written as one #a #b line under the heading, the shape zn create --tag and zn capture --tag write, not as frontmatter. The footer reads Shift+↵ new note; the in-app manual has the entry under Search, in the quick-start card and in the Space f leader row. Deliberately not done: no frontmatter; no folder creation UI beyond typing the path; no create row in the other palettes; the default destination stays the Inbox root rather than the "New notes go to" setting, matching :e and the dead-wikilink flow. How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-826-search-create.mjs form (scratch vault with inbox/Alpha.md tagged #ops #prod, inbox/Beta.md tagged #ops and inbox/projects/Roadmap.md, both stores isolated, Vim mode on through config.toml; 42 checks, see Verification; exits 0 on pass and writes /tmp/zn-826-form.json plus /tmp/zn-826-form-*.png). By hand: Mod+P, type Meeting notes, Shift+Enter. Before: "No matches." and Enter does nothing. After: the Create row with its destination (tools/826-form-1-create-row.png), then the form with the name selected (tools/826-form-2-form.png); Enter opens inbox/Meeting notes.md in the editor (tools/826-form-3-after-create.png). Mod+P, Q4 plan, Shift+Enter, Tab: every folder listed (tools/826-form-4-folder-picker.png); type proj, Enter, then in Tags pr, Enter and k8s, comma (tools/826-form-5-tags.png); Enter opens the note with the tag line under the heading (tools/826-form-6-tagged-note.png). Type the name of a note that lives in another folder: the yellow warning with Create still enabled (tools/826-form-7-warning.png); the name of a note in the same folder: the red block with Create disabled and Open it Shift+↵ (tools/826-form-8-blocked.png).

🐛 Fixes

  • A tapped link target leaves the footer once the link is followed (#820). Reported from the Android app (1.1.24 on core 2.53.0): in Reading mode, tap [[Alpha plan#Milestones]] and the target opens at the heading, but the status bar's left slot on the new note reads Alpha plan#Milestones and keeps reading it until the next tap on something that is not a link. With a mouse the same slot behaves like a browser: the target shows while the pointer rests on a link and goes away when it leaves. A tap reaches the page as touchstart, touchend, mouseover, mousemove, mousedown, mouseup, click, with no mouseleave ever; the mousemove put the target in the shared hovered-link store and the only clears were the article's mouseleave and the preview root's unmount, and since android#74 the preview stays mounted when a heading link is followed in Reading mode, so neither fired. Two clears now, the two the report asked for: following a link clears the slot (the preview's click handler for every anchor, and the editor on a rendered-wikilink mousedown and in both followLinkTarget branches, so Edit mode gets the same), and the status bar drops the target whenever the active note's path changes, keyed on the path rather than the note object so typing does not blank a live hover. A mouse still over a link puts its target back on the next move, so desktop hover reads as before. Deliberately not done: gating the slot behind pointer: coarse; the brief flash of the target during a tap stays, and the wikilink hover card is a separate surface the Android shell already hides with its own pointer: coarse rule (android#74). How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-820-status-bar-hovered-link.mjs tap (scratch vault with inbox/Beta.md linking [[Alpha plan#Milestones]], isolated stores, default view Reading, replays the tap sequence, reads the slot from [data-hovered-link]; hover checks the mouse path and edit the editor path; each exits 0 on pass and writes /tmp/zn-820-<mode>.png). By hand: Settings → Editor → default mode Reading, open a note with [[Other#Heading]], tap or click the link. Before: Other opens and the footer's left slot reads Other#Heading (tools/820-before.png). After: Other opens and the slot is empty (tools/820-after.png); hovering a link with the mouse still shows its target and leaving still clears it.

  • Harper's dictionary survives a restart and a dialect change (#829). Reported by @uNyanda on Linux (Niri, Wayland, 2.53.0): words added to the custom dictionary with zg were underlined again after updating and restarting ZenNotes, and had to be added a second time. The update never touched them on disk: vault.json keeps harper.words and harper.ignoredLints through every normalizer. The loss happened at runtime, in the one live Harper session, and the next zg or zG then wrote that session's export over the vault's list, which is when the words left vault.json for good. Two ways a session ended up without the vault's words. First, a dialect change: harper.js 2.7.0 answers setDialect by freeing the Linter and building a new one, and the dictionary and the ignore list live inside the Linter; configure() set the dialect and never imported them back, so switching Settings → Editor → Harper dialect, or harper_dialect under [editor] in config.toml, emptied the session on the spot. Reproduced live: after the switch every vault word was underlined, and a zg on a third word left vault.json with only that word. Second, the boot race: an enabled setting warms the session 1.5 s after the window mounts, at idle, and when the vault is still loading at that point (the store sets vault and vaultSettings together at the end of init()) the session was built from empty settings; the old reconcile then recorded the words that landed during the 15 MB compile as already seen, without importing them. That is the shape of the report (a restart, the words underlined, vault.json intact until the next zg), and the first launch after an update is a slow one; which of the two the reporter hit cannot be told from the report, and both are closed. Three changes. configure() compares the linter's dialect with the requested one and, only on a change, exports the dictionary and the ignore list, switches, and imports them into the new Linter. The runtime records what a session is built from the moment the build starts, before the compile is awaited, so anything the store loads meanwhile reads as a change to import (the first-pass "only record what it already holds" special case is gone). persist() writes the union of what vault.json holds and what the session exports, never the shorter list, and when the vault knew more it teaches the session the difference so the re-lint after a zg clears every word the vault has. The union is safe because nothing in the app removes a dictionary word or un-ignores a suggestion; a future "remove word" feature has to revisit mergeHarperVaultState. Deliberately not done: no recovery of words already lost (they are gone from vault.json; add them once more and they now stay), and no change to where or how the words are stored. How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-829-harper-words.mjs (scratch vault whose vault.json knows Zennotez and Flurbish, a note using both plus an unknown Glorpish, Harper on through config.toml, both stores isolated; it switches the dialect to british by editing config.toml the way a user would, types a teh to prove the lint pass it reads is a fresh one, undoes it, then does zg on Glorpish; exits 0 on pass and writes /tmp/zn-829.png). By hand: turn on Settings → Editor → Grammar and spelling with Harper, zg a made-up word in a note and confirm .zennotes/vault.json lists it under harper.words, then change Settings → Editor → Harper dialect (or edit harper_dialect in ~/.config/zennotes/config.toml while the app runs). Before: the word is underlined again within a few seconds, and a zg on any other word rewrites vault.json with only that word (tools/829-before.png: the two vault words underlined after the zg). After: the underline stays gone, and vault.json keeps every word it had and gains the new one (tools/829-after.png).

  • Leaving Reading mode opens the editor where you were reading (#822). Reported by @saran-ncsu: since #543, switching into Preview opens the reading view at the line you were editing, but the way back never followed. Read on through a long note, scroll to a later section, press Cmd+4, and the editor came back at the line the cursor had when you left (the top of the note, for a note you had just opened), so you scrolled the whole way down again to fix the sentence you had just read. The reading view was never consulted: Preview hides the editor with its caret in place and the mode switch only showed it again. Now every route out of Preview (Cmd+4 and Cmd+5, the Edit / Split toggle in the pane header, the command palette, :view edit|split, :editmode, :splitmode) reads what the reading view has on screen off the rendered blocks' source-line stamps. If the cursor's line is inside that range, nothing moves: the "peek at the render, come back to the cursor" contract from #543 holds. Otherwise the editor opens with the first block still in view at its top (24 px in, the outline-jump distance) and the cursor on that line, a frame after the switch so CodeMirror has measured the freshly shown scroller. Into Split the reading view reflows to half its width and that block moves, so the landing holds the split scroll sync and scrolls the reading view back to the same block beside the editor's copy. Also from the report: double-click a paragraph, heading, list item, quote or table in Preview and the note opens in Edit with the cursor on that block's source line, kept at the height the block had on screen (clamped so it neither hugs the top edge nor drops off the bottom); in Split, the double-click brings the editor to the block and the reading view stays where it is. Links, buttons and form controls, image, PDF and Excalidraw embeds, diagrams (whose double-click resets pan and zoom) and transcluded ![[notes]] (their stamps count the expanded markdown's lines, not this file's) keep their own double-click. The image embed's "Edit this block" button, which switched to Edit and left the cursor wherever it was, now opens on the image's line too. Explicit jumps that leave Preview with a target of their own (jump to a comment, an "open at this line" request) keep their target. One more thing had to change for the landing to hold: with the code above in place the Split route still landed on line 1 about two runs in three. Instrumenting the built app showed why. CodeMirror (6.43.8) mirrors a new selection into the DOM only while the editor has focus; dispatched into the editor that Preview had just hidden, unfocused, the state moved but the DOM caret stayed parked where the last click left it, and the DOM observer's next flush, some 3 ms later, read that stale caret back as a user selection and snapped the cursor to the old line, a frame before the deferred focus of focusEditorNormalMode arrived. The runs that passed were the ones where focus won the race. The landing now focuses the editor before it dispatches. Deliberately not done: Cmd/Ctrl-click to edit (the reading view already gives that chord a meaning, creating a linked note at once, and a modifier click on plain prose is not discoverable; the double-click is what the VS Code markdown preview does), single-click to edit (it would take selecting and copying text away from the reading view), any change to how Preview lands when you enter it (#543 as shipped), and a landing for renders without source-line stamps (nothing on screen with a line means the cursor stays put, as before). How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-822-preview-to-edit.mjs scrolled (scratch vault with a 20-section note, both stores isolated: cursor on line 1, Cmd+6, scroll the reading view to Section 20, Cmd+4; checks the editor's top line and the cursor line; peek, dblclick, split and split-dblclick cover the other paths; each exits 0 on pass and writes /tmp/zn-822-<scenario>.png). By hand: open a note longer than the window with the cursor at the top, Cmd+6, scroll down a few screens, Cmd+4. Before: the editor comes back at the top of the note, cursor on line 1 (tools/822-before.png). After: the section you were reading is at the top of the editor with the cursor on its first line (tools/822-after.png); Cmd+5 instead opens Split with both halves on that section (tools/822-split.png); double-clicking a paragraph in Preview opens Edit with the cursor on it (tools/822-dblclick.png). Cmd+6 and Cmd+4 without scrolling in between leave the cursor where it was.

  • The YAML header no longer renders in heading type with Live Preview off (#827). Reported by @vinivinoo with three screenshots: a note that opens with ---, type: topic, parent: [[Informatik]], created: 2026-09-20, --- shows the three property lines big and bold in Edit mode with Live Preview off; adding a tags: line leaves them big; adding - todo under tags: makes the whole block normal again. CommonMark is the cause. The editor parsed the whole note as markdown, and markdown knows nothing about frontmatter: a paragraph of key: value lines followed by a line of --- is a setext H2, so the block wore heading typography end to end, and the two fences read as horizontal rules. The - todo line looked like a cure because a list item interrupts the paragraph, so the closing --- became a plain rule and the heading went away, which is exactly what the third screenshot shows. The properties card (#616, Live Preview on only) carried CSS that suppressed heading typography inside the card, so the bug hid there and showed with Live Preview off, where the card does not load and the raw text was unprotected. The fix is at the parser, not in CSS: every ZenNotes editor now parses with a small note grammar (cm-markdown-language.ts) that carves the frontmatter block out of the document, the exact range the card computes through one shared fence predicate, and hands the rest to markdown, so no heading, list or rule token can come out of the frontmatter in any mode. On screen: with Live Preview off the property lines are body text and the two fences read as plain --- in body color (no longer accent-colored rules); with Live Preview on the card looks as before and [[Informatik]] still renders as a wikilink chip. Two ergonomics markdown used to provide by accident are kept on purpose: Enter at the end of - todo inside the frontmatter still inserts the next - at the same indentation, and Enter on an empty - clears it (a new command, insertNewlineContinueFrontmatterList, registered ahead of markdown's Enter and deferred to Vim in normal mode like the rest of the keymap). Deliberately not done: yamlFrontmatter from @codemirror/lang-yaml (an unclosed --- turns the entire note into YAML, and it disagrees with the card about a fence with stray whitespace), a markdown block-parser extension (Lezer reuses the old rule node when the closing fence is typed lines below an existing ---, so the stale heading stays until the note is reopened), YAML syntax coloring in the raw view (with Live Preview off a note reads as its raw text, #616), the template editor (it keeps its own yamlFrontmatter setup, where the block is the point), and inline markdown inside the card (**bold** in a frontmatter value used to render bold because the value was parsed as markdown; YAML values are strings, and now they read as such). How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-827-frontmatter-heading.mjs off and node docs/releases/v2.54.0/tools/check-827-frontmatter-heading.mjs on (scratch vault with the reporter's frontmatter over a body that has a # InfSec H1, a real setext H2 and a standalone ---, both stores isolated; off checks the three property lines carry no heading token and sit at body height while the body's H1 and H2 keep theirs, on checks the five-line card, exactly one rule widget and the wikilink chip; each exits 0 on pass and writes /tmp/zn-827-<mode>-<label>.png). By hand: Settings → Editor → Live Preview off, open a note that starts with the reporter's frontmatter. Before: the three property lines in H2 size and weight with an accent rule under them (tools/827-before-off.png). After: the lines at body size, the fences plain, # InfSec and the body's setext heading unchanged (tools/827-after-off.png); with Live Preview on the properties card reads as before (tools/827-after-on.png).

  • Ctrl+D and Ctrl+U move by half a page in visual mode too (#825). Reported by @vinivinoo: in visual mode the Vim motions Ctrl+D and Ctrl+U could not be used. Reproduced in the built app on a 200-line note. Where Mod is Ctrl (Linux, Windows), v then Ctrl+D added a new cursor at the start of the next line on every press instead of growing the selection, and Ctrl+U undid the selection; nothing scrolled either way. On macOS the keys fell to codemirror-vim's stock moveByScroll, which happens to move on a plain note but is the motion ZenNotes replaced in normal mode back in 2.0 because its pixel math can snap the cursor back to line 1 under live-preview decorations and folded headings. Two things were wrong. The half-page keys were mapped as Vim actions in normal mode only, and an action cannot extend a visual selection at all: it moves the CodeMirror selection but leaves Vim's own vim.sel alone. With the visual context unmapped the key never reached Vim there, and CodeMirror's keymap handler runs before the Vim plugin sees a key (the keymap facet registers its handler where the first keymap.of sits in the extension list, and the snippet keymap precedes vim()), so the search keymap's Mod-d (select next occurrence) and the history keymap's Mod-u (undo selection) took the chords; vimHalfPageKeymap, which exists to hand these chords to Vim ahead of those keymaps, bailed out in visual mode on purpose, since the action would not have done anything useful there. The half-page move is now a real Vim motion, zenMoveByHalfPage, mapped in normal and visual mode. Vim moves the head itself, so v plus Ctrl+D grows the selection exactly as far as Ctrl+D moves the cursor in normal mode, V grows it by whole lines, Ctrl+U shrinks it back, and Escape leaves the cursor where the selection ended. The cursor walks display lines and stops at the first or last line instead of wrapping; the viewport scrolls by a fixed half of the visible editor, clamped to the note, which is what the old action did in normal mode. The goal column survives consecutive presses through a short line. A count is Vim's: 5 Ctrl+D moves five lines and scrolls five line heights; a bare press still reads as half a page. Your configured half-page bindings (Settings → Keymap → Half-page down / Half-page up) apply in both modes in the main editor, and insert-mode Ctrl+D keeps Vim's own meaning (unindent). The floating note, Quick Note and external-file windows build their own editor and never had the half-page keys mapped at all, so they had the stock motion on macOS and, where Mod is Ctrl, lost the chords to the search and history keymaps in normal mode as well: Ctrl+D selected the word under the cursor, visual Ctrl+D stacked cursors. Each now maps the default Ctrl+D / Ctrl+U to the motion in both modes (those windows consult no keymap overrides for any other binding either, so a remapped half-page key still applies to the main editor only). Deliberately not done: operator-pending d Ctrl+D stays on Vim's default motion, like j / k; insert-mode Ctrl+D is untouched; and the in-app manual already says Vim keeps Ctrl+D / Ctrl+U for half-page scrolling, which is now true in every mode, so its wording stands. How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-825-visual-half-page.mjs mac after and node docs/releases/v2.54.0/tools/check-825-visual-half-page.mjs linux after (scratch vault with a 200-line note, both stores isolated, Vim mode on through config.toml; the linux run overrides navigator.platform so Mod reads as Ctrl; 13 checks each: normal Ctrl+D lands on line 11 with the scroll to match, v plus Ctrl+D selects lines 1 to 11 as one range with the same scroll, a second press reaches 21, Ctrl+U back to 11, V selects whole lines, a selection started mid-window stays on screen, insert-mode Ctrl+D does not page; exits 0 on pass and writes /tmp/zn-825-*.png), and node docs/releases/v2.54.0/tools/check-825-floating.mjs linux after plus ... linux after quick for the floating note and Quick Note windows (mac as well; asserts on CodeMirror's logical selection, see the contributors note). By hand: Vim mode on, open a note longer than the window, gg, then v and press Ctrl+D twice, then Ctrl+U. Before: on Linux and Windows each Ctrl+D adds another cursor and nothing scrolls, Ctrl+U undoes the selection (tools/825-before.png: the main editor with three stacked cursors; tools/825-floating-before.png: the same in a floating window). After: the selection grows from line 1 to line 11, then 21, with the view scrolling to follow, and Ctrl+U shrinks it back to line 11 (tools/825-after.png, tools/825-after-scrolled.png); the same in a floating note window, right-click a note or its tab, Open in Floating Window (tools/825-floating-after.png: one selection from line 13 to 25 after a second press, the view scrolled) and in the Quick Note window (tools/825-quicknote-after.png: lines 8 to 15, the smaller window paging by its own half height).

  • zn mcp honours --vault, --server and --token (#831). Reported by @diazkev314 on Omarchy Linux with the bundled JavaScript zn: with vault A open in the desktop app, zn mcp --vault <vault-B> served vault A. vault_info named the app's workspace, and even --vault /tmp still answered with the real vault. The same path left --server and --token unused, although both are listed as global flags and the MCP authentication error itself suggests --token. The reporter had read the bundle correctly: the CLI dispatcher parsed the flags and then called cmdMcp() without them, and runMcpServer() had exactly one way to find a vault, resolveDefaultTarget(), which is the environment first (ZENNOTES_SERVER, ZENNOTES_VAULT) and then the workspace the desktop app has open (#688). That is the right answer for the legacy out/main/mcp.js entry, which takes no flags at all, and it was the only answer zn mcp could give. The environment variables worked because they live inside that default path, which is also how they could hide the bug when set next to the flags. Now zn mcp resolves its target through the same resolveTarget() every other zn command uses, so --vault <name or path>, --server <name or host> and --token <token> mean in zn mcp what they mean in zn list: zn mcp --vault work in an MCP client config points the agent at the work vault while the app stays on another, zn mcp --server home --token <token> points it at a server. Without flags the server follows the environment and then the app, as before, and the legacy entry is unchanged. The vault resolves once at startup and stays pinned for the session; a failure is retried on every tool call rather than repeated. A --vault that names nothing does not kill the server: the CLI's usual "resolve, then fail the command" would leave an MCP client with a dead process and no message, so zn mcp prints the error on stderr at startup ([zennotes-mcp] No vault named "/nope". Known vaults: work, personal. You can also pass a directory path. The MCP server is running anyway; every tool call returns this error until a vault resolves.), keeps serving, returns the same error text from every tool call so the assistant can relay it, and tries again on the next call, so a fixed config or a vault registered in the app is picked up without a restart. stdout is the protocol channel and stays clean; a healthy start prints nothing. The in-app manual's "MCP for AI agents" card now tells people to put zn mcp --vault work or zn mcp --server home --token <token> in the client config, next to the environment variables it already named. Deliberately not done: the legacy stdio entry gains no flags (its clients launch it with none); the Go zn was not touched, it already passed its flags into its MCP server (it prints no startup warning for a bad vault, a possible follow-up in that repo). How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-831-mcp-flags.mjs after (spawns the built cli.js as zn mcp over stdio with ZENNOTES_CONFIG_DIR and ZENNOTES_USER_DATA_PATH pointed at a scratch directory holding two vaults, alpha active and beta known, and the vault environment variables unset; speaks JSON-RPC to it, initialize then vault_info and list_notes; a fake HTTP server stands in for --server; 11 checks, exits 0 on pass and writes /tmp/zn-831-after.json). By hand: open vault A in the app, then in your MCP client config launch zn mcp --vault <name or path of vault B> and ask the assistant for vault_info. Before: it names vault A, and --server and --token are ignored (tools/831-before.json: 5 of 7 checks failed, every flag scenario answered alpha and the fake server was never contacted). After: it names vault B and list_notes returns B's notes; --vault /nope shows the [zennotes-mcp] No vault named ... line in the client's server log while the server stays up and vault_info returns that error; --server <host> --token <token> reports kind: remote and every request carries Authorization: Bearer <token> (tools/831-after.txt, tools/831-after.json: 11 of 11).

  • A custom Vim escape no longer rewrites the note (#828). Reported by @uNyanda (2.53.0, Linux/Niri/Wayland): with Vim mode on and the insert escape set to jk (Settings → Editor → Vim, "Exit insert mode with"), leaving insert mode with jk without changing any text updated {{modified_time}}, {{modified_date}} and {{modified_datetime}}, while a plain Escape did not. Reproduced in the built app on a note carrying Updated: {{modified_time:HH:mm:ss}}: jk moved the file's mtime and the rendered stamp, and so did typing a character and deleting it again before Escape. The cause is how codemirror-vim (6.3.0) handles a two-key insert mapping: on the j it cannot know whether a k follows, so the j is really typed into the document, then removed again when the k arrives inside the 200 ms window. Two edits, same text at the end. The editor's update listener reported both to the store, which marked the note dirty on the insert and never noticed the removal undid it, so the 350 ms debounced save wrote the identical bytes back to disk; the mtime moved with the write, and the tokens render from the note's updatedAt, which follows mtime. Nothing in that chain is vim-specific: type-then-backspace and undo-back-to-the-saved-text took the same path. The store now remembers what is on disk for every note with unsaved changes (the body the buffer had the moment it first drifted from disk, moved forward by each completed save) and treats an edit that brings the buffer back to those bytes as no change: the note reads clean, the pending save is cancelled, nothing is written, and the "Unsaved changes" dot goes out (what VS Code does). Real typing that ends in jk still saves once. Two choices in the comparison: it is skipped while a save is in flight, since the bytes on disk are changing at that moment, so a revert then just saves normally and an edit is never lost to the shortcut; and a buffer back on its saved bytes is clean even if another program changed the file in the meantime, so the app keeps showing its own text and no longer clobbers the outside change with a pending save (before, it did). Deliberately not done: skipping editor updates while a Vim key buffer is pending (a lone j followed by the 200 ms timeout would lose the character if the app quit in between), comparing bytes in the main process before writing (an extra read on every save, desktop only, while the web client shares this store), and any change to the watcher or to the in-app manual, whose wording (the tokens show when the file was last saved) is now true. How to test: cd apps/desktop && npx electron-vite build, then from the repo root node docs/releases/v2.54.0/tools/check-828-escape-mtime.mjs after (scratch vault with inbox/Stamp.md carrying the stamp, both stores isolated, config.toml with [vim] enabled = true and insert_escape = "jk" and the default view Edit; 12 checks: file mtime through fs.stat, the .cm-live-token text and the [aria-label="Unsaved changes"] dot from the DOM, keys through Input.dispatchKeyEvent; exits 0 on pass and writes /tmp/zn-828-after.json plus /tmp/zn-828-after-{start,after-jk,after-edit}.png). By hand: Vim mode on with jk as the escape, a note with Updated: {{modified_time:HH:mm:ss}}, press i then jk, wait a second. Before: the stamp changes and the file's modified time moves (tools/828-before-after-jk.png: 09:19:37 became 09:19:40). After: the stamp and the mtime stay where they were (tools/828-after-after-jk.png); type x and leave it, and the stamp moves as it should (tools/828-after-real-edit.png).

🧰 For contributors

  • #820: the slot is fed by packages/app-core/src/lib/hovered-link.ts (setHoveredLink / useHoveredLinkStore), written by the mousemove handlers in Preview.tsx and EditorPane.tsx. StatusBar.tsx now owns the "belongs to the note it was hovered in" rule with useEffect(() => setHoveredLink(null), [note?.path]); there is one StatusBar, rendered by Editor.tsx with the store's activeNote, so the effect runs once per note switch. The slot carries data-hovered-link for tests and drivers (the old selector, span.font-mono.truncate.flex-1, still matches). Link follows that clear it: Preview.tsx root onClick after closest("a") (every earlier return there is a non-link control: task marker, checkbox, code copy and fold, diagram expand), cm-wikilink-render.ts wikilinkClick mousedown before openWikilink, and both followLinkTarget success branches in EditorPane.tsx. Tests: StatusBar.test.ts "hovered link slot" (a same-path re-render keeps the target, a path change drops it) and the new jsdom Preview-hovered-link.test.ts (a rendered wikilink through mousemove then click: the store is set, then cleared, and the mocked openWikilinkTarget receives Alpha plan.md and Alpha plan#Milestones).

  • #829: three files. packages/shared-domain/src/harper-settings.ts gains mergeHarperVaultState(base, extra): the union of two states, base order first, extra's additions appended, ignore hashes kept digit-only. packages/app-core/src/lib/harper-lint.ts lifts exportState / importState to module functions over a Linter (the session methods delegate to them), and configure() only touches the dialect when linter.getDialect() differs from the target, exporting before setDialect and importing after; setLintConfig still runs every time. packages/app-core/src/lib/harper-runtime.ts: session() is the one that creates the shared session when harperLoaded() is false, and in that case it sets applied and seenVaultState synchronously before awaiting loadHarper, resetting both if the load fails; reconcile() waits in a loop on reconciling (a failed pass belongs to its own caller, so a waiter swallows the rejection and re-reads the store) and imports on any vault change, no first-pass exception; persist() merges the store's state with the session's export before saveHarperVaultState, and re-imports the union into the session when it was larger than the export. Nothing changed in the store, the bridge, vault.json's shape, or the editor extension. Tests: harper-settings.test.ts "mergeHarperVaultState (#829)" (order kept, never shrinks to the side that holds less); harper-lint.test.ts "keeps the dictionary and the ignored suggestions across a dialect change (#829)" on a real LocalLinter under Node; and the new harper-runtime.test.ts (node environment, hoisted fakes for ../store and ./harper-lint with a deferred loadHarper the test releases by hand): the vault landing while the session compiles is imported and a zg saves all three words; a session that forgot its words never writes a shorter list and learns the difference; an outside change is imported once and the echo of an own write never; a failed load leaves a clean slate for the next session(). The dialect case and the first two runtime cases were red on the old code.

  • #822: the landing is owned by EditorPane.tsx. applyPaneMode(nextMode, { landing? }) is the one place that sets a pane's mode (the header ToggleGroup, the zen:set-pane-mode route used by Mod+4/5/6, the palette and the ex commands all end there); leaving 'preview' it calls readingPositionLanding, which reads the reading view's visible source range with previewVisibleSourceLines (first stamped block still in view, first block below the fold) and returns null when the caret's line is inside it (previewShowsSourceLine), else { line: visible.top, topMargin: OUTLINE_JUMP_TOP_MARGIN }. The target is parked in pendingEditorLandingRef (path-keyed) and, for Split, lockOutlinePreviewSync() is taken at the same moment, before the first sync pass can run. A useEffect on [content?.path, editorReady, mode, ...] commits it one rAF after the editor is shown, through landEditorOnLine(view, line, topMargin), and in Split also re-runs lockOutlinePreviewSync() and scrollPreviewToSourceLine(line, topMargin) (the helper gained the optional topMargin). EditorLanding is 'reading-position' | 'caller' | { line, topMargin }: jumpToComment and the pending-jump-location route pass 'caller', so an explicit target is never overridden. landEditorOnLine focuses the view before dispatching; the comment above it states the CodeMirror constraint (the DOM selection is written only while focused; an unfocused dispatch leaves the stale DOM caret for the observer to read back as a userEvent: "select" transaction). The pointer side: Preview.tsx adds a root dblclick listener next to click, which resolves the target through previewEditRequestForTarget (closest [data-source-line], after an inert check against PREVIEW_EDIT_INERT_SELECTOR: a, button, input, textarea, select, summary, label, iframe, video, audio, canvas, [data-local-asset-kind], [data-zen-diagram-kind], [data-excalidraw-embed], .note-embed) and calls onRequestEdit(request); onRequestEdit is now (request?: PreviewEditRequest | null) => void in Preview.tsx, LazyPreview.tsx and local-assets.ts. handlePreviewRequestEdit in EditorPane.tsx turns the request into a landing (editorLandingTopMargin keeps the block's on-screen offset, clamped to [minMargin, viewportHeight - 2 * minMargin]; no block → 'reading-position') and either calls applyPaneMode('edit', { landing }) from Preview or, in Split, lockOutlinePreviewSync() + landEditorOnLine directly. buildImageEmbed moves the replaced paragraph's data-source-line onto the <figure> (the split scroll sync and the reading position read it there) and the "Edit this block" button reports { sourceLine, blockClientTop }. Pure helpers live in lib/preview-outline-jump.ts (previewVisibleSourceLines, previewShowsSourceLine, PreviewEditRequest, previewEditRequestForTarget, editorLandingTopMargin). Tests: preview-outline-jump.test.ts gains "previewVisibleSourceLines (what the reader has on screen, #822)" (partly visible top block counts, a block whose bottom edge merely touches the top does not, open end at the end of the note, unstamped and malformed blocks skipped, half-open range), "previewEditRequestForTarget (double-click in the reading view, #822)" (inline content resolves to its top-level block with its client top; links, controls, embeds, diagrams and transcluded notes are inert; off-block clicks and unreadable stamps give null) and "editorLandingTopMargin" (height kept, both clamps, the fallback margin); the new local-assets-image-edit-block.test.ts (jsdom) checks the stamp moves onto the figure and reaches the button's request, and that an unstamped paragraph reports no line. The CDP harness tools/check-822-preview-to-edit.mjs is the behavioural test; with ZN822_TRACE=1 it prints pane samples every 40 ms plus DOM selectionchange / focusin events, which is how the race was found.

  • #827: the grammar lives in packages/app-core/src/lib/cm-markdown-language.ts. NoteParser is a Lezer Parser whose NoteParse re-scans the frontmatter from the raw Input on every parse (scanFrontmatter(input, from, to), chunked 4096-char reads; O(1) unless line 1 is a fence, one pass to the closing fence otherwise) and builds Document > Frontmatter(FrontmatterMark, FrontmatterMark) Body; parseMixed mounts the markdown parser (with resolveCodeLanguage for code fences, as before) onto Body, so the body tree is the same markdown tree every existing extension walks. Document carries languageDataProp and indentNodeProp () => null (without it the default Enter drops the indentation of a nested frontmatter line). FrontmatterMark is styled t.meta; the content between the fences has no tokens. noteLanguage / noteMarkdown() replace markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage, addKeymap: false }) in EditorPane.tsx, ExternalFileApp.tsx, FloatingNoteApp.tsx, QuickCaptureApp.tsx and PinnedReferencePane.tsx; TemplateEditorModal.tsx keeps yamlFrontmatter. Incrementality: the mount hands the previous markdown tree to the inner parser as fragments, so body edits reuse untouched blocks (measured 35 of 40 blocks reused for a body edit, 39 of 40 for an edit inside the frontmatter); the two moments the body start moves (closing fence typed for the first time, or broken) cost one full body parse, because @lezer/common's StructureCursor.moveTo drops IgnoreMounts and parseMixed cannot find the old mount across the move (same on upstream main). Fence predicate: isFrontmatterFence(line) (trim() === '---') exported from packages/shared-domain/src/markdown-lines.ts, used by frontmatterEndIndex, frontmatterRange (cm-frontmatter.ts) and the grammar, so the card and the tree cannot disagree about where the block ends. Keymap: insertNewlineContinueFrontmatterList (cm-frontmatter.ts) matches ^(\s*)-(?: +(.*))?$ on a line strictly between the fences with a single empty cursor at or after the marker, returns false for everything else (fence lines, readOnly, multi-cursor, selection, cursor before the marker); cm-vim-default-keymap.ts puts it first in deferKeysToVim([frontmatterListEnter, fencedCodeIndentEnter, ...markdownKeymap], ...). Cleanup: cm-wysiwyg-blocks.ts drops frontmatterEndLine and the HR skip (the fences are no longer HorizontalRule nodes); index.css drops the setext suppression and the token-reset list inside the card, keeping .cm-frontmatter-line .tok-meta { color: inherit } so the fence mark inherits the card's transparent fence rows. Tests: cm-markdown-language.test.ts (13: the reported note parses without a heading, agrees with frontmatterRange, an unclosed fence stays a rule, only line 1 starts a block, longer dash runs and fences with text are not fences, empty and frontmatter-only documents, a closing fence straddling the 4096 read boundary, markdown active in the body and inactive in the frontmatter, nested code fence languages, fragment reuse for body and frontmatter edits, the block recognised the moment the closing fence is typed and dropped the moment it is broken) and cm-frontmatter-enter.test.ts (10, jsdom, a real EditorView dispatch through the full keymap: list continuation at the same indentation, empty item cleared, nested key copies indentation, top-level key plain newline, closing fence left to the default Enter, cursor before the marker, a body list still continues, an unclosed block is not frontmatter, Vim normal mode Enter is a motion, Vim insert mode continues the list).

  • #825: the motion lives in packages/app-core/src/lib/cm-vim-half-page-motion.ts. zenMoveByHalfPage(cm, head, motionArgs, vim) is a codemirror-vim motion (registered under HALF_PAGE_MOTION, 'zenMoveByHalfPage', by the idempotent registerHalfPageMotion(); every renderer has its own Vim singleton, so each window with an editor calls it). halfPageDistance(clientHeight, lineHeight, count) is the pure part: lines and pixels for one press (half the visible editor, at least one line, an 18 px row when the line height reads as zero, or count lines and count line heights with a count). The cursor moves through moveVertically one display line at a time so it stops at the first or last line instead of wrapping, the goal column rides on vim.lastHSPos while vim.lastMotion is this motion, and the scroll is a fixed scrollTop delta clamped to the note (scrollPreviewBy in VimNav does the same for the reading view). The pixel path runs inside pixelMotionFallback, the #574 safety net now exported from cm-vim-display-line.ts: a throwing coordinate query still moves the cursor by logical lines and still scrolls. halfPageMotionArgs(forward) returns { forward, explicitRepeat: true }, so motionArgs.repeat is the typed count as is (0 for a bare press). Wiring: in Editor.tsx VimKeymapMapping is now { id, bindings, contexts? } & ({ action } | { motion, motionArgs }), and syncVimKeymaps maps the configured nav.halfPageDown / nav.halfPageUp bindings to the motion with contexts: ["normal", "visual"] (the old zenHalfPageDown / zenHalfPageUp actions are gone). vimHalfPageKeymap(vimMode, overrides) (vim-half-page-keymap.ts) still sits ahead of the search and history keymaps and feeds the sequence to Vim.handleKey; it now bails only on vim.insertMode (was insertMode || visualMode). The three secondary windows (FloatingNoteApp.tsx, QuickCaptureApp.tsx, ExternalFileApp.tsx) list ...vimHalfPageKeymap(prefs.vimMode, {}) first in their keymap.of and call registerHalfPageMotion() + mapDefaultHalfPageKeys(), which maps the default binding of each id (getKeymapBinding(null, id)) in normal and visual context through Vim.mapCommand; those windows consult no overrides for any binding, so a remapped half-page key still applies to the main editor only. Tests: cm-vim-half-page-motion.test.ts (12, a fake view with known geometry: distance in lines and pixels, the typed count, the one-line floor, forward and back with the scroll clamped at both ends, no wrap at either end, a count moving and scrolling N lines, a leftover repeat ignored without explicitRepeat, the goal column kept through a short line, the logical fallback when the pixel path throws, one line per step without a view); vim-half-page-keymap.test.ts (5) now mounts a keymap before vim() the way EditorPane does, so the "visual selection grows as one range" case is red against the old bail (two ranges) and three of the five are red when the keymap is left out of the mount; cm-vim-half-page-default-keys.test.ts (4, jsdom, the secondary windows' wiring with the real motion; no Range.prototype stub on purpose, since the views are destroyed before the next frame so the deferred measurement never runs, and a stub would hand the motion an empty geometry instead of the fallback under test: normal Ctrl+D moves instead of selecting the word, a visual selection grows as one range and shrinks back, a count moves N lines, insert mode is left alone; three of four red when either mapDefaultHalfPageKeys() or the keymap entry is removed). Harness note: tools/check-825-floating.mjs asserts on CodeMirror's logical selection through document.querySelector('.cm-content').cmTile.root.view.state (this @codemirror/view version's DOM back-reference), because getSelection().toString() undercounts once the anchor line is rendered as an isolated block; its quick mode opens the capture window through window.zen.toggleQuickCapture(), types the 200-line note with Input.insertText, and never presses Escape in normal mode (the window-level Escape saves and hides the capture, #765).

  • #831: four files in apps/desktop/src. mcp/server.ts: runMcpServer(options: McpServerOptions = {}) with resolveTarget?: () => Promise<VaultTarget> (default resolveDefaultTarget, so the legacy mcp/index.ts entry is unchanged) and transport?: Transport (default StdioServerTransport; the tests bind an InMemoryTransport pair). The cached-promise getBackend is as before (one resolveTarget().then(createBackend) per session, reset on rejection) and is now probed once before the Server is built: on rejection the message goes to process.stderr with the [zennotes-mcp] prefix the legacy entry's fatal line already uses, and startup continues. cli/commands/mcp.ts: cmdMcp(args: ParsedArgs) calls runMcpServer({ resolveTarget: () => resolveTarget(args) }), then awaits forever as before. cli/index.ts: the mcp branch passes parsed; it stays ahead of the generic "resolve a backend, then run the command" path on purpose, since a resolution failure there exits the process. cli/vault-target.ts: doc comment only. packages/app-core/src/lib/help.ts: the HELP_CLI "MCP for AI agents" body. Tests, mcp/server.test.ts "runMcpServer follows the target it is given (#831)": a scratch ZENNOTES_CONFIG_DIR with alpha active and beta known, a session() helper that spies on process.stderr.write, runs the real server over InMemoryTransport.createLinkedPair() and drives it with the SDK Client; a fake node:http server answers /api/vault, /api/vault/settings, /api/folders, /api/notes and records Authorization; the pin/retry case injects a resolver with scripted outcomes [Error, Error, beta, alpha] and asserts the startup warning, one failed call, then beta pinned with the resolver called three times. The live harness is tools/check-831-mcp-flags.mjs (before / after label only names the output file): it runs apps/desktop/out/main/cli.js mcp <flags> under ELECTRON_RUN_AS_NODE=1, with ZENNOTES_VAULT, ZENNOTES_SERVER and ZENNOTES_REMOTE_TOKEN deleted from the child's environment, and its waitForWarning option waits up to 4 s for [zennotes-mcp] on stderr before it sends initialize, which is what pins that the warning is a startup event and not a side effect of the first tool call.

  • #828: one file, packages/app-core/src/store.ts. A module-level savedBodies = new Map<string, string>() sits next to pathSaveTimers and pathSaveQueues; its doc comment states the invariant (entries are consulted only while noteDirty[path] is true and replaced by the note's next edit once it is clean, so a leftover for a clean note is never read). Two touch points. In updateNoteBody, inside the set callback: when the note is not dirty the previous existing.body is snapshotted (a clean buffer equals disk); backOnDisk = !pathSaveQueues.has(path) && savedBodies.get(path) === body; noteDirty[path] becomes !backOnDisk; after the set, a backOnDisk edit deletes the snapshot, clears the pending pathSaveTimers entry and returns before the debounced write is scheduled. In persistNote's completion set: stillCurrent || !cur.noteContents[path] deletes the snapshot, otherwise it is set to writtenBody (the buffer is ahead of disk and the next comparison must be against what landed). The in-flight guard is the reason the "revert during a write" case still writes: while pathSaveQueues holds the path the comparison is skipped, and the completion re-arms it. Nothing changed in the watcher, the vault resets or withoutNoteInWorkspace (trash and archive of a dirty note throw before the entry would matter; a rename remap leaves an orphan under the old path that is never read). Tests: store-note-integrity.test.ts gains afterEach(() => vi.useRealTimers()) and the describe "#828: a buffer back on its saved bytes is not rewritten" with five cases: an inserted character removed again cancels the save (no bridge write, noteDirty false, activeDirty false); real typing ending in the escape sequence saves once; a revert is measured against the last save, not the body the note opened with; a revert while a write is in flight (a gated bridge write the test releases by hand) still lands on disk; typing ahead of a write and returning to the written body is clean. The first, third and fifth were red on the old store. The live harness is tools/check-828-escape-mtime.mjs (before / after only names the output files): it reads the file's mtime through fs.stat between steps, so a rewrite of identical bytes is visible without diffing content.

  • #826: four files in packages/app-core/src plus the manual, and one new component. lib/search-create.ts is the pure part: searchCreateDraft(freeText, tagTokens) splits the free text of the query into the form's { name, folderText, tags } in the :e dialect (a projects/x prefix becomes the Folder field, the query's #tags become chips, text parseCreateNotePath rejects is kept so the form can say why); parseDestinationText(text) reads the Folder field into a NoteDestination ({ folder, subpath }) or a reason (trash/..., a database folder, dot segments, forbidden characters); checkNoteName(text) trims, drops a .md suffix and names what is wrong; findNameCollision(title, destination, notes, settings) returns the live note with the same title (case-insensitive) in the chosen folder (sameFolder: true, blocking) or elsewhere (warning), never a trashed note or a partial match, reading each note's folder through the vault's systemFolderPaths rather than the literal path; buildDestinationChoices(folders, labels) lists the area roots then subfolders by depth and name, skipping databases and the Trash, filterDestinationChoices narrows by value, then label, then substring, and destinationLabel / destinationText turn a destination into Inbox › projects and back into field text; normalizeTag, addTag and rankTagChoices(text, counts, chosen) handle the chips; composeNewNoteBody(title, tags) is # title\n\n#a #b\n\n, mirroring the CLI's composeBody. lib/tags.ts gained countVaultTags(notes) and rankTagCompletions (moved out of cm-hashtag-complete.ts; the frontmatter completion imports them too), so the form and the editor completions rank tags the same way. components/SearchCreateForm.tsx is the form: three controlled inputs (#search-create-name, #search-create-folder, #search-create-tags), the chips as [data-search-create-tag], one status element [data-search-create-status] = error | collision | ok, one shared list under the fields that shows folder rows ([data-search-form-folder]) while Folder is focused (all of them until the user types, folderTyped) and tag rows ([data-search-form-tag]) while Tags is focused, with [data-search-form-idx] and the same bg-paper-200 active class as the palette rows; the container's onKeyDown handles Escape (back), Shift+Enter (open the colliding note) and Ctrl/Cmd+Enter (create); Enter in Name creates, Enter in Folder picks the highlighted row or creates from a typed value at index -1, Enter in Tags picks or commits, Space and comma commit, Backspace on empty removes the last chip. components/SearchPalette.tsx keeps a draft state: createRow is shown for any non-empty free text (Create "name"… with destinationLabel on the right), Enter on it, a click, or Shift+Enter anywhere sets draft from searchCreateDraft, the results view is swapped for <SearchCreateForm> inside the same Modal, onBack clears the draft and refocuses the search input, onCreate is setSearchOpen(false), await createAndOpen(folder, subpath, { title, tags }), focusEditorNormalMode(). store.ts: createAndOpen takes tags and, when there are any, writes composeNewNoteBody with the title the vault settled on through window.zen.writeNote after createNote. lib/help.ts: the Shift+Enter (in Search notes) item, the quick-start sentence and the Space f leader detail describe the form. Tests: lib/search-create.test.ts (21), the describe "SearchPalette: the create row opens a New note form" in components/SearchPalette.test.ts (13, jsdom, against a stubbed store with createAndOpen a vi.fn(); the arguments asserted include 'inbox', 'projects', { title: 'Q4 plan', tags: ['planning', 'q4'] }), and "createAndOpen with tags (#826 follow-up)" in store.test.ts (2).

Demos

  • 826-search-create.mp4: 64 s, 1650 × 1080 at 30 fps, recorded from the built app over CDP with both stores isolated and Vim mode on, on a scratch vault (Reading list #reading, Weekly review #planning #review, Ideas #ideas, projects/Roadmap #planning #release, projects/Launch checklist #release), real key events, captions and key badges drawn into the page before capture (no .vtt sidecar; the website entry omits captions). Three scenes. Cmd+P, "Meeting notes": the Create "Meeting notes"… row with Inbox on its right replaces "No matches.", Shift+Enter opens the New note form with the name selected and Creates "Meeting notes" in Inbox under the fields, Enter creates inbox/Meeting notes.md and the editor opens on it; j, i and a typed line land under the heading (Vim mode opens the note in Normal mode, hence the i). Cmd+P, "Q4 plan", Shift+Enter, Tab: the Folder list shows Inbox, Inbox › projects, Quick Notes, Archive; "proj" narrows to projects, Enter picks it and lands in Tags, where the vault's tags are listed with their counts; "pl" + Enter takes planning, "q4" + comma adds a new chip, the status line reads Creates "Q4 plan" in Inbox › projects with #planning #q4, Enter creates it and the note opens with the two tags on the first line. Space f, "Weekly review" (exists in Inbox), Shift+Enter: the form blocks Create in red, "Weekly review" already exists in Inbox. Change the name, or open it., and Shift+Enter opens the existing note ("No duplicates."). Closing caption: "Search notes · Shift+↵ · name, folder, tags · ↵. Cmd+P everywhere, Space f with Vim on." Recorder: tools/demo-826-search-create.mjs (about 65 s to run, after cd apps/desktop && npx electron-vite build; needs ffmpeg with libx264; the app window must stay unobscured; every scene asserts the palette rows, the form's fields, status and list before it is shown). Stills at 16, 31, 43, 47 and 56 s inspected.

  • 822-preview-to-edit.mp4: 48 s, 1650 × 1080 at 30 fps, recorded from the built app over CDP with both stores isolated, on the harness's 20-section note, real key and mouse events. Edit at line 1, Cmd+6, a smooth scroll to Section 20, Cmd+4: the editor opens with ## Section 20 at the top and the status bar reads Ln 157, Col 4 (the caption states the before: line 1). Cmd+6 then Cmd+4 without scrolling: the cursor stays on Ln 157. Cmd+6, scroll to Section 12, the drawn pointer double-clicks its second paragraph: Edit at Ln 97, Col 1, the paragraph at the height it had on screen. Cmd+6, scroll to Section 20, Cmd+5: Split with the editor on Ln 157 and the reading view re-aligned beside it. Closing caption names every route out of Preview. Captions and the key badge are drawn into the page before capture (the local ffmpeg has no drawtext), so the clip carries its own captions and has no .vtt sidecar; the website entry omits captions, which the release page treats as optional. Recorder: tools/demo-822-preview-to-edit.mjs (about 50 s to run, after cd apps/desktop && npx electron-vite build; the app window must stay unobscured). Stills at 3, 13, 16, 26, 31, 34, 40 and 45 s inspected.

Verification

  • Unit: npm run typecheck 7 of 7; npm run test:run green on every package (shared-domain 1683, app-core 2545 passed and 1 skipped, desktop 886 passed and 4 skipped). The StatusBar.test.ts path-change case and Preview-hovered-link.test.ts were confirmed red on the old code; the same-path case guards the effect's key (it would fail if the effect were keyed on the note object).
  • Live for #820, in the built desktop app over CDP with both stores isolated and default view Reading (tools/check-820-status-bar-hovered-link.mjs), three modes. tap: Beta open in Reading, the tap sequence replayed on the [[Alpha plan#Milestones]] anchor (touchstart, touchend, mouseover, mousemove, mousedown, mouseup, click, no mouseleave); before the fix the slot read Alpha plan#Milestones on the opened Alpha plan (tools/820-before.png), after it is empty (tools/820-after.png), Alpha plan open in Reading mode. hover: mouse over the link shows the target, moving off the article clears it, moving back shows it again, click leaves it empty, and a nudge afterwards keeps it empty; three runs, same result each time. edit: the same tap with the default view Edit opens Alpha plan in the editor with the cursor on ## Milestones (line 5) and the slot empty (the editor follows on mousedown with preventDefault, so no click fires there; the mousedown clear covers it). Both screenshots inspected.
  • Not exercised: the Android and iPhone shells themselves (the change is all core, and their releases adopt this version), and the web client (same core). The hover card staying open after a tap in this desktop emulation (which does not flip pointer: coarse) is the android#74 shell rule's job and was left alone on purpose.
  • Unit for #829: npx turbo run typecheck --force 7 of 7 with nothing cached; npm run test:run green with the fix (shared-domain 1685, app-core 2550 passed and 1 skipped, desktop 886 passed and 4 skipped). Red before, green after: harper-lint.test.ts dialect case (the old configure() left the linter with no words and no ignores), harper-runtime.test.ts "imports the words the vault loads while the session is still compiling" (old code: the session stayed empty and the save carried one word) and "never writes a shorter list than the vault holds".
  • Live for #829, in the built desktop app over CDP with both stores isolated (tools/check-829-harper-words.mjs), run against both builds. Sources at HEAD: the boot check passes (only Glorpish underlined, so the vault's words were imported) and the three checks that follow fail the way the report reads: after the dialect change the marks are Zennotez, Flurbish, Glorpish, teh, vault.json after the zg is ["Glorpish"], and Zennotez and Flurbish stay underlined (tools/829-before.png). With the fix: after the change only Glorpish and the typed teh are underlined, vault.json is ["Zennotez","Flurbish","Glorpish"], and nothing is underlined at the end (tools/829-after.png). Both screenshots inspected.
  • Not exercised live for #829: the boot race itself (it needs a vault load slower than the 1.5 s warm-up, which this machine does not produce; the unit test forces the ordering instead), an actual version-to-version update, Linux/Wayland (the change is renderer-side TypeScript, the same on every platform), and the web client (same core, supportsHarper on in its bridge, vault.json written by the server unchanged).
  • Unit for #822: npx turbo run typecheck --force 7 of 7 with nothing cached; npm run test:run green (shared-domain 1685, app-core 2563 passed and 1 skipped, 13 new, desktop 886 passed and 4 skipped). The previewVisibleSourceLines "bottom edge merely touches the top" case and the previewEditRequestForTarget inert cases are the ones a plausible wrong implementation fails (an >= on the top edge would land one block too early; without the inert check a double-click on a link would switch modes after the first click had already navigated).
  • Live for #822, in the built desktop app over CDP with both stores isolated (tools/check-822-preview-to-edit.mjs), five scenarios on a 20-section note. scrolled: cursor on line 1, Cmd+6, reading view scrolled to Section 20, Cmd+4; before the fix the editor came back at the top with the cursor on line 1 (tools/822-before.png), after it opens with ## Section 20 at the top and the cursor on line 157 (tools/822-after.png); 3 of 3 runs. peek: cursor on Section 6, Cmd+6 (Reading lands there, #543), Cmd+4 without scrolling; the cursor is still on the Section 6 heading, 3 of 3. dblclick: in Reading, scroll to Section 12, double-click its second paragraph; the pane is in Edit with the cursor on that paragraph's line (97), the editor focused (tools/822-dblclick.png), 3 of 3. split: like scrolled with Cmd+5; the editor opens on Section 20 and the reflowed reading view is re-aligned so the same heading sits near its top (tools/822-split.png); 9 of 9 after the focus-before-dispatch change, about 1 in 3 before it (the failing runs landed on line 1 some 3 ms after the landing, the ZN822_TRACE=1 samples show the DOM selectionchange that undid it). split-dblclick: already in Split, scroll the reading view, double-click a paragraph fully inside its frame; the cursor lands on that line within 5 px of the height the paragraph had, the reading view does not move, the editor is focused; 3 of 3. All three screenshots inspected.
  • Observed, not changed, for #822: in Split, after a user scroll of the reading view, the two-way sync drifts the reading view a little (preview → editor ratio sync, then the editor's viewport change maps the reading view back to the editor's top line). It predates this change (none of the sync code is in the diff) and the harness works around it by picking a paragraph that is fully on screen at the moment of the double-click. Not exercised live: the web client (same core, same Preview and EditorPane), and the phone shells (they adopt this core; no double-tap mapping was added, a browser synthesizes dblclick from two quick taps on its own).
  • Cause for #827, pinned before the fix: parsing the reporter's three texts with the plain markdownLanguage parser gives a SetextHeading2 spanning the three property lines with the closing --- as its underline (tokens tok-heading2 on the lines), the same for the text with tags: added, and a paragraph plus a HorizontalRule once - todo is added, which is why the third screenshot looked normal.
  • Unit for #827: npx turbo run typecheck --force 7 of 7 with nothing cached; npx turbo run test:run --filter=@zennotes/app-core --filter=@zennotes/shared-domain --force green (app-core 228 files, 2586 passed and 1 skipped, 23 new; shared-domain green). The two fence-transition tests ("recognises the block the moment the closing fence is typed below an open one", "drops the block the moment its closing fence is broken") are the ones the rejected block-parser-extension approach fails (the stale HorizontalRule node is reused and the heading stays), and the two reuse tests pin the incremental contract (an edit in the body or inside the frontmatter reuses the body's untouched blocks).
  • Live for #827, in the built desktop app over CDP with both stores isolated (tools/check-827-frontmatter-heading.mjs), run against both builds. Sources at HEAD, off: the heading-token check and the body-height check fail (the three property lines carry tok-heading2 and are taller than body lines), tools/827-before-off.png; on: only the heading-token check fails (the card's CSS hid the size, the token was still there), tools/827-before-on.png. With the fix, off: every check passes (no heading token on the property lines, all three at body line height, # InfSec still an H1, Setext title still an H2, no properties card), tools/827-after-off.png; on: every check passes (the five-line card, exactly one horizontal rule widget for the standalone ---, [[Informatik]] as a wikilink chip, no heading token), tools/827-after-on.png. All four screenshots inspected.
  • Not exercised live for #827: typing the frontmatter from scratch in the built app (the fence transitions are pinned at parser level by the two unit tests above), the web client and the phone shells (same core; every editor surface in app-core now uses noteMarkdown()), and the template editor, which was not changed.
  • Cause for #825, pinned before the fix: with navigator.platform reading as Linux, v then Ctrl+D twice in the built app left three stacked cursors and scrollTop 0, and Ctrl+U dropped a range instead of moving, the search keymap's Mod-d and the history keymap's Mod-u; on macOS the same presses ran codemirror-vim's stock moveByScroll. Floating window, Linux: normal Ctrl+D selected the word under the cursor, visual Ctrl+D stacked cursors, no scroll either way (tools/825-floating-before.png).
  • Unit for #825: npx turbo run typecheck --force 7 of 7 with nothing cached; npx turbo run test:run --filter=@zennotes/app-core --force green (app-core 2603 passed and 1 skipped, 17 new; shared-domain 1685). Red before, green after: vim-half-page-keymap.test.ts "visual selection grows as one range" (the old insertMode || visualMode bail let the search keymap add a second range); cm-vim-half-page-default-keys.test.ts three of four (normal Ctrl+D selected the word, visual Ctrl+D stacked a cursor, the count moved nothing) when either mapDefaultHalfPageKeys() or the vimHalfPageKeymap entry is removed from the mount; and three of the five keymap cases when the keymap is left out of the mount, which is what pins that the chords must reach Vim ahead of the search and history keymaps.
  • Live for #825, in the built desktop app over CDP with both stores isolated (tools/check-825-visual-half-page.mjs), a macOS run and a Linux run (navigator.platform overridden before a reload), 13 of 13 each: normal Ctrl+D from line 1 lands on line 11 with scrollTop 278; v then Ctrl+D selects lines 1 to 11 as one range with the same scroll, a second press reaches line 21, Ctrl+U shrinks the selection back to line 11 and scrolls back; V then Ctrl+D selects whole lines; a selection started mid-window stays on screen after the press; insert-mode Ctrl+D moves nothing. Before the fix the Linux run failed the visual checks with three stacked cursors and no scroll (tools/825-before.png); after, tools/825-after.png (lines 1 to 11 selected) and tools/825-after-scrolled.png (lines 1 to 21, the view scrolled). Floating note and Quick Note windows (tools/check-825-floating.mjs, mac and linux, with and without quick), every check green in all four combinations: floating window 1 to 13 to 25 (scrollTop 336 then 668), Quick Note window 1 to 8 to 15 (scrollTop 206 then 396), one range throughout, the cursor count unchanged by the presses, normal Ctrl+U back to line 1 with the view at the top (the Quick Note editor rests at scrollTop 15, so "top" is asserted as under 40), visual Ctrl+U shrinking the selection back by half a page, and a selection started mid-window growing by the same distance while staying on screen; tools/825-floating-after.png (lines 13 to 25 selected mid-window) and tools/825-quicknote-after.png (lines 8 to 15). All six screenshots inspected.
  • Not exercised live for #825: the external-file window (same wiring as the floating window, covered by cm-vim-half-page-default-keys.test.ts), the web client (same core, Editor.tsx maps the motion there too), operator-pending d Ctrl+D (deliberately left on Vim's default), and a remapped half-page binding in the main editor (the keymap sync path is the same one the old actions used; the unit tests drive it with the default binding).
  • Cause for #831, pinned before the fix: the built cli.js at 450df3ee, run as zn mcp over stdio with the vault environment variables unset and a scratch config holding alpha (active) and beta (known), answered vault_info with alpha for --vault <path of beta>, --vault beta and --vault /no/such/dir, and a fake HTTP server named with --server 127.0.0.1:<port> --token secret-831 recorded no request at all (tools/831-before.json: 5 of 7 checks failed; the two that passed are the no-flag session and the legacy mcp.js entry, both of which are supposed to follow the app).
  • Unit for #831: npx turbo run typecheck --force 7 of 7 with nothing cached; npx turbo run test:run --filter=@zennotes/desktop --force green (desktop 891 passed and 4 skipped, 5 new in server.test.ts; app-core 2603 passed and 1 skipped in the same run). Red before, green after: with the server made to ignore the injected resolver, four of the five new cases fail (the two --vault cases answer alpha, the --server case never reaches the fake server, the pin/retry case sees the resolver called zero times); only the no-flag case passes, which is the behaviour that had to be preserved.
  • Live for #831, with the built cli.js over stdio (tools/check-831-mcp-flags.mjs after, ZENNOTES_CONFIG_DIR and ZENNOTES_USER_DATA_PATH on a scratch directory), 11 of 11 (tools/831-after.txt, tools/831-after.json): no flags follows alpha and lists only Alpha; --vault <path> and --vault beta target beta and list_notes returns only Beta; --vault /no/such/dir-831 writes exactly one stderr line, [zennotes-mcp] No vault named "/no/such/dir-831". Known vaults: alpha, beta. You can also pass a directory path. The MCP server is running anyway; every tool call returns this error until a vault resolves., before the harness sends initialize, and the session still answers vault_info with Error: No vault named "/no/such/dir-831". Known vaults: alpha, beta. You can also pass a directory path. instead of alpha; the three healthy sessions print nothing on stderr; --server 127.0.0.1:<port> reports kind: remote and the fake server saw /api/vault, /api/vault/settings, /api/folders and /api/notes, each with Authorization: Bearer secret-831; the legacy mcp.js entry still follows alpha. Also by hand in a terminal: zn mcp --vault /nope with an isolated config prints the warning at once and stays up until stdin closes, exit 0.
  • Not exercised live for #831: a real MCP client (Claude Desktop, Claude Code, Codex) reading the stderr line into its server log (the harness reads the same pipe), a real self-hosted server behind --server (the fake answers the four endpoints the startup path calls; the request shape and the Bearer header are what the check pins), and the Go zn, which was not changed.
  • Cause for #828, pinned before the fix: @replit/codemirror-vim 6.3.0 handleKeyInsertMode types the j of a partial insert-mode mapping into the document and removes it with replaceRange when the k completes the sequence, two changes with the same final text; the store's updateNoteBody marked the note dirty on the first and did not un-mark it on the second, so persistNote wrote identical bytes 350 ms later. In the built app at 5afec707 with insert_escape = "jk" (tools/828-before.json, 4 of 12 checks failed): jk moved inbox/Stamp.md's mtime from 1790086777102 to 1790086780473 and the rendered {{modified_time:HH:mm:ss}} from 09:19:37 to 09:19:40, with the "Unsaved changes" dot lit right after the jk; typing x, Backspace, Escape moved them again (09:19:42). The controls passed: the text was unchanged after jk, and a real edit reached disk (tools/828-before-after-jk.png).
  • Unit for #828: npx turbo run typecheck --force 7 of 7 with nothing cached; npx turbo run test:run --force green on every package (shared-domain 1685; app-core 230 files, 2608 passed and 1 skipped, 5 new; desktop 891 passed and 4 skipped). Red before, green after, in store-note-integrity.test.ts: "cancels the save when an inserted character is removed again" (the old store wrote once and reported dirty), "measures a revert against the last save, not the body the note opened with" (the old store wrote again after the revert), and "typing ahead of a write and then returning to the written body is clean" (the old store scheduled a second write). The two that were already green pin what must not change: real typing ending in the escape sequence still saves exactly once, and a revert while a write is in flight still lands on disk (the in-flight guard; a version that compared against the stale snapshot during the write would skip that save).
  • Live for #828, in the built desktop app over CDP with both stores isolated (tools/check-828-escape-mtime.mjs after, tools/828-after.json), 12 of 12: the editor opens focused in normal mode on line 1 with the stamp rendered; i enters insert mode; jk leaves it with the text unchanged, the mtime unchanged (1790086815719.673 before and after), the stamp unchanged (09:20:15) and the dirty dot off right after the jk; i, x, Backspace, Escape leave mtime and stamp unchanged as well; a real edit (i, x, Escape) reaches disk (x# Stamp on line 1), moves the mtime (1790086823960) and updates the stamp (09:20:23). Screenshots tools/828-after-after-jk.png (stamp still 09:20:15 after the jk) and tools/828-after-real-edit.png (09:20:23 after the real edit) inspected, next to tools/828-before-after-jk.png (09:19:40 on the old build).
  • Not exercised live for #828: undo back to the saved text in the built app (covered by the "measures a revert against the last save" unit case, which drives the store the way the editor does), the web client (same store; its bridge write path is the one the unit tests gate), and Linux/Wayland itself (the change is renderer-side TypeScript, the same on every platform).
  • Unit for #826 (first cut and the form follow-up): npx turbo run typecheck 7 of 7; npm run test:run green on every package (app-core 2644 passed and 1 skipped, desktop 891). npx vitest run src/lib/search-create.test.ts src/components/SearchPalette.test.ts src/store.test.ts in app-core: 143 of 143, of which search-create.test.ts 21 (draft splitting in the :e dialect and the query #tags carried over deduplicated; destination parsing with the Trash, database, dot-segment and forbidden-character refusals; name checks; collisions preferring the chosen folder, ignoring case, never a partial match or a trashed note, folders read through systemFolderPaths; the folder picker's order, labels, filtering and round-trip; tag normalization, adding once in the vault's spelling, ranking exact then prefix then substring minus the chosen ones; the body the CLI would write), the form describe in SearchPalette.test.ts 13 (Shift+Enter opens the form with the name selected and Enter creates in Inbox; the row follows fuzzy matches and Enter on it opens the form instead of creating; plain Enter on a match never opens the form; a typed path fills Folder and the picker filters, picks and moves on to Tags; ArrowUp keeps a typed folder that does not exist; a same-folder name blocks Create until it changes and Shift+Enter opens the note; a same-named note elsewhere only warns and the chosen folder decides; query #words become chips, the picker offers vault tags, typing adds new ones; leftover Tags text counts on Create and a non-tag blocks; Ctrl+Enter and Cmd+Enter create from any field; an unfileable name opens the form with the reason and Escape goes back with the query kept; a Trash path is refused in Folder; nothing for an empty or tag-only query), and store.test.ts 2 (the tag line under the title the vault settled on; the body untouched without tags). The cases a plausible wrong implementation fails: "never counts a partial match, an empty name, or a trashed note" (a startsWith check would block "Road" next to Roadmap; a filter that forgot the Trash would block a name whose only twin is trashed), "a same-named note elsewhere only warns, and the chosen folder decides" (a title-only check would block every duplicate anywhere), "plain Enter on a match still opens it, never the form" (an Enter handler that read the create row first would open the form on every search), and "text left in the Tags field still counts when Create is clicked; text that is no tag blocks" (a form that read only the chips would drop the tag being typed).
  • Live for #826, in the built desktop app over CDP with both stores isolated and Vim mode on (tools/check-826-search-create.mjs form), 42 of 42 (tools/826-form.json): an empty query offers nothing; the footer advertises Shift+Enter; "Meeting notes" shows exactly one row, the Create row, highlighted, naming the note and Inbox, with no "No matches."; Shift+Enter opens the form with the name filled in, focused and selected, in Inbox with no tags and Creates "Meeting notes" in Inbox; Enter creates inbox/Meeting notes.md with the heading and nothing else and the editor opens it focused in normal mode; Space f opens the palette from the editor; "Alph" lists Alpha then the Create row with the match highlighted, ArrowDown reaches the row, Enter opens the form named after the query, Escape goes back to the results with the query kept and the search box focused, and nothing was created; "projects/Q4 plan" shows Inbox › projects on the row and lands the path in Folder and the name without it; Tab reaches Folder with its text selected, landing there lists every folder with none highlighted, typing narrows and highlights the first, Enter picks it and moves to Tags; Tags offers the vault's tags most used first, "pr" highlights prod and offers pr as new, Enter adds the chip and clears the field, "k8s" is offered as new and a comma commits it, the status line names the folder and the tags; Enter creates inbox/projects/Q4 plan.md with #prod #k8s on its own line under the heading; "roadmap" still offers the Create row and the form warns about Roadmap in Inbox › projects with Create enabled, Shift+Enter opens the existing note and creates nothing; "alpha" blocks Create, Enter does nothing, retyping the name as "Alpha 2" unblocks it, Escape returns to the results; "#ops" offers no row and Shift+Enter keeps the palette open with no form; the vault ends with exactly the two created notes added. Screenshots tools/826-form-1-create-row.png through tools/826-form-8-blocked.png inspected (the row and its destination, the form with the name selected, the folder list, the chips with the status line, the tag line in the new note, the yellow warning, the red block with Open it Shift+↵ and Create disabled).
  • Not exercised live for #826: the web client (same core, same SearchPalette and SearchCreateForm; createAndOpen and the tag write go through its bridge the way every other create and write does), the phone shells (they adopt this core), a click on the Create row or on a folder or tag row (the live runs and the jsdom cases used the keys; the rows' onClick calls the same handlers as Enter), a vault whose Inbox is remapped through systemFolderPaths (createAndOpen hands the host the folder id, inbox, and the main process maps it through folderRoot, the same path New Note takes; the collision check reads folders through the same settings, covered by "reads folders the way the vault lays them out, not from the literal path"), and Quick Notes or Archive as the chosen destination (the picker lists them and parseDestinationText is covered for quick/x and archive/x; the live run created into Inbox and Inbox › projects only).

Distribution channels

  • Release workflow: the tag's run 35761564630 built every platform and finished green on the first attempt, 42 minutes from tag to the last upload (17:34 to 18:15 UTC on September 22). 25 installer assets, the same set as 2.53.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 from Contents/Resources/terminal/manifest.json inside each DMG).
  • AUR: 2b7c62a in the separate zennotes-bin clone (zennotes-bin 2.54.0-1), mirrored on desktop main as 70a56776; aur-check.yml passed (runs 35763100122 on main and 35763097945 on v2.54.0). The tarball downloaded from the release has SHA-256 1eeae398a1674a5a88bc5adaaae9f6884ef2d0825fe801c944c2873afdd7497e, equal to GitHub's asset digest and to the pinned value.
  • Nix: nix-update.yml run 35762970258 opened bot PR #834, lifted byte-for-byte onto main as 6a91142a and closed with a one-line comment pointing at that commit; nix-build.yml run 35763186337 passed on main first try. desktopHash (sha256-HurjmKFnSlqIvFraqun2iE7y0IJf6AHJRMKHOv3XSX4=) 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.54.0 (fork commit bad73530, body in nixpkgs-pr-body.md). Only version, npmDepsHash and the source hash change.
  • Homebrew: 14ff611 in the tap (zennotes 2.54.0), mirrored on desktop main as 0ccfa0b5. The cask pins both DMG digests from the GitHub API (arm64 cab03a6a31bd8784eb83bdf67292b1656db1d1d2c0ad06dfb962e3198158b18a, x64 f4c29ca18aae737809125635b5184c1e1e4e50a4c57c39130be196c76b74ae68), and both equal a local shasum of the downloaded DMGs.
  • Website: the 2.54 docs (leaving Reading mode lands where you were reading and the double-click route, the Create row and New note form in search, zn mcp --vault, --server and --token) and the 2.54.0 releases entry with 2 clips merged as website PR #33 (06799989, a merge commit like PR #32). Laravel Cloud deployed it on its own from main; the release page answered with 2.54.0 within four minutes of the merge (18:19 to 18:23 UTC).
  • Docker and the server: unchanged by this release.
  • npm run verify:channels -- 2.54.0 passed at 18:22 UTC (before the website deploy landed, since the download redirects read GitHub's latest release) and again at 18:24: Homebrew tap, AUR, Nix, and all seven website download redirects serve 2.54.0. releases/latest on GitHub answers v2.54.0. The live release page leads with 2.54.0 and serves both clips (the .mp4 files answer 200 with the same byte counts as the uploaded assets).

Release validation

  • Release PR #833's first CI run 35758424455 failed on macOS only, in packages/app-core/src/store-note-integrity.test.ts: one new #828 test received a write it never asked for, and the body of that write (INDEX_BODY with unsaved edits) belonged to the older test "still refuses to let an add event overwrite unsaved edits", which edited the note, armed the store's real 350 ms autosave timer, and returned without settling it; on the slow runner the timer fired into the later test. Reproduced locally by adding a 400 ms pause between the two tests (the pause was deleted again). The fix, 173c61f5, is test-only: the older test now awaits persistNote and asserts the vault, and the last #828 test installs fake timers right after it opens the note. store.ts was not touched. Second run 35760430631 green on macOS, Ubuntu, Ubuntu arm64 and Windows plus the production dependency audit, with CodeQL, the self-hosted web artifact boundary check and the public share viewer artifact check green on the same commit, before the fast-forward and the tag. The AUR package check ran on the tag push and passed too.
  • Before the cut, on the bumped branch: npm run typecheck 7 of 7 with nothing cached and npm run test:run green (shared-domain 1685, app-core 2644 passed and 1 skipped, desktop 891 passed and 4 skipped), apps/desktop build:prod fresh, npm run pack, the packaged launch check on the local pack (page target in 1.5 s, version 2.54.0), and the test:vim-editor, test:sidebar-vim and test:editor-improvements smoke suites against the built app with no renderer console errors.
  • The release workflow's tag-push path behaved as it did for 2.53.0: each platform's installers went up one at a time and its update manifest last (Linux: assets 17:46 to 17:47, latest-linux-arm64.yml 17:47:18, latest-linux.yml 17:47:29 UTC; macOS: DMGs and zips 18:10:17 to 18:10:59, latest-mac.yml 18:11:01; Windows: exe 18:15:32, blockmap 18:15:34, zip 18:15:44, latest.yml 18:15: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 size and SHA-512 in their manifest (verify-installers.py 2.54.0 --download, "ALL GOOD"); both macOS apps are accepted by Gatekeeper as Notarized Developer ID (Lumary Labs LLC) with a valid stapled ticket, read version 2.54.0 and bundle zn 0.4.0 (arm64 and x86_64 binaries); the released arm64 app, copied out of its DMG, launches with both stores isolated (CDP page target in 2.5 s, version 2.54.0, Welcome screen rendered), so the artifact users download was launch-checked, not only the local pack.
  • Expected red marks, none of them about the release: runs 35763097166 (CI), 35763097041 (Nix build) and 35763096934 (CodeQL) started on the bot branch nix/update-v2.54.0 and lost their branch when PR #834 was closed, and CI run 35763100007 on main (the AUR packaging commit 70a56776) was cancelled by the next push, the Nix packaging commit, which CI then ran on. The same pattern appeared on 2.53.0, 2.52.0 and 2.51.1. The runs that count, on main, passed.

Local-first and keyboard-first, as always.

Don't miss a new zennotes release

NewReleases is sending notifications on new releases.