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 fin 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:edialect (empty is the Inbox root,projects/xnests under Inbox,archive/xandquick/xpick 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#tagin 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 #bline under the heading, the shapezn create --tagandzn capture --tagwrite, not as frontmatter. The footer readsShift+↵ new note; the in-app manual has the entry under Search, in the quick-start card and in theSpace fleader 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:eand the dead-wikilink flow. How to test:cd apps/desktop && npx electron-vite build, then from the repo rootnode docs/releases/v2.54.0/tools/check-826-search-create.mjs form(scratch vault withinbox/Alpha.mdtagged#ops #prod,inbox/Beta.mdtagged#opsandinbox/projects/Roadmap.md, both stores isolated, Vim mode on throughconfig.toml; 42 checks, see Verification; exits 0 on pass and writes/tmp/zn-826-form.jsonplus/tmp/zn-826-form-*.png). By hand: Mod+P, typeMeeting 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 opensinbox/Meeting notes.mdin 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); typeproj, Enter, then in Tagspr, Enter andk8s, 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 andOpen 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 readsAlpha plan#Milestonesand 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 astouchstart, touchend, mouseover, mousemove, mousedown, mouseup, click, with nomouseleaveever; themousemoveput the target in the shared hovered-link store and the only clears were the article'smouseleaveand 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 bothfollowLinkTargetbranches, 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 behindpointer: 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 ownpointer: coarserule (android#74). How to test:cd apps/desktop && npx electron-vite build, then from the repo rootnode docs/releases/v2.54.0/tools/check-820-status-bar-hovered-link.mjs tap(scratch vault withinbox/Beta.mdlinking[[Alpha plan#Milestones]], isolated stores, default view Reading, replays the tap sequence, reads the slot from[data-hovered-link];hoverchecks the mouse path andeditthe 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 readsOther#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
zgwere underlined again after updating and restarting ZenNotes, and had to be added a second time. The update never touched them on disk: vault.json keepsharper.wordsandharper.ignoredLintsthrough every normalizer. The loss happened at runtime, in the one live Harper session, and the nextzgorzGthen 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 answerssetDialectby 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, orharper_dialectunder[editor]inconfig.toml, emptied the session on the spot. Reproduced live: after the switch every vault word was underlined, and azgon 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 setsvaultandvaultSettingstogether at the end ofinit()) 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 nextzg), 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 azgclears 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 revisitmergeHarperVaultState. 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 rootnode docs/releases/v2.54.0/tools/check-829-harper-words.mjs(scratch vault whose vault.json knowsZennotezandFlurbish, a note using both plus an unknownGlorpish, Harper on throughconfig.toml, both stores isolated; it switches the dialect to british by editingconfig.tomlthe way a user would, types atehto prove the lint pass it reads is a fresh one, undoes it, then doeszgonGlorpish; exits 0 on pass and writes/tmp/zn-829.png). By hand: turn on Settings → Editor → Grammar and spelling with Harper,zga made-up word in a note and confirm.zennotes/vault.jsonlists it underharper.words, then change Settings → Editor → Harper dialect (or editharper_dialectin~/.config/zennotes/config.tomlwhile the app runs). Before: the word is underlined again within a few seconds, and azgon any other word rewrites vault.json with only that word (tools/829-before.png: the two vault words underlined after thezg). 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 offocusEditorNormalModearrived. 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 rootnode 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,splitandsplit-dblclickcover 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 atags:line leaves them big; adding- todoundertags: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 ofkey: valuelines 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- todoline 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- todoinside 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:yamlFrontmatterfrom@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 ownyamlFrontmattersetup, 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 rootnode docs/releases/v2.54.0/tools/check-827-frontmatter-heading.mjs offandnode 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# InfSecH1, a real setext H2 and a standalone---, both stores isolated;offchecks the three property lines carry no heading token and sit at body height while the body's H1 and H2 keep theirs,onchecks 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,# InfSecand 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),
vthen 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 stockmoveByScroll, 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 ownvim.selalone. With the visual context unmapped the key never reached Vim there, and CodeMirror's keymap handler runs before the Vim plugin sees a key (thekeymapfacet registers its handler where the firstkeymap.ofsits in the extension list, and the snippet keymap precedesvim()), 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, sovplus Ctrl+D grows the selection exactly as far as Ctrl+D moves the cursor in normal mode,Vgrows 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:5Ctrl+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-pendingdCtrl+D stays on Vim's default motion, likej/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 rootnode docs/releases/v2.54.0/tools/check-825-visual-half-page.mjs mac afterandnode 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 throughconfig.toml; thelinuxrun overridesnavigator.platformso Mod reads as Ctrl; 13 checks each: normal Ctrl+D lands on line 11 with the scroll to match,vplus Ctrl+D selects lines 1 to 11 as one range with the same scroll, a second press reaches 21, Ctrl+U back to 11,Vselects 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), andnode docs/releases/v2.54.0/tools/check-825-floating.mjs linux afterplus... linux after quickfor the floating note and Quick Note windows (macas well; asserts on CodeMirror's logical selection, see the contributors note). By hand: Vim mode on, open a note longer than the window,gg, thenvand 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 mcphonours--vault,--serverand--token(#831). Reported by @diazkev314 on Omarchy Linux with the bundled JavaScriptzn: with vault A open in the desktop app,zn mcp --vault <vault-B>served vault A.vault_infonamed the app's workspace, and even--vault /tmpstill answered with the real vault. The same path left--serverand--tokenunused, 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 calledcmdMcp()without them, andrunMcpServer()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 legacyout/main/mcp.jsentry, which takes no flags at all, and it was the only answerzn mcpcould 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. Nowzn mcpresolves its target through the sameresolveTarget()every otherzncommand uses, so--vault <name or path>,--server <name or host>and--token <token>mean inzn mcpwhat they mean inzn list:zn mcp --vault workin an MCP client config points the agent at theworkvault 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--vaultthat 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, sozn mcpprints 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 putzn mcp --vault workorzn 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 Goznwas 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 rootnode docs/releases/v2.54.0/tools/check-831-mcp-flags.mjs after(spawns the builtcli.jsaszn mcpover stdio withZENNOTES_CONFIG_DIRandZENNOTES_USER_DATA_PATHpointed at a scratch directory holding two vaults, alpha active and beta known, and the vault environment variables unset; speaks JSON-RPC to it,initializethenvault_infoandlist_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 launchzn mcp --vault <name or path of vault B>and ask the assistant forvault_info. Before: it names vault A, and--serverand--tokenare 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 andlist_notesreturns B's notes;--vault /nopeshows the[zennotes-mcp] No vault named ...line in the client's server log while the server stays up andvault_inforeturns that error;--server <host> --token <token>reportskind: remoteand every request carriesAuthorization: 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 withjkwithout 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 carryingUpdated: {{modified_time:HH:mm:ss}}:jkmoved 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 thejit cannot know whether akfollows, so thejis really typed into the document, then removed again when thekarrives 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'supdatedAt, 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 injkstill 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 lonejfollowed 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 rootnode docs/releases/v2.54.0/tools/check-828-escape-mtime.mjs after(scratch vault withinbox/Stamp.mdcarrying the stamp, both stores isolated,config.tomlwith[vim] enabled = trueandinsert_escape = "jk"and the default view Edit; 12 checks: file mtime throughfs.stat, the.cm-live-tokentext and the[aria-label="Unsaved changes"]dot from the DOM, keys throughInput.dispatchKeyEvent; exits 0 on pass and writes/tmp/zn-828-after.jsonplus/tmp/zn-828-after-{start,after-jk,after-edit}.png). By hand: Vim mode on withjkas the escape, a note withUpdated: {{modified_time:HH:mm:ss}}, pressithenjk, 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); typexand 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 themousemovehandlers inPreview.tsxandEditorPane.tsx.StatusBar.tsxnow owns the "belongs to the note it was hovered in" rule withuseEffect(() => setHoveredLink(null), [note?.path]); there is oneStatusBar, rendered byEditor.tsxwith the store'sactiveNote, so the effect runs once per note switch. The slot carriesdata-hovered-linkfor tests and drivers (the old selector,span.font-mono.truncate.flex-1, still matches). Link follows that clear it:Preview.tsxrootonClickafterclosest("a")(every earlier return there is a non-link control: task marker, checkbox, code copy and fold, diagram expand),cm-wikilink-render.tswikilinkClickmousedown beforeopenWikilink, and bothfollowLinkTargetsuccess branches inEditorPane.tsx. Tests:StatusBar.test.ts"hovered link slot" (a same-path re-render keeps the target, a path change drops it) and the new jsdomPreview-hovered-link.test.ts(a rendered wikilink throughmousemovethenclick: the store is set, then cleared, and the mockedopenWikilinkTargetreceivesAlpha plan.mdandAlpha plan#Milestones). -
#829: three files.
packages/shared-domain/src/harper-settings.tsgainsmergeHarperVaultState(base, extra): the union of two states,baseorder first,extra's additions appended, ignore hashes kept digit-only.packages/app-core/src/lib/harper-lint.tsliftsexportState/importStateto module functions over aLinter(the session methods delegate to them), andconfigure()only touches the dialect whenlinter.getDialect()differs from the target, exporting beforesetDialectand importing after;setLintConfigstill runs every time.packages/app-core/src/lib/harper-runtime.ts:session()is the one that creates the shared session whenharperLoaded()is false, and in that case it setsappliedandseenVaultStatesynchronously before awaitingloadHarper, resetting both if the load fails;reconcile()waits in a loop onreconciling(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 beforesaveHarperVaultState, 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 realLocalLinterunder Node; and the newharper-runtime.test.ts(node environment, hoisted fakes for../storeand./harper-lintwith a deferredloadHarperthe test releases by hand): the vault landing while the session compiles is imported and azgsaves 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 nextsession(). 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 headerToggleGroup, thezen:set-pane-moderoute used by Mod+4/5/6, the palette and the ex commands all end there); leaving'preview'it callsreadingPositionLanding, which reads the reading view's visible source range withpreviewVisibleSourceLines(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 inpendingEditorLandingRef(path-keyed) and, for Split,lockOutlinePreviewSync()is taken at the same moment, before the first sync pass can run. AuseEffecton[content?.path, editorReady, mode, ...]commits it one rAF after the editor is shown, throughlandEditorOnLine(view, line, topMargin), and in Split also re-runslockOutlinePreviewSync()andscrollPreviewToSourceLine(line, topMargin)(the helper gained the optionaltopMargin).EditorLandingis'reading-position' | 'caller' | { line, topMargin }:jumpToCommentand the pending-jump-location route pass'caller', so an explicit target is never overridden.landEditorOnLinefocuses 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 auserEvent: "select"transaction). The pointer side:Preview.tsxadds a rootdblclicklistener next toclick, which resolves the target throughpreviewEditRequestForTarget(closest[data-source-line], after an inert check againstPREVIEW_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 callsonRequestEdit(request);onRequestEditis now(request?: PreviewEditRequest | null) => voidinPreview.tsx,LazyPreview.tsxandlocal-assets.ts.handlePreviewRequestEditinEditorPane.tsxturns the request into a landing (editorLandingTopMarginkeeps the block's on-screen offset, clamped to[minMargin, viewportHeight - 2 * minMargin]; no block →'reading-position') and either callsapplyPaneMode('edit', { landing })from Preview or, in Split,lockOutlinePreviewSync()+landEditorOnLinedirectly.buildImageEmbedmoves the replaced paragraph'sdata-source-lineonto 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 inlib/preview-outline-jump.ts(previewVisibleSourceLines,previewShowsSourceLine,PreviewEditRequest,previewEditRequestForTarget,editorLandingTopMargin). Tests:preview-outline-jump.test.tsgains "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 newlocal-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 harnesstools/check-822-preview-to-edit.mjsis the behavioural test; withZN822_TRACE=1it prints pane samples every 40 ms plus DOMselectionchange/focusinevents, which is how the race was found. -
#827: the grammar lives in
packages/app-core/src/lib/cm-markdown-language.ts.NoteParseris a LezerParserwhoseNoteParsere-scans the frontmatter from the rawInputon 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 buildsDocument > Frontmatter(FrontmatterMark, FrontmatterMark) Body;parseMixedmounts the markdown parser (withresolveCodeLanguagefor code fences, as before) ontoBody, so the body tree is the same markdown tree every existing extension walks.DocumentcarrieslanguageDataPropandindentNodeProp() => null(without it the default Enter drops the indentation of a nested frontmatter line).FrontmatterMarkis styledt.meta; the content between the fences has no tokens.noteLanguage/noteMarkdown()replacemarkdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage, addKeymap: false })inEditorPane.tsx,ExternalFileApp.tsx,FloatingNoteApp.tsx,QuickCaptureApp.tsxandPinnedReferencePane.tsx;TemplateEditorModal.tsxkeepsyamlFrontmatter. 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'sStructureCursor.moveTodropsIgnoreMountsandparseMixedcannot find the old mount across the move (same on upstream main). Fence predicate:isFrontmatterFence(line)(trim() === '---') exported frompackages/shared-domain/src/markdown-lines.ts, used byfrontmatterEndIndex,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.tsputs it first indeferKeysToVim([frontmatterListEnter, fencedCodeIndentEnter, ...markdownKeymap], ...). Cleanup:cm-wysiwyg-blocks.tsdropsfrontmatterEndLineand the HR skip (the fences are no longerHorizontalRulenodes);index.cssdrops 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 withfrontmatterRange, 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) andcm-frontmatter-enter.test.ts(10, jsdom, a realEditorViewdispatch 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 underHALF_PAGE_MOTION,'zenMoveByHalfPage', by the idempotentregisterHalfPageMotion(); 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, orcountlines andcountline heights with a count). The cursor moves throughmoveVerticallyone display line at a time so it stops at the first or last line instead of wrapping, the goal column rides onvim.lastHSPoswhilevim.lastMotionis this motion, and the scroll is a fixedscrollTopdelta clamped to the note (scrollPreviewByin VimNav does the same for the reading view). The pixel path runs insidepixelMotionFallback, the #574 safety net now exported fromcm-vim-display-line.ts: a throwing coordinate query still moves the cursor by logical lines and still scrolls.halfPageMotionArgs(forward)returns{ forward, explicitRepeat: true }, somotionArgs.repeatis the typed count as is (0 for a bare press). Wiring: inEditor.tsxVimKeymapMappingis now{ id, bindings, contexts? } & ({ action } | { motion, motionArgs }), andsyncVimKeymapsmaps the configurednav.halfPageDown/nav.halfPageUpbindings to the motion withcontexts: ["normal", "visual"](the oldzenHalfPageDown/zenHalfPageUpactions are gone).vimHalfPageKeymap(vimMode, overrides)(vim-half-page-keymap.ts) still sits ahead of the search and history keymaps and feeds the sequence toVim.handleKey; it now bails only onvim.insertMode(wasinsertMode || visualMode). The three secondary windows (FloatingNoteApp.tsx,QuickCaptureApp.tsx,ExternalFileApp.tsx) list...vimHalfPageKeymap(prefs.vimMode, {})first in theirkeymap.ofand callregisterHalfPageMotion()+mapDefaultHalfPageKeys(), which maps the default binding of each id (getKeymapBinding(null, id)) in normal and visual context throughVim.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 leftoverrepeatignored withoutexplicitRepeat, 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 beforevim()the wayEditorPanedoes, 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; noRange.prototypestub 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 eithermapDefaultHalfPageKeys()or the keymap entry is removed). Harness note:tools/check-825-floating.mjsasserts on CodeMirror's logical selection throughdocument.querySelector('.cm-content').cmTile.root.view.state(this@codemirror/viewversion's DOM back-reference), becausegetSelection().toString()undercounts once the anchor line is rendered as an isolated block; itsquickmode opens the capture window throughwindow.zen.toggleQuickCapture(), types the 200-line note withInput.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 = {})withresolveTarget?: () => Promise<VaultTarget>(defaultresolveDefaultTarget, so the legacymcp/index.tsentry is unchanged) andtransport?: Transport(defaultStdioServerTransport; the tests bind anInMemoryTransportpair). The cached-promisegetBackendis as before (oneresolveTarget().then(createBackend)per session, reset on rejection) and is now probed once before theServeris built: on rejection the message goes toprocess.stderrwith the[zennotes-mcp]prefix the legacy entry's fatal line already uses, and startup continues.cli/commands/mcp.ts:cmdMcp(args: ParsedArgs)callsrunMcpServer({ resolveTarget: () => resolveTarget(args) }), then awaits forever as before.cli/index.ts: themcpbranch passesparsed; 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: theHELP_CLI"MCP for AI agents" body. Tests,mcp/server.test.ts"runMcpServer follows the target it is given (#831)": a scratchZENNOTES_CONFIG_DIRwith alpha active and beta known, asession()helper that spies onprocess.stderr.write, runs the real server overInMemoryTransport.createLinkedPair()and drives it with the SDKClient; a fakenode:httpserver answers/api/vault,/api/vault/settings,/api/folders,/api/notesand recordsAuthorization; 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 istools/check-831-mcp-flags.mjs(before/afterlabel only names the output file): it runsapps/desktop/out/main/cli.js mcp <flags>underELECTRON_RUN_AS_NODE=1, withZENNOTES_VAULT,ZENNOTES_SERVERandZENNOTES_REMOTE_TOKENdeleted from the child's environment, and itswaitForWarningoption waits up to 4 s for[zennotes-mcp]on stderr before it sendsinitialize, 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-levelsavedBodies = new Map<string, string>()sits next topathSaveTimersandpathSaveQueues; its doc comment states the invariant (entries are consulted only whilenoteDirty[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. InupdateNoteBody, inside thesetcallback: when the note is not dirty the previousexisting.bodyis snapshotted (a clean buffer equals disk);backOnDisk = !pathSaveQueues.has(path) && savedBodies.get(path) === body;noteDirty[path]becomes!backOnDisk; after theset, abackOnDiskedit deletes the snapshot, clears the pendingpathSaveTimersentry and returns before the debounced write is scheduled. InpersistNote's completionset:stillCurrent || !cur.noteContents[path]deletes the snapshot, otherwise it is set towrittenBody(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: whilepathSaveQueuesholds the path the comparison is skipped, and the completion re-arms it. Nothing changed in the watcher, the vault resets orwithoutNoteInWorkspace(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.tsgainsafterEach(() => 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,noteDirtyfalse,activeDirtyfalse); 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 istools/check-828-escape-mtime.mjs(before/afteronly names the output files): it reads the file's mtime throughfs.statbetween steps, so a rewrite of identical bytes is visible without diffing content. -
#826: four files in
packages/app-core/srcplus the manual, and one new component.lib/search-create.tsis the pure part:searchCreateDraft(freeText, tagTokens)splits the free text of the query into the form's{ name, folderText, tags }in the:edialect (aprojects/xprefix becomes the Folder field, the query's#tagsbecome chips, textparseCreateNotePathrejects is kept so the form can say why);parseDestinationText(text)reads the Folder field into aNoteDestination({ folder, subpath }) or a reason (trash/..., a database folder, dot segments, forbidden characters);checkNoteName(text)trims, drops a.mdsuffix 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'ssystemFolderPathsrather than the literal path;buildDestinationChoices(folders, labels)lists the area roots then subfolders by depth and name, skipping databases and the Trash,filterDestinationChoicesnarrows by value, then label, then substring, anddestinationLabel/destinationTextturn a destination intoInbox › projectsand back into field text;normalizeTag,addTagandrankTagChoices(text, counts, chosen)handle the chips;composeNewNoteBody(title, tags)is# title\n\n#a #b\n\n, mirroring the CLI'scomposeBody.lib/tags.tsgainedcountVaultTags(notes)andrankTagCompletions(moved out ofcm-hashtag-complete.ts; the frontmatter completion imports them too), so the form and the editor completions rank tags the same way.components/SearchCreateForm.tsxis 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 samebg-paper-200active class as the palette rows; the container'sonKeyDownhandles 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.tsxkeeps adraftstate:createRowis shown for any non-empty free text (Create "name"…withdestinationLabelon the right), Enter on it, a click, or Shift+Enter anywhere setsdraftfromsearchCreateDraft, the results view is swapped for<SearchCreateForm>inside the same Modal,onBackclears the draft and refocuses the search input,onCreateissetSearchOpen(false),await createAndOpen(folder, subpath, { title, tags }),focusEditorNormalMode().store.ts:createAndOpentakestagsand, when there are any, writescomposeNewNoteBodywith the title the vault settled on throughwindow.zen.writeNoteaftercreateNote.lib/help.ts: theShift+Enter (in Search notes)item, the quick-start sentence and theSpace fleader detail describe the form. Tests:lib/search-create.test.ts(21), the describe "SearchPalette: the create row opens a New note form" incomponents/SearchPalette.test.ts(13, jsdom, against a stubbed store withcreateAndOpenavi.fn(); the arguments asserted include'inbox', 'projects', { title: 'Q4 plan', tags: ['planning', 'q4'] }), and "createAndOpen with tags (#826 follow-up)" instore.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.vttsidecar; the website entry omitscaptions). 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 andCreates "Meeting notes" in Inboxunder the fields, Enter createsinbox/Meeting notes.mdand the editor opens on it;j,iand a typed line land under the heading (Vim mode opens the note in Normal mode, hence thei). 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 readsCreates "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, aftercd 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.vttsidecar; the website entry omitscaptions, which the release page treats as optional. Recorder:tools/demo-822-preview-to-edit.mjs(about 50 s to run, aftercd 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 typecheck7 of 7;npm run test:rungreen on every package (shared-domain 1683, app-core 2545 passed and 1 skipped, desktop 886 passed and 4 skipped). TheStatusBar.test.tspath-change case andPreview-hovered-link.test.tswere 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, nomouseleave); before the fix the slot readAlpha plan#Milestoneson 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 withpreventDefault, 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 --force7 of 7 with nothing cached;npm run test:rungreen 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.tsdialect case (the oldconfigure()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 (onlyGlorpishunderlined, 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 areZennotez, Flurbish, Glorpish, teh, vault.json after thezgis["Glorpish"], andZennotezandFlurbishstay underlined (tools/829-before.png). With the fix: after the change onlyGlorpishand the typedtehare 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,
supportsHarperon in its bridge, vault.json written by the server unchanged). - Unit for #822:
npx turbo run typecheck --force7 of 7 with nothing cached;npm run test:rungreen (shared-domain 1685, app-core 2563 passed and 1 skipped, 13 new, desktop 886 passed and 4 skipped). ThepreviewVisibleSourceLines"bottom edge merely touches the top" case and thepreviewEditRequestForTargetinert 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 20at 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: likescrolledwith 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, theZN822_TRACE=1samples show the DOMselectionchangethat 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
PreviewandEditorPane), and the phone shells (they adopt this core; no double-tap mapping was added, a browser synthesizesdblclickfrom two quick taps on its own). - Cause for #827, pinned before the fix: parsing the reporter's three texts with the plain
markdownLanguageparser gives aSetextHeading2spanning the three property lines with the closing---as its underline (tokenstok-heading2on the lines), the same for the text withtags:added, and a paragraph plus aHorizontalRuleonce- todois added, which is why the third screenshot looked normal. - Unit for #827:
npx turbo run typecheck --force7 of 7 with nothing cached;npx turbo run test:run --filter=@zennotes/app-core --filter=@zennotes/shared-domain --forcegreen (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 staleHorizontalRulenode 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 carrytok-heading2and 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,# InfSecstill an H1,Setext titlestill 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.platformreading as Linux,vthen Ctrl+D twice in the built app left three stacked cursors andscrollTop0, 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 stockmoveByScroll. 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 --force7 of 7 with nothing cached;npx turbo run test:run --filter=@zennotes/app-core --forcegreen (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 oldinsertMode || visualModebail let the search keymap add a second range);cm-vim-half-page-default-keys.test.tsthree of four (normal Ctrl+D selected the word, visual Ctrl+D stacked a cursor, the count moved nothing) when eithermapDefaultHalfPageKeys()or thevimHalfPageKeymapentry 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.platformoverridden before a reload), 13 of 13 each: normal Ctrl+D from line 1 lands on line 11 withscrollTop278;vthen 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;Vthen 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) andtools/825-after-scrolled.png(lines 1 to 21, the view scrolled). Floating note and Quick Note windows (tools/check-825-floating.mjs,macandlinux, with and withoutquick), every check green in all four combinations: floating window 1 to 13 to 25 (scrollTop336 then 668), Quick Note window 1 to 8 to 15 (scrollTop206 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 atscrollTop15, 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) andtools/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.tsxmaps the motion there too), operator-pendingdCtrl+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.jsat450df3ee, run aszn mcpover stdio with the vault environment variables unset and a scratch config holding alpha (active) and beta (known), answeredvault_infowith alpha for--vault <path of beta>,--vault betaand--vault /no/such/dir, and a fake HTTP server named with--server 127.0.0.1:<port> --token secret-831recorded no request at all (tools/831-before.json: 5 of 7 checks failed; the two that passed are the no-flag session and the legacymcp.jsentry, both of which are supposed to follow the app). - Unit for #831:
npx turbo run typecheck --force7 of 7 with nothing cached;npx turbo run test:run --filter=@zennotes/desktop --forcegreen (desktop 891 passed and 4 skipped, 5 new inserver.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--vaultcases answer alpha, the--servercase 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.jsover stdio (tools/check-831-mcp-flags.mjs after,ZENNOTES_CONFIG_DIRandZENNOTES_USER_DATA_PATHon a scratch directory), 11 of 11 (tools/831-after.txt,tools/831-after.json): no flags follows alpha and lists onlyAlpha;--vault <path>and--vault betatarget beta andlist_notesreturns onlyBeta;--vault /no/such/dir-831writes 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 sendsinitialize, and the session still answersvault_infowithError: 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>reportskind: remoteand the fake server saw/api/vault,/api/vault/settings,/api/foldersand/api/notes, each withAuthorization: Bearer secret-831; the legacymcp.jsentry still follows alpha. Also by hand in a terminal:zn mcp --vault /nopewith 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 Gozn, which was not changed. - Cause for #828, pinned before the fix:
@replit/codemirror-vim6.3.0handleKeyInsertModetypes thejof a partial insert-mode mapping into the document and removes it withreplaceRangewhen thekcompletes the sequence, two changes with the same final text; the store'supdateNoteBodymarked the note dirty on the first and did not un-mark it on the second, sopersistNotewrote identical bytes 350 ms later. In the built app at5afec707withinsert_escape = "jk"(tools/828-before.json, 4 of 12 checks failed):jkmovedinbox/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 thejk; typingx, Backspace, Escape moved them again (09:19:42). The controls passed: the text was unchanged afterjk, and a real edit reached disk (tools/828-before-after-jk.png). - Unit for #828:
npx turbo run typecheck --force7 of 7 with nothing cached;npx turbo run test:run --forcegreen 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, instore-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;ienters insert mode;jkleaves 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 thejk;i,x, Backspace, Escape leave mtime and stamp unchanged as well; a real edit (i,x, Escape) reaches disk (x# Stampon line 1), moves the mtime (1790086823960) and updates the stamp (09:20:23). Screenshotstools/828-after-after-jk.png(stamp still 09:20:15 after thejk) andtools/828-after-real-edit.png(09:20:23 after the real edit) inspected, next totools/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 typecheck7 of 7;npm run test:rungreen 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.tsin app-core: 143 of 143, of whichsearch-create.test.ts21 (draft splitting in the:edialect and the query#tagscarried 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 throughsystemFolderPaths; 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 inSearchPalette.test.ts13 (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#wordsbecome 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), andstore.test.ts2 (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" (astartsWithcheck 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 andCreates "Meeting notes" in Inbox; Enter createsinbox/Meeting notes.mdwith the heading and nothing else and the editor opens it focused in normal mode;Space fopens 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 createsinbox/projects/Q4 plan.mdwith#prod #k8son 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. Screenshotstools/826-form-1-create-row.pngthroughtools/826-form-8-blocked.pnginspected (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 withOpen it Shift+↵and Create disabled). - Not exercised live for #826: the web client (same core, same
SearchPaletteandSearchCreateForm;createAndOpenand 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'onClickcalls the same handlers as Enter), a vault whose Inbox is remapped throughsystemFolderPaths(createAndOpenhands the host the folder id,inbox, and the main process maps it throughfolderRoot, 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 andparseDestinationTextis covered forquick/xandarchive/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 fromContents/Resources/terminal/manifest.jsoninside each DMG). - AUR:
2b7c62ain the separatezennotes-binclone (zennotes-bin 2.54.0-1), mirrored on desktopmainas70a56776;aur-check.ymlpassed (runs 35763100122 onmainand 35763097945 onv2.54.0). The tarball downloaded from the release has SHA-2561eeae398a1674a5a88bc5adaaae9f6884ef2d0825fe801c944c2873afdd7497e, equal to GitHub's asset digest and to the pinned value. - Nix:
nix-update.ymlrun 35762970258 opened bot PR #834, lifted byte-for-byte ontomainas6a91142aand closed with a one-line comment pointing at that commit;nix-build.ymlrun 35763186337 passed onmainfirst 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 commitbad73530, body innixpkgs-pr-body.md). Onlyversion,npmDepsHashand the sourcehashchange. - Homebrew:
14ff611in the tap (zennotes 2.54.0), mirrored on desktopmainas0ccfa0b5. The cask pins both DMG digests from the GitHub API (arm64cab03a6a31bd8784eb83bdf67292b1656db1d1d2c0ad06dfb962e3198158b18a, x64f4c29ca18aae737809125635b5184c1e1e4e50a4c57c39130be196c76b74ae68), and both equal a localshasumof 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,--serverand--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 frommain; 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.0passed 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/lateston GitHub answersv2.54.0. The live release page leads with 2.54.0 and serves both clips (the.mp4files 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 awaitspersistNoteand asserts the vault, and the last #828 test installs fake timers right after it opens the note.store.tswas 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 typecheck7 of 7 with nothing cached andnpm run test:rungreen (shared-domain 1685, app-core 2644 passed and 1 skipped, desktop 891 passed and 4 skipped),apps/desktopbuild:prodfresh,npm run pack, the packaged launch check on the local pack (page target in 1.5 s, version 2.54.0), and thetest:vim-editor,test:sidebar-vimandtest:editor-improvementssmoke 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.yml17:47:18,latest-linux.yml17:47:29 UTC; macOS: DMGs and zips 18:10:17 to 18:10:59,latest-mac.yml18:11:01; Windows: exe 18:15:32, blockmap 18:15:34, zip 18:15:44,latest.yml18: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 asNotarized Developer ID(Lumary Labs LLC) with a valid stapled ticket, read version 2.54.0 and bundlezn0.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.0and lost their branch when PR #834 was closed, and CI run 35763100007 onmain(the AUR packaging commit70a56776) 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, onmain, passed.
Local-first and keyboard-first, as always.