Skip to content

Release 2.79.0 - #1010

Merged
adibhanna merged 46 commits into
mainfrom
v2.79.0
Oct 28, 2026
Merged

adibhanna merged 46 commits into
mainfrom
v2.79.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Release 2.79.0: the Timeline panel (#1007), a key for every palette command (#971), Share to ZenNotes on macOS, Unsplash covers with your own key, a cover's frontmatter folded to one line, a Status bar switch, sidebar trash by key (#972), the keyboard with Vim mode off in the sidebar and every side panel, and fixes for #1003, #1006 and #1008. The release workflow now marks a release latest only once all five update feeds are on it.

Gates on 77c2afb: ci-sweep since v2.78.0 found no failed jobs; hazard scan clean; production audit has no high advisories; typecheck and test:run (shared-domain 2203, app-core 4016, desktop 1250, quicklook 18) after a fresh npm ci; build:prod; a Developer ID signed pack whose Share and Quick Look extensions verify; the packaged launch check (page target, drawn, 1.5 s, version 2.79.0); vim-editor, sidebar-vim and editor-improvements all passed.

Full notes go on the GitHub release.

… and other apps to Quick Notes

charles b. asked on Discord for a native Share to ZenNotes extension, so
Safari and other apps could send a URL or selected text straight to Quick
Notes. ZenNotes.app now carries ZenNotesShare.appex in Contents/PlugIns
(apps/share-extension), next to the Quick Look extension.

The sheet previews what is shared (the page title and site, the selection
quoted), takes an optional note line and a vault, and saves on Return.
Escape cancels, and Cmd+1 to Cmd+9 pick a vault: with macOS's default
keyboard settings Tab never reaches a pop-up button. Safari runs
ShareExtensionPreprocessing.js on the page, so the sheet gets the page's
own title, address and selection; other apps hand a selection over as
plain text, RTF or HTML, and all three are read as text.

The extension is sandboxed, as macOS requires, and reaches no vault. It
leaves each share as one JSON file in a share inbox,
~/Library/Application Support/ZenNotes/Share Inbox, the one folder a
temporary-exception entitlement opens to it (the kind Quick Look already
ships). The app files the shares (main/share-inbox.ts): at startup, as they
arrive while it runs, and when a server a share names connects. It writes
the note through vault.ts's createNote, so a Quick Notes folder remapped in
systemFolderPaths is honored and no Swift copy of the folder rules exists,
and it deletes a share only after its note is written. The app keeps
vaults.json in the inbox, following every config save, so the sheet can
offer the local and Cloud vaults and the servers the app knows.

What it chooses:
- Not an App Group. macOS refuses the unsandboxed app's own mkdir of its
  group folder even with the entitlement; only containermanagerd creates
  it, for a Foundation caller, which Electron's main process is not. The
  inbox folder needs no change to the app's own entitlements.
- A share made while ZenNotes is closed waits for the next launch; nothing
  launches behind the user's back.
- The note: the page title as heading and filename (fetched through
  link-metadata.ts when the sharing app gives none; else the first line of
  the text, then the host), the selection quoted, the note line, and
  [title](url) last. Text alone follows Quick Capture: its first line is
  the title. deriveTitleFromBody and timestampTitle move to shared-domain
  so both surfaces name notes alike.
- A share file is untrusted input. Its vault is a key looked up in the
  app's remembered vaults, never a path; sizes are capped; a vault whose
  folder is gone is not recreated by filing into it; unreadable files are
  set aside, not deleted. A share for a self-hosted server waits until that
  server is the connected workspace.

What it does not do: files, images and Finder documents are not accepted;
there is no toast in the app (the note appears in Quick Notes as the
watcher sees it); macOS may list a newly registered share extension
switched off, and the app does not switch it on for the user.

The in-app manual and the website docs describe it.

Verified on packaged builds through a test copy with its own bundle ids and
scratch vaults: a share made with the app closed was filed on the next
launch into a remapped Quick Notes folder, titled from the fetched page;
with the app running a text share was filed within two seconds and showed
under Recent; inside the extension, Cmd+2, typing and Return saved to the
second vault, and Escape cancelled; RTF text and a link with a multi-line
selection came through; a share for a self-hosted server waited, then was
filed through the server into its remapped folder once it connected. The
release pack signs the extension with the Developer ID, hardened runtime
and a timestamp, carries no self-test code, and opens a page target in two
seconds. A share from Safari itself was not exercised.
…photographers

charles b. asked on Discord for Unsplash as a source of covers. The
cover picker now has a second tab, Unsplash, next to Covers. Tab in the
search field switches between them and keeps the text, so "fog" on
Covers then Tab searches Unsplash for fog. Space l u in Vim mode, Add
Cover from Unsplash… in the palette and :cover unsplash <search> open
the tab directly; the last fills in the search with the first photo
selected, so Enter then sets it. Photos appear as you type, after a
400 ms pause and from two characters. The arrows, Ctrl+N/P and
Ctrl+J/K move a row, reaching the last row loads the next page, and the
line under the grid credits the selected photo, "Photo by <name> on
Unsplash", with both names linked.

A tab rather than another section, on purpose: a section would send
every search typed in the picker to the network, the names of vault
images included, and would spend an hourly allowance that every
ZenNotes user shares on people who never wanted a photo. The Covers
tab never goes online.

Unsplash's API terms shape the rest. They require the photo to be
hotlinked ("you must directly use or embed the related image URLs
returned by the API", and their own example is a picked background),
so unlike a gradient or a gallery photo an Unsplash pick is not copied
into assets/. It is an https cover, which covers already were, with the
photographer's credit on the lines under it:

    banner: "https://images.unsplash.com/photo-...?ixid=...&w=2400&q=80&auto=format"
    banner_credit: Jane Doe
    banner_credit_url: "https://unsplash.com/@janedoe"

The size parameters are appended as text, so Unsplash's own query
string, ixid included, stays byte for byte. The credit keys are
generic (note-cover.ts reads and writes them for either cover key) and
belong to the picture: a new image or a removed cover takes them away
with banner_y, and the whole pick is one edit, so u takes it back. For
a photo on images.unsplash.com the cover draws "Photo by ... on
Unsplash" in its bottom-left corner, adding the referral tags Unsplash
asks for at that moment so the file keeps clean links; any other
credit draws as written. It shows with the cover's buttons on hover or
focus, all the time on a screen that cannot hover, and all the time on
a published note's page. Picking a photo pings its download_location
(Unsplash's download tracking), fire and forget, once the note has it.
A web cover that cannot load now says "Could not load the cover from
images.unsplash.com" instead of calling the image missing.

The app holds no Unsplash key: the terms keep it confidential and do
not let users bring their own. Search and the ping go through
zennotes.org/api/v1/unsplash, which signs them with ZenNotes' key and
keeps the budget (the website's feat/unsplash-covers). Two optional
bridge methods carry them. Desktop main sends them with net.fetch, no
cookies, a bare user agent and no language, after checking the
arguments, and caps one run at 40 requests a minute so a renderer bug
cannot spend everyone's hour; the renderer's CSP is unchanged. The web
client calls the proxy with fetch. A host without the methods, the
phone shells today, shows no Unsplash tab. Everything the proxy returns
is checked in shared-domain unsplash.ts before it is drawn: images only
from images.unsplash.com, links only to unsplash.com, a ping only to an
api.unsplash.com download endpoint. ZENNOTES_UNSPLASH_PROXY_URL points
a test run at another proxy and, like the other overrides, is honored
only unpackaged or with ZEN_PERF=1.

The tab says what went wrong when a search fails: Unsplash could not be
reached ("the Covers tab works offline"), the shared hourly allowance
is spent ("resting until 3:00 PM"), the service is unavailable, or
nothing matches. Answers are kept for the session, so switching tabs or
reopening the picker asks nothing again.

Not here: the phones get the tab when their shells add the two bridge
methods after a core release, and Quick Look still leaves web covers
out.

Verified in the built app over CDP, both stores isolated, against a
local stand-in for the proxy (48 checks, Vim on and off): Space l u
and its which-key row; one search for "fog" typed letter by letter,
with no cookie and a bare agent; hotlinked thumbnails; the credit's
tagged links; ↓, Ctrl+N and Ctrl+J, and page 2 at the last row; Enter
writing the banner and credit with nothing in assets/ and one ping;
the cover loading and its credit showing on hover; u and Ctrl+R; Tab
and Shift+Tab with a cached answer; :cover unsplash then Enter; the
rate-limited, unavailable, no-match and unreachable messages; an
unreachable web cover; a new image dropping the credit; a cover seen
before drawing offline, in the same run and after a restart; :cover!
restoring the note; with Vim off, the palette route, j typing into the
field and ↓ moving. Then live, through the website served locally with
a demo key: a real search, real thumbnails and credit, a banner keeping
Unsplash's ixid, a 2400 px cover, and the ping counted against the key.

Tests: the credit keys, the URL rules, response parsing, the proxy
client and its failures, the desktop client's argument checks, cap and
override, the search controller's debounce, stale answers, cache and
paging, and the read-only cover's credit. shared-domain 2,184, app-core
3,609, desktop 1,202 and quicklook 18 pass; typecheck clean.

How to test locally: serve the website's feat/unsplash-covers branch
with UNSPLASH_ACCESS_KEY set, build the desktop app, then run
ZENNOTES_UNSPLASH_PROXY_URL=http://127.0.0.1:<port>/api/v1/unsplash
electron apps/desktop/out/main/index.js. Open a note, press Space l u
(or Tab in the Space l c picker), type "fog", move with ↓ and press
Enter: the note gains banner, banner_credit and banner_credit_url, and
hovering the cover shows "Photo by ... on Unsplash". Without a proxy
the tab says Unsplash could not be reached, and the Covers tab works as
before.
…ion from Safari and other apps to Quick Notes
… as raw Markdown

Found on the phones once their read-open guard shipped (iPhone 1.34.0,
Android 1.1.49): a note opened from Home, a list or a zennotes:// link
now opens with the keyboard down, as it should, and with its title drawn
as a raw `# Garden plan`. Live preview hides the Markdown markers on
every line except the ones the selection covers, and a note opened to be
read still has a selection: the caret parked on line 1. Nobody was
editing that line, but it read as source. Desktop showed the same
wherever an editor sits unfocused: a hovered link's preview popover, the
pinned reference pane, and the note a window restores at launch.

Source is now revealed only on lines the user is editing. A state field
(lib/cm-source-reveal.ts) says whether the editor has taken the focus
since its note opened. Until it has, every line renders, the caret's
too. The first focus turns revealing on, and a blur leaves it on, so a
palette, a menu or a dialog over a note being edited redraws nothing
(and a phone's keyboard going down is not the end of editing either). A
note put into an editor in place of another starts over, unless the
editor holds the focus as it arrives: a tab switch or a followed link
from the keyboard keeps revealing. Every reveal asks the same question:
the line markers, links, ==highlights==, template tokens, callout heads,
rules, fences and bullets, and the math, Mermaid/D2 and embed blocks.
The editors that share these extensions (the main panes, the reference
pane, the link popover, floating and external-file windows) all carry
the field; one without it (Quick Capture, the template editor) reveals
as before.

The focus is taken up in a microtask of the focus event, not on
CodeMirror's own focus notice, which comes 10 ms later: by then a frame
with the caret's line rendered would already be painted, and every note
desktop opens would flicker its first line. A focus a phone shell turns
straight back (the read-open guard blurs it in focusin) never reveals.

What it deliberately does not change: when anything takes the focus,
and the parser (the note grammar's full reparse is untouched). Desktop
opens a note and focuses it exactly as before, so the caret's line shows
its markers from the first painted frame.

Desktop, visible only where an editor is unfocused: the link preview
popover and the reference pane draw their first line rendered, a note a
split's other pane switched to while unfocused renders its caret line,
and a note restored at launch renders its caret line until the editor is
clicked or focused.

Tests: cm-source-reveal (an unfocused note renders its caret line; an
editor without the field reveals; the first focus reveals before any
timer runs; a blur keeps revealing; a refused focus never reveals; a
note swapped in unfocused is read, swapped in focused reveals; a note
left to read renders again; highlights and inline math follow it). In
the built app over CDP with isolated stores, sampled once per painted
frame on the 2.78.0 build and on this one: opening from Home, the
sidebar, Search and Search Text in Vault, Vim gg/j/k and an A edit that
reaches disk, a palette over a focused editor, Read to Edit, Split and
the find panel paint the same caret-line states on both; the link
popover and the launch restore show the change. On the iPhone shell
(web build at phone size, rehearsal core from this tree): a note opened
from Home, and the one restored at launch, show their heading rendered
with no `#`, and a tap into the text focuses it and reveals that line.
…d down

The phones' read-open guard keeps the keyboard down for a note opened
from Home, the lists and a zennotes:// link, but a pick in Search still
raised it: every one of core's searches ends with focusEditorNormalMode,
the hand-off desktop wants (the next keystroke edits or moves in the
note), and on a phone that focus is the soft keyboard over half the note
the user came to read. Traced on the iPhone shell, that one call was the
only editor focus after a pick from the note search, from Search in all
notes and from the outline, and after cancelling a search too (the
shell's Cancel sends the Escape that App's window handler answers the
same way). The outline jump also focused the editor itself.

The host now says how it wants a pick to land. EditorHostOptions (the
editor host the shells already install for native typing and keyboard
insets) gains `openNotesToRead`, asked at each pick. Where it returns
true, a pick in the note search (notes and tagged blocks), Search in
all notes, the outline palette and the buffer switcher opens the note
at the pick without taking the focus, and the note is left to read
(every line rendered, see the previous commit). If the closing palette
gave the focus back to an editor that opened it (an iPhone keeps the
focus in the text under a tap on a button), that hand-back is undone a
frame later. Closing one of those searches without a pick leaves the
focus where the palette returns it, to what had it before. Where it is
absent, false or throws, nothing changes: desktop and the web app focus
exactly as before.

Why the editor host and not a bridge-contract capability: the answer is
the phone layout, which a tablet changes at runtime (Split View), so it
has to be a question asked at the pick, not a static capability; the
shells already register this host; and the bridge is the vault and IO
contract, with nothing for desktop or web to say here. The touch media
query was not enough either: it is true on tablets in the desktop
layout, which keep core's focus as the guard does.

What it deliberately does not do: the list openers the shells' guard
already covers keep focusing as before (the guard refuses it), and the
command, template and drawing palettes keep their hand-off. Creating a
note from Search still focuses it, as a new note should be written in.

Shells: one line in src/ui-mobile/editor-host.ts, in both phones:
`openNotesToRead: isPhoneViewport` in the installEditorHost options
(isPhoneViewport from '../viewport'). Without it, a phone that adopts
this core behaves as today for Search.

Tests: editor-focus (desktop focuses; a host that answers no or throws
keeps it; a reading host leaves the editor unfocused and read, undoes a
hand-back to the editor, and leaves the focus alone on close);
SearchPalette (a pick goes through the pick hand-off, Escape through the
close one) and SearchPaletteBlocks (a block pick). On the iPhone shell
(web build at phone size, rehearsal core from this tree, the one line
above): picks from Search (from a note and from Home), Search in all
notes and the outline open the note at the pick with the editor never
focused and the caret line rendered; Cancel keeps it unfocused; a tap
into the text focuses it and shows that line's `#`. Without the line,
the same picks focus as before. On desktop over CDP every search still
focuses the editor, painted the same as on 2.78.0.
… command, keyboard down

After 0a46efb a Search pick on a phone opened its note to read, but two
of core's hand-offs still raised the keyboard there. Closing the command
palette, the template palette or the drawing palette without picking
anything called focusEditorNormalMode, from the palette's own Escape and
from App's window handler that answers the shells' Cancel button. And a
command run from the command palette that only goes to a note already
there (Go Back, today's daily note) was followed by the same editor
focus, twice for the periodic notes: the palette refocuses after every
command that lands on the editor, and openDailyNoteForDate and its
weekly and monthly twins ask for the focus themselves (#353).

Both now go through the editor host's `openNotesToRead`, the question
0a46efb added. The two hand-offs it introduced are renamed for what they
now cover: focusEditorAfterReadOpen (a note opened to be read, from a
search pick or a command) and focusEditorAfterPaletteClose (any of these
palettes closed without a pick). On a host that opens notes to read, a
palette closed without a pick leaves the focus where the palette returns
it, to whatever had it before the palette opened, so backing out over a
note being read keeps the keyboard down and backing out over a note
being written keeps it up. Desktop and the web app focus the editor
exactly as before, with the same retries.

Commands say whether they open to read through a new
`Command.opensToRead`, asked just before the command runs (afterwards a
daily note exists either way). The rule: a command whose whole effect is
to put an existing note on screen opens it to read; a command that
creates a note, or works on the note in place, keeps the focus.

Open to read (the palette hands over like a search pick):
- nav.back, nav.forward, nav.toggle-recent (Go Back, Go Forward, Switch
  to Previous Note): history only holds notes that exist.
- tab.reopen, tab.next, tab.previous (Reopen Closed Tab, Next Tab,
  Previous Tab): another note that is already open.
- tab.close (Close Tab): what remains on screen is the neighboring tab.
- note.daily.today, note.weekly.thisWeek, note.monthly.thisMonth: only
  when that note is there already. When the command has to create it,
  the note is new and is focused to be written in, as today. The
  command passes `readIfExisting`, so the opener's own focus request
  follows the same rule (a `read` detail on zen:focus-editor that App
  lands like a pick); other callers of those openers (the leader keys,
  the Calendar, Home, the phones' Daily note row) are unchanged.
- note.daily.rollover (Roll Over Unfinished Tasks to Today): the same
  rule on today's note.

Keep the focus, unchanged: every command that creates a note to write
in (note.new.quick, note.new.inbox, note.new.here, task.new,
task.new.folder, database.new, drawing.new, embed.drawing.new, and the
template palette's create and insert); commands that act on the open
note in place (format, rename, favorite, cover, archive and restore,
copy, export, folds, task state, the editor toggles); nav.focus.editor
and ref.focus, whose point is the focus; and split.right/split.down. The
command palette has no command that follows a link: Open Link Popover
peeks at one over the current note and keeps its own focus. Commands
that open another palette or dialog still hand nothing over, and a
theme picked in the palette still returns to the editor.

What it deliberately does not do: change the commands themselves,
Home or the Calendar, or anything on a host without `openNotesToRead`.

Tests: commands (exactly those eleven commands answer opensToRead,
history and tab commands always, the periodic ones only for an existing
note, the writing commands never, and the periodic commands ask their
opener for readIfExisting); store (the daily opener requests a read
landing only for an existing note opened that way); editor-focus (a
read open keeps its caller's retries on desktop); a palette test (the
command, template and drawing palettes close through the close
hand-off, a read command lands through the read hand-off, a writing
one still focuses). app-core 3,624 and shared-domain 2,164 pass;
typecheck clean (8/8). In the built app over CDP with isolated stores,
on 2.78.0 and on this build: Escape out of the command palette (from
its input and from a window Escape like the phones' Cancel), the
template palette and the drawing palette, then Open Today's Daily Note
creating the note, Go Back, Open Today's Daily Note again, Switch to
Previous Note, Close Tab, Reopen Closed Tab and Next Tab each leave the
same note on screen with the editor focused on both builds.
…xcept while Cloud needs you

Piotr asked on Discord (2026-10-28), with an Android screenshot: "On
android, can we hide the cloud connected / setup bar??" On the phones
the bar along the bottom carries only its Cloud half, so a device that
is signed in but not linked showed "ZenNotes Cloud · Cloud connected ·
Set up" under every note for as long as it stayed that way, and on
Android the circle was lifted above that row too. There was no way to
turn it off, on the phones or on desktop.

Settings, Appearance, Chrome gains Status bar, on by default, next to
the title bar switch, with the words people search for (cloud, cloud
connected, footer, word count). Turned off, the bar is not drawn at all
on any surface: desktop gets its 32 px back, and on Android the Cloud
row goes and the circle drops back to the bottom, because the shell
sizes that row and lifts the circle from the bar's data-cloud-sync-*
hooks, which are no longer in the page.

It stays off except while Cloud needs the person. cloudSyncNeedsUser
(cloud-auto-sync.ts) is the same set the phones already key their Cloud
row on: the attention and error phases (a stopped or failed run,
including Can't reach Cloud), a Cloud vault that went away and a device
Cloud signed out (both drawn as attention, now one cloudSyncShownPhase
that the status row uses too), and files or the vault settings question
waiting for a decision. Then the bar comes back until that is dealt
with, so turning it off never hides the one place that says sync has
stopped. Zen mode still clears the bar whatever it says, as before.
statusBarVisible (lib/status-bar-visibility.ts) holds the rule.

It is a portable preference: showStatusBar in PORTABLE_PREF_KEYS,
show_status_bar under [appearance] in config.toml beside
show_window_title_bar. The phones keep it with the rest of app-core's
preferences (zen:prefs:v2, which both shells mirror into native
storage), so it needs no shell code. Keyboard: the command palette's
Toggle Status Bar (and so :view_status_bar), and `:set statusbar`,
`:set nostatusbar`, `:set statusbar?` (alias `stb`) in Vim mode, the way
`:set undofile` works. No single-key binding, so Vim-off gating is
untouched. The option is defined without a default value: codemirror-vim
applies a truthy default through the callback when the option is
defined, and the first desktop relaunch over a saved
show_status_bar = false came back with the bar on.

Docs: the Appearance entry, the Cloud card and the ex command list in
help.ts; the website follows in its own branch.

What it deliberately does not do: change the iPhone, whose shell
already shows the Cloud row only while Cloud needs the user, so there
the switch changes nothing until the shell decides otherwise; or add a
global shortcut or leader key, which none of the other chrome switches
have.

Tests: statusBarVisible (on by default, off until Cloud needs the user,
Zen mode wins); cloudSyncNeedsUser (quiet for the everyday phases; needs
the user for attention, error, a removed vault, a signed-out device,
waiting files even mid-sync, and the settings question); the Settings
search finds Status bar by "cloud connected" and its switch turns it
off; Toggle Status Bar flips and saves it; `:set statusbar` leaves a
saved choice alone when it is defined (fails with a truthy default) and
sets, clears and reports it; config.toml round-trips
[appearance].show_status_bar; PORTABLE_DEFAULTS still matches
DEFAULT_PREFS. app-core 3,636, shared-domain 2,164 and desktop 1,198
pass; typecheck clean (8/8). In the built app over CDP with isolated
stores: Toggle Status Bar hides the bar and the editor gains 32 px,
config.toml says show_status_bar = false, `:set statusbar` and
`:set nostatusbar` bring it back and hide it; with the main process's
Cloud handlers swapped for a linked account whose sync fails, the hidden
bar comes back as "Sync failed", and goes again once a sync succeeds;
a relaunch with a fresh userData and the same config.toml opens with
the bar hidden.
…oard down

Found while verifying 42865ca: on a phone, Toggle Status Bar from the
command palette raised the keyboard. Every command that lands on the
editor is followed by focusEditorNormalMode, so any setting or view
toggle run from the palette (Light Sidebar, Line Numbers, Word Wrap,
Live Preview, a theme) put the soft keyboard over half the note, with
nothing to type. Two commands also focused the editor themselves: the
four fold commands (runFoldCommand) and Copy Link Under Cursor.

The palette now asks where the keyboard should go
(lib/command-landing.ts), with the editor host's `openNotesToRead`, the
same question the Search picks and palette closes ask. The rule
defaults to leaving the focus where it was before the palette opened,
and names the commands that should give the editor the keyboard, so a
toggle added later cannot raise a phone's keyboard by being forgotten.
On desktop every answer focuses the editor, exactly as before.

On a phone, after a command run from the palette:

- read, the note is left to read, keyboard down (e443b86, unchanged):
  nav.back, nav.forward, nav.toggle-recent, tab.reopen, tab.next,
  tab.previous, tab.close, and note.daily.today, note.weekly.thisWeek,
  note.monthly.thisMonth and note.daily.rollover when that note already
  exists.
- edit, the editor takes the keyboard, because typing or the focus is
  the point: note.new.quick, note.new.inbox, note.new.here, task.new,
  task.new.folder, database.new, drawing.new, embed.drawing.new; the
  four periodic-note commands when they create the note; template.create
  and template.insert; note.format (Format Markdown),
  editor.reflow-paragraph and editor.add-comment; nav.focus.editor and
  ref.focus; split.right, split.down and the four pane.focus commands.
- stay, the focus goes back where it was: everything else. That covers
  every View, UI and App command (sidebar, status bar, line numbers,
  sort and group, panels, Tasks, Tags, Atlas, zoom, themes, Settings);
  the Editor toggles (word wrap, live preview, completed-task style,
  which-key hints, tabs, keep panels, undo history, auto reveal, quick
  note dates, PDF embeds); task state, folds, copies and exports;
  archive, trash, rename, favorite and cover; vault, Cloud, CLI and
  workflow commands. Enable and Disable Vim Mode stay too: the mode
  applies the next time the editor has the focus, and on a phone that
  is a tap into the text, with a hardware keyboard or without.

The fold commands and Copy Link Under Cursor no longer focus the
editor themselves on such a host.

What it deliberately does not do: change desktop, the web app, or the
commands a phone's own menu runs through runAppCommand (those never
handed the focus over).

Tests: commandLanding (an existing note is read even for a writing id,
the edit list takes the keyboard, toggles and an unknown future command
stay); every id on the edit list is a real command; the palette hands a
toggle back through the close hand-off and still focuses for a writing
command. app-core 3,641, shared-domain 2,164 and desktop 1,198 pass;
typecheck clean (8/8). In the built app over CDP with isolated stores,
on 2.78.0 and on this build, Light Sidebar, Line Numbers: Relative,
Disable Word Wrap, Completed Tasks: Strikethrough, Fold All Headings
and Lists, Disable Live Preview and Format Markdown each leave the
editor focused on both (Toggle Status Bar too, on this build).
…promises (#1008)

davi-jorge-art cleared the Quick Note prefix in Settings, on Windows and
on Android, and new quick notes still came out as "Quick Note
2026-10-24 1658". The field promises "Leave blank for a bare timestamp
or date".

The blank value was saved all along. The store keeps a cleared field as
null, config.toml writes it as quick_note_title_prefix = "" and reads it
back as null, and the field is still empty after a restart.
The title builder lost it: resolveQuickNoteTitle took the prefix as an
optional argument that defaulted to "Quick Note", and all four callers
(the sidebar +, Shift+Cmd+N, New Quick Note in the palette, n in the
Quick Notes view) passed `prefix ?? undefined`. null became undefined
and the default came back. The prefix argument is now required and
typed string | null, null meaning no prefix, so a caller can no longer
leave it out and pick up the old default.

The field also looked as if the default were still set. Once it was
empty its placeholder showed "Quick Note" in grey, which is what the
reporter read as the field going back to "Quick Notes". It now reads
"No prefix". And the field could not take a two-word prefix typed key
by key, including the default "Quick Note": live Settings rows commit
the trimmed text on every keystroke and drew the stored copy back into
the field, so a typed space was gone at once. TextInputRow now shows
what was typed while it has focus and the stored value otherwise. Rows
still commit on every keystroke, so a phone that leaves Settings
without a blur keeps the edit. The same fix lets the Inbox, Quick
Notes, Archive and Trash label rows take a space.

The phones and the web app build quick-note titles in this core code,
so they pick the fix up with it. Quick Capture and the Share menu title
a note from its first line and are left alone, as are the MCP server
and zn, which never use the prefix. The help text already promised a
bare timestamp or date and is now true.

Tests: resolveQuickNoteTitle (null, empty and blank prefixes give the
bare timestamp or date, date titles still number " (2)"); New Quick
Note after the setter clears the prefix (fails before this change);
the Settings field keeps a typed space, says "No prefix" and saves an
empty field as null; config.toml round-trips a null prefix as "".
In the built app over CDP with both stores isolated: before the fix,
clearing the field and pressing the Quick Notes + wrote
"Quick Note 2026-10-28 1005.md", and Shift+Cmd+N after a relaunch wrote
"Quick Note 2026-10-28 1005 2.md". After it, the same steps wrote
"2026-10-28 1011.md" and "2026-10-28 1011 2.md" with matching
headings, the field stayed empty with "No prefix" across the relaunch,
config.toml kept quick_note_title_prefix = "", and "My Notes" typed key
by key kept its space.
…ne sentence (#1003)

A Windows 11 copy of 2.71.0 (NSIS) pressed Check for Updates at
21:14:03 UTC on October 27 and got a dialog holding electron-updater's
whole error: the request, twenty response headers, a stack through
app.asar, and at the end "GitHub returned a temporary network or server
error". Nothing was wrong with GitHub. release.yml creates the release
public and "latest" first (2.78.0 at 20:42:54) and uploads each
platform's update feed after its installers, so for a while "latest"
named a release without the feed a copy reads: latest-linux.yml came
12 minutes later, latest-linux-arm64.yml 15, latest-mac.yml 32,
latest.yml 39 and latest-arm64.yml 50. electron-updater follows
releases/latest, asks that tag for <channel>.yml, gets a 404 and throws
ERR_UPDATER_CHANNEL_FILE_NOT_FOUND with the HTTP error's message and
stack pasted into its own.

humanizeUpdateError showed that message and picked a hint by testing
the whole text. Its `5\d\d` matched the `536` in GitHub's
strict-transport-security header (max-age=31536000), so every GitHub
404 read as a temporary server error, and isRetryableUpdateError, the
same test, retried the 404 twice more before giving up. The notify-only
check (AUR, tarball) said "GitHub answered 404 for the release feed"
followed by a hint about private-repo tokens.

describeUpdateError (update-error-copy.ts) now turns a failed check or
download into a sentence, the original text as `detail`, a kind, and
whether an instant retry makes sense. The HTTP status is read only
where errors keep it: `statusCode`, the HTTP_ERROR_NNN code,
"HttpError: NNN" at the head of a wrapped error, or the notify-only
fetch's own status, which it now throws as ReleaseFeedHttpError with
the URL GitHub redirected it to. Never from header values, request ids
or line numbers.

- A missing feed on the newest release, on every platform's feed, is
  "ZenNotes 2.79.0 is still being published. Try again in a few
  minutes.", the version read from the feed's URL, and is not retried.
- A 5xx, a 429 or a timeout keeps the temporary-error sentence (a
  download gets its own wording); only the 5xx and the timeout retry.
- A refused code signature on macOS, and a download that lost the
  network ("Check the connection and try again"), keep their hints.
- Everything else is one plain sentence per action.

AppUpdateState carries the original text as an optional `detail`. The
About page shows it under a closed Details disclosure with Copy
details and Report an Issue; that disclosure moves out of
CloudErrorMessage into ErrorDetails, and Cloud errors render it as
before. Main writes it to the log as `[updater] check failed (<kind>)`.
The alert behind Check for Updates and the menu's native dialog show
the sentence only. The in-app manual's update guide says what the new
sentence means.

Deliberately not here: the state stays `error`, so the badge, the
background notice and the retry and recheck rules are unchanged; a
new phase for "publishing" would ripple through every host. The cause,
release.yml marking the release latest before its feeds exist, is a
workflow change that needs its own yes (proposed: create the release
with --latest=false and mark it latest once all five feeds are up).
The website's docs mirror is not updated here.

The 2.71.0 copy in the report keeps its old wording; this changes what
copies from the next release say.

Verified: desktop 1,239 tests and app-core 3,609 pass, typecheck clean.
In the built app over CDP, both stores isolated, the notify-only check
pointed at a local stand-in for github.com (302 from
releases/latest/download, then 404, 503, and a real feed; 23 checks):
the background check and Check for Updates both say "ZenNotes 2.79.0
is still being published. Try again in a few minutes." with one
request and no dump, Details holds the 404 with Copy details and
Report an Issue, a 503 is retried three times and gets the
temporary-error sentence, and a published feed clears the details.
Real electron-updater 6.8.9 (MacUpdater) against the same kind of
stand-in throws ERR_UPDATER_CHANNEL_FILE_NOT_FOUND, and the classifier
words it as the same sentence for Windows, macOS and Linux.
…1008)

davi-jorge-art asked, in the same issue, whether it is on purpose that
the Tasks filter sees a tag in a task's title but not in its body.
The task they mean is a file task, the kind New task makes (+ New task
in the Tasks header, :newtask, a phone's Action Button): a note with title,
status and `tags: [task]` in its frontmatter and the rest of the note
as its body. The title is typed in the New task prompt, which has no tag
picker, and so is its `title:` line, since the editor offers no tags
inside frontmatter. The body is ordinary note text where # brings up the
picker, so on a phone that is where a tag is easy to add.

parseTaskFile takes a file task's tags from the frontmatter list alone,
and the filter matches the task text (the title), the note title, those
tags, priority and fields. A #tag typed in the title was found as text;
one in the body was found by nothing. The Tags view meanwhile listed
the same note under that tag, since a note's tags include its body.

filterTasks now takes each note's tags by path (NoteMeta.tags, the list
the Tags view and the sidebar read) and lets a file task match the tags
anywhere in its note. The `task` marker is left out, as parseTaskFile
leaves it out, so #task does not narrow to every task note. A checkbox
task keeps to its own line: a tag elsewhere in its note says nothing
about that one task. TasksView builds the map once and hands it to the
list, to the calendar through the filtered list, and to the board, so
the three sub-views keep agreeing.

What it deliberately leaves alone: a task's `tags` still hold only the
frontmatter list, so parseTaskFile and its copies (MCP list_tasks, zn,
the Go server) report what they reported, the row's tag chips are
unchanged, and none of the four task-line parser copies moves. The
indented lines under a `- [ ]` are not treated as its body here; that
would need the parser copies to agree on where a task's block ends.

The Tasks filter paragraph in the in-app manual says so. The website's
Tasks Filter entry (docs.blade.php) mirrors that paragraph and still
needs the sentence; this branch does not touch the website repo.

Tests: filterTasks with a task parsed by parseTaskFile whose body
carries #errands and #Admin/Paperwork: found by #errands, errands,
#admin and the full nested tag; a checkbox in a note tagged #errands on
another line is not; #task finds nothing; without the map nothing
changes; computeTasksRender passes the map through. In the built app
over CDP with both stores isolated, a vault with that task note, a
second task note with no tag, "- [ ] Buy milk #errands" and "- [ ] Water
the plants" in a note that says #errands on another line: before,
"#errands" listed Buy milk alone (1 of 4), the calendar showed 1 task
today and the board 1 card. After, it lists Renew the passport and Buy
milk (2 of 4), the calendar shows 2 and the board 2 cards, Water the
plants and Call the bank stay out, #task lists nothing, and the Tags
view for #errands lists the same two notes.
…one (#1006)

Ctrl+A in a note selected the title along with the text. The title here
is not the header or a separate field: a new note is written as
`# <title>`, a rename keeps that heading in step with the file name
(#455), and Live Preview shows it as the large heading at the top of the
page. CodeMirror's select all takes the whole document, so it took the
title too, and typing or pasting over a select all replaced it. Measured
on the built app over CDP with a `# Shopping list` note: every mode that
selects (Vim off, Vim insert, Vim normal and visual on macOS, Live
Preview and source) selected 0 to 82 of 82 characters, and the DOM
selection never left the editor. Nothing outside the editor could be
the title on screen anyway, since the app chrome is user-select: none.

Select all from the note's text now selects that text: from the first
non-blank line under the title to the end of the note. A second press
selects the whole note, frontmatter and title included, as before. The
first press does that too when the cursor is on the title or above it,
where the user is editing the title rather than the text, and in a note
that does not start with a `# ` heading. The title line is the same one
a rename retitles: `leadingTitleHeading` in shared-domain now locates it
for both, and `retitleLeadingHeading` is rebuilt on it unchanged. Every
editor keymap gets the new select all from one place, Vim on or off and
on every platform, and the editor menu's Select All runs the same
command.

#993 stays as it was. In Vim normal and visual mode off macOS, Ctrl+A is
still Vim's increment. With no number on the rest of the line it does
nothing, as in Vim, and Vim still consumes the key; it does not fall
back to select all. That would make Ctrl+A increment or select depending
on where the cursor sits, and a missed increment would leave a visual
selection for the next `x` or `c` to delete. `ggVG` selects in Vim, and
insert mode selects with Ctrl+A.

On the built macOS app over CDP, Cmd+A from the text now selects 17 to
82 (the text) in every mode that selects, a second press 0 to 82, and
from the title line 0 to 82. The non-mac keymap was checked in the same
build: the preload reported linux and navigator.platform was Linux, so
both the app's platform check and CodeMirror's Mod read Ctrl, and Ctrl+A
carried Chromium's Linux and Windows default action (selectAll), which
runs only when nothing handles the key. Before, Vim off and insert mode
selected 0 to 82; after, 17 to 82, then 0 to 82. Normal mode increments
`Buy 3` to `Buy 4` and does nothing on a line without a number, before
and after. The editor menu's Select All selects the text, and typing
over Cmd+A leaves `# Shopping list` in the file on disk.

Tests: select all dispatched through the keymap and Vim in the editors'
key order (fails on the old keymap), the title locator, and the guards
for #993. app-core 3,619 tests, shared-domain 2,184 and desktop 1,221
pass; typecheck clean. The website docs mirror of the new help entry is
not in this change.
…amp, and the Tasks filter reads a task note's body tags (#1008)
…dow that dims Safari

The first real Safari share opened the ZenNotes sheet as an empty window:
Safari dimmed behind it, typing reached the note line, and nothing showed.
Safari had done its part (the page script handed over the title, address
and selection). The view was built in code with a zero frame, and the share
sheet takes the view's size when it presents, before Auto Layout has run.

The root view now starts with a real frame, and the controller sizes itself
to its fitted layout and reports it as preferredContentSize whenever what
it shows changes (the shared text, the vault row, the notice). The second
line under the title shows the page's address without its scheme, so a page
shown by its host alone still says which page it is.

Verified: the NSSharingService harness, which had shown the same empty
window, draws the full sheet; then a real Safari share of the Zen article
with a selection saved on Return and was filed with the page's own title,
the quoted selection, the note line and the link.
Shares always landed in Quick Notes. Settings > Vault > Notes now has a
Default shares location beside the drawings, databases and tasks location
rows: Quick Notes (the default), the primary notes location, or a specific
folder inside it such as Clippings. Search finds it by "safari", "share" or
"clippings".

It is a vault setting, `sharesLocation` in vault.json, like the other
location settings, so each vault keeps its own choice and it follows the
vault to every machine. shared-domain's share-location module reads it the
same way for every caller: the main process resolves it per vault when it
files a share (createNote still resolves remapped system folders), and the
vault list it keeps for the sheet now names each vault's destination, so the
sheet's heading says where a share will go ("Save to Clippings"). The list
is rewritten when a vault's settings are saved or change on disk, not only
when the remembered vaults change. The normalizers in main and app-core and
the settings-conflict sections carry the new key, so no round trip drops it.

What it does not do: the row shows only in the macOS desktop app with a
local vault. Windows, Linux and the web have no share extension, and a
self-hosted server rebuilds vault.json from the fields it knows, so it
would drop the setting; a server vault keeps Quick Notes until znserver
mirrors the field. No "active note's folder" mode: a share is filed with no
note in view.

The in-app manual and the website docs describe it.

Verified in the packed app over CDP: searching Settings for "safari" lands
on the row; Specific folder with Clippings saved to vault.json, the sheet's
vault list and heading said Clippings, and a share was filed in
inbox/Clippings/; switching to Primary location relabelled the vault Inbox.
Unit tests cover the resolver and labels, the vault.json round trip, the
destination list, and the Settings row on macOS and its absence elsewhere.
… on it (#1003)

The release workflow created the GitHub release public and latest in its
first job, then uploaded each platform's installers and update feed as its
build finished. On 2.78.0 that left releases/latest pointing at a release
with no latest.yml for 39 minutes and no latest-arm64.yml for 50. Every
in-app update check in that window asked releases/latest for its feed and
got a 404; a Windows user on 2.71.0 hit it and filed #1003. The app now
says "still being published" in that case, but the window itself was ours.

The release is still created public at the start, because the AUR, Nix
and Homebrew steps and the boundary packages read per-tag download URLs
mid-run, and a draft's assets answer 404 to the public. What changes is
--latest=false at create, and a promote-latest job after the platform
jobs that marks the release latest only when all five feeds (latest.yml,
latest-arm64.yml, latest-mac.yml, latest-linux.yml,
latest-linux-arm64.yml) are on it. It runs after a failed platform job
too (!cancelled()), so a dispatch that rebuilds the missing platform and
uploads the last feed promotes the release from its own run. It never
takes latest from a newer release, so rebuilding an old tag by dispatch
leaves latest alone.

It does not change how notes are published (gh release edit without
--latest leaves latest where it is), and the feed list must change with
the platform matrix. A run cancelled and finished by hand from its
artifact skips promotion, so that path now ends with
gh release edit vX.Y.Z --latest; the release runbook says so.
… land

Default shares location stayed hidden for a vault on a self-hosted server,
and the Share sheet always said "Save to Quick Notes" for one, because the
server dropped vault.json's sharesLocation on every settings save. znserver
2.79 keeps the field and advertises supportsSharesLocation.

The Settings row now shows for a server workspace when its server advertises
that capability, and stays hidden behind an older server, where a choice
would be lost on the next save. Shares were already filed from the server's
own setting (fileRemote reads it), so with a 2.79 server they land in the
chosen folder; what was wrong was only what the sheet said. The sheet now
names the connected server's choice, falls back to Quick Notes when the
server is older or cannot be read, and is refreshed after a server vault's
settings are saved and when a server connects.

It deliberately does not remember a disconnected server's choice: that
server's shares wait for the connection anyway, and its label is corrected
as it connects. The in-app manual says server 2.79 is needed.

Tests: a Settings case shows the row for a server with
supportsSharesLocation and hides it for one without (the old gate hid it
for every server). Typecheck clean; shared-domain 2188, app-core 3677,
quicklook 18 and desktop 1241 pass.
…ine (#1007)

#1007 asked for tasks in date order in the right sidebar, with overdue
work and milestones marked. The Calendar panel shows one week of one
month; nothing showed what is coming up across the vault at a glance.

The Timeline is a fifth right-hand panel beside Outline, Connections,
Comments and Calendar. Overdue tasks sit on top in the danger color with
an "Nd late" pill, Today is the accent ring ("Today · Wed 28"), each later
day with work gets a node, and a run of empty days folds into one "N quiet
days" line. The header counts what the rail shows ("9 open · 2 overdue"),
the switch reaches 2 weeks (the default), a month (to the same date next
month) or All, and the filter takes the Tasks filter syntax, note-tags map
included (#1008). Tasks past the range are counted at the foot and a click
shows them all; "N undated in Tasks" opens the Tasks view.

It writes no rule of its own. The input is the list every Tasks surface
starts from (display filter, the daily-note date inference the Calendar
panel uses, filterTasks), groupTasks decides which tasks are live Today
and Upcoming work, and isOverdue decides lateness. So a [/] task is on the
rail with its half-filled box, while @waiting and forwarded tasks stay in
their own Tasks groups, as the list keeps them. The rail is a pure builder
in lib/timeline-rail; the four task-line parsers are untouched.

A task tagged #milestone draws as a diamond on a day labelled
"· milestone", its row stands out, and it says how many open tasks are due
before it, counted since the previous milestone (the first one counts from
the top of the rail, overdue included). Counting from today instead made
every later milestone repeat the earlier ones' work; the rail already shows
the running position, so the number says what it cannot: the size of this
milestone's own stretch, each task counted once. It counts what the filter
leaves, so it matches the rows above the diamond. A file task's body tag
counts, as in the filter. The tag is special nowhere else.

Rows reuse the task pieces: TaskStateBox, the shared task menu (with the
inline Edit the calendar has; taskTail and the priority marks moved to
lib so both panels and the Tasks list share one copy), the note hover
preview, and toggleTaskFromList, so a ticked task leaves the rail the way
it leaves the calendar's list.

Keys: Mod+7, leader n, :timeline and Toggle Timeline in the palette, all
in the keymap catalog and rebindable. Leader n opens the panel with the
keyboard in it, as leader c does for the calendar. With Vim on, j/k, gg/G,
x, m (task menu) and / (filter) work; with Vim off only the arrows,
Home/End, Enter and Escape, the hints in the footer show only with Vim on,
and the handler stands down while a menu or dialog is open. Escape or Left
hands the keyboard back. The handler is the panel's own onKeyDown, which
runs after VimNav's capture handler; VimNav stands down for the panel
except for Ctrl+W and the leader, so leader n closes it from inside and
leader h hints over its rows. The panel claims focusedPanel 'timeline' on
its own capture handlers, after the pane's claim of 'editor'.

It resizes, tucks into the rail, joins the Alt+hjkl and Ctrl+W cycle after
the calendar, closes with :closepanel, and its open state lives where the
other panels' does: sticky per pane, or per note in the workspace snapshot
with "Keep panels when switching notes" off. Its width joins panelWidths
(290 by default), which stay on the device like every panel width. The
range is portable, timeline_range under [view] in config.toml beside
tasks_view_mode: it is how you like to read your tasks, not a fact about a
screen. The filter is session state, like the Tasks filter. The panel is
one of the surfaces that keep the task cache fresh, and it rescans when it
opens after a while closed, not on a quick remount while a note loads.

Phones: their shells hide the pane toolbar and turn any right panel into a
sheet with Done, so nothing new shows there. The web client gets the panel
as desktop does.

Docs: the in-app manual has a Timeline card with the #milestone
convention, Mod+7 and Space n in the shortcut lists, <Space> n and
:timeline in the Vim commands, and the Timeline in the panel cycle, the
task menu and :closepanel texts.

Tests: timeline-rail (overdue, late days, today, day nodes, quiet-day
folds, range cuts, milestone counts, undated, the inferred date, the
groups), keymaps (defaults, no clash, Cmd+7 vs Ctrl+7, rebind and unbind),
pane-panels snapshot, the focus cycle, store and config.toml round trips,
and TimelinePanel component cases in the style of CalendarPanel.test.ts.
Typecheck clean; shared-domain 2188, app-core 3715, quicklook 18 and
desktop 1241 pass. Driven over CDP in the built app (69 checks) and the
web client at 390 px; the Vim editor, sidebar Vim and editor-improvements
smoke suites pass on the same build.
…s out by date, overdue and milestones marked (#1007)
#971 asked for Move Note to Trash, Archive, Restore and Favorite on a key.
None of them had a slot: the keymap table binds the app's built-in actions,
141 of them, while the palette has 187 commands, and a "note.trash" line
under [keymaps] in config.toml was dropped by the normalizer without a word,
although the manual said every shortcut was remappable.

Commands now get keys of their own, in a map of their own:
commandKeymaps, command id to binding, beside keymapOverrides and never
mixed with it. One id lives in both worlds, nav.back (the `h` back-out
action and the Go Back command), which is why the two cannot share a map.
Every command with a fixed id is bindable and starts unbound; the ones the
palette makes per vault (a saved Tasks filter, a workflow, a remote
profile, an assistant's edit) are left out, since their ids come and go. A
generated catalog in shared-domain lists the bindable ids with a stable
title (bindTitle for the toggles whose title flips with state), guarded
against drift by a test that rebuilds it from buildCommands().

A binding is a chord in the keymap table's format ("Mod+Backspace", or a
function key; a bare letter would eat typing), or, with Vim mode on, a
leader sequence stored as "Leader x t" so it follows a rebound leader.

Precedence: a key the user chose wins over every default. The chord
listener is on window in the capture phase, installed at import time right
after the ignored-keys guard, ahead of VimNav, App's own shortcuts and
CodeMirror: a command on Mod+Backspace trashes the note from the editor,
where that key would delete to the line start, and one on Mod+L takes the
key from Toggle checkbox. It stands down for What's New, Settings (and every
recorder), the palettes, dialogs, menus, an open completion list and hint
mode, and only listens in the main window, not quick capture or a floating
note. A held chord runs once. Leader sequences are the exception: they run
in VimNav after every built-in leader key, because built-in leader keys
always win, and they show in the which-key hints. With Vim off they are
inert.

When a command's when() says no, nothing runs and a toast says why. The
note family carries its reason ("Restore from Trash only works on a note in
the Trash"), the three shared guards (an open note, a note in the editor,
an open tab) get theirs in one place, and the rest say "isn't available
here".

Settings, Keymap gains a fifth group, Commands: title, category and id per
row, found by any of those or the keywords, with the same Change, Unbind and
Reset. A command that already shows a leader chord keeps it ("also Space l
s"). The recorder moved out of SettingsModal into KeymapRecorder and onto
ui/Modal; for a command, Enter saves, Escape cancels and Backspace clears,
since its binding is never one of those keys. Conflicts are checked both
ways: a key another of the user's own bindings holds is refused, a built-in
default is allowed with a warning naming the action that loses it, and a
sequence starting on a built-in leader key is refused (Space t x, since
Space t is the template picker). Action rows show "Taken by command" and
the like. Settings search finds a command row by its title.

In the palette, Alt+Enter on a row opens that same recorder over the
palette rather than jumping to Settings: the query and row stay put,
Escape comes back to them, the row shows its key at once, and nothing is
two Escapes away. The footer names the key. A bound key shows on its row
ahead of any key the command already had, a sequence only with Vim on.
Themes…, Switch Vault… and Run Workflow… gained a run() that opens the
palette on their list, so they work from a key and from the ex line.

On the ex line, `:bind <keys> <command.id>` takes Vim notation (<C-BS>,
<D-BS>, <leader>xa, <Space>xa) or the Settings spelling, with the same
refusals as the recorder. `:unbind <action.id>` keeps working, and
`:unbind command <command.id>` takes a command's key away; a bare id that
names no action but a command means the command, so nav.back stays the
action and `:unbind command nav.back` is the command.

Tests: parsing, normalizing (unknown ids, nav.back, leader refusals,
order), conflicts both ways, chord and leader matching, the recorder steps,
:bind and :unbind parsing and effects, dispatch with every bail-out and the
when() toast, VimNav sequences and hints with Vim on and off, the palette's
Alt+Enter flow, the Settings Commands group, and the catalog drift guard.
…nds] (#971)

commandKeymaps joins the portable preferences, so a key given to a command
syncs with the rest of the dotfile and a hand edit applies live.

The writer puts it in its own table under the action overrides:

  [keymaps.commands]
  "note.trash" = "Mod+Backspace"
  "note.archive" = "Leader x a"

followed by every bindable command as a commented reference line with its
category and title (`# "note.trash" = ""  # Note: Move Note to Trash`).
Every command starts unbound, so the reference shows "" and the header
says what a key may be. It is static, so the file reads the same bytes on
every OS (#901). "" is a deliberate unbind, as in [keymaps], and is marked.

The reader splits [keymaps]: action ids stay overrides, the commands
sub-table becomes commandKeymaps. As a courtesy to anyone who followed the
issue's example, a command id written straight under [keymaps] is read as a
command key when no action has that id; nav.back, which both have, stays
the back-out action. The canonical table wins over such a line, and since
the app rewrites the file in canonical form on launch, the line moves into
[keymaps.commands] the next time the file is written.

The renderer validates what it reads like any other pref: unknown ids and
keys it cannot read are dropped, and so is a sequence that starts on a
built-in leader key, which could never be reached. The web client keeps
these keys in its stored preferences, the same place as the overrides.

Tests: the round trip with an unbind, the reference, the courtesy read and
its precedence, byte-identical output across platforms, and a launch that
moves a bare id into its table on disk.
The manual promised that every shortcut was remappable while the palette's
commands had no keys at all. Now it can say so truthfully, and it says how:

- a How-To, "Give any command a key": Alt+Enter in the palette, the
  Commands group in Settings, chords and leader sequences, what Vim off
  does, why built-in leader keys win, the precedence over defaults and the
  editor's keys, the toast, :bind and :unbind command, [keymaps.commands];
- Alt+Enter (in the command palette) among the global shortcuts;
- a :bind entry on the ex line, and :unbind command beside :unbind;
- the Commands group, the two-way conflict rules and Reset all in the
  Keymaps settings section;
- [keymaps.commands] and the courtesy read in the configuration file entry.
… in Settings, config.toml, the palette and the ex line (#971)
…eir actions are bound to (#972)

#972 found two ways a list view's binding went dead without a word.

The sequence normalizer dropped `Mod`. `"nav.delete" = "Mod+Backspace"`
was stored as a bare Backspace, so plain Backspace started deleting in
the Archive and Trash views (behind their confirms) and Cmd+Backspace did
nothing. Keydowns never spell `Mod` (they say Ctrl, Alt, Meta), which is
why it was thrown away. Now the stored token keeps `Mod`, the portable
"Cmd on macOS, Ctrl elsewhere" that the shortcut format already uses, so
one config.toml means the same chord on a Mac and a Linux box; matching
resolves it per platform (resolveSequenceToken, applied by
getSequenceTokens and advanceSequence for every caller). A letter under
Mod is uppercased like one under Ctrl. Vim's sequence form follows: Mod is
<D-> on macOS and <C-> elsewhere, where it used to be <D-> everywhere.
Entries already saved as the bare key stay as they are; nothing guesses
which ones were meant to carry Mod.

Then every list view opened with `if (e.metaKey || e.ctrlKey || e.altKey)
return`, ahead of its nav.* checks, so a chord bound to one of its actions
never reached it. One helper replaces that bail (lib/list-view-keys.ts):

- a typed key reaches the view exactly as before, so bare x, d, j and k
  are untouched;
- a chord reaches it only when it is the key of one of the view's own
  actions, and never when a global or tab shortcut, or with Vim on the
  pane prefix, holds it: those keep their key everywhere;
- a chord never answers the view's hard-coded keys, so a binding on
  Alt+ArrowDown runs its action instead of moving the cursor;
- with Vim off typed keys stay off, as the Vim-off rule says, while a chord
  bound to an action works in either mode.

Converted: Archive, Trash, Quick Notes, Tags, Tasks (list), Unresolved
Links, the Timeline panel and the Workflows list. Left alone on purpose,
because none of them answers a bindable action and their letters are
hard-coded: the Kanban board, the Tasks calendar, the calendar panel, the
database grid, the Workflows canvas (its own Cmd+A comes before the bail)
and the icon and color pickers, which are modals. AtlasView, What's New,
the context menu, the date picker and the editor helpers keep theirs too.

A binding that can still never fire is now said out loud instead of saved
silently. Settings, Keymap marks the row "Never fires: Search notes has
this key", and the recorder warns before Save; it does not refuse, since
the user may be about to move the other key. The recorder also records a
Backspace or Delete held with Ctrl, Alt or Cmd: Backspace always cleared
the recording, so Mod+Backspace could not be recorded at all. A bare
Backspace still clears.

Tests: tokens with and without Mod on macOS, Linux and Windows, the bare
entry left bare, resolved sequences and Vim sequences, the shared guard
(typed keys, unrelated chords, a user chord, a global shortcut and the
pane prefix in the way), and the Settings warning.
…h, and Move Note to Trash follows the sidebar (#972)

The sidebar is where notes are walked with j and k, and it was the one list
with no key to trash the row under the cursor: nav.delete was handled by
the Archive, Trash and Workflows views only, so the mouse or the palette
(which acts on the open note, not the row) were the only ways.

Now, with the sidebar holding the keyboard:

- `x` (nav.delete) with Vim on, and Mod+Backspace in either mode (a new
  action, Move sidebar note to Trash, nav.sidebarTrash, rebindable and
  unbindable like any other) act on the row under the cursor. With Vim off
  `x` does nothing, as every single-key list shortcut.
- A note moves to the Trash after the same confirm the Archive view and the
  palette show. Escape on it leaves everything as it was.
- When that note is part of a multi-selection, the whole selection goes,
  with one confirm ("Move 3 notes to Trash?"), through the batch path the
  selection menu uses, and the selection clears.
- A note already in the Trash would be deleted for good, after the Trash
  view's confirm. The sidebar does not list trashed notes today (Trash is
  a row that opens its view), so this only guards the path.
- A folder is left alone and a note says to use its menu. Deleting a
  folder takes everything inside and cannot be undone, while the same key
  on a note is a reversible move; one key meaning both on neighbouring
  rows is how a j/k walk ends in a lost folder. Any other row (a tag, the
  Trash row) gets "Only a note can go to the Trash from here".

The cursor then sits on the row that took the note's place, or the one
before it at a folder's end: the anchoring in lib/sidebar-cursor.ts already
does that once the row is gone. What it did not cover is the open note:
trashing it closes its tab, the next tab becomes the open note, and the
sidebar's reveal effect read that as a new pick and pulled the cursor onto
it. Measured in the built app: without a hold the cursor jumped from P23
to P20. The sidebar now holds its cursor while one of these actions runs.
The editor is not focused afterwards.

The typed `x` is handled in VimNav's sidebar handler, after the leader and
pane-prefix machinery, so `Space x` still reaches a leader binding; it is
consumed there, so a list view left active behind the sidebar never acts
on its own row for it. The chord form is a capture listener App installs
at import time, after the command keymap dispatcher (a key the user gave a
command still wins) and ahead of every component, since the Archive and
Trash views listen on the window too. It stands down for dialogs, menus,
palettes, Settings and text fields, steps aside for a global shortcut on
the same chord, and runs once per press.

Move Note to Trash and Move Note to Archive, from the palette or a key the
user gave them (#971), follow the same row while the sidebar holds focus
and its cursor is on a note, selection rule included, instead of the open
note. Elsewhere, or on a row that is not a note, they act on the open note
as before. Their when() works with no note open, and a refusal says why
("This note is already in the Trash").

The sidebar's selection is React state, so it registers a small host
(lib/sidebar-note-actions.ts) that hands over a snapshot, clears it after a
batch and holds the cursor.

Tests: the plan for a single row, a selection with and without the cursor
row, a selection with folders, a trashed row, archiving; the chord
listener (both platforms, Vim off, a user's nav.delete chord, focus,
dialogs, text fields, a global shortcut, auto-repeat, order against a
later window listener); the cursor-row runner; and the palette commands
following the sidebar.
…st chords work (#972)

- a How-To, "Trash notes from the sidebar": Mod+Backspace in either mode
  and `x` with Vim on, the one confirm for a selection, Escape, where the
  cursor lands, why folders are left to their menu, Move Note to Trash
  and Move Note to Archive following the sidebar, and the action's name
  in Settings;
- a How-To, "Ctrl, Alt and Cmd keys in list views": a chord bound to a
  list action works with Vim on or off, `Mod` is portable, a global
  shortcut keeps its key, and what "Never fires" in Settings means;
- `x` and Mod+Backspace in the sidebar shortcut table, which now says
  Mod+Backspace works with Vim off too;
- the Tasks, tags and trash views note that a rebound chord works in
  either mode.
…ar, Move Note to Trash follows it, and list views hear their chords (#972)
…idebar keeps the keyboard

With Vim mode off, a focused sidebar went through App's catch-all for
panels, which treats every panel as a flat list: the arrows moved the
cursor, Home and End jumped, Enter clicked the row, and ← or Escape handed
the keyboard back to the editor. A folder could not be expanded or
collapsed from the keyboard (→ did nothing, ← left the sidebar), no key
opened a row's menu, and Page Up / Page Down did nothing. The handler that
knows the sidebar's rows (folders, tags, date groups, databases), VimNav's
handleSidebarKey, only listened with Vim on.

That handler now serves the sidebar in both modes. With Vim off, a
listener VimNav installs once routes the focused sidebar's keys to it, and
the handler keeps the typed keys of its actions (j, k, l, h, o, m, /, gg,
G, x) off in that mode through listViewBindingMatches, the rule every list
follows. With Vim off:

- ↑ / ↓ move the cursor, Home / End jump to the ends, and Page Up /
  Page Down move a screenful of rows (what the scroll box shows, less one).
- → and Enter open the row, as l does: a note opens and the editor takes
  the keyboard, a folder expands.
- ← collapses a folder, or from a note folds its folder and lands on it,
  as h does. It no longer leaves the sidebar; Escape does.
- Shift+F10 and the Context Menu key open the row's menu.
- Mod+Backspace trashes the cursor row, as before (#972).

The listener stands down for chords, dialogs, menus, palettes, Settings,
text fields and self-keyed surfaces, and the catch-all leaves the sidebar
to it. It is installed once rather than per mode so it keeps its early
place among the window's capture listeners, ahead of a list view left
active behind the sidebar. Enter on a note now opens it the way Vim's
Enter and l do (a kept tab), where the catch-all clicked the row (a
preview tab).

Vim mode is unchanged: there the gating is matchesSequenceToken, as
before, and Home, End and the page keys are left alone (gg and G are its
keys). The three Vim smoke suites and #972's sidebar driver pass on the
built app.

Reaching the sidebar with Vim off: Alt+H works. Focus Sidebar from the
command palette did not, in either mode. Closing the palette gives focus
back to the editor, the editor claims its panel again, and the palette's
refocus, which read the panel after the command had run, then took the
keyboard straight back. Measured over CDP: the sidebar took DOM focus, and
the editor took it back a millisecond later. The palette now reads the
panel a command moved the keyboard to as it ran, before that close lands.
Commands that do not move the panel as they run see no change.

A plain click on a note still opens it and hands the keyboard to the
editor (#599), so Mod+Backspace right after one is the editor's delete to
line start, undoable, never a trash. The clicks that mean the row keep the
keyboard in the sidebar, where Mod+Backspace acts on the cursor row:
Cmd-click and Shift-click (the selection), a folder row, a row's menu.

Tests: with Vim off, the arrows, Home / End and the page keys move, Enter
opens and hands over, → expands and ← collapses without leaving, ← steps
from a note to its folder, Escape returns, the menu keys open the cursor
row's menu, every typed key is left alone, chords pass, and nothing
answers unless the sidebar holds the keyboard or while a dialog, a menu
or a text field has it. With Vim on, j / k, G, gg, l, / and Escape as
before, and Home, End and the page keys untouched.
When the sidebar's cursor sat on a row that was part of the
multi-selection, the row drew the selection's tint and nothing else:
NoteLeaf and TreeRow pick one class for a selected row and never looked at
the cursor. A cursor walking through a selection vanished on every
selected row, and x or Mod+Backspace then acted on a row that showed no
cursor. #972's driver measured it without meaning to: with three notes
Cmd-clicked, no row in the sidebar wore the cursor.

A selected row under the cursor now keeps the tint and adds the cursor's
ring, `vim-cursor-on-selected` (an inset 2px ring in the theme accent at
65%), the class the note list already uses for the same case. The ring
stacks with the tint instead of replacing it, follows each theme's
accent, and needs no new token or arbitrary value. Both row kinds share
one helper. Only a class changes, so the windowed sidebar's DOM contract
(the data-sidebar-* attributes the keys and range-select read) is
untouched.

Checked in the built app with P20 to P22 selected and the cursor on P21,
in Gruvbox dark, Apple light, Catppuccin Latte (the loudest accent, peach)
and Catppuccin Mocha: the cursor row carries the ring over the same tint
as its selected neighbours, and they carry none.

Tests: the helper adds the ring only under the cursor and keeps the tint
either way, and the stylesheet's ring is an inset accent outline with no
background of its own.
… tree, and the cursor shows on a selected row
…ments answer their keys

With Vim mode off, these four panels went through App's catch-all for
panels, which treats each as a flat list: ↑ / ↓, Home / End, Enter (not in
comments), and ← or Escape back to the editor. Measured in the built app
against Vim on, they lacked:

- Note list: no menu keys (Shift+F10, the Context Menu key); ← went to
  the editor where h goes to the sidebar; → did nothing; End stopped at
  the last row on screen instead of the last note, because the list
  renders only the rows near the viewport.
- Connections and outline: → did not open the row.
- Comments: →, Enter, Backspace and Delete did nothing.
- All four: Page Up / Page Down did nothing.

The handlers that know these panels are VimNav's, and they only listened
with Vim on. As 9b070af did for the sidebar, each now serves its panel in
both modes: the listener VimNav installs once for Vim off routes the
focused panel's keys to the handler Vim mode uses, and the handler keeps
the typed keys of its actions off in that mode through
listViewBindingMatches (the comments panel's own letters, o e r a d n +,
through a vimMode check). The catch-all served only these panels and the
sidebar, so it is gone, with the panel-rows helpers only it used. With
Vim off, in each panel:

- ↑ / ↓ move, Home / End jump to the ends, Page Up / Page Down move a
  screenful.
- → and Enter open the row, as l does: the note list and connections
  open the note and hand the keyboard to the editor, the outline jumps to
  the heading, comments jump to the comment's text.
- ← goes back as h does: from the note list to the sidebar, from the
  other three to the editor. Escape returns to the editor.
- Shift+F10 and the Context Menu key open the row's menu in the note
  list, the one panel of the four whose rows have one.
- Backspace and Delete delete the selected comment, after a confirm.
- Letters do nothing.

A page in the note list, which renders only the rows near the viewport,
is the rows its box shows, less one, so the row the cursor leaves stays on
screen. It is counted by the step from one row to the next and the height
inside the box's padding: the note list's rows sit 4 px apart in a padded
box, and counting them by their own height paged one row too far. The
sidebar's page goes through the same helper and stays what it was (its
rows touch and its box has no padding). Connections, the outline and
comments render every card, and the cards differ in height, so a page
there is the furthest card that still fits on one screen with the one the
cursor leaves: a tall card never makes the cursor skip one the screen has
not shown.

Enter on a note in the note list now opens it the way Vim's Enter and l
do (a kept tab), where the catch-all clicked the row (a preview tab).

Two deliberate changes with Vim on:

- Deleting a comment from the keyboard asks first, in both modes: d,
  Backspace and Delete with Vim on, Backspace and Delete with Vim off
  ("Delete this comment? This cannot be undone."). A deleted comment has
  no Trash and no undo, and Backspace is the key most often pressed by
  accident. Escape on the confirm leaves the comment and the cursor where
  they were. The card's own Delete button, a deliberate click, still
  deletes at once, without asking.
- Enter or l on a file row in the note list (an image beside the notes)
  opens it in a file tab, the way its click does. Vim mode handed the
  file to selectNote, which loaded the PNG into the editor as a note's
  text; the catch-all clicked the row, so Vim off got it right and would
  have inherited the bug.

Otherwise Vim mode is unchanged: its gating is still matchesSequenceToken,
and Home, End and the page keys are left alone (gg and G are its keys).

The Vim-off listener also yields to the surfaces Vim mode yields to by
DOM focus (the calendar and timeline panels, the selection toolbar, an
Excalidraw canvas): the store can still name a right-side panel while one
of them holds focus.

Reaching the panels: Alt+H and Alt+L reach each one with Vim off and the
panel keeps the keyboard. The palette has no Focus command for any of the
four (it has Focus Sidebar and Focus Editor; Focus Pane Left and its
siblings move between editor splits), so the refocus 9b070af fixed for
Focus Sidebar has nothing to hit here.

Checked in the built app over CDP, Vim off, in each panel: reached with
Alt+H / Alt+L, walked, paged (the row left behind still on screen), End
and Home, → opens, ← goes back, Escape returns, letters do nothing;
Shift+F10 and the Context Menu key open the note list's menu; Backspace
and Delete in comments ask, Escape keeps the comment, Enter deletes it on
disk; the card's Delete button deletes without asking (58 checks). Vim on:
j / k, G, gg, m, h, l in each panel, Page Down and End left alone, d and
Backspace ask, l on a file row opens a file tab (24 checks). The three Vim
smoke suites pass on the same build.

Tests: per panel, with Vim off the arrows, Home / End and the page keys
move, → and Enter open, ← and Escape go back, every typed key is left
alone, and nothing answers under a dialog or a menu or from a text field;
the note list's menu keys open the cursor row's menu and its file row
opens through its click; comments' Backspace and Delete ask and delete
only on yes. With Vim on, the letters as before, Home, End and the page
keys untouched, d / Backspace / Delete ask. The page helpers: the page
keys only with Vim off and never as a chord, a page counted by row step
inside the padding, and a tall card never skipped. 17 of the new panel
tests fail on the code before this change.

The manual says how each panel answers with Vim off, and that deleting a
comment from the keyboard asks first.
With Vim mode off the command palette still showed keys that only Vim
mode has: leader chords (Space d for today's note, Space l s for
Favorite), the Ctrl+W pane prefix on the Focus Pane commands, the fold
keys (z c, z M), Ctrl+O / Ctrl+I on Go Back and Go Forward, and ex
commands (:w, :format, :cover!, :split, :tasks, :tag, :unresolved). None
of those keys does anything with Vim off, which is the leak the Vim-off
hint audit closed everywhere else on screen. A few rows already guarded
their leader chord by hand; most did not.

The palette's key helpers now carry the rule once: leader chords, the
pane prefix, ex commands and the vim.* keys show only with Vim mode on.
Go Back and Go Forward show their global keys (Alt+Left / Alt+Right)
with Vim off instead of nothing, since those work in both modes. Keys a
user gave a command (#971) are untouched by this.

Test: with Vim off no palette row shows a leader chord, the pane
prefix, an ex command or a Vim-group key (17 rows did before), with Vim
on they still show, and Go Back / Go Forward show the global keys.
… on the next one

Deleting the active comment clears activeCommentId in the store, and
the comments panel puts a cleared cursor on the first comment, so after
d / Backspace / Delete the cursor jumped back to the top of the list
and a run of deletes could not walk down it.

The keyboard delete now notes the comment after the deleted one in the
panel's order (the one before it, when the last goes) and makes it
active once the delete has run. Cancelling the confirm still leaves the
cursor where it was, and the card's own Delete button is unchanged.

Tests: after deleting c1 the cursor is on c2; after deleting the last
comment it is on the one before. Both fail on the previous code.
…our machine

Adib changed the Unsplash design: each person pastes their own Unsplash
Access Key and the app asks api.unsplash.com directly. The zennotes.org
proxy and its one shared key are gone from the app (the website's
feat/unsplash-covers stays unmerged), so the release waits on nothing
from Unsplash, and one person's searches never spend anyone else's hour.

The key goes in under Settings, Typography, in a new Covers section that
now also holds the Note covers placement row. Paste and press Enter (or
Save): the key is checked first with one request, GET /photos?per_page=1,
and kept unless Unsplash answers 401, so a mistyped key or a Secret Key
is refused on the spot ("Check that you copied the Access Key, not the
Secret Key"). A key that could not be checked (no connection, the hour
spent) is kept, and the line says why. Test asks again and says what is
left of the hour ("48 of 50 requests left this hour"); Clear removes it.
The field is write-only, so a saved key is never drawn back. The buttons
stay enabled while a request is out, and when the one that had the
keyboard disables itself (Clear, or Save once the field empties) the
field takes the keyboard rather than the page. Settings search finds the
row by "unsplash", "cover" and "api key", and Set Unsplash Access Key…
in the palette opens Settings in the field.

Desktop main keeps the key where the Cloud credential lives: sealed by
Electron safeStorage in the secrets file under userData (secret-store),
never in config.toml, vault.json, a log, an error, or anything the
renderer keeps. The renderer hears only whether a key is set, where it
is kept and whether it could be. Without OS encryption (a Linux
compositor Chromium does not recognize, as on Niri) the key is refused
rather than written in the clear, and the row suggests
--password-store=gnome-libsecret.

Search, the check and the download ping go from main to
api.unsplash.com with Authorization: Client-ID <key> and Accept-Version:
v1, with no cookies, a bare user agent, no language and no referrer;
the key travels in the header, never the URL. Search asks for
landscape, safe-search photos, 24 a page, and the ping is a GET of the
photo's download_location with the key. The 40-a-minute cap per run
stays, and now keeps a renderer loop from spending the person's own 50
an hour. The answer is Unsplash's own JSON, so shared-domain unsplash.ts
reads it directly (user.links.html, links.download_location,
alt_description) and keeps the proxy's rules: images only from
images.unsplash.com, links only to unsplash.com, a ping only to an
api.unsplash.com download endpoint, no Unsplash+ photo. The client
itself (status, save, test, clear, search, ping, the cap and the
argument checks) moved to shared-domain as createUnsplashClient, so
desktop main and the web client run the same code with their own fetch
and key store. ZENNOTES_UNSPLASH_PROXY_URL becomes
ZENNOTES_UNSPLASH_API_URL, honored as before only unpackaged or with
ZEN_PERF=1.

Without a key the Unsplash tab sends nothing and shows a setup panel in
place of photos: what the key is for, the three steps (create a New
Application at unsplash.com/developers, copy its Access Key, paste it
in Settings), Open Settings and the developers page. The field keeps
focus as it does over the tiles: ↑/↓ choose, Enter runs, so Space l u
then Enter is the whole way to the key field, with Vim on or off. A
refused key (401) reads "Unsplash did not accept your access key" with
Change key; a spent hour (403 with X-Ratelimit-Remaining at 0) explains
that a new Unsplash app allows 50 requests an hour until Unsplash
approves it for production, or names the allowance Unsplash reports;
no connection is a plain sentence.

The web client keeps its key in the browser, under its own
localStorage entry that only the bridge reads, and calls Unsplash from
the browser. api.unsplash.com answers any origin, allows the
Authorization header in its preflight and exposes X-Ratelimit-Remaining,
and znserver's CSP already allows https: in connect-src, so the key
never reaches the self-hosted server or zennotes.org. A browser has no
keychain to offer, the web client's own session is a cookie and not in
that storage, and the key is worth an hour of photo searches and can be
revoked at unsplash.com/oauth/applications, so the tab stays on the web
rather than going dark there. The phones still have no tab.

Not here: the website's docs and privacy text are on its
feat/unsplash-own-key branch.

Verified in the built app over CDP (56 checks), both stores isolated
and --use-mock-keychain so the real Keychain is never touched, against a
local stand-in for api.unsplash.com: Space l u and :cover unsplash fog
without a key showing the panel and sending nothing; ↓ and Enter
opening unsplash.com/developers; Enter on Open Settings landing in the
highlighted key field; a malformed key refused without a request and a
wrong one refused after one, neither stored; the right key saved sealed
(the secrets file holds it encrypted, no file under the run holds it as
typed, nor the page or localStorage); Tab to Test; a search with
Client-ID, no cookie, referrer or key in the URL, and the Unsplash+
photo left out; Enter writing the banner and credit, one ping with the
key, the credit on hover; the rate-limited, refused-key (Change key
into Settings, then Test) and offline messages; Clear from the
keyboard and the panel coming back; the palette command; Settings
search by unsplash, cover and api key.

Tests: the key store against a stand-in safeStorage (round trip,
cleared, sealed on disk, refused without encryption, never in config or
an answer), the API client (headers and query, 401, 403 with and
without allowance left, network, the ping's target, the check, save,
clear, the cap, argument checks), the no-key tab (the panel, no search,
Enter into Settings, ↓ Enter to the developers page, Change key, a key
that works), and the messages. shared-domain 2,198, app-core 3,992,
desktop 1,250 and quicklook 18 pass; typecheck clean.

How to test locally: build the desktop app and run
electron apps/desktop/out/main/index.js. Open a note and press Space l u:
the tab shows how to get a key. Press Enter, paste the Access Key of an
app made at unsplash.com/developers and press Enter: the line says
Saved, with the requests left this hour. Escape, Space l u, type "fog",
pick a photo with Enter: the note gains banner, banner_credit and
banner_credit_url, and hovering the cover shows "Photo by ... on
Unsplash". Back in Settings, Clear: the tab asks for a key again.
An Unsplash cover leaves four lines in a note's frontmatter, and the first
is a URL a few hundred characters long. Adib opened such a note in Edit mode
and called the properties card too much text to look at. The cover drawn
above the note already shows the picture and its credit, so in Edit and in
Split's editor (Live Preview) those lines now fold into one muted line in
the card, where the first of them was:

  cover · Unsplash photo by Kalen Emsley
  cover · lisbon.jpg                 an image in the vault
  cover · Dusk, cover · The Great Wave   a gradient, a gallery photo
  cover · image from example.com     any other web image
  cover                              nothing more to say

The other properties keep their rows, so a note with only cover keys shows
a card of one line, and tags and due dates stay as they were.

Which lines fold comes from the shared cover reader, not a second key list.
readNoteCoverLines (shared-domain) finds the line of each key the cover owns
(banner, banner_y, banner-y, banner_credit, banner_credit_url, or the cover
family when a note uses Obsidian's cover:), with the indented lines a YAML
value runs on to, and noteCoverFieldKeys is now the one list that
setNoteCover and removeNoteCover clear too. The line names a gradient or a
gallery photo by the title the picker shows, matched by the file name the
pick wrote (builtinCoverForAsset). A cover nothing draws folds nothing, and
neither does a pane with covers hidden in Settings, where the raw lines are
the only place the cover shows. Source mode and Preview are unchanged.

The lines open while the selection touches one of them and fold again when
it leaves, the rule Live Preview uses for a heading's #, and a note being
read (a phone with the keyboard down) keeps them folded. j/k and the arrow
keys step into the folded line one logical line at a time, the way they
step into a rendered math block: k from under the card lands on the last
cover line, j from above on the first, with Vim on or off, in normal and
insert mode. A banner_y written later under other keys takes no row and
opens the same way. A click on the line opens it with the caret at the
start of the first cover line.

Opening the lines from below grew the card above the caret and drew the
caret two to four rows lower than the line it stepped onto. The
transaction that opens or closes the fold now notes where the head's line
is drawn, and the scroll that follows puts it back there, after Vim's
scrolloff. A note shorter than the window gets an empty box under the text
to make that room, gone again once the view fits the note. At the very top
of a note nothing can scroll further up, so closing the lines downward
there still pulls the text up by the folded height.

It is decoration only. The document never changes, so select-all, copy,
find and undo see the raw lines, and no file is written. The in-app manual
says all this in the two cover guides and the Note covers setting.

Verified with unit tests (the line reader, the line's text for each cover
kind, opening and folding, Vim and arrow stepping, the click, the scroll
back), typecheck, test:run, the three Vim smoke suites, and 53 CDP checks
on the built app: Vim on and off, insert mode, Split, source mode, a short
note, a mixed note and a banner_y under tags.
…ugh one handle

CodeQL flagged a file-system race in drainShareInbox (js/file-system-race,
high): it stat()ed a capture to enforce the 2 MB cap, then read it with a
separate readFile, so a capture swapped or grown between the two calls
could slip past the cap. The share extension writes these files into the
app's own Application Support folder, so the window was narrow, but the
check should hold anyway.

The drain now opens the capture once, reads its size from that handle and
reads the text from the same handle, and sets aside anything over the cap
whether the stat or the text says so. A capture that is gone or locked is
still left for the next drain, as before.

Test: a capture over 2 MB is set aside into the unreadable folder and never
filed (there was no test for the cap before).
The 2.79.0 release PR (#1010) failed on three test problems, none of them
in the product:

- commands.test.ts checked "no Vim key in the palette with Vim mode off" by
  comparing displayed keys, and on Linux and Windows Open File's Mod+O and
  Vim's Ctrl+O both read Ctrl+O, so the global key was flagged. The six
  commands that show a Vim-group key are now checked by id.
- test-app-core-browser.mjs replaced a note's text with Mod+A then typing.
  Since #1006 the first Mod+A leaves a note's leading `# Title` out (the
  second takes the whole note), so a note that starts with a title kept it
  and the saved file never matched. Every step that replaces a note's text
  now selects all twice; on a note without a title both presses select
  everything, so nothing else changes.
- unsplash.test.ts walked a directory with stat() then readFile() on each
  path, which CodeQL reports as a file-system race even in a test; it now
  reads the directory's entry types instead.
noteFolderSubpath stopped being used in commands.ts during the 2.79.0 cycle
(CodeQL js/unused-local-variable on the release PR).
@adibhanna
adibhanna merged commit 584487f into main Oct 28, 2026
10 checks passed
@adibhanna
adibhanna deleted the v2.79.0 branch October 28, 2026 22:05
@adibhanna
adibhanna restored the v2.79.0 branch October 28, 2026 22:05
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 22:18 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 22:19 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 22:21 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 23:17 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 23:18 — with GitHub Actions Active
@adibhanna
adibhanna deployed to boundary-artifacts October 28, 2026 23:18 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
boundary-artifacts — 584487ff Deployed Oct 28, 2026 by adibhanna via draft #83
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants