diff --git a/apps/desktop/build/tray/tray.png b/apps/desktop/build/tray/tray.png new file mode 100644 index 00000000..77feb3fa Binary files /dev/null and b/apps/desktop/build/tray/tray.png differ diff --git a/apps/desktop/build/tray/tray@2x.png b/apps/desktop/build/tray/tray@2x.png new file mode 100644 index 00000000..0520250e Binary files /dev/null and b/apps/desktop/build/tray/tray@2x.png differ diff --git a/apps/desktop/build/tray/trayTemplate.png b/apps/desktop/build/tray/trayTemplate.png new file mode 100644 index 00000000..9820afa4 Binary files /dev/null and b/apps/desktop/build/tray/trayTemplate.png differ diff --git a/apps/desktop/build/tray/trayTemplate@2x.png b/apps/desktop/build/tray/trayTemplate@2x.png new file mode 100644 index 00000000..d2c7aaee Binary files /dev/null and b/apps/desktop/build/tray/trayTemplate@2x.png differ diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index d587e3f9..7c712a32 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -145,10 +145,13 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-textmate' } - // No manualChunks rule for mermaid / cytoscape / dagre on purpose. Mermaid is - // only ever reached through a dynamic import (Preview.tsx does - // `import("mermaid")`, behind LazyPreview), but forcing its modules into a - // named chunk made Rollup hoist that chunk into the entry's STATIC graph: + // No manualChunks rule for the diagram stacks on purpose. Mermaid fences are + // drawn by Pele, reached only through `import("./pele-runtime")` in + // mermaid-render.ts; mermaid.js itself (with cytoscape / dagre) now arrives + // only with Excalidraw's Mermaid-to-Excalidraw dialog, also behind a dynamic + // import. Both stay async only while Rollup is left to chunk them. When + // mermaid's modules were forced into a named chunk, Rollup hoisted that chunk + // into the entry's STATIC graph: // `index-*.js` ended up with `import{m as gt}from"./vendor-mermaid-*.js"`, // where gt is mermaid itself, so every launch fetched and evaluated 2.5MB of // diagram code (and vendor-markdown with it, which vendor-mermaid imports) @@ -200,8 +203,14 @@ function isDeferredRendererPreload(dep: string): boolean { dep.includes('vendor-highlight') || dep.includes('vendor-textmate') || dep.includes('vendor-d3') || - // Mermaid is no longer one named chunk; it splits into mermaid.core plus a - // chunk per diagram type, with cytoscape/dagre alongside. + // Pele, the mermaid-fence renderer: its runtime chunk, after which each + // diagram type fetches a chunk of its own on first use. + dep.includes('pele-runtime') || + // D2, the ```d2 renderer: one chunk of about 8 MB with its WebAssembly + // compiler inside, fetched only when a note holds a d2 fence. + dep.includes('d2-runtime') || + // mermaid.js (Excalidraw's Mermaid dialog only) splits into mermaid.core plus + // a chunk per diagram type, with cytoscape/dagre alongside. dep.includes('mermaid') || dep.includes('cytoscape') || dep.includes('dagre') || diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6b8f49c3..6b88af84 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.70.0", + "version": "2.71.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -53,6 +53,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", @@ -69,8 +70,8 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", "node-tikzjax": "^1.0.5", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -139,6 +140,10 @@ "from": "build/icon.png", "to": "icon.png" }, + { + "from": "build/tray", + "to": "tray" + }, { "from": "build/zen", "to": "zen" diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4118d227..90501a5c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,5 +1,6 @@ import { app, + autoUpdater as nativeAutoUpdater, BrowserWindow, dialog, globalShortcut, @@ -280,6 +281,15 @@ import { installRaycastExtension, } from "./raycast-integration"; import { sendUsageCount } from "./usage-count"; +import { + hideTray, + parseTraySyncStatus, + SessionEndWatch, + setTraySyncStatus, + shouldHideOnClose, + showTray, +} from "./tray"; +import { shouldQuitWithOnlyQuickCaptureLeft } from "./last-window"; import { checkForAppUpdates, describeInstall, @@ -367,6 +377,18 @@ const installedBundle = createInstalledBundleGuard( let mainWindow: BrowserWindow | null = null; let mainWindowReadyForAppEvents = false; let creatingMainWindow: Promise | null = null; +// "Keep running in the tray" (#947), mirrored from the persisted config so a +// window's close handler can decide synchronously. +let keepRunningInTray = false; +// Set once the app is really exiting, so close-to-tray stands aside: the +// tray's Quit, Cmd+Q, or an update's restart. +let appQuitting = false; +// Windows signing out, restarting or shutting down stands it aside too, but +// only while the session is really ending: a cancelled sign-out lapses. +const sessionEnd = new SessionEndWatch(); +// Workspace windows a close hid to the tray, so the tray brings back exactly +// those and never a window that is still loading behind `show: false`. +const trayHiddenWindowIds = new Set(); let currentVault: VaultInfo | null = null; let currentWorkspaceMode: "local" | "remote" = "local"; let remoteWorkspaceConfig: PersistedRemoteWorkspaceConfig | null = null; @@ -788,6 +810,60 @@ function focusWindow(win: BrowserWindow): void { } } +// A real hide, not a minimise: a tiling window manager has nowhere to +// minimise to, which is the case the tray exists for (#947). +function hideWindowToTray(win: BrowserWindow): void { + if (win.isDestroyed()) return; + trayHiddenWindowIds.add(win.id); + win.hide(); +} + +function hasVisibleWorkspaceWindow(): boolean { + return BrowserWindow.getAllWindows().some( + (win) => !win.isDestroyed() && isWorkspaceWindow(win) && win.isVisible(), + ); +} + +/** Bring back every window the tray is holding, the main one in front. With + * none held and none open, open the main window. */ +function revealTrayHiddenWindows(): void { + const held = BrowserWindow.getAllWindows().filter( + (win) => !win.isDestroyed() && trayHiddenWindowIds.has(win.id), + ); + trayHiddenWindowIds.clear(); + for (const win of held) if (win !== mainWindow) win.show(); + if (mainWindow && !mainWindow.isDestroyed()) { + focusWindow(mainWindow); + return; + } + const last = held.at(-1); + if (last) { + focusWindow(last); + return; + } + void ensureMainWindow(); +} + +function applyKeepRunningInTray(enabled: boolean): void { + keepRunningInTray = enabled; + if (!enabled) { + hideTray(); + // Nothing would bring a held window back once the icon is gone, even + // with another window still open: closing that one later would leave + // the app running with no window and no icon. + if (trayHiddenWindowIds.size > 0) revealTrayHiddenWindows(); + return; + } + showTray({ + showWindows: revealTrayHiddenWindows, + openQuickCapture: () => void toggleQuickCaptureWindow(), + quit: () => { + appQuitting = true; + app.quit(); + }, + }); +} + // Dispatch a note-open to a specific window, deferring until that // window's renderer reports ready so the request isn't dropped on a // freshly created window. @@ -1769,8 +1845,32 @@ async function createWindow( win.on("maximize", scheduleWindowStatePersist); win.on("unmaximize", scheduleWindowStatePersist); win.on("close", flushWindowStatePersist); + win.on("close", (event) => { + const hide = shouldHideOnClose({ + keepRunningInTray, + quitting: appQuitting, + sessionEnding: sessionEnd.isEnding(performance.now()), + isWorkspaceWindow: isWorkspaceWindow(win), + otherVisibleWorkspaceWindows: BrowserWindow.getAllWindows().filter( + (other) => + other !== win && + !other.isDestroyed() && + isWorkspaceWindow(other) && + other.isVisible(), + ).length, + }); + if (!hide) return; + event.preventDefault(); + hideWindowToTray(win); + }); + // Windows asks every window before logging off; a close we prevent there + // would hold up the session ending. Asking is not ending, so the question + // lapses unless `session-end` follows. + win.on("query-session-end", () => sessionEnd.query(performance.now())); + win.on("session-end", () => sessionEnd.end()); win.on("closed", () => { if (persistWindowStateTimer) clearTimeout(persistWindowStateTimer); + trayHiddenWindowIds.delete(win.id); workspaceWindowIds.delete(win.id); windowVaults.clearWindow(win.id); readyWindowIds.delete(win.id); @@ -4953,6 +5053,35 @@ function registerIpc(): void { return { ok: result.ok, hotkey: trimmed, error: result.error }; }); + handle(IPC.APP_GET_KEEP_RUNNING_IN_TRAY, () => keepRunningInTray); + + handle(IPC.APP_SET_KEEP_RUNNING_IN_TRAY, async (_e, enabled: unknown) => { + const next = enabled === true; + await updateConfig((cfg) => ({ ...cfg, keepRunningInTray: next })); + applyKeepRunningInTray(next); + return keepRunningInTray; + }); + + handle(IPC.WINDOW_HIDE_TO_TRAY, (event) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!keepRunningInTray || !win || !isWorkspaceWindow(win)) return false; + hideWindowToTray(win); + return true; + }); + + on(IPC.APP_QUIT, () => { + appQuitting = true; + app.quit(); + }); + + on(IPC.APP_TRAY_SYNC_STATUS, (event, raw: unknown) => { + // One line for one app: the main window speaks for the tray, and a + // second window's vault does not overwrite it. + const win = BrowserWindow.fromWebContents(event.sender); + if (!win || win !== mainWindow) return; + setTraySyncStatus(parseTraySyncStatus(raw)); + }); + handle(IPC.APP_GET_QUICK_CAPTURE_PINNED, async () => { const cfg = await loadConfig(); quickCapturePinned = cfg.quickCapturePinned; @@ -5322,6 +5451,9 @@ async function ensureQuickCaptureWindow(): Promise { if (quickCaptureWindow === win) quickCaptureWindow = null; windowVaults.clearWindow(win.id); }); + // A panel left on screen after the last window closed holds the app only + // until it hides. + win.on("hide", scheduleQuitIfOnlyQuickCaptureLeft); win.on("blur", () => { // Focus-out hides the panel so the user's flow snaps back to whatever // they were doing — same UX as Spotlight / Raycast. When pinned, the @@ -6003,6 +6135,13 @@ app.whenReady().then(async () => { scheduleBackgroundAppUpdateCheck(); scheduleCliUpdateChecks(); + try { + const { keepRunningInTray: trayOn } = await loadConfig(); + if (trayOn) applyKeepRunningInTray(true); + } catch (err) { + console.warn("Tray setup failed", err); + } + try { const cfg = await loadConfig(); // loadConfig always yields a normalized string here, and empty string is @@ -6018,8 +6157,10 @@ app.whenReady().then(async () => { } app.on("activate", () => { - // A dock click must bring back a usable window when none is left. + // A dock click must bring back a usable window when none is left, and + // the windows the tray is holding when only those are left. if (!hasWorkspaceWindow()) void ensureMainWindow(); + else if (!hasVisibleWorkspaceWindow()) revealTrayHiddenWindows(); }); app.on("new-window-for-tab", () => { @@ -6072,11 +6213,15 @@ app.on("second-instance", (_event, argv) => { // window is left to show. void settleFileOpens().then(() => { if (!hasWorkspaceWindow()) return ensureMainWindow(); + // A path the opener ignored (not markdown, gone) leaves the windows + // the tray holds out of sight; opening ZenNotes again must show one. + if (!hasVisibleWorkspaceWindow()) revealTrayHiddenWindows(); }); return; } if (mainWindow && !mainWindow.isDestroyed()) { - focusWindow(mainWindow); + // Focuses the main window, and shows any the tray is holding (#947). + revealTrayHiddenWindows(); return; } void ensureMainWindow(); @@ -6088,7 +6233,52 @@ app.on("window-all-closed", () => { if (!isMac()) app.quit(); }); +// Electron counts the hidden Quick capture panel as an open window, so +// `window-all-closed` above never fires once the panel has been used. +let windowClosedThisRun = false; + +// Deferred so the closed window's own handlers finish and the panel's +// visibility settles before the windows are counted. +function scheduleQuitIfOnlyQuickCaptureLeft(): void { + setImmediate(() => { + const capture = + quickCaptureWindow && !quickCaptureWindow.isDestroyed() + ? quickCaptureWindow + : null; + const otherWindows = BrowserWindow.getAllWindows().filter( + (win) => !win.isDestroyed() && win !== capture, + ).length; + let quickCapture: "none" | "hidden" | "visible" = "none"; + if (capture) quickCapture = capture.isVisible() ? "visible" : "hidden"; + const quit = shouldQuitWithOnlyQuickCaptureLeft({ + isMac: isMac(), + keepRunningInTray, + quitting: appQuitting, + windowClosed: windowClosedThisRun, + otherWindows, + quickCapture, + }); + if (quit) app.quit(); + }); +} + +app.on("browser-window-created", (_event, win) => { + win.once("closed", () => { + windowClosedThisRun = true; + scheduleQuitIfOnlyQuickCaptureLeft(); + }); +}); + +// Squirrel.Mac closes every window before `before-quit` fires when it +// installs an update, and a close hidden to the tray would stall that install. +if (process.platform === "darwin") { + nativeAutoUpdater.on("before-quit-for-update", () => { + appQuitting = true; + }); +} + app.on("before-quit", () => { + appQuitting = true; cloudSyncService?.stop(); void cloudAuthLoopbackServer?.stop(); windowVaults.stopAll(); diff --git a/apps/desktop/src/main/last-window.test.ts b/apps/desktop/src/main/last-window.test.ts new file mode 100644 index 00000000..fedbc030 --- /dev/null +++ b/apps/desktop/src/main/last-window.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { shouldQuitWithOnlyQuickCaptureLeft } from './last-window' + +describe('a hidden Quick capture panel does not keep the app alive', () => { + // The last workspace window just closed on Linux or Windows, the tray is + // off, and the panel was opened once and dismissed. + const base = { + isMac: false, + keepRunningInTray: false, + quitting: false, + windowClosed: true, + otherWindows: 0, + quickCapture: 'hidden' as const + } + + it('quits once only the hidden panel is left', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft(base)).toBe(true) + }) + + it('leaves macOS running with no window, as always', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, isMac: true })).toBe(false) + }) + + it('keeps running with the tray on, where the icon shows it and the hotkey still works', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, keepRunningInTray: true })).toBe(false) + }) + + it('stands aside while the app is already quitting', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quitting: true })).toBe(false) + }) + + it('keeps any other window open, hidden or not, the way Electron counts them', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, otherWindows: 1 })).toBe(false) + }) + + it('waits for a panel still on screen to hide', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quickCapture: 'visible' })).toBe(false) + }) + + it('leaves the no-window case to window-all-closed', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, quickCapture: 'none' })).toBe(false) + }) + + it('keeps a quick-capture deep-link launch resident until a window is closed', () => { + expect(shouldQuitWithOnlyQuickCaptureLeft({ ...base, windowClosed: false })).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/last-window.ts b/apps/desktop/src/main/last-window.ts new file mode 100644 index 00000000..566a4d19 --- /dev/null +++ b/apps/desktop/src/main/last-window.ts @@ -0,0 +1,37 @@ +/** + * Closing the last window quits ZenNotes on Windows and Linux. The Quick + * capture panel hides instead of closing, so the hotkey's next press is + * instant, and Electron counts a hidden window as open: once the panel had + * been used, closing the last window never fired `window-all-closed`, and the + * app kept running with nothing on screen, no taskbar entry and no tray icon. + * A hidden panel is not a window anyone can get back to, so it does not keep + * the app alive. This runs after any window closes and when the panel hides. + */ +export function shouldQuitWithOnlyQuickCaptureLeft(input: { + /** macOS keeps an app running with no window at all, as it always has. */ + isMac: boolean + /** With the tray on (#947) the icon keeps a running app reachable and in + * sight, so the panel and its hotkey may go on running. */ + keepRunningInTray: boolean + /** Windows close one by one while the app quits; one quit is enough. */ + quitting: boolean + /** Whether a window has closed in this run. A `zennotes://quick-capture` + * launch opens the panel alone and stays resident between captures (the + * deep link stands in for the hotkey where the compositor owns the + * keyboard), until a window it opened is closed. */ + windowClosed: boolean + /** Every other window, hidden ones included: a window the tray holds, a + * floating note or a standalone file keeps the app, as Electron would. */ + otherWindows: number + /** A visible panel is still in use, so the app waits for it to hide. */ + quickCapture: 'none' | 'hidden' | 'visible' +}): boolean { + return ( + !input.isMac && + !input.keepRunningInTray && + !input.quitting && + input.windowClosed && + input.otherWindows === 0 && + input.quickCapture === 'hidden' + ) +} diff --git a/apps/desktop/src/main/tray.test.ts b/apps/desktop/src/main/tray.test.ts new file mode 100644 index 00000000..c8ec2b21 --- /dev/null +++ b/apps/desktop/src/main/tray.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' + +// tray.ts imports electron at module load; the helpers under test are pure. +vi.mock('electron', () => ({ + app: { isPackaged: false }, + Menu: { buildFromTemplate: () => ({}) }, + nativeImage: { createFromPath: () => ({ setTemplateImage: () => undefined }) }, + Tray: class {} +})) + +import { + parseTraySyncStatus, + SESSION_END_GRACE_MS, + SessionEndWatch, + shouldHideOnClose, + traySyncLine, + trayTooltip +} from './tray' + +const clock = (epochMs: number): string => `T${epochMs}` + +describe('close to tray (#947)', () => { + const base = { + keepRunningInTray: true, + quitting: false, + sessionEnding: false, + isWorkspaceWindow: true, + otherVisibleWorkspaceWindows: 0 + } + + it('hides the last visible workspace window', () => { + expect(shouldHideOnClose(base)).toBe(true) + }) + + it('closes as before with the setting off', () => { + expect(shouldHideOnClose({ ...base, keepRunningInTray: false })).toBe(false) + }) + + it('lets every window close while the app quits, or it could never exit', () => { + expect(shouldHideOnClose({ ...base, quitting: true })).toBe(false) + }) + + it('closes one of several windows instead of hiding it', () => { + expect(shouldHideOnClose({ ...base, otherVisibleWorkspaceWindows: 1 })).toBe(false) + }) + + it('never holds a utility window such as Quick capture', () => { + expect(shouldHideOnClose({ ...base, isWorkspaceWindow: false })).toBe(false) + }) + + it('lets windows close while the session ends, or a sign-out would wait on them', () => { + expect(shouldHideOnClose({ ...base, sessionEnding: true })).toBe(false) + }) +}) + +describe('Windows ending the session (#947)', () => { + const closeHides = (watch: SessionEndWatch, now: number): boolean => + shouldHideOnClose({ + keepRunningInTray: true, + quitting: false, + sessionEnding: watch.isEnding(now), + isWorkspaceWindow: true, + otherVisibleWorkspaceWindows: 0 + }) + + it('hides to the tray when Windows has not asked', () => { + expect(closeHides(new SessionEndWatch(), 0)).toBe(true) + }) + + it('closes for real once Windows asks to sign out', () => { + const watch = new SessionEndWatch() + watch.query(1_000) + expect(closeHides(watch, 1_000)).toBe(false) + expect(closeHides(watch, 1_000 + SESSION_END_GRACE_MS - 1)).toBe(false) + }) + + it('hides to the tray again after a cancelled sign-out, which sends no session-end', () => { + const watch = new SessionEndWatch() + watch.query(1_000) + expect(closeHides(watch, 1_000 + SESSION_END_GRACE_MS)).toBe(true) + }) + + it('stays closing for real once the session really ends', () => { + const watch = new SessionEndWatch() + watch.query(1_000) + watch.end() + expect(closeHides(watch, 1_000 + SESSION_END_GRACE_MS)).toBe(false) + expect(closeHides(watch, 1_000 + 100 * SESSION_END_GRACE_MS)).toBe(false) + }) + + it('counts the grace from the latest question, since every window is asked', () => { + const watch = new SessionEndWatch() + watch.query(1_000) + watch.query(4_000) + expect(closeHides(watch, 1_000 + SESSION_END_GRACE_MS)).toBe(false) + expect(closeHides(watch, 4_000 + SESSION_END_GRACE_MS)).toBe(true) + }) +}) + +describe('tray sync line (#947)', () => { + it('says nothing without a connected vault', () => { + expect(traySyncLine(null, clock)).toBeNull() + expect(trayTooltip(null)).toBe('ZenNotes') + }) + + it('gives a clock time for the last sync, which stays true while the menu is open', () => { + const line = traySyncLine({ state: 'idle', vaultName: 'Work', lastSyncedAt: 1_700_000_000_000 }, clock) + expect(line).toBe('Cloud: synced at T1700000000000') + expect(trayTooltip(line)).toBe('ZenNotes\nCloud: synced at T1700000000000') + }) + + it('names each state', () => { + const at = (state: 'idle' | 'syncing' | 'attention' | 'error') => + traySyncLine({ state, vaultName: null, lastSyncedAt: null }, clock) + expect(at('idle')).toBe('Cloud: ready') + expect(at('syncing')).toBe('Cloud: syncing…') + expect(at('attention')).toBe('Cloud: needs attention') + expect(at('error')).toBe('Cloud: sync failed') + }) +}) + +describe('tray sync status from the renderer (#947)', () => { + it('keeps a well-formed status', () => { + expect(parseTraySyncStatus({ state: 'syncing', vaultName: ' Work ', lastSyncedAt: 5 })).toEqual({ + state: 'syncing', + vaultName: 'Work', + lastSyncedAt: 5 + }) + }) + + it('drops anything else, since the renderer is untrusted', () => { + expect(parseTraySyncStatus(null)).toBeNull() + expect(parseTraySyncStatus('idle')).toBeNull() + expect(parseTraySyncStatus({ state: 'exploded' })).toBeNull() + expect(parseTraySyncStatus({ state: 'idle', vaultName: 42, lastSyncedAt: Number.NaN })).toEqual({ + state: 'idle', + vaultName: null, + lastSyncedAt: null + }) + }) + + it('caps a long vault name', () => { + const status = parseTraySyncStatus({ state: 'idle', vaultName: 'x'.repeat(500), lastSyncedAt: null }) + expect(status?.vaultName).toHaveLength(120) + }) +}) diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000..388c2595 --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,191 @@ +import { app, Menu, nativeImage, Tray, type NativeImage } from 'electron' +import path from 'node:path' +import type { TraySyncState, TraySyncStatus } from '@shared/ipc' + +/** + * The tray icon behind "Keep running in the tray" (#947). With it on, closing + * the last workspace window hides it instead of ending the session, so Cloud + * sync (which runs in the window's renderer) and the Quick capture hotkey keep + * working. The icon is how a hidden app stays reachable and honest: a click + * brings the window back, the menu quits for real, and a connected vault's + * sync state shows in the tooltip and on a menu line. + */ + +export interface TrayActions { + showWindows(): void + openQuickCapture(): void + quit(): void +} + +const SYNC_STATES: ReadonlySet = new Set(['idle', 'syncing', 'attention', 'error']) +const MAX_VAULT_NAME_LENGTH = 120 + +/** The renderer is untrusted: keep only a well-formed status. */ +export function parseTraySyncStatus(raw: unknown): TraySyncStatus | null { + if (!raw || typeof raw !== 'object') return null + const value = raw as Record + if (typeof value.state !== 'string' || !SYNC_STATES.has(value.state as TraySyncState)) return null + const vaultName = + typeof value.vaultName === 'string' && value.vaultName.trim() + ? value.vaultName.trim().slice(0, MAX_VAULT_NAME_LENGTH) + : null + const lastSyncedAt = + typeof value.lastSyncedAt === 'number' && Number.isFinite(value.lastSyncedAt) && value.lastSyncedAt > 0 + ? value.lastSyncedAt + : null + return { state: value.state as TraySyncState, vaultName, lastSyncedAt } +} + +/** The tray's sync line. A menu is not redrawn while it is open, so a time is + * given as a clock time ("Synced at 14:05"), which stays true, rather than + * "2 min ago", which would not. */ +export function traySyncLine( + status: TraySyncStatus | null, + formatTime: (epochMs: number) => string +): string | null { + if (!status) return null + switch (status.state) { + case 'syncing': + return 'Cloud: syncing…' + case 'attention': + return 'Cloud: needs attention' + case 'error': + return 'Cloud: sync failed' + case 'idle': + return status.lastSyncedAt === null + ? 'Cloud: ready' + : `Cloud: synced at ${formatTime(status.lastSyncedAt)}` + } +} + +export function trayTooltip(syncLine: string | null): string { + return syncLine ? `ZenNotes\n${syncLine}` : 'ZenNotes' +} + +/** + * Whether a workspace window's close should hide it instead. Only the last + * visible workspace window hides: closing one of several still closes it, and + * quitting (the tray's Quit, Cmd+Q, an update's restart) or the session + * ending always lets windows close, or the app could never exit. + */ +export function shouldHideOnClose(input: { + keepRunningInTray: boolean + quitting: boolean + sessionEnding: boolean + isWorkspaceWindow: boolean + otherVisibleWorkspaceWindows: number +}): boolean { + return ( + input.keepRunningInTray && + !input.quitting && + !input.sessionEnding && + input.isWorkspaceWindow && + input.otherVisibleWorkspaceWindows === 0 + ) +} + +/** Long enough to cover the gap between Windows asking every app and telling + * them the answer, short enough that someone who cancelled a sign-out is + * unlikely to close a window inside it. */ +export const SESSION_END_GRACE_MS = 10_000 + +/** + * Windows asks every window before it signs out, restarts or shuts down + * (`query-session-end`), and a close turned into a hide from then on would + * hold the session up, so windows close for real. The question is not the + * answer, though: another app can refuse, or the user can cancel, and then + * Electron says nothing more, since it emits `session-end` only when the + * session really ends. So a question holds for a grace period and lapses, + * and closing hides to the tray again; `session-end` is final. Times come + * from a monotonic clock the caller passes in. + */ +export class SessionEndWatch { + private queriedAt: number | null = null + private ended = false + + constructor(private readonly graceMs = SESSION_END_GRACE_MS) {} + + query(now: number): void { + this.queriedAt = now + } + + end(): void { + this.ended = true + } + + isEnding(now: number): boolean { + if (this.ended) return true + return this.queriedAt !== null && now - this.queriedAt < this.graceMs + } +} + +let tray: Tray | null = null +let actions: TrayActions | null = null +let syncStatus: TraySyncStatus | null = null + +function trayIconFile(name: string): string { + return app.isPackaged + ? path.join(process.resourcesPath, 'tray', name) + : path.join(__dirname, '../../build/tray', name) +} + +/** macOS gets a template glyph, which the menu bar tints for light and dark. + * Windows and Linux panels come in both shades with no tinting, so they get + * the app icon's dark tile, which reads on either; Linux at 32 px, since its + * panels draw tray icons at 22 to 24 and scale down more cleanly than up. */ +function trayIcon(): NativeImage { + if (process.platform === 'darwin') { + const image = nativeImage.createFromPath(trayIconFile('trayTemplate.png')) + image.setTemplateImage(true) + return image + } + if (process.platform === 'linux') return nativeImage.createFromPath(trayIconFile('tray@2x.png')) + return nativeImage.createFromPath(trayIconFile('tray.png')) +} + +function formatClockTime(epochMs: number): string { + return new Date(epochMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function refreshTray(): void { + if (!tray || tray.isDestroyed() || !actions) return + const current = actions + const syncLine = traySyncLine(syncStatus, formatClockTime) + tray.setToolTip(trayTooltip(syncLine)) + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: 'Open ZenNotes', click: () => current.showWindows() }, + { label: 'Quick Capture', click: () => current.openQuickCapture() }, + ...(syncLine ? [{ type: 'separator' as const }, { label: syncLine, enabled: false }] : []), + { type: 'separator' }, + { label: 'Quit ZenNotes', click: () => current.quit() } + ]) + ) +} + +export function isTrayShown(): boolean { + return tray !== null && !tray.isDestroyed() +} + +/** Create the icon if it is not there yet. The Tray object lives in this + * module for the life of the app; a collected Tray takes its icon with it. */ +export function showTray(next: TrayActions): void { + actions = next + if (!isTrayShown()) { + tray = new Tray(trayIcon()) + // A left click opens the window. On Linux most hosts open the menu on any + // click instead, and the menu's first item does the same. + tray.on('click', () => actions?.showWindows()) + } + refreshTray() +} + +export function hideTray(): void { + if (tray && !tray.isDestroyed()) tray.destroy() + tray = null +} + +export function setTraySyncStatus(status: TraySyncStatus | null): void { + syncStatus = status + refreshTray() +} diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index db3cb525..0d9b185e 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -572,3 +572,48 @@ describe('checkForAppUpdates with no network (issue #812)', () => { }) }) + +// #947: a process kept in the tray runs for days, so the background check +// repeats instead of running once at launch. +describe('background update checks repeat while the app runs (#947)', () => { + const original = { format: process.env.ZENNOTES_UPDATER_FORMAT, feed: process.env.ZENNOTES_UPDATE_FEED_URL } + + afterEach(() => { + vi.useRealTimers() + restoreUpdaterEnv(original) + }) + + it('checks again every six hours until a newer version turns up', async () => { + vi.useFakeTimers() + let latest = '2.0.2' + const { mod, fetchMock } = await loadManagedUpdater(() => FEED(latest)) + + mod.scheduleBackgroundAppUpdateCheck(0) + await vi.advanceTimersByTimeAsync(0) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(mod.getAppUpdateState().phase).toBe('not-available') + + await vi.advanceTimersByTimeAsync(mod.BACKGROUND_UPDATE_RECHECK_MS - 1) + expect(fetchMock).toHaveBeenCalledTimes(1) + + latest = '2.0.3' + await vi.advanceTimersByTimeAsync(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(mod.getAppUpdateState().phase).toBe('available') + expect(mod.getAppUpdateState().availableVersion).toBe('2.0.3') + + // Found: the person owns it from here, so the repeats stand aside. + await vi.advanceTimersByTimeAsync(mod.BACKGROUND_UPDATE_RECHECK_MS * 2) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('repeats only from a settled state', async () => { + const { mod } = await loadManagedUpdater(FEED('2.0.2')) + for (const phase of ['idle', 'not-available', 'error'] as const) { + expect(mod.canRecheckInBackground(phase)).toBe(true) + } + for (const phase of ['checking', 'available', 'downloading', 'downloaded', 'installing', 'offline', 'unsupported'] as const) { + expect(mod.canRecheckInBackground(phase)).toBe(false) + } + }) +}) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 45eb289a..525c8d2c 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -16,6 +16,9 @@ const execFileAsync = promisify(execFile) const UPDATE_CHECK_MAX_ATTEMPTS = 3 const UPDATE_CHECK_RETRY_DELAY_MS = 1500 const BACKGROUND_UPDATE_CHECK_DELAY_MS = 8000 +/** A process kept in the tray can run for days (#947), and one check at + * launch would leave it silent about every release after that. */ +export const BACKGROUND_UPDATE_RECHECK_MS = 6 * 60 * 60_000 /** How often a check that found no network looks at the link again. */ export const OFFLINE_POLL_MS = 15_000 /** First wait before re-checking when the link is up but GitHub still could @@ -573,6 +576,20 @@ export function scheduleBackgroundAppUpdateCheck( // A manual check/download/install owns its result once the user starts it. if (updateState.phase === 'idle') void checkForAppUpdates() }, Math.max(0, delayMs)) + setInterval(() => { + if (canRecheckInBackground(updateState.phase)) void checkForAppUpdates() + }, BACKGROUND_UPDATE_RECHECK_MS) +} + +/** + * Whether a periodic check may run. Not while a check, download or install is + * under way or waiting on the person ('available', 'downloaded'); not while + * offline either, since that state already watches the link and checks again + * the moment it is back. A repeat never notifies twice: notices are kept per + * version. + */ +export function canRecheckInBackground(phase: AppUpdateState['phase']): boolean { + return phase === 'idle' || phase === 'not-available' || phase === 'error' } export async function downloadAppUpdate(): Promise { diff --git a/apps/desktop/src/main/vault-config.test.ts b/apps/desktop/src/main/vault-config.test.ts index 41420459..b6095445 100644 --- a/apps/desktop/src/main/vault-config.test.ts +++ b/apps/desktop/src/main/vault-config.test.ts @@ -50,7 +50,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) const cfg = await loadConfig() @@ -77,7 +78,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) // Second save triggers the rotation: the first config is copied to .bak, // then the new payload replaces the primary. @@ -92,7 +94,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) // Backup now holds the first config; corrupt the primary. await writeFile(configFile(), '{ not valid json', 'utf8') @@ -132,7 +135,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) for (let i = 0; i < 5; i += 1) { @@ -163,7 +167,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) const raw = await readFile(configFile(), 'utf8') expect(() => JSON.parse(raw)).not.toThrow() @@ -181,7 +186,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) await saveConfig({ workspaceMode: 'local', @@ -194,7 +200,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) // Delete the primary — but the backup from the previous save survives. await rm(configFile()) @@ -218,7 +225,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) await chmod(configFile(), 0o000) try { @@ -243,7 +251,8 @@ describe('config persistence', () => { zoomFactor: 1, quickCaptureHotkey: 'CommandOrControl+Shift+Space', quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false }) // Corrupt the primary AND delete the backup so loadConfigSafely() must // return `readable: false` — the dangerous state where the old code diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 17123deb..37f086ed 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -61,7 +61,7 @@ import { VaultChangeEvent, VaultInfo } from '@shared/ipc' -import { currentDemoTourPath, DEMO_TOUR_DIR } from '@shared/demo-tour' +import { currentDemoTourPath, DEMO_TOUR_DIR, demoTourNestedDirs } from '@shared/demo-tour' import { normalizeNoteComments } from '@shared/note-comments' import { FRONTMATTER_BLOCK_RE, frontmatterTags } from '@shared/frontmatter' import { rewriteAssetReferences } from '@shared/asset-link-rename' @@ -316,6 +316,10 @@ export interface PersistedConfig { * Kept here, not in the portable config.toml, because it governs this * machine's managed copy under userData, like the install location. */ cliAutoUpdate: boolean + /** Closing the last window hides it to the tray instead (#947). Kept here, + * not in the portable config.toml: main needs it before any renderer runs, + * and whether a tray exists at all depends on this machine's desktop. */ + keepRunningInTray: boolean } export const DEFAULT_QUICK_CAPTURE_HOTKEY = 'CommandOrControl+Shift+Space' @@ -331,7 +335,8 @@ const DEFAULT_CONFIG: PersistedConfig = { zoomFactor: 1, quickCaptureHotkey: DEFAULT_QUICK_CAPTURE_HOTKEY, quickCapturePinned: false, - cliAutoUpdate: true + cliAutoUpdate: true, + keepRunningInTray: false } let configWriteQueue = Promise.resolve() @@ -464,6 +469,7 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { : DEFAULT_QUICK_CAPTURE_HOTKEY const quickCapturePinned = candidate.quickCapturePinned === true const cliAutoUpdate = candidate.cliAutoUpdate !== false + const keepRunningInTray = candidate.keepRunningInTray === true return { workspaceMode: candidate.workspaceMode === 'remote' ? 'remote' : 'local', vaultRoot: typeof candidate.vaultRoot === 'string' ? candidate.vaultRoot : null, @@ -481,7 +487,8 @@ function normalizePersistedConfig(value: unknown): PersistedConfig { zoomFactor, quickCaptureHotkey, quickCapturePinned, - cliAutoUpdate + cliAutoUpdate, + keepRunningInTray } } @@ -4308,6 +4315,9 @@ export async function removeDemoTour(root: string): Promise await removeFileIfExists(resolveSafe(root, asset.path)) } + for (const dir of demoTourNestedDirs(DEMO_TOUR_ASSETS.map((asset) => asset.path))) { + await removeDirIfEmpty(resolveSafe(root, dir)) + } await removeDirIfEmpty(resolveSafe(root, DEMO_TOUR_DIR)) invalidateNoteMetaCache(root) diff --git a/apps/desktop/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts index cc2f0d31..9043d80d 100644 --- a/apps/desktop/src/mcp/instructions.ts +++ b/apps/desktop/src/mcp/instructions.ts @@ -9,8 +9,8 @@ */ export const MCP_SERVER_INSTRUCTIONS = `You are connected to a user's ZenNotes vault: plain .md files on -disk, rendered live with KaTeX, TikZ, function-plot, JSXGraph, and -Mermaid. Treat the vault like a shared filesystem. Prompts will be +disk, rendered live with KaTeX, TikZ, function-plot, JSXGraph, +Mermaid, and D2. Treat the vault like a shared filesystem. Prompts will be short; you must infer the right note shape, voice, and visuals from the subject itself. @@ -24,7 +24,7 @@ the subject itself. bullet-list template onto a subject that wants prose, and never force prose onto a subject that wants steps. 2. **Always use the native renderers for anything visual.** KaTeX - for math, TikZ / function-plot / JSXGraph / Mermaid for every + for math, TikZ / function-plot / JSXGraph / Mermaid / D2 for every diagram. Never ASCII art, never unicode-arrow sketches. 3. **Structure logically, flow consistently.** Decide the section order before writing. Sections carry a reader from orientation @@ -153,7 +153,7 @@ or \`$$\u2026$$\`. Not \`R^n\`, not \`x_1\`, not \`|v|\`, not \`A^T\`, not made of \`/ \\ | - + * ^ . o > < \u2192 \u2190 \u2191 \u2193 \u2022\` or box-drawing characters, fenced or not, is a bug. If you catch yourself about to make one, delete it and emit a \`\`\`tikz (or -\`\`\`function-plot / \`\`\`jsxgraph / \`\`\`mermaid) block instead. +\`\`\`function-plot / \`\`\`jsxgraph / \`\`\`mermaid / \`\`\`d2) block instead. Triggers that mean "use TikZ, not ASCII": axes, origin, point at, arrow from\u2026to, head to tail, parallelogram, rotation, projection, span, plane, angle, triangle, unit circle, subspace, tangent, normal, @@ -166,7 +166,13 @@ Pick the renderer by intent: vectors over axes. - \`\`\`jsxgraph: interactive / draggable constructions. - \`\`\`mermaid: flow, sequence, class, state, ER, mindmap, - Gantt. NOT for coordinate geometry. + Gantt. NOT for coordinate geometry. Labels are plain text plus + \`
\` breaks: no other HTML and no \`$$\` math inside a label + (put the formula in a KaTeX block next to the diagram). +- \`\`\`d2: architecture, infrastructure and system diagrams, nested + containers, data models (\`shape: sql_table\`) and sequence + diagrams. Labels may be Markdown (\`|md \u2026|\`). Icons, images and + links are not drawn: a diagram never fetches anything. Fence body rules: - \`tikz\`: emit the TikZ content itself, usually a bare @@ -299,6 +305,19 @@ flowchart LR B -- no --> D[Do Y] \`\`\` +Architecture: + +\`\`\`d2 +direction: right +app: Desktop app { + ui: Renderer + main: Main process + ui -> main: IPC +} +cloud: Sync server {shape: cloud} +app.main -> cloud: sync +\`\`\` + Adjust these. Don't invent ASCII replacements. ## Intent mapping diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c4c932a4..5f98f61c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -99,7 +99,8 @@ import type { VaultTextSearchBackendPreference, VaultTextSearchCapabilities, VaultTextSearchMatch, - VaultTextSearchToolPaths + VaultTextSearchToolPaths, + TraySyncStatus } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' import type { @@ -755,6 +756,15 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.APP_GET_QUICK_CAPTURE_PINNED), setQuickCapturePinned: (pinned: boolean): Promise => ipcRenderer.invoke(IPC.APP_SET_QUICK_CAPTURE_PINNED, pinned), + getKeepRunningInTray: (): Promise => + ipcRenderer.invoke(IPC.APP_GET_KEEP_RUNNING_IN_TRAY), + setKeepRunningInTray: (enabled: boolean): Promise => + ipcRenderer.invoke(IPC.APP_SET_KEEP_RUNNING_IN_TRAY, enabled), + hideWindowToTray: (): Promise => + ipcRenderer.invoke(IPC.WINDOW_HIDE_TO_TRAY), + reportTraySyncStatus: (status: TraySyncStatus | null): void => + ipcRenderer.send(IPC.APP_TRAY_SYNC_STATUS, status), + quitApp: (): void => ipcRenderer.send(IPC.APP_QUIT), renderTikz: (source: string): Promise<{ ok: boolean; svg?: string; error?: string }> => ipcRenderer.invoke(IPC.TIKZ_RENDER, source), diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 63ec424a..9408b8e0 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", @@ -22,6 +22,7 @@ "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -32,7 +33,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index de1295db..a0954f5b 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", @@ -23,6 +23,7 @@ "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -33,7 +34,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/web/package.json b/apps/web/package.json index cf7a473f..0670482a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", @@ -27,6 +27,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", @@ -39,7 +40,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5bc76c3d..b3b0f0a9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -147,10 +147,13 @@ function rendererManualChunk(id: string): string | undefined { return 'vendor-textmate' } - // No manualChunks rule for mermaid / cytoscape / dagre on purpose. Mermaid is - // only ever reached through a dynamic import (Preview.tsx does - // `import("mermaid")`, behind LazyPreview), but forcing its modules into a - // named chunk made Rollup hoist that chunk into the entry's STATIC graph: + // No manualChunks rule for the diagram stacks on purpose. Mermaid fences are + // drawn by Pele, reached only through `import("./pele-runtime")` in + // mermaid-render.ts; mermaid.js itself (with cytoscape / dagre) now arrives + // only with Excalidraw's Mermaid-to-Excalidraw dialog, also behind a dynamic + // import. Both stay async only while Rollup is left to chunk them. When + // mermaid's modules were forced into a named chunk, Rollup hoisted that chunk + // into the entry's STATIC graph: // `index-*.js` ended up with `import{m as gt}from"./vendor-mermaid-*.js"`, // where gt is mermaid itself, so every launch fetched and evaluated 2.5MB of // diagram code (and vendor-markdown with it, which vendor-mermaid imports) @@ -202,8 +205,14 @@ function isDeferredRendererPreload(dep: string): boolean { dep.includes('vendor-highlight') || dep.includes('vendor-textmate') || dep.includes('vendor-d3') || - // Mermaid is no longer one named chunk; it splits into mermaid.core plus a - // chunk per diagram type, with cytoscape/dagre alongside. + // Pele, the mermaid-fence renderer: its runtime chunk, after which each + // diagram type fetches a chunk of its own on first use. + dep.includes('pele-runtime') || + // D2, the ```d2 renderer: one chunk of about 8 MB with its WebAssembly + // compiler inside, fetched only when a note holds a d2 fence. + dep.includes('d2-runtime') || + // mermaid.js (Excalidraw's Mermaid dialog only) splits into mermaid.core plus + // a chunk per diagram type, with cytoscape/dagre alongside. dep.includes('mermaid') || dep.includes('cytoscape') || dep.includes('dagre') || diff --git a/package-lock.json b/package-lock.json index 6e55e90b..2e4ca36f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.70.0", + "version": "2.71.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.70.0", + "version": "2.71.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.70.0", + "version": "2.71.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -41,6 +41,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", @@ -57,8 +58,8 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", "node-tikzjax": "^1.0.5", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -914,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.70.0", + "version": "2.71.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -926,6 +927,7 @@ "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -936,7 +938,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -987,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.70.0", + "version": "2.71.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -999,6 +1001,7 @@ "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -1009,7 +1012,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1058,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.70.0", + "version": "2.71.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1074,6 +1077,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", @@ -1086,7 +1090,7 @@ "highlight.js": "^11.10.0", "jsxgraph": "^1.12.2", "katex": "^0.16.15", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -5343,6 +5347,12 @@ "node": ">=10" } }, + "node_modules/@terrastruct/d2": { + "version": "0.1.33", + "resolved": "https://registry.npmjs.org/@terrastruct/d2/-/d2-0.1.33.tgz", + "integrity": "sha512-eK5hyfGIJFolC7sUsiKvWdY9xGFctTe3d+PSijo09IYDso8psztC+A4SammizXtlwYZpnnW0AtDjfBYauceSeA==", + "license": "MPL-2.0" + }, "node_modules/@turbo/darwin-64": { "version": "2.10.12", "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.10.12.tgz", @@ -12886,6 +12896,15 @@ "url": "https://github.com/sponsors/jet2jet" } }, + "node_modules/pele": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/pele/-/pele-1.6.0.tgz", + "integrity": "sha512-VfAiVItsOGlGhvnBfphdT28VCG7mXMZxQNOwAGsCtxI2ztbUsT39MraEOYjsapLxVETkdUZscm0DdotYDQMLdw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/perfect-freehand": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", @@ -16390,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.70.0", + "version": "2.71.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16422,6 +16441,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -16434,7 +16454,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "mdast": "^3.0.0", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -16478,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.70.0", + "version": "2.71.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.70.0", + "version": "2.71.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16499,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.70.0" + "version": "2.71.0" } } } diff --git a/package.json b/package.json index 5ca3b4b7..7e84fa54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.70.0", + "version": "2.71.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 11990a27..a94b271c 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "exports": { "./main": "./src/main.tsx", @@ -53,6 +53,7 @@ "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", "@replit/codemirror-vim": "^6.3.0", + "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", @@ -65,7 +66,7 @@ "jsxgraph": "^1.12.2", "katex": "^0.16.15", "mdast": "^3.0.0", - "mermaid": "^11.4.1", + "pele": "1.6.0", "prettier": "^3.8.2", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index d9ff4f32..cb32c963 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -63,6 +63,7 @@ import { useAppUpdateState } from './lib/app-update-state' import { ensureCloudAutoSyncStarted, stopCloudAutoSync } from './lib/cloud-auto-sync' +import { loadKeepRunningInTray, startTraySyncStatusReporter } from './lib/tray-runtime' import { installHarperRuntime } from './lib/harper-runtime' import { installIgnoredKeysGuard } from './lib/ignored-keys' @@ -441,6 +442,19 @@ function App(): JSX.Element { ensureCloudAutoSyncStarted(vault.root) return stopCloudAutoSync }, [vault?.root]) + // Keep running in the tray (#947): the setting for the palette and leader, + // and this window's sync state for the tray icon. + useEffect(() => { + void loadKeepRunningInTray() + // Another window's Settings may have changed it meanwhile. + const refresh = (): void => void loadKeepRunningInTray() + window.addEventListener('focus', refresh) + const stopReporting = startTraySyncStatusReporter() + return () => { + window.removeEventListener('focus', refresh) + stopReporting() + } + }, []) useEffect(() => installHarperRuntime(), []) useEffect(() => { diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 1bdb6ea9..f9c1d2f3 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -82,11 +82,12 @@ import { NoteHoverPreview } from "./NoteHoverPreview"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import { ArrowUpRightIcon, MaximizeIcon, MinimizeIcon } from "./icons"; import { promptApp } from "../lib/prompt-requests"; -import { peekMermaidSvg, renderMermaidSvg } from "../lib/mermaid-render"; +import { renderMermaidSvg } from "../lib/mermaid-render"; import { confirmApp } from "../lib/confirm-requests"; -// Mermaid's lazy loader and theme live in `lib/mermaid-render`, shared with -// the editor's live preview so both draw the same diagram the same way. +// The mermaid renderer's lazy loader and options live in `lib/mermaid-render`, +// shared with the editor's live preview so both draw the same diagram the same +// way; its colours come from the `.pele` variables in index.css. type ExpandedDiagramKind = DiagramTabKind; @@ -97,6 +98,7 @@ interface ExpandedDiagram { const DIAGRAM_CLASS_BY_KIND: Record = { mermaid: "mermaid", + d2: "zen-d2", tikz: "zen-tikz", jsxgraph: "zen-jsxgraph", "function-plot": "zen-function-plot", @@ -104,6 +106,7 @@ const DIAGRAM_CLASS_BY_KIND: Record = { const DIAGRAM_SOURCE_ATTR_BY_KIND: Record = { mermaid: "data-mermaid-source", + d2: "data-d2-source", tikz: "data-tikz-source", jsxgraph: "data-jsxgraph-source", "function-plot": "data-function-plot-source", @@ -138,36 +141,18 @@ function prepareMermaidShell(el: HTMLElement, source: string): HTMLDivElement { } // Every preview rebuild hands over fresh DOM, so the diagrams went back -// through mermaid's parse-and-layout on each keystroke in split view, which +// through a full parse-and-layout on each keystroke in split view, which // is what made typing next to a diagram feel laggy (#184). The rendered SVG -// only depends on the source and the theme, so it comes from the cache the -// editor's live widget already keeps in `mermaid-render`; a rebuild costs an -// innerHTML assignment, and the two surfaces share one render per diagram. +// only depends on the source and the fonts (colour comes from CSS), so it +// comes from the cache the editor's live widget already keeps in +// `mermaid-render`; a rebuild costs an innerHTML assignment, and the two +// surfaces share one render per diagram. Waiting for a loading font before +// measuring labels happens there too. async function renderMermaidBlocks( root: HTMLElement, - mode: "light" | "dark", - opts: { expanded?: boolean; themeKey?: string } = {}, + opts: { expanded?: boolean } = {}, ): Promise { const blocks = Array.from(root.querySelectorAll(".mermaid")); - if (blocks.length === 0) return; - const themeKey = opts.themeKey ?? mode; - const needsRender = blocks.some((el) => { - const source = el.getAttribute("data-mermaid-source") ?? el.textContent ?? ""; - return source.trim() !== "" && peekMermaidSvg(source, mode, themeKey) === null; - }); - // Mermaid measures text in a temporary element. If the active font is - // still loading, metrics are taken against a fallback and the rendered - // labels end up clipped once the real font applies. Wait for fonts first - // so the measured widths match the final painted text. A cached diagram - // was measured already and skips the wait. - if (needsRender && typeof document !== "undefined" && document.fonts) { - try { - await document.fonts.ready; - } catch { - /* ignore font-api failures and render anyway */ - } - } - for (let i = 0; i < blocks.length; i++) { const el = blocks[i]; if (opts.expanded) el.dataset.zenDiagramExpanded = "true"; @@ -179,14 +164,19 @@ async function renderMermaidBlocks( const surface = prepareMermaidShell(el, source); // The preview renders into a detached stage and commits afterwards, so // the block is not in the document yet: always paint it. - const result = await renderMermaidSvg(source, mode, themeKey, "zen-mermaid-preview"); + const result = await renderMermaidSvg(source, "zen-mermaid-preview"); if (result.ok) { surface.innerHTML = result.svg; // Inline pan/zoom (Cmd/Ctrl+wheel, drag, dblclick reset). The // expanded modal has its own React pan/zoom frame. if (!opts.expanded) attachInlineDiagramPanZoom(surface); } else { - surface.innerHTML = `
Mermaid error: ${result.error}
`; + // The message quotes the diagram source back, so it is text, never + // markup: a note must not be able to inject elements through a typo. + const error = document.createElement("pre"); + error.className = "zen-diagram-error"; + error.textContent = `Mermaid error: ${result.error}`; + surface.replaceChildren(error); } } } @@ -906,7 +896,7 @@ export const Preview = memo(function Preview({ const applyRenderedDom = async (): Promise => { try { - await renderMermaidBlocks(stage, diagramTheme.mode, { themeKey: diagramTheme.key }); + await renderMermaidBlocks(stage); } catch { /* render errors are surfaced inline per block */ } @@ -1422,7 +1412,7 @@ function DiagramPanZoomFrame({ const render = async (): Promise => { if (diagram.kind === "mermaid") { - await renderMermaidBlocks(host, diagramMode, { expanded: true, themeKey }); + await renderMermaidBlocks(host, { expanded: true }); } else { await renderDiagrams(host, { themeKey, expanded: true }); } diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts index 1675422c..d5cd5416 100644 --- a/packages/app-core/src/components/SettingsModal.test.ts +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -126,6 +126,7 @@ vi.mock("../lib/app-update-state", () => ({ vi.mock("@zennotes/bridge-contract/bridge", () => ({ getZenBridge: () => ({ + platformSync: () => "linux", getAppInfo: () => ({ runtime: mocks.runtime, version: "2.4.0", diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index e49bca6d..dca1cf7f 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -21,6 +21,7 @@ import { import type { VimWrappedLineMotionMode } from "@shared/app-config"; import { HARPER_DIALECTS } from "@shared/harper-settings"; import { harperSupported } from "../lib/harper-runtime"; +import { setKeepRunningInTray, useTrayStore } from "../lib/tray-runtime"; import type { AppUpdateState, CliInstallStatus, @@ -935,6 +936,7 @@ export function SettingsModal(): JSX.Element { const setSystemFolderLabel = useStore((s) => s.setSystemFolderLabel); const darkSidebar = useStore((s) => s.darkSidebar); const showWindowTitleBar = useStore((s) => s.showWindowTitleBar); + const keepRunningInTray = useTrayStore((s) => s.keepRunningInTray); const setShowWindowTitleBar = useStore((s) => s.setShowWindowTitleBar); const setDarkSidebar = useStore((s) => s.setDarkSidebar); const showSidebarChevrons = useStore((s) => s.showSidebarChevrons); @@ -1568,6 +1570,14 @@ export function SettingsModal(): JSX.Element { keywords: ["decorations", "frameless", "traffic lights", "tiling", "hyprland", "linux"], available: appInfo.runtime === "desktop", }, + { + id: "keep-running-in-tray", + title: "Keep running in the tray", + description: + "Closing the last window hides it to the tray, so Cloud sync and the Quick capture hotkey keep working.", + keywords: ["tray", "system tray", "menu bar", "background", "close", "quit", "minimize", "hide", "tiling", "waybar"], + available: appInfo.runtime === "desktop", + }, { id: "sidebar-arrows", title: "Sidebar arrows", @@ -2020,6 +2030,21 @@ export function SettingsModal(): JSX.Element { /> + {appInfo.runtime === "desktop" && ( +
+ void setKeepRunningInTray(next)} + /> +
+ )} +
{ }) }) +// #949: a task note's `status: done` both closes it and names its column, so a +// Custom status board with a configured `done` column must keep it there. +describe('closed task notes on the Custom status board (#949)', () => { + const taskNote = (content: string, status: string): VaultTask => + card({ + content, + kind: 'file', + sourcePath: `inbox/${content}.md`, + taskIndex: -1, + status, + fields: { status }, + checked: status === 'done', + cancelled: status === 'cancelled', + inProgress: status === 'in-progress' + }) + // A note with frontmatter `status: in-progress` lends that status to its + // checkboxes, and a checked box keeps its own explicit `@status:done`. + const checkbox = (content: string, status: string, checked: boolean, taskIndex: number): VaultTask => + card({ content, sourcePath: 'inbox/Checklist.md', taskIndex, status, fields: { status }, checked }) + const tasks = [ + taskNote('Completed demo task', 'done'), + taskNote('Active demo task', 'in-progress'), + taskNote('Dropped demo task', 'cancelled'), + checkbox('Finished step', 'in-progress', true, 0), + checkbox('Next step', 'in-progress', false, 1), + checkbox('Shipped step', 'done', true, 2) + ] + const statuses = ['open', 'in-progress', 'waiting', 'done'] + + it('keeps a done task note in the configured Done column, still checked', () => { + const columns = fieldColumns(tasks, 'status', statuses) + expect(ids(columns)).toEqual(['open', 'in-progress', 'waiting', 'done', NO_VALUE_COLUMN_ID]) + expect(columnIds(columns, 'done')).toEqual(['Completed demo task']) + expect(columns.find((c) => c.id === 'done')!.tasks[0].checked).toBe(true) + expect(columnIds(columns, 'in-progress')).toEqual(['Active demo task', 'Next step']) + }) + + it('leaves closed task notes off when their status has no configured column', () => { + const columns = fieldColumns(tasks, 'status', ['open', 'in-progress']) + expect(ids(columns)).toEqual(['open', 'in-progress', NO_VALUE_COLUMN_ID]) + expect(columns.flatMap((c) => c.tasks.map((t) => t.content))).toEqual(['Active demo task', 'Next step']) + }) + + it('shows a cancelled task note once a cancelled column is configured', () => { + const columns = fieldColumns(tasks, 'status', [...statuses, 'cancelled']) + expect(columnIds(columns, 'cancelled')).toEqual(['Dropped demo task']) + }) + + it('keeps checked checkbox tasks off, whatever status they carry or inherit', () => { + const columns = fieldColumns(tasks, 'status', statuses) + const shown = columns.flatMap((c) => c.tasks.map((t) => t.content)) + expect(shown).not.toContain('Finished step') + expect(shown).not.toContain('Shipped step') + }) +}) + // #889: a column can follow its cards' due dates instead of the order they // were dragged into, on every board, custom status boards included. describe('card order by due date (#889)', () => { diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 337217dc..b87c6cd4 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -336,13 +336,23 @@ function fieldKeyOf(groupBy: KanbanGroupBy): string | null { /** Columns for a free-form `@:` field board. Configured values come * first in order, then any value found on tasks but not configured (sorted, so * nothing is hidden), then a trailing "No " column. Completed and - * forwarded tasks are left off, matching the priority/folder boards. (#354) */ -function fieldColumns(tasks: VaultTask[], fieldKey: string, order: string[]): Column[] { + * forwarded tasks are left off, matching the priority/folder boards. (#354) + * + * One exception (#949): a task note whose value names a configured column + * stays in it, done or cancelled. A task note's `status:` is its one state, + * so `status: done` both closes it and names its column, and a `done` column + * someone listed in `kanban_statuses` is a request to see that work. Only a + * configured column counts: a discovered one would turn every finished note in + * the vault into a column. Checkbox tasks keep the live-work rule, because + * their box and their `@status:` are separate, and a status they inherit from + * the note's frontmatter would pull every checked subtask into that column. */ +export function fieldColumns(tasks: VaultTask[], fieldKey: string, order: string[]): Column[] { const byValue = new Map() const noValue: VaultTask[] = [] + const configured = new Set(order) for (const task of tasks) { - if (!isBoardCard(task)) continue const value = task.fields?.[fieldKey] + if (!isBoardCard(task) && !(task.kind === 'file' && value && configured.has(value))) continue if (value) { const list = byValue.get(value) if (list) list.push(task) diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index a416b94d..86aafd9b 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -56,6 +56,7 @@ import { useCloudSyncStatusStore } from '../lib/cloud-auto-sync' import { requestSettingsTarget } from '../lib/settings-navigation' +import { hideWindowToTray, useTrayStore } from '../lib/tray-runtime' import { EXCALIDRAW_SURFACE, SELF_KEYED_SURFACES } from '../lib/self-keyed-surfaces' import { isWorkspaceVirtualTabPath } from '../lib/workspace-tabs' import { @@ -204,6 +205,7 @@ export function VimNav(): JSX.Element | null { setHint(false) focusEditor() }, [focusEditor, setHint]) + const keepRunningInTray = useTrayStore((s) => s.keepRunningInTray) // The calendar toggle only works when the active pane holds a note (it can't // render in the note-less Tasks/Tags views), so its leader hint is hidden // there rather than shown as a dead key. (#413) @@ -350,6 +352,15 @@ export function VimNav(): JSX.Element | null { label: 'Quick capture', detail: 'Open the floating capture window.' }, + ...(keepRunningInTray + ? [ + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderHideToTray'), + label: 'Hide to tray', + detail: 'Hide this window. ZenNotes keeps running, and its tray icon brings the window back.' + } + ] + : []), { keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderTemplatePicker'), label: 'New from template', @@ -1018,6 +1029,16 @@ export function VimNav(): JSX.Element | null { void window.zen.toggleQuickCapture() return } + if ( + useTrayStore.getState().keepRunningInTray && + matchesSequenceToken(e, overrides, 'vim.leaderHideToTray') + ) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + void hideWindowToTray() + return + } if (matchesSequenceToken(e, overrides, 'vim.leaderTemplatePicker')) { e.preventDefault() e.stopImmediatePropagation() diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts index b10ed7e8..a54cbcf0 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.test.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { listGuideStyle, listIndentPadCh, + listPrefixCells, markdownListHangingIndentCh } from './cm-markdown-list-indent' @@ -66,3 +67,34 @@ describe('listGuideStyle (#491)', () => { expect(style).toContain('background-size: 1px 100%') }) }) + +describe('listPrefixCells (#952)', () => { + it('boxes every indent and marker character to one column', () => { + expect(listPrefixCells('- item', 0)).toEqual([1, 1]) + expect(listPrefixCells(' - item', 4)).toEqual([1, 1, 1, 1, 1, 1]) + expect(listPrefixCells(' 1. item', 2)).toEqual([1, 1, 1, 1, 1]) + expect(listPrefixCells('10) item', 0)).toEqual([1, 1, 1, 1]) + }) + + it('gives a tab the configured width, as the hanging indent counts it', () => { + expect(listPrefixCells('\t- item', 1, 4)).toEqual([4, 1, 1]) + expect(listPrefixCells(' \t* item', 2, 8)).toEqual([1, 8, 1, 1]) + }) + + it('adds up to the hanging indent, so the first line meets the wrapped ones', () => { + for (const [text, offset] of [['- a', 0], [' + b', 6], ['\t\t3. c', 2], [' - spaced', 2]] as const) { + const cells = listPrefixCells(text, offset, 4) ?? [] + expect(cells.reduce((a, b) => a + b, 0)).toBe(markdownListHangingIndentCh(text, offset, 4)) + } + }) + + it('boxes only the indent of a task, whose marker renders as a checkbox', () => { + expect(listPrefixCells('- [ ] task', 0)).toEqual([]) + expect(listPrefixCells(' - [x] done', 4)).toEqual([1, 1, 1, 1]) + }) + + it('leaves a list inside a quote or callout alone', () => { + expect(listPrefixCells('> - quoted', 2)).toBeNull() + expect(listPrefixCells('> - nested', 4)).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-list-indent.ts b/packages/app-core/src/lib/cm-markdown-list-indent.ts index aa0874a2..a7c5e49d 100644 --- a/packages/app-core/src/lib/cm-markdown-list-indent.ts +++ b/packages/app-core/src/lib/cm-markdown-list-indent.ts @@ -103,6 +103,33 @@ export function listGuideStyle(depth: number, width: number): string { ) } +/** + * The width in ch of each character of a list line's prefix, leading + * whitespace first, then the marker and its spaces (#952). The hanging indent + * and the guides are measured in ch, so with a monospace font the source + * already matches them; with a proportional font a space is about half a ch, + * and the bullet sat left of its guide and of its parent's wrapped text. Each + * character is boxed to at least its width (a tab to `tabWidth`, as the hanging + * indent counts it), so a monospace line renders exactly as before. + * + * Null for a line whose prefix is not pure whitespace (a list in a quote or a + * callout, where `>` has rendering of its own). A task's marker gets no cells: + * live preview hides its `- ` and draws a checkbox narrower than the six + * columns the hanging indent counts, a separate mismatch this does not touch. + */ +export function listPrefixCells( + lineText: string, + markerOffset: number, + tabWidth = DEFAULT_EDITOR_TAB_SIZE +): number[] | null { + const indent = lineText.slice(0, markerOffset) + if (!/^[ \t]*$/.test(indent)) return null + const marker = lineText.slice(markerOffset).match(LIST_MARKER_FROM_OFFSET_RE)?.[0] + if (marker == null) return null + const boxed = /\[[ xX>/-]\]/.test(marker) ? indent : indent + marker + return [...boxed].map((ch) => (ch === '\t' ? tabWidth : 1)) +} + interface ListLineFacts { markerOffset: number /** 1-based nesting level: ListItem ancestors of the marker. */ @@ -152,14 +179,31 @@ function computeDecorations(view: EditorView): DecorationSet { ] .filter(Boolean) .join('; ') + const cells = listPrefixCells(line.text, facts.markerOffset, width) + const boxedMarker = cells != null && cells.length > facts.markerOffset builder.add( line.from, line.from, Decoration.line({ - class: 'cm-markdown-list-line', + class: boxedMarker ? 'cm-markdown-list-line cm-list-boxed-marker' : 'cm-markdown-list-line', attributes: { style } }) ) + // One box per character, not one for the run: the fold arrow is a + // widget anchored where the parser starts the item, often inside the + // indent, and a mark split around it gave each piece the full width; the + // bullet widget is not wrapped at all. The column keeps CodeMirror from + // merging neighbours back into one span. + for (const [col, cellCh] of (cells ?? []).entries()) { + builder.add( + line.from + col, + line.from + col + 1, + Decoration.mark({ + class: 'cm-list-prefix-cell', + attributes: { style: `min-width: ${cellCh}ch`, 'data-list-col': String(col) } + }) + ) + } } } diff --git a/packages/app-core/src/lib/cm-mermaid-render.test.ts b/packages/app-core/src/lib/cm-mermaid-render.test.ts index 192af8df..1a9e0beb 100644 --- a/packages/app-core/src/lib/cm-mermaid-render.test.ts +++ b/packages/app-core/src/lib/cm-mermaid-render.test.ts @@ -79,7 +79,10 @@ describe('mermaidRenderExtension', () => { view.destroy() }) - it('redraws when the theme identity changes without changing light/dark mode', async () => { + // The theme identity carries the text font, and a new font has to be measured + // again, so the widget is rebuilt and asks the renderer (which answers from + // its cache when only the palette changed). + it('rebuilds when the theme identity changes without changing light/dark mode', async () => { mermaidMocks.renderMermaidSvg.mockResolvedValue({ ok: true, svg: '' }) const theme = new Compartment() const view = mount(DIAGRAM, undefined, theme.of(mermaidRenderExtension('light', 'theme-a'))) @@ -89,13 +92,8 @@ describe('mermaidRenderExtension', () => { effects: theme.reconfigure(mermaidRenderExtension('light', 'theme-b')) }) - await vi.waitFor(() => - expect(mermaidMocks.renderMermaidSvg).toHaveBeenCalledWith( - expect.stringContaining('flowchart LR'), - 'light', - 'theme-b' - ) - ) + await vi.waitFor(() => expect(mermaidMocks.renderMermaidSvg).toHaveBeenCalledTimes(2)) + expect(mermaidMocks.renderMermaidSvg).toHaveBeenLastCalledWith(expect.stringContaining('flowchart LR')) view.destroy() }) diff --git a/packages/app-core/src/lib/cm-mermaid-render.ts b/packages/app-core/src/lib/cm-mermaid-render.ts index b7733778..241f71bb 100644 --- a/packages/app-core/src/lib/cm-mermaid-render.ts +++ b/packages/app-core/src/lib/cm-mermaid-render.ts @@ -1,5 +1,8 @@ /** - * WYSIWYG mermaid rendering for the editor's live preview (#530). + * WYSIWYG diagram rendering for the editor's live preview (#530): ```mermaid + * fences, drawn by Pele, and ```d2 fences, drawn by D2. One widget serves both + * so the reveal-on-cursor, last-good-drawing and keyboard-entry rules cannot + * drift apart between languages. * * A ```mermaid fence draws as the diagram while the cursor is elsewhere, and * turns back into its source the moment the cursor lands anywhere in the block, @@ -8,11 +11,12 @@ * * Two things make this cheap enough to run on a hot editor: * - * 1. The mermaid module is imported lazily and only when a fence exists, so a - * note without diagrams never pulls the heaviest chunk in the renderer. - * 2. Rendered SVG is cached by (source, full theme identity) in - * `mermaid-render`, so a keystroke elsewhere in the note repaints from the - * cache, and moving the cursor in and out of a block costs nothing. + * 1. The renderer (Pele) is imported lazily and only when a fence exists, so + * a note without diagrams never pulls diagram code into the renderer. + * 2. Rendered SVG is cached by (fonts, source) in `mermaid-render`, so a + * keystroke elsewhere in the note repaints from the cache, and moving the + * cursor in and out of a block costs nothing. Once the renderer has + * loaded, a diagram it has not seen draws synchronously in `toDOM`. * * A diagram mid-edit is usually invalid, so a failed render keeps the LAST good * drawing of that block on screen rather than flashing an error at every @@ -26,6 +30,7 @@ import { syntaxTree } from '@codemirror/language' import { Facet, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view' +import { peekD2Svg, renderD2Svg, withUniqueD2Ids } from './d2-render' import { peekMermaidSvg, renderMermaidSvg } from './mermaid-render' interface DiagramThemeFacetValue { @@ -33,17 +38,64 @@ interface DiagramThemeFacetValue { key: string } -/** Which palette the widgets draw in. The full identity matters because theme - * variants, custom CSS, overrides, tweaks, and fonts can change while the - * light/dark mode stays the same. */ +/** The theme the widgets were drawn under. Colour no longer needs a redraw (the + * SVG paints through CSS variables), but the identity also carries the text + * font, and a font change must re-measure every label. A palette-only change + * rebuilds the widgets from the cache, which costs an innerHTML each. */ const diagramThemeFacet = Facet.define({ combine: (values) => values[values.length - 1] ?? { mode: 'light', key: 'light' } }) -/** The info string that marks a fence as mermaid: the language token only, so - * ```mermaid and ```mermaid title="x" both count. */ -function isMermaidInfo(info: string): boolean { - return info.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid' +type FenceRenderResult = { ok: true; svg: string; ownTheme?: boolean } | { ok: false; error: string } + +interface FenceDiagram { + label: string + errorLabel: string + className: string + peek(source: string): FenceRenderResult | null + render(source: string): Promise + /** Adjust the cached SVG for one insertion (D2: ids of its own). */ + prepare?(svg: string): string +} + +const FENCE_DIAGRAMS = { + mermaid: { + label: 'Mermaid diagram', + errorLabel: 'Mermaid error', + className: 'cm-mermaid-block', + peek: (source: string) => peekMermaidSvg(source), + render: (source: string) => renderMermaidSvg(source) + }, + // D2 always renders in its worker, so a new diagram passes through the + // pending state once; the cache makes every later paint synchronous. + d2: { + label: 'D2 diagram', + errorLabel: 'D2 error', + className: 'cm-mermaid-block cm-d2-block', + peek: peekD2Svg, + render: renderD2Svg, + prepare: withUniqueD2Ids + } +} satisfies Record + +type FenceLanguage = keyof typeof FENCE_DIAGRAMS + +/** The diagram language a fence's info string names: the language token only, + * so ```mermaid and ```mermaid title="x" both count. */ +function fenceLanguage(info: string): FenceLanguage | null { + const token = info.trim().split(/\s+/)[0]?.toLowerCase() + return token === 'mermaid' || token === 'd2' ? token : null +} + +/** Paint a finished drawing into a widget. A D2 diagram that chose no theme of + * its own takes the app palette from `index.css`. */ +function paint( + el: HTMLElement, + result: { svg: string; ownTheme?: boolean }, + diagram: FenceDiagram +): void { + el.innerHTML = diagram.prepare ? diagram.prepare(result.svg) : result.svg + el.classList.toggle('zen-d2-themed', result.ownTheme === false) } /** Cursor/selection overlaps (or just touches an edge of) `[from, to]`. */ @@ -58,11 +110,12 @@ function selectionTouches(state: EditorState, from: number, to: number): boolean * be keyed by source: the whole point is to survive that source being edited * into a temporarily invalid value. */ interface MermaidBlockMemory { - lastGood?: { themeKey: string; svg: string } + lastGood?: { themeKey: string; svg: string; ownTheme?: boolean } } class MermaidBlockWidget extends WidgetType { constructor( + readonly language: FenceLanguage, readonly source: string, readonly theme: DiagramThemeFacetValue, readonly memory: MermaidBlockMemory @@ -72,6 +125,7 @@ class MermaidBlockWidget extends WidgetType { eq(other: MermaidBlockWidget): boolean { return ( + other.language === this.language && other.source === this.source && other.theme.key === this.theme.key && other.memory === this.memory @@ -79,15 +133,16 @@ class MermaidBlockWidget extends WidgetType { } toDOM(): HTMLElement { + const diagram: FenceDiagram = FENCE_DIAGRAMS[this.language] const el = document.createElement('div') - el.className = 'cm-mermaid-block' + el.className = diagram.className el.setAttribute('role', 'img') - el.setAttribute('aria-label', 'Mermaid diagram') + el.setAttribute('aria-label', diagram.label) - const cached = peekMermaidSvg(this.source, this.theme.mode, this.theme.key) + const cached = diagram.peek(this.source) if (cached?.ok) { - el.innerHTML = cached.svg - this.memory.lastGood = { themeKey: this.theme.key, svg: cached.svg } + paint(el, cached, diagram) + this.memory.lastGood = { themeKey: this.theme.key, svg: cached.svg, ownTheme: cached.ownTheme } return el } @@ -95,26 +150,26 @@ class MermaidBlockWidget extends WidgetType { // otherwise leave the space empty rather than collapsing the line and // making the note jump as diagrams arrive. const previous = - this.memory.lastGood?.themeKey === this.theme.key ? this.memory.lastGood.svg : undefined - if (previous) el.innerHTML = previous + this.memory.lastGood?.themeKey === this.theme.key ? this.memory.lastGood : undefined + if (previous) paint(el, previous, diagram) else el.classList.add('cm-mermaid-pending') - void renderMermaidSvg(this.source, this.theme.mode, this.theme.key).then((result) => { - // The widget may have been replaced while mermaid was working (a + void diagram.render(this.source).then((result) => { + // The widget may have been replaced while the renderer loaded (a // keystroke, a cursor move). Writing into a detached node is harmless and // the live widget renders from the cache, so no check is needed beyond // this one for a node that is still ours. if (result.ok) { - el.innerHTML = result.svg + paint(el, result, diagram) el.classList.remove('cm-mermaid-pending', 'cm-mermaid-error') el.removeAttribute('title') - this.memory.lastGood = { themeKey: this.theme.key, svg: result.svg } + this.memory.lastGood = { themeKey: this.theme.key, svg: result.svg, ownTheme: result.ownTheme } return } if (this.memory.lastGood?.themeKey === this.theme.key) return el.classList.remove('cm-mermaid-pending') el.classList.add('cm-mermaid-error') - el.textContent = `Mermaid error: ${result.error}` + el.textContent = `${diagram.errorLabel}: ${result.error}` }) return el } @@ -163,7 +218,8 @@ function buildMermaidDecorations( // The info string lives on the opening line, after the fence marker. const openLine = state.doc.lineAt(from) const info = openLine.text.replace(/^\s*(?:`{3,}|~{3,})/, '') - if (!isMermaidInfo(info)) return + const language = fenceLanguage(info) + if (!language) return const memory = previousMemory.get(openLine.from) ?? {} blocks.push({ from: openLine.from, memory }) @@ -186,7 +242,7 @@ function buildMermaidDecorations( builder.add( openLine.from, closeLine.to, - Decoration.replace({ block: true, widget: new MermaidBlockWidget(body, theme, memory) }) + Decoration.replace({ block: true, widget: new MermaidBlockWidget(language, body, theme, memory) }) ) } }) @@ -216,8 +272,8 @@ const mermaidRenderField = StateField.define({ }) /** - * 1-based line ranges of every mermaid fence in the document, or `[]` when - * mermaid rendering is not active in this editor. Read by the arrow-key and + * 1-based line ranges of every mermaid and d2 fence in the document, or `[]` + * when diagram rendering is not active in this editor. Read by the arrow-key and * Vim `j`/`k` helpers, which otherwise sail straight over a rendered block: it * is one widget with no cursor coordinates inside it, so pixel-based vertical * motion has nowhere to land and a keyboard-only user could never open the @@ -227,7 +283,7 @@ export function mermaidBlockLineRanges(state: EditorState): readonly MermaidBloc return state.field(mermaidRenderField, false)?.blockLines ?? [] } -/** Live-preview mermaid rendering, drawn in the given palette. */ +/** Live-preview mermaid and d2 rendering, drawn in the given palette. */ export function mermaidRenderExtension( mode: 'light' | 'dark', themeKey: string = mode diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index d4b4c639..7855c66c 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -10,6 +10,7 @@ import { requestAgentEditReview } from './agent-review-runtime' import { isTagsViewActive, isTasksViewActive, isTrashViewActive, useStore } from '../store' import { confirmApp } from './confirm-requests' import { requestSettingsTarget } from './settings-navigation' +import { hideWindowToTray, useTrayStore } from './tray-runtime' import { promptApp } from './prompt-requests' import { captureNavigationContext } from './navigation-context' import { buildMoveNotePrompt, moveNoteVocabulary, parseMoveNoteTarget } from './move-note' @@ -858,6 +859,24 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => window.zen.getAppInfo().runtime === 'desktop', run: () => void getState().openWhatsNew() }, + { + id: 'app.hideToTray', + title: 'Hide Window to Tray', + category: 'App', + keywords: 'tray menu bar background hide window close minimize keep running', + shortcut: getState().vimMode ? leaderShortcut('vim.leaderHideToTray') : undefined, + when: () => + window.zen.getAppInfo().runtime === 'desktop' && useTrayStore.getState().keepRunningInTray, + run: () => void hideWindowToTray() + }, + { + id: 'app.quit', + title: 'Quit ZenNotes', + category: 'App', + keywords: 'quit exit close app tray background stop', + when: () => window.zen.getAppInfo().runtime === 'desktop' && !!window.zen.quitApp, + run: () => window.zen.quitApp?.() + }, { id: 'app.report-issue', title: 'Report an Issue', diff --git a/packages/app-core/src/lib/d2-render.test.ts b/packages/app-core/src/lib/d2-render.test.ts new file mode 100644 index 00000000..cecb1234 --- /dev/null +++ b/packages/app-core/src/lib/d2-render.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The real runtime is an 8 MB WebAssembly build; these tests drive a stand-in +// with the same one-callback-per-worker contract, which is what the queue in +// d2-render exists for. +const runtime = vi.hoisted(() => { + const calls: { kind: 'compile' | 'render'; arg: unknown }[] = [] + let pending: ((value: unknown) => void) | null = null + const settle = (value: unknown): Promise => + new Promise((resolve) => { + // Like the wrapper: a call made while another is pending replaces its + // callback, so an unqueued second call strands the first. + pending = resolve + setTimeout(() => { + if (pending === resolve) { + pending = null + resolve(value) + } + }, 5) + }) + class D2 { + compile(source: string): Promise { + calls.push({ kind: 'compile', arg: source }) + if (source.includes('->-')) return Promise.reject(new Error(JSON.stringify([{ errmsg: 'index:2:3: unexpected text' }]))) + return settle({ diagram: { source }, renderOptions: { pad: 100, themeID: 0 } }) + } + render(diagram: { source: string }, options: unknown): Promise { + calls.push({ kind: 'render', arg: options }) + return settle( + `${diagram.source}` + + '
label
' + ) + } + } + return { calls, D2 } +}) + +vi.mock('./d2-runtime', () => ({ D2: runtime.D2 })) + +import { d2SourceSetsTheme, formatD2Error, peekD2Svg, renderD2Svg, sanitizeD2Svg, withUniqueD2Ids } from './d2-render' + +beforeEach(() => { + runtime.calls.length = 0 +}) + +describe('renderD2Svg', () => { + it('queues concurrent diagrams so neither is stranded by the shared worker callback', async () => { + const [a, b] = await Promise.all([renderD2Svg('a -> b'), renderD2Svg('c -> d')]) + expect(a.ok && a.svg).toContain('a -> b') + expect(b.ok && b.svg).toContain('c -> d') + }) + + it('draws at natural size with a small pad unless the diagram sets its own', async () => { + await renderD2Svg('x -> y') + expect(runtime.calls.find((c) => c.kind === 'render')?.arg).toMatchObject({ noXMLTag: true, scale: 1, pad: 16 }) + runtime.calls.length = 0 + await renderD2Svg('vars: { d2-config: { pad: 40 } }\nx -> z') + const options = runtime.calls.find((c) => c.kind === 'render')?.arg as Record + expect(options.pad).toBe(100) + expect(options.scale).toBe(1) + }) + + it('caches by source, so the editor and Preview share one drawing', async () => { + await renderD2Svg('p -> q\n') + runtime.calls.length = 0 + const again = await renderD2Svg(' p -> q') + expect(runtime.calls).toHaveLength(0) + expect(peekD2Svg('p -> q')).toEqual(again) + }) + + it('reports a broken diagram as text with its line and column', async () => { + const result = await renderD2Svg('a ->- b') + expect(result).toEqual({ ok: false, error: 'line 2:3: unexpected text' }) + }) + + it('never hands the DOM anything that runs, fetches or navigates', async () => { + const result = await renderD2Svg('safe -> label') + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.svg).not.toMatch(/onerror|onclick| { + it('lets a diagram that picks a D2 theme keep it', () => { + expect(d2SourceSetsTheme('vars: {\n d2-config: {\n theme-id: 300\n }\n}\na -> b')).toBe(true) + expect(d2SourceSetsTheme('vars: { d2-config: { dark-theme-id: 200 } }')).toBe(true) + expect(d2SourceSetsTheme('a -> b: theme-id is just words')).toBe(false) + expect(d2SourceSetsTheme('a -> b')).toBe(false) + }) +}) + +describe('formatD2Error', () => { + it('turns the JSON error list into lines', () => { + expect( + formatD2Error(new Error('[{"range":"index,0:0:0-0:4:4","errmsg":"index:1:1: connection missing destination"},{"errmsg":"index:4:2: bad"}]')) + ).toBe('line 1:1: connection missing destination\nline 4:2: bad') + }) + + it('keeps any other message as it came', () => { + expect(formatD2Error(new Error('worker crashed'))).toBe('worker crashed') + }) +}) + +describe('sanitizeD2Svg', () => { + it('keeps the stylesheet and labels while removing links', () => { + const out = sanitizeD2Svg( + '' + + 'Server' + ) + expect(out).toContain('.fill-B1') + expect(out).toContain('Server') + expect(out).not.toMatch(/]|javascript:|https:\/\/example/) + }) +}) + +describe('withUniqueD2Ids', () => { + it('gives each inserted copy its own ids, references included', () => { + const svg = '' + const a = withUniqueD2Ids(svg) + const b = withUniqueD2Ids(svg) + const idsOf = (s: string) => [...s.matchAll(/id="([^"]+)"/g)].map((m) => m[1]) + expect(idsOf(a)).not.toEqual(idsOf(b)) + for (const copy of [a, b]) { + const [marker, mask] = idsOf(copy) + expect(copy).toContain(`marker-end="url(#${marker})"`) + expect(copy).toContain(`mask="url(#${mask})"`) + // A reference to an id this SVG does not define is left alone. + expect(copy).toContain('url(#elsewhere)') + } + }) +}) diff --git a/packages/app-core/src/lib/d2-render.ts b/packages/app-core/src/lib/d2-render.ts new file mode 100644 index 00000000..33e12871 --- /dev/null +++ b/packages/app-core/src/lib/d2-render.ts @@ -0,0 +1,195 @@ +/** + * D2 diagrams (d2lang.com), shared by every surface that draws one: the + * Preview pipeline and the editor's live preview. + * + * A ```d2 fence is compiled and laid out by D2 itself, through the WebAssembly + * build in `@terrastruct/d2`, which runs in a worker of its own. The module is + * about 8 MB, so it loads only when a note holds a d2 fence (see `d2-runtime`). + * + * Three things here are not optional: + * + * - One request at a time. The wrapper keeps a single pending callback per + * worker, so a second call made before the first settles overwrites it and + * the first never settles. Every call goes through `enqueue`. + * - Sanitizing. D2 passes the HTML of a Markdown label straight into the SVG + * (an `` in a label arrives intact), draws `icon:` as an + * `` that fetches its URL, and wraps `link:` in an ``. A note must + * never run script, reach the network or navigate by being looked at, which + * is what Mermaid's strict mode guaranteed and Pele keeps, so the SVG goes + * through DOMPurify with images and links removed before any DOM sees it. + * - Colour comes from the theme. D2 paints through classes (`fill-N1`, + * `stroke-B1` and so on) that `index.css` maps onto the `--z-*` tokens, so a + * diagram matches the app and a theme switch repaints it without a redraw. + * A diagram that picks a D2 theme of its own (`theme-id` in `d2-config`) + * keeps it: the result says so, and the caller leaves the mapping off. + */ +import DOMPurify, { type DOMPurify as DOMPurifyInstance } from 'dompurify' + +type D2Module = typeof import('./d2-runtime') +type D2Instance = InstanceType + +let d2Promise: Promise | null = null + +function loadD2(): Promise { + if (!d2Promise) { + d2Promise = import('./d2-runtime').then((module) => new module.D2()) + // A failed load (an offline web client that never fetched the chunk) must + // not poison every later diagram. + d2Promise.catch(() => { + d2Promise = null + }) + } + return d2Promise +} + +let queue: Promise = Promise.resolve() + +function enqueue(job: () => Promise): Promise { + const run = queue.then(job, job) + queue = run.catch(() => undefined) + return run +} + +export type D2RenderResult = + | { ok: true; svg: string; ownTheme: boolean } + | { ok: false; error: string } + +const svgCache = new Map() +const inFlight = new Map>() +/** Diagrams are big strings, fonts included; the number is small on purpose. */ +const SVG_CACHE_LIMIT = 40 + +/** The editor trims a fence's body and Preview keeps its trailing newline; + * whitespace around a diagram never changes it, so both share one entry. */ +function normalizeSource(source: string): string { + return source.trim() +} + +function remember(key: string, result: D2RenderResult): D2RenderResult { + if (svgCache.size >= SVG_CACHE_LIMIT) { + const oldest = svgCache.keys().next().value + if (oldest !== undefined) svgCache.delete(oldest) + } + svgCache.set(key, result) + return result +} + +/** A `d2-config` that names a theme of D2's own wins over the app palette. */ +export function d2SourceSetsTheme(source: string): boolean { + return /(^|[\s{;])(dark-)?theme-(id|overrides)\s*:/m.test(source) +} + +function sourceSets(source: string, key: string): boolean { + return new RegExp(`(^|[\\s{;])${key}\\s*:`, 'm').test(source) +} + +/** D2 reports errors as a JSON list of `{ errmsg }` entries whose messages lead + * with the virtual file name (`index:3:5: …`). The line and column are what a + * person needs; the file name is ours, not theirs. */ +export function formatD2Error(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + try { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + const lines = parsed + .map((entry) => + entry && typeof entry === 'object' && typeof (entry as { errmsg?: unknown }).errmsg === 'string' + ? (entry as { errmsg: string }).errmsg.replace(/^index:/, 'line ') + : null + ) + .filter((line): line is string => line !== null) + if (lines.length > 0) return lines.join('\n') + } + } catch { + /* not the JSON list: show the message as it came */ + } + return raw +} + +let purifier: DOMPurifyInstance | null = null + +/** Strip everything from a D2 SVG that could run, fetch or navigate. A + * separate instance, so the hooks the Markdown sanitizer installs on the + * shared one never apply here and these settings never leak there. */ +export function sanitizeD2Svg(svg: string): string { + purifier ??= DOMPurify(window) + return purifier.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true, html: true }, + // Markdown labels are XHTML inside a foreignObject. + ADD_TAGS: ['foreignObject', 'style'], + // `icon:` and `shape: image` fetch their URL; `link:` navigates. Their + // contents stay, so a linked shape keeps its label. + FORBID_TAGS: ['image', 'img', 'a', 'video', 'audio', 'iframe', 'object', 'embed', 'use'], + FORBID_ATTR: ['href', 'xlink:href', 'src', 'srcset', 'action', 'formaction'], + // DOMPurify lets HTML into SVG only under `annotation-xml` unless told + // otherwise, and Chromium then empties every Markdown label (jsdom keeps + // them, so only the built app shows it). The forbidden tags and attributes + // above still apply inside the foreignObject. + HTML_INTEGRATION_POINTS: { foreignobject: true } + }) +} + +async function compileAndRender(source: string): Promise { + const d2 = await loadD2() + const compiled = await enqueue(() => d2.compile(source)) + const svg = await enqueue(() => + d2.render(compiled.diagram, { + ...compiled.renderOptions, + noXMLTag: true, + // Drawn at its own size and shrunk to fit only when wider than the note; + // D2's default stretches even a two-box diagram to the full width. + ...(sourceSets(source, 'scale') ? {} : { scale: 1 }), + // D2 pads for a standalone file (100 px); a diagram in a note sits in a + // card that pads it already. + ...(sourceSets(source, 'pad') ? {} : { pad: 16 }) + }) + ) + return { ok: true, svg: sanitizeD2Svg(svg), ownTheme: d2SourceSetsTheme(source) } +} + +let idSeq = 0 + +/** Give one inserted copy of a D2 SVG ids of its own. D2's ids are fixed per + * diagram, and Preview and the editor's live widget show the same cached + * string: with two copies in the document, `marker-end="url(#mk-…)"` resolves + * to the first one, and when that copy sits in the hidden editor every arrow + * in Preview loses its head. Call this at each insertion, not on the cache. */ +export function withUniqueD2Ids(svg: string): string { + const suffix = `-z${(idSeq += 1)}` + const ids = new Set() + const renamed = svg.replace(/\bid="([^"]+)"/g, (_match, id: string) => { + ids.add(id) + return `id="${id}${suffix}"` + }) + if (ids.size === 0) return svg + return renamed.replace(/url\(#([^)]+)\)/g, (match, id: string) => + ids.has(id) ? `url(#${id}${suffix})` : match + ) +} + +/** A finished render, or null when this diagram has not been drawn yet. D2 + * always renders in its worker, so nothing is drawn synchronously here; this + * lets a widget repaint from the cache on re-mount instead of flashing. */ +export function peekD2Svg(rawSource: string): D2RenderResult | null { + return svgCache.get(normalizeSource(rawSource)) ?? null +} + +/** Render to a sanitized SVG string, reusing an in-flight render of the same + * diagram. Never throws: a broken diagram is a result the caller shows, and + * its message quotes the source, so it is shown as text. */ +export function renderD2Svg(rawSource: string): Promise { + const source = normalizeSource(rawSource) + const cached = svgCache.get(source) + if (cached) return Promise.resolve(cached) + const running = inFlight.get(source) + if (running) return running + + const task = compileAndRender(source).catch( + (error: unknown): D2RenderResult => ({ ok: false, error: formatD2Error(error) }) + ) + inFlight.set(source, task) + return task.then((result) => { + inFlight.delete(source) + return remember(source, result) + }) +} diff --git a/packages/app-core/src/lib/d2-runtime.ts b/packages/app-core/src/lib/d2-runtime.ts new file mode 100644 index 00000000..d87dd8d7 --- /dev/null +++ b/packages/app-core/src/lib/d2-runtime.ts @@ -0,0 +1,11 @@ +/** + * The one module that imports D2 (d2lang.com), reached only through the + * `import()` in `d2-render`. + * + * It exists so the renderer's async chunk carries a name of ours, + * `d2-runtime-*.js`: the browser build of `@terrastruct/d2` is one file of + * about 8 MB with the compiler's WebAssembly inside it, and the perf harness + * and the packaged-web browser test assert that this chunk is not fetched + * before a note needs a D2 diagram. + */ +export { D2 } from '@terrastruct/d2' diff --git a/packages/app-core/src/lib/demo-tour.test.ts b/packages/app-core/src/lib/demo-tour.test.ts index 30491b9b..2c5fab5b 100644 --- a/packages/app-core/src/lib/demo-tour.test.ts +++ b/packages/app-core/src/lib/demo-tour.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from '@shared/demo-tour-data' -import { currentDemoTourPath, DEMO_TOUR_START_PATH, isDemoTourStartPath } from '@shared/demo-tour' +import { + currentDemoTourPath, + DEMO_TOUR_START_PATH, + demoTourNestedDirs, + isDemoTourStartPath +} from '@shared/demo-tour' +import { formDirFromCsvPath, formTitleFromCsvPath } from '@shared/databases' import { extractWikilinkTargets, resolveWikilinkTarget } from './wikilinks' // Built from its code point so this file never contains the character itself. @@ -15,14 +21,25 @@ const tourNotes = DEMO_TOUR_NOTES.map((note) => ({ folder: 'inbox' as const })) +// Files the tour ships beside its notes answer to their file name, the way +// `![[Tour Sketch.excalidraw]]` finds the drawing, and a database to its title, +// the way `[[Reading List]]` opens `Reading List.base`. +const assetTargets = new Set( + DEMO_TOUR_ASSETS.flatMap((asset) => [ + asset.path, + asset.path.slice(asset.path.lastIndexOf('/') + 1), + ...(formDirFromCsvPath(asset.path) ? [formTitleFromCsvPath(asset.path)] : []) + ]) +) + describe('demo tour vault', () => { - it('resolves every wikilink and embed to a note the tour ships', () => { + it('resolves every wikilink and embed to a note or file the tour ships', () => { const unresolved: string[] = [] let resolved = 0 for (const note of DEMO_TOUR_NOTES) { for (const target of extractWikilinkTargets(note.body)) { if (INTENTIONALLY_MISSING.has(target)) continue - if (DEMO_TOUR_ASSETS.some((asset) => asset.path === target)) continue + if (assetTargets.has(target)) continue if (resolveWikilinkTarget(tourNotes, target)) resolved++ else unresolved.push(`${note.path}: [[${target}]]`) } @@ -72,3 +89,23 @@ describe('demo tour notes under their older names', () => { expect(isDemoTourStartPath('inbox/demo/01 Markdown Basics.md')).toBe(false) }) }) + +describe('folders the tour ships inside its own folder', () => { + it('lists them deepest first, so each is empty when its turn comes', () => { + expect( + demoTourNestedDirs([ + 'zennotes-demo-card.svg', + 'inbox/demo/Tour Sketch.excalidraw', + 'inbox/demo/Reading List.base/data.csv', + 'inbox/demo/Reading List.base/pages/One.md', + 'inbox/demo/Reading List.base/schema.json' + ]) + ).toEqual(['inbox/demo/Reading List.base/pages', 'inbox/demo/Reading List.base']) + }) + + it('covers the sample database the tour ships', () => { + expect(demoTourNestedDirs(DEMO_TOUR_ASSETS.map((asset) => asset.path))).toContain( + 'inbox/demo/Reading List.base' + ) + }) +}) diff --git a/packages/app-core/src/lib/diagram-renderers.ts b/packages/app-core/src/lib/diagram-renderers.ts index 76623ded..8cd5ae6c 100644 --- a/packages/app-core/src/lib/diagram-renderers.ts +++ b/packages/app-core/src/lib/diagram-renderers.ts @@ -1,6 +1,6 @@ /** - * Client-side render loop for the three "math diagram" block types: - * TikZ, JSXGraph, and function-plot. + * Client-side render loop for the fenced diagram types other than Mermaid: + * TikZ, D2, JSXGraph, and function-plot. * * Preview.tsx calls `renderDiagrams(rootEl, { themeKey })` after each * markdown render (and again when the theme changes). Each function @@ -11,11 +11,12 @@ * Every library is loaded once, lazily, and memoized. */ +import { renderD2Svg, withUniqueD2Ids } from "./d2-render"; import { attachInlineDiagramPanZoom } from "./inline-diagram-pan-zoom"; function prepareDiagramShell( el: HTMLElement, - kind: "tikz" | "jsxgraph" | "function-plot", + kind: "tikz" | "d2" | "jsxgraph" | "function-plot", source: string, ): HTMLDivElement { const expanded = el.dataset.zenDiagramExpanded === "true"; @@ -331,6 +332,36 @@ async function renderTikzBlock(el: HTMLElement): Promise { } } +// --------------------------------------------------------------------------- +// D2: architecture and flow diagrams, compiled in a WebAssembly worker +// --------------------------------------------------------------------------- + +async function renderD2Block(el: HTMLElement): Promise { + const source = el.getAttribute("data-d2-source") ?? el.textContent ?? ""; + if (!source.trim()) return; + el.setAttribute("data-d2-source", source); + const surface = prepareDiagramShell(el, "d2", source); + const loading = document.createElement("div"); + loading.className = "zen-d2-loading"; + loading.textContent = "Rendering D2…"; + surface.replaceChildren(loading); + const result = await renderD2Svg(source); + if (result.ok) { + // Already sanitized in d2-render: no script, no fetch, no navigation. + surface.innerHTML = withUniqueD2Ids(result.svg); + // The app palette repaints D2's theme classes unless the diagram chose a + // D2 theme of its own. + el.classList.toggle("zen-d2-themed", !result.ownTheme); + if (el.dataset.zenDiagramExpanded !== "true") attachInlineDiagramPanZoom(surface); + } else { + // The message quotes the source, so it is text, never markup. + const error = document.createElement("pre"); + error.className = "zen-diagram-error"; + error.textContent = `D2 error: ${result.error}`; + surface.replaceChildren(error); + } +} + // --------------------------------------------------------------------------- // JSXGraph — interactive 2D geometry / function plots // --------------------------------------------------------------------------- @@ -629,6 +660,17 @@ export async function renderDiagrams( tasks.push(renderTikzBlock(el)); } + for (const el of Array.from(root.querySelectorAll(".zen-d2"))) { + if (opts.expanded) el.dataset.zenDiagramExpanded = "true"; + else delete el.dataset.zenDiagramExpanded; + const source = el.getAttribute("data-d2-source") ?? el.textContent ?? ""; + // Colour comes from CSS, so a theme switch needs no redraw. + const stamp = `d2|${opts.expanded ? "expanded" : "normal"}|${source}`; + if (el.getAttribute("data-zen-rendered-hash") === stamp) continue; + el.setAttribute("data-zen-rendered-hash", stamp); + tasks.push(renderD2Block(el)); + } + for (const el of Array.from( root.querySelectorAll(".zen-jsxgraph"), )) { diff --git a/packages/app-core/src/lib/diagram-tabs.ts b/packages/app-core/src/lib/diagram-tabs.ts index d1061fb8..757d9a17 100644 --- a/packages/app-core/src/lib/diagram-tabs.ts +++ b/packages/app-core/src/lib/diagram-tabs.ts @@ -1,6 +1,6 @@ const DIAGRAM_TAB_PREFIX = 'zen://diagram/' -export type DiagramTabKind = 'mermaid' | 'tikz' | 'jsxgraph' | 'function-plot' +export type DiagramTabKind = 'mermaid' | 'd2' | 'tikz' | 'jsxgraph' | 'function-plot' export interface DiagramTabPayload { id: string @@ -13,6 +13,7 @@ let diagramTabSequence = 0 const DIAGRAM_KIND_LABELS: Record = { mermaid: 'Mermaid diagram', + d2: 'D2 diagram', tikz: 'TikZ diagram', jsxgraph: 'JSXGraph diagram', 'function-plot': 'Function plot' diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 6e6c1d8e..bc3c08ed 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -312,7 +312,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'The Tasks Kanban board, custom statuses, and any field', body: - 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between Upcoming and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date, and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' + 'Switch Tasks to Kanban (the button, or `3` in Vim mode) for a column board. "Group by" offers Status (Today / Upcoming / In progress / Waiting / Done, derived from due dates, `[/]` and `@waiting`), Priority, Folder, Custom status, and one entry per inline `@field` you use. The Folder board gives every note folder its own column (`Projects/alpha`, `Areas`, the Inbox root, Quick Notes), so notes that live together group together with nothing to maintain; point it at one folder under Settings → Tasks → Folder board, with `:folderroot Projects` on the board, or with `kanban_folder_root = "Projects"` in config.toml, and that folder\'s children become the columns, deeper notes roll up to their child, and notes outside it share an Other folders column. Folder columns stay read-only: move a note to change its column. On the Status board a started task (`[/]`) sits in its own In progress column between Upcoming and Waiting: drop a card there (or send it with `Shift+L`) to mark it `[/]`, drop it back on Today or Upcoming to reopen it with that date, and a `@waiting` card keeps its `[/]` underneath so clearing the wait returns it to In progress. Any task field works: tag tasks with `@key:value` tokens like `@status:review`, `@sprint:24`, or `@area:backend`, and each key becomes its own board with a column per value (auto-discovered, so it appears the moment you use it). For the status field, define the columns under Settings → Tasks → Kanban statuses, or list them in order in `config.toml` under `[view]`, e.g. `kanban_statuses = ["backlog", "in_progress", "review", "done"]`; other fields sort their columns automatically. A note-level `status:` in frontmatter sets a default for that note’s tasks. A note tagged `task` is a card of its own: its frontmatter `status:` is its custom status, and without one it sits in the trailing No status column until you move it. Finished and cancelled tasks leave the boards, with one exception: a task note whose `status:` names one of your Kanban statuses stays in that column on the Custom status board, so a `done` column holds the task notes you finished, still checked. Everything is keyboard-first: `h`/`l` move between columns, `j`/`k` between cards, `g` cycles the group-by, `Shift+H` / `Shift+L` send the focused card to the previous/next column (rewriting its `@field` token), `<` / `>` reorder the columns themselves (saved per board), and `Space`/`Enter` toggle/open. Drag does the same with the mouse, including dragging a column header to reorder, and dragging a card to a new spot inside its column to hand-prioritize it (that arrangement is saved per column and restored when you come back to the board). To have the cards follow their dates instead, set **Order** in the board\'s toolbar to Due date (`s` in Vim mode, `:order due` on the ex line, or `kanban_card_sort = "due"` under `[view]` in config.toml): every column on every board, custom status boards included, then sorts by due date, earliest first and cards without one last, and a card dropped into another column lands in its date slot. Your dragged arrangement is kept meanwhile, and Manual brings it back. On a board wider than the window, hold a dragged card or column header near the board\'s left or right edge and the board scrolls that way, faster the closer you get to the edge; a card held near the top or bottom of a long column scrolls the column. After a drop the cursor sits on the card or column you moved, so the keyboard picks up where the mouse left off. Renaming a column (click its title, or `[kanban_column_titles]` in `config.toml`) sets a display label only: the column still shows its underlying `@field:value` beneath the name, and moving a card in writes that value, not the label.' }, { title: 'Filter the Tasks views to one project', @@ -467,7 +467,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Math, diagrams, and plots render from plain fences', body: - 'Inline `$…$` and display `$$…$$` math render via KaTeX by default. Settings ▸ Editor ▸ Math renderer switches the typesetter to Typst, which reads the same `$…$` / `$$…$$` blocks as Typst markup instead of LaTeX (a note’s math is written for whichever engine you pick). Both typesetters complete commands as you write math: type two letters inside a math region to get Greek letters, operators, arrows, sets, and functions with rendered previews, and argument-taking commands insert editable snippets such as `frac(a, b)`. Beyond math, four fenced block languages turn into live diagrams in preview and split mode: `mermaid` for flow, sequence, state, gantt, and graph diagrams (and `mermaid` alone also draws inline in the editor, with live preview on: the diagram stands in for the fence until your cursor enters it, which brings the source back for editing); `tikz` for LaTeX-native coordinate systems, commutative diagrams, and figure-quality plots (the TeX engine runs on-device so no network is required); `jsxgraph` for interactive geometry and function plots driven by a small JSON config; and `function-plot` for compact Cartesian function plotting. Each block is ordinary markdown on disk, so the source remains portable and diffable.' + 'Inline `$…$` and display `$$…$$` math render via KaTeX by default. Settings ▸ Editor ▸ Math renderer switches the typesetter to Typst, which reads the same `$…$` / `$$…$$` blocks as Typst markup instead of LaTeX (a note’s math is written for whichever engine you pick). Both typesetters complete commands as you write math: type two letters inside a math region to get Greek letters, operators, arrows, sets, and functions with rendered previews, and argument-taking commands insert editable snippets such as `frac(a, b)`. Beyond math, five fenced block languages turn into live diagrams in preview and split mode: `mermaid` for flow, sequence, class, state, ER, gantt, pie, mindmap, timeline, and every other diagram type Mermaid has built in, drawn by Pele (pele.run) in your theme’s colours and fonts, so a layout can differ from mermaid.live or GitHub while the syntax stays Mermaid’s; labels are plain text with `
` breaks and markdown bold or italic, so other HTML and `$$…$$` math inside a label show as written; `d2` (D2, d2lang.com) for architecture and system diagrams, with nested containers, `shape: sql_table` data models, sequence diagrams and sketch mode, coloured from your theme unless the diagram picks a D2 theme of its own (`theme-id` in `d2-config`), with Markdown labels allowed but icons, images and links left out so a diagram never reaches the network (the D2 compiler is a large download that loads the first time a note holds a `d2` fence, so that first diagram takes a moment); `mermaid` and `d2` also draw inline in the editor with live preview on, the diagram standing in for the fence until your cursor enters it, which brings the source back for editing; `tikz` for LaTeX-native coordinate systems, commutative diagrams, and figure-quality plots (the TeX engine runs on-device so no network is required); `jsxgraph` for interactive geometry and function plots driven by a small JSON config; and `function-plot` for compact Cartesian function plotting. Each block is ordinary markdown on disk, so the source remains portable and diffable.' }, { title: 'Equation environments number themselves', @@ -585,6 +585,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space r', action: 'Review Cloud conflicts', detail: 'Open whatever Cloud sync is waiting on: the queue of files two devices changed at once, one decision at a time, or the vault settings question, which lists each setting that differs with this device’s value beside the cloud’s and lets you pick a side per setting. When the open note is one of the waiting files, the queue starts on it. A waiting note also says so across its top, in Edit and Preview alike: sync is paused for it until you choose what to keep, and its Review button opens the queue on it. A notification with Review appears once when a new conflict arrives. When the Cloud vault this vault syncs with is deleted, on this device, another device or the website, the status bar says Cloud vault deleted and the binding opens Settings → Cloud instead, where This vault says what happened until you choose a cloud vault, start a new one, or dismiss the notice. If another device moved it into an end-to-end encrypted vault, the status bar says Cloud vault now encrypted instead, and This vault asks you to unlock this device if needed, then choose the encrypted vault. The binding and the command palette entry appear only while something is waiting, and open the same place as Review in the status bar.' }, { keys: 'Space g', action: 'Open atlas', detail: 'Open the Atlas view: the whole vault drawn as a map of notes and links.' }, { keys: 'Space q', action: 'Quick capture window', detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' }, + { keys: 'Space b', action: 'Hide to tray', detail: 'With Settings → Appearance → Keep running in the tray on, hide this window. ZenNotes keeps running, Cloud sync and the Quick capture hotkey keep working, and the tray icon brings the window back. Hide Window to Tray in the command palette does the same.' }, { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it. Without Vim mode, give “Insert template into current note” a key under Settings → Keymap.' }, { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, { keys: 'Space l s', action: 'Toggle favorite', detail: 'Add or remove the active note from the Favorites section shown in the sidebar and on the home view. Folders join it from their context menu.' }, @@ -1117,6 +1118,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'Theme, mode, and variant', detail: 'Pick a theme family: Apple, Gruvbox, Catppuccin, GitHub, Solarized, One, Nord, Tokyo Night, Kanagawa (Wave / Dragon / Paper Ink (Custom) / Lotus), Rosé Pine (Rosé Pine / Moon / Dawn), or the monochrome, true-black (OLED-friendly) Black Metal, plus light or dark mode and the active flavor or contrast where the theme supports it.' }, { label: 'Window title bar', detail: 'Under Settings → Appearance → Chrome, turn off Window title bar to remove the main window title and controls on macOS, Windows, and Linux, including tiling desktops such as Hyprland. The sidebar and editor stay visible, and the change applies to open vault windows without a restart. Drag blank space around the sidebar header to move the window, or use your window manager shortcuts. Press Cmd/Ctrl+, to restore the setting. The preference is saved as [appearance].show_window_title_bar in config.toml.' }, + { label: 'Keep running in the tray', detail: 'Off by default. Under Settings → Appearance → Window, turn it on and closing the last window hides it instead of ending the session: a ZenNotes icon in the system tray (the menu bar on macOS) stays, Cloud sync and the Quick capture hotkey keep working, and the icon\'s tooltip and menu show the sync state while a Cloud vault is linked. Click the icon, or choose Open ZenNotes in its menu, to bring the window back; on macOS the Dock icon does too. Quit from the icon\'s menu, with Quit ZenNotes in the command palette, or with Cmd+Q on macOS. On a desktop with no tray host the icon does not appear: open ZenNotes again and the window comes back. `Space b` (Hide Window to Tray in the palette) hides the window on purpose, which a tiling window manager cannot do with minimise. The setting belongs to this computer and is not part of config.toml. While ZenNotes runs it also checks for updates every six hours, not only at launch.' }, { label: 'Dark sidebar', detail: 'Tint the sidebar slightly darker than the canvas so the chrome reads as a distinct surface.' }, { label: 'Sidebar arrows', detail: 'Show or hide disclosure arrows for collapsible sidebar folders and sections.' }, { label: 'Use theme for PDF export', detail: 'Under Settings → Appearance → PDF export. Off by default, so exported PDFs use a clean light print theme. Turn it on to export in your current look instead: your theme (colors and dark/light, including custom themes), plus your enabled CSS snippets and color tweaks, as a full-bleed page. This toggle is the single switch for how the PDF looks; your CSS snippets style the export only while it is on, so you customize the PDF by editing your snippets, not with a separate print stylesheet.' } @@ -1135,7 +1137,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Scroll offset', detail: 'Vim scrolloff: keep N lines visible above and below the cursor so it never hugs the top or bottom edge. Default 0 (off). Only applies with Vim mode on.' }, { label: 'Smooth preview scroll', detail: 'Animate Ctrl+D / Ctrl+U half-page scrolling in the preview pane.' }, { label: 'Vault text search backend and binary paths', detail: 'Choose Auto, the built-in searcher, ripgrep, or fzf for vault-wide text search. Auto prefers system tools when they are installed and falls back cleanly when they are not, you can provide explicit binary paths for ripgrep or fzf if they are not on your PATH, and Settings now shows the resolved runtime backend that will actually be used.' }, - { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` diagrams in place; each turns back into its source when the cursor enters it.' }, + { label: 'Live preview', detail: 'Hide markdown syntax on lines you are not actively editing. Also draws math, tables, and `mermaid` and `d2` diagrams in place; each turns back into its source when the cursor enters it.' }, { label: 'Math size', detail: 'Scales inline `$…$` and block `$$…$$` math relative to the surrounding text, 50 to 200 percent, in the editor and the reading view, for both KaTeX and Typst; `math_font_scale` under `[editor]` in `config.toml`.' }, { label: 'Render tables in live preview', detail: 'Show Markdown tables as interactive WYSIWYG widgets (edit cells, drag, right-click/`m` menu). Turn it off to keep tables as plain markdown text so you can edit them with the keyboard and Vim motions like any other line. When widgets are on, Arrow keys (and h/j/k/l) navigate cells; Shift+V then Shift+J/Shift+K move whole lines in the raw source. In Vim mode the cell cursor speaks the editor\'s language: motions (w/b/e, f/t, 0/$), operators (d/c/y with motions and text objects), visual selections, and yank/paste (y/p/P) through the same registers as the rest of the note, so you can yank in a cell and paste in the body, or the other way around. Shift+V selects whole rows: y takes them as Markdown table rows (from the header, with its divider row), so p in the note pastes a working table, and ⌘C (Ctrl+C on Windows and Linux) copies a selection without changing it. The `m` menu can also copy the whole table as Markdown or duplicate it below.' }, { label: 'Sync title heading on rename', detail: 'On by default. A new note is created as `# `, and with this on a rename carries that heading along: rename `Untitled` to `Groceries` and line one becomes `# Groceries`, from the breadcrumb, the sidebar, or the note list alike. Only an existing top-level `#` heading is rewritten and one is never invented, so a note that opens with prose, a list, or a `##` heading is untouched; deleting the `#` line opts that note out permanently. The heading is found after any frontmatter, and the rest of the note is left byte for byte as it was.' }, diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 1491dcac..ea614983 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -67,6 +67,7 @@ export type KeymapId = | "vim.leaderToggleFavorite" | "vim.leaderTableToDatabase" | "vim.leaderQuickCapture" + | "vim.leaderHideToTray" | "vim.leaderTemplatePicker" | "vim.leaderInsertTemplate" | "vim.leaderDailyNote" @@ -701,6 +702,18 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderHideToTray", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: hide window to tray", + // `b` for background: the window steps away and the app keeps running. + description: "Hide this window to the tray, with Keep running in the tray on.", + defaultBinding: "b", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderTemplatePicker", kind: "sequence", diff --git a/packages/app-core/src/lib/markdown.test.ts b/packages/app-core/src/lib/markdown.test.ts index 0c1fe1a7..e30c1a82 100644 --- a/packages/app-core/src/lib/markdown.test.ts +++ b/packages/app-core/src/lib/markdown.test.ts @@ -124,6 +124,29 @@ describe('renderMarkdown', () => { expect(html).toContain('graph TD; A-->B') }) + it('stamps every list item with its own source line, nested ones included (#953)', () => { + const html = renderMarkdown('Intro\n\n- [ ] Task 1\n - Detail 1a\n- [ ] Task 2\n - Detail 2a\n - Detail 2b') + const doc = new DOMParser().parseFromString(html, 'text/html') + const items = [...doc.querySelectorAll('li')].map((li) => [li.getAttribute('data-source-line'), li.textContent?.trim().split('\n')[0]]) + expect(items).toEqual([ + ['3', 'Task 1'], + ['4', 'Detail 1a'], + ['5', 'Task 2'], + ['6', 'Detail 2a'], + ['7', 'Detail 2b'] + ]) + // The list itself keeps the line it starts on, as every top-level block does. + expect(doc.querySelector('ul')?.getAttribute('data-source-line')).toBe('3') + }) + + it('turns a d2 fence into a placeholder that keeps its source for the renderer', () => { + const html = renderMarkdown('```d2\nserver -> db: query\n```') + + expect(html).toContain('class="zen-d2"') + expect(html).toContain('data-d2-source="server -> db: query"') + expect(html).not.toContain('<pre') + }) + it('renders Obsidian image embeds as local image nodes', () => { const html = renderMarkdown('![[CleanShot 2026-04-13 at 14.31.31@2x.png]]') diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index ee3d0eba..53849535 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -12,7 +12,7 @@ import rehypeKatex from 'rehype-katex' import rehypeHighlight from 'rehype-highlight' import rehypeStringify from 'rehype-stringify' import { visit, SKIP } from 'unist-util-visit' -import type { Root as MdRoot } from 'mdast' +import type { ListItem, Root as MdRoot } from 'mdast' import type { Root as HastRoot, Element as HastElement } from 'hast' import type { VFile } from 'vfile' import { recordRendererPerf } from './perf' @@ -728,7 +728,7 @@ function rehypeMermaid() { } /** - * Rehype plugin: replace fenced blocks tagged `tikz`, `jsxgraph`, and + * Rehype plugin: replace fenced blocks tagged `tikz`, `d2`, `jsxgraph`, and * `function-plot` with placeholder divs. Each placeholder keeps the raw * source in a `data-*-source` attribute so the runtime side (Preview.tsx) * can render and re-render on demand — the same pattern as @@ -737,6 +737,8 @@ function rehypeMermaid() { function rehypeMathDiagrams() { const map: Record<string, { className: string; sourceAttr: string }> = { 'language-tikz': { className: 'zen-tikz', sourceAttr: 'data-tikz-source' }, + // D2 (d2lang.com) architecture and flow diagrams; drawn by `renderDiagrams`. + 'language-d2': { className: 'zen-d2', sourceAttr: 'data-d2-source' }, 'language-jsxgraph': { className: 'zen-jsxgraph', sourceAttr: 'data-jsxgraph-source' @@ -892,14 +894,23 @@ function rehypeTableColWidths() { * Applied via `data.hProperties` so `remarkRehype` carries it onto the element. */ function remarkSourceLines() { + const stamp = (node: MdRoot['children'][number] | ListItem): void => { + const line = node.position?.start?.line + if (line == null) return + const data = (node.data ??= {}) + const hProperties = ((data.hProperties ??= {}) as Record<string, unknown>) + hProperties['data-source-line'] = line + } return (tree: MdRoot): void => { - for (const node of tree.children) { - const line = node.position?.start?.line - if (line == null) continue - const data = (node.data ??= {}) - const hProperties = ((data.hProperties ??= {}) as Record<string, unknown>) - hProperties['data-source-line'] = line - } + for (const node of tree.children) stamp(node) + // A list is one top-level block, so a note that is mostly one long list + // resolved every double-click and every return from Preview to the list's + // first line (#953). Each item, nested ones included, carries its own + // line; readers that want blocks only (split scroll sync) still select + // direct children of the content root. + visit(tree, 'listItem', (node: ListItem) => { + stamp(node) + }) } } diff --git a/packages/app-core/src/lib/mermaid-render.test.ts b/packages/app-core/src/lib/mermaid-render.test.ts index 1ec60c56..5f8d74e6 100644 --- a/packages/app-core/src/lib/mermaid-render.test.ts +++ b/packages/app-core/src/lib/mermaid-render.test.ts @@ -1,88 +1,103 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderMermaidSvg } from './mermaid-render' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { peekMermaidSvg, renderMermaidSvg } from './mermaid-render' -// What the stand-in mermaid measured: a capped label inside a diagram, and the -// same capped box outside one. -const measured = vi.hoisted(() => ({ label: [] as number[], outside: [] as number[] })) - -// Stands in for mermaid's addHtmlSpan: lay a label out under an inline -// max-width inside a foreignObject and read its width the way mermaid does, -// after an await like its fastdom read. -vi.mock('mermaid', () => ({ - default: { - initialize: vi.fn(), - render: vi.fn(async (id: string) => { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject') - const label = document.createElement('div') - label.style.maxWidth = '200px' - const outside = document.createElement('div') - outside.style.maxWidth = '200px' - foreignObject.append(label) - svg.append(foreignObject) - document.body.append(svg, outside) - await Promise.resolve() - measured.label.push(label.getBoundingClientRect().width) - measured.outside.push(outside.getBoundingClientRect().width) - svg.remove() - outside.remove() - return { svg: `<svg id="${id}"></svg>` } - }) - } -})) +// jsdom has no canvas, so Pele measures labels with its built-in width table, +// which keeps these layouts deterministic. The stub answers getContext the way +// jsdom does (null) without its "not implemented" warning on every render. +beforeAll(() => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) +}) -const unpatched = Element.prototype.getBoundingClientRect -let reportedWidth = 0 -function layoutReports(width: number): void { - reportedWidth = width +async function drawn(source: string): Promise<string> { + const result = await renderMermaidSvg(source) + if (!result.ok) throw new Error(`expected a drawing, got: ${result.error}`) + return result.svg } -const measuring = function (): DOMRect { - return new DOMRect(0, 0, reportedWidth, 21) + +function parse(svg: string): SVGSVGElement { + const host = document.createElement('div') + host.innerHTML = svg + const root = host.querySelector('svg') + if (!root) throw new Error('no <svg> in the result') + return root } -describe('renderMermaidSvg label measurement (#911)', () => { - beforeEach(() => { - measured.label.length = 0 - measured.outside.length = 0 - Element.prototype.getBoundingClientRect = measuring +describe('renderMermaidSvg', () => { + afterEach(() => { + document.documentElement.style.removeProperty('--z-text-font') }) - afterEach(() => { - Element.prototype.getBoundingClientRect = unpatched + it('draws a flowchart as an SVG whose labels are SVG text, not HTML in a foreignObject', async () => { + // The #911 repro: long labels that mermaid clipped at 200px when its HTML + // labels were measured at a fractional device scale. + const root = parse( + await drawn( + 'flowchart TB\n' + + ' admin["aurora: forwarding disabled PLUS SOME MORE EXTRA TEXT"]\n' + + ' rbnic["USB NIC enp0s20f0u9 at 10.255.255.2/30"]\n' + + ' admin --- rbnic' + ) + ) + expect(root.classList.contains('pele')).toBe(true) + expect(root.querySelector('foreignObject')).toBeNull() + // The long label wraps onto lines of its own box instead of being cut off. + const lines = [...root.querySelectorAll('[data-id="admin"] .pele-label tspan')].map( + (line) => line.textContent + ) + expect(lines.length).toBeGreaterThan(1) + expect(lines.join(' ')).toBe('aurora: forwarding disabled PLUS SOME MORE EXTRA TEXT') + }) + + it('measures and paints labels in the note text font', async () => { + document.documentElement.style.setProperty('--z-text-font', '"Test Serif", serif') + const root = parse(await drawn('flowchart LR\n A[font probe] --> B')) + expect(root.getAttribute('style')).toContain('font-family:var(--pele-font,"Test Serif", serif)') + }) + + it('paints through the theme variables rather than baking in a palette', async () => { + const svg = await drawn('flowchart LR\n A[palette probe] --> B') + expect(svg).toContain('var(--pele-surface') + expect(svg).toContain('var(--pele-line') }) - // Both widths are what Chromium reported for a label held at its 200px cap. - it.each([ - ['120% app zoom on a 2x display', 200.00001525878906], - ['a 1.333 display scale', 199.9953155517578] - ])('reads a label held at its cap as exactly the cap at %s', async (scale, width) => { - layoutReports(width) - await renderMermaidSvg(`flowchart TB\n a["at ${scale}"]`, 'light') - expect(measured.label).toEqual([200]) + it('returns a syntax error as a result instead of throwing', async () => { + const result = await renderMermaidSvg('flowchart TD\n A --> B\n B --> ') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toMatch(/^Parse error on line \d+:/) }) - it('leaves a label well under its cap alone', async () => { - layoutReports(133.33333333333334) - await renderMermaidSvg('flowchart TB\n a["short"]', 'light') - expect(measured.label).toEqual([133.33333333333334]) + it('returns an unknown diagram type as an error result', async () => { + const result = await renderMermaidSvg('zenuml\n Alice->Bob: hi') + expect(result.ok).toBe(false) }) - it('touches nothing outside a diagram label, and restores the browser measurement after', async () => { - layoutReports(199.9953155517578) - await renderMermaidSvg('flowchart TB\n a["outside"]', 'light') - expect(measured.outside).toEqual([199.9953155517578]) - expect(Element.prototype.getBoundingClientRect).toBe(measuring) + it('keeps markup in diagram text inert and draws no images or links', async () => { + const root = parse( + await drawn( + 'flowchart LR\n' + + ' A["<img src=https://example.invalid/p.png onerror=alert(1)>"] --> B["<a href=https://example.invalid>x</a>"]\n' + + ' click B "https://example.invalid/out"' + ) + ) + expect(root.querySelector('img, image, a, script, foreignObject')).toBeNull() + expect(root.outerHTML).not.toMatch(/onerror|https:\/\/example\.invalid/) + }) + + it('hands back an earlier drawing from the cache, and once loaded draws new diagrams synchronously', async () => { + const source = 'flowchart LR\n cached --> probe' + const first = await renderMermaidSvg(source) + expect(peekMermaidSvg(source)).toBe(first) + + // The renderer and the flowchart type are loaded now, so a diagram it has + // never seen is drawn on the spot instead of reporting "not yet". + const fresh = peekMermaidSvg('flowchart LR\n never --> seen') + expect(fresh?.ok).toBe(true) }) - it('keeps reporting the cap until the last of two overlapping renders finishes', async () => { - layoutReports(199.9953155517578) - await Promise.all([ - renderMermaidSvg('flowchart TB\n a["first"]', 'light'), - renderMermaidSvg('flowchart TB\n a["second"]', 'dark') - ]) - expect(measured.label).toEqual([200, 200]) - expect(Element.prototype.getBoundingClientRect).toBe(measuring) + it('shares one drawing between Preview (trailing newline kept) and the editor (body trimmed)', async () => { + const fromPreview = await renderMermaidSvg('flowchart LR\n split --> view\n') + expect(peekMermaidSvg('flowchart LR\n split --> view')).toBe(fromPreview) }) }) diff --git a/packages/app-core/src/lib/mermaid-render.ts b/packages/app-core/src/lib/mermaid-render.ts index b2d725a8..c24c112e 100644 --- a/packages/app-core/src/lib/mermaid-render.ts +++ b/packages/app-core/src/lib/mermaid-render.ts @@ -1,288 +1,93 @@ /** - * Mermaid, shared by every surface that draws one: the Preview pipeline and the - * editor's live preview. + * Mermaid diagrams, shared by every surface that draws one: the Preview + * pipeline and the editor's live preview. + * + * A ```mermaid fence is drawn by Pele (pele.run), which reads Mermaid syntax + * with parsers of its own and lays the diagram out itself, returning one + * self-contained SVG string. It replaced mermaid.js: the syntax notes already + * hold keeps working, while the drawing is synchronous, a fraction of the size, + * and labels are SVG text rather than HTML inside `foreignObject`, which is the + * layer every clipped-label bug here came from (#529, #911). * * It lives here rather than beside either caller because a second copy of the - * theme is a second answer to "what colour is a node", and this codebase has - * already paid for duplicated rendering rules more than once. The lazy import - * is a singleton for the same reason the chunk is lazy at all: mermaid is the - * heaviest thing the renderer can pull, and it must stay off the boot path, so - * nothing here runs until a note actually contains a mermaid fence. + * render options is a second answer to "what does this diagram look like", and + * this codebase has already paid for duplicated rendering rules more than once. + * The lazy import is a singleton for the same reason the chunk is lazy at all: + * diagrams must stay off the boot path, so nothing here runs until a note + * actually contains a mermaid fence. + * + * Colour is not decided here. The SVG paints through `--pele-*` variables that + * `index.css` maps onto the app theme, so one drawing is right in every theme + * and a theme switch repaints without drawing again. Only the fonts change the + * layout, because Pele measures every label to size its box, so they are the + * only theme input in the cache key. */ -let mermaidPromise: Promise<typeof import("mermaid").default> | null = null; -export function loadMermaid(): Promise<typeof import("mermaid").default> { - if (!mermaidPromise) { - mermaidPromise = import("mermaid").then((m) => m.default); - } - return mermaidPromise; -} +type PeleRuntime = typeof import("./pele-runtime"); -/** Read a `--z-*` CSS variable (stored as `"R G B"` triplet) as a hex - * color string. Mermaid's themeVariables expect real color values, not - * raw triplets. Falls back to a neutral grey if the var is missing. */ -function readThemeColor(name: string, fallback = "#888888"): string { - const raw = getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); - if (!raw) return fallback; - const parts = raw.split(/[\s,]+/).map((n) => Number(n)); - if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return fallback; - const hex = (n: number): string => - Math.max(0, Math.min(255, Math.round(n))) - .toString(16) - .padStart(2, "0"); - return `#${hex(parts[0])}${hex(parts[1])}${hex(parts[2])}`; +let runtime: PeleRuntime | null = null; +let runtimePromise: Promise<PeleRuntime> | null = null; + +function loadPele(): Promise<PeleRuntime> { + if (!runtimePromise) { + runtimePromise = import("./pele-runtime").then((module) => { + runtime = module; + return module; + }); + } + return runtimePromise; } /** The stack `.prose-zen` falls back to when no text font is configured. - * Kept byte-identical to the `--z-text-font` fallback in index.css: mermaid - * measures against whatever this says, so a drift here reintroduces the - * clipped labels this helper exists to prevent. */ + * Kept byte-identical to the `--z-text-font` fallback in index.css: labels are + * measured against whatever this says, so a drift here reintroduces the + * clipped labels this helper exists to prevent (#529). */ const PROSE_FONT_FALLBACK = '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace'; -/** Read a `--z-*` CSS font variable as a concrete font-family string. - * Mermaid measures text in a temporary element appended to the document - * body, so `fontFamily: "inherit"` can resolve to a different font than the - * one used inside `.prose-zen`. Passing the resolved stack avoids clipped - * labels caused by mismatched text metrics. */ -function readThemeFont(name: string, fallback = PROSE_FONT_FALLBACK): string { +/** The code font's fallback, as `index.css` spells it for `--z-mono-font`. */ +const MONO_FONT_FALLBACK = '"SF Mono", ui-monospace, monospace'; + +/** Matches the 14px the diagrams were drawn at before Pele, so a note's + * diagrams keep their scale next to its text. */ +const DIAGRAM_FONT_SIZE = 14; + +/** Read a `--z-*` CSS font variable as a concrete font-family string. Pele + * measures labels on a canvas, which knows nothing of `inherit`, and writes the + * same family into the SVG it returns, so the label is painted in exactly the + * font it was measured in. */ +function readThemeFont(name: string, fallback: string): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue(name) .trim(); return raw || fallback; } -export interface MermaidThemeConfig { - theme: "base"; - themeVariables: Record<string, string>; - darkMode: boolean; +interface DiagramFonts { + text: string; + mono: string; } -/** Build a complete Mermaid themeVariables map from the current `--z-*` - * CSS custom properties on `<html>`. We use mermaid's `base` theme and - * drive every color from the app theme so the diagram naturally matches - * whichever of the 16+ app themes is active. */ -export function buildMermaidTheme(mode: "light" | "dark"): MermaidThemeConfig { - const bg = readThemeColor("--z-bg"); - const bg1 = readThemeColor("--z-bg-1"); - const bg2 = readThemeColor("--z-bg-2"); - const bg3 = readThemeColor("--z-bg-3"); - const bgSofter = readThemeColor("--z-bg-softer", bg1); - const fg = readThemeColor("--z-fg"); - const fg1 = readThemeColor("--z-fg-1", fg); - const grey = readThemeColor("--z-grey-1"); - const accent = readThemeColor("--z-accent", "#c35e0a"); - const red = readThemeColor("--z-red", "#c14a4a"); - const green = readThemeColor("--z-green", "#6c782e"); - const yellow = readThemeColor("--z-yellow", "#b47109"); - const blue = readThemeColor("--z-blue", "#45707a"); - const purple = readThemeColor("--z-purple", "#945e80"); - const aqua = readThemeColor("--z-aqua", "#4c7a5d"); - const fontFamily = readThemeFont("--z-text-font"); - +function currentFonts(): DiagramFonts { return { - theme: "base", - darkMode: mode === "dark", - themeVariables: { - // Typography - fontFamily, - fontSize: "14px", - - // Core palette — mermaid derives most diagrams from these. - background: bg, - primaryColor: bg2, - primaryTextColor: fg1, - primaryBorderColor: bg3, - secondaryColor: bg1, - secondaryTextColor: fg, - secondaryBorderColor: bg3, - tertiaryColor: bgSofter, - tertiaryTextColor: fg, - tertiaryBorderColor: bg3, - - // Flow nodes + edges - mainBkg: bg2, - nodeBorder: bg3, - nodeTextColor: fg1, - lineColor: grey, - arrowheadColor: grey, - edgeLabelBackground: bg, - - // Cluster / subgraph - clusterBkg: bgSofter, - clusterBorder: bg3, - titleColor: fg1, - - // Sequence diagrams - actorBkg: bg2, - actorBorder: bg3, - actorTextColor: fg1, - actorLineColor: grey, - signalColor: fg, - signalTextColor: fg, - labelBoxBkgColor: bg2, - labelBoxBorderColor: bg3, - labelTextColor: fg1, - loopTextColor: fg, - noteBkgColor: bgSofter, - noteBorderColor: bg3, - noteTextColor: fg1, - activationBkgColor: bg3, - activationBorderColor: grey, - sequenceNumberColor: bg, - - // State / class diagrams - labelColor: fg1, - altBackground: bgSofter, - transitionColor: grey, - transitionLabelColor: fg, - stateLabelColor: fg1, - stateBkg: bg2, - compositeBackground: bgSofter, - compositeBorder: bg3, - compositeTitleBackground: bg1, - specialStateColor: accent, - innerEndBackground: fg1, - - // ER diagrams - attributeBackgroundColorOdd: bg, - attributeBackgroundColorEven: bgSofter, - - // Gantt - taskBkgColor: accent, - taskTextColor: bg, - taskTextOutsideColor: fg1, - taskTextLightColor: bg, - taskTextDarkColor: fg1, - taskTextClickableColor: accent, - activeTaskBkgColor: accent, - activeTaskBorderColor: accent, - doneTaskBkgColor: bg3, - doneTaskBorderColor: grey, - gridColor: bg3, - sectionBkgColor: bg1, - sectionBkgColor2: bgSofter, - altSectionBkgColor: bgSofter, - - // XY chart - xyChart: JSON.stringify({ - backgroundColor: bg, - titleColor: fg1, - xAxisLabelColor: fg, - xAxisTitleColor: fg1, - xAxisTickColor: grey, - xAxisLineColor: grey, - yAxisLabelColor: fg, - yAxisTitleColor: fg1, - yAxisTickColor: grey, - yAxisLineColor: grey, - plotColorPalette: [accent, blue, green, purple, yellow, red, aqua].join( - ", ", - ), - }), - - // Git graph - git0: accent, - git1: blue, - git2: green, - git3: purple, - git4: yellow, - git5: red, - git6: aqua, - git7: fg, - gitBranchLabel0: bg, - gitBranchLabel1: bg, - gitBranchLabel2: bg, - gitBranchLabel3: bg, - gitBranchLabel4: fg1, - gitBranchLabel5: bg, - gitBranchLabel6: bg, - gitBranchLabel7: bg, - - // Pie - pie1: accent, - pie2: blue, - pie3: green, - pie4: purple, - pie5: yellow, - pie6: red, - pie7: aqua, - pie8: fg1, - pie9: grey, - pie10: bg3, - pieTitleTextColor: fg1, - pieSectionTextColor: bg, - pieLegendTextColor: fg1, - pieStrokeColor: bg, - pieOuterStrokeColor: grey, - - // Signals / errors - errorBkgColor: red, - errorTextColor: bg, - }, + text: readThemeFont("--z-text-font", PROSE_FONT_FALLBACK), + mono: readThemeFont("--z-mono-font", MONO_FONT_FALLBACK), }; } -/* -------------------------------------------------------------------------- */ -/* Label measurement at fractional scales */ -/* -------------------------------------------------------------------------- */ - -/** - * Mermaid decides whether an HTML label wraps by laying it out on one line - * under an inline `max-width: <wrappingWidth>px` and asking whether it came - * back exactly that wide (`bbox.width === width` in its addHtmlSpan, - * unchanged through 12.1). At a fractional device scale the browser lays the - * cap out in device pixels and hands back a width just off it: 200.0000152px - * at 120% app zoom on a 2x display, 199.9953px at a 1.333 display scale. The - * comparison fails, the label never wraps, its node is sized to the cap, and - * every long label is cut off at 200px (#911). - * - * While mermaid lays a diagram out, a label measured within half a device - * pixel of its own cap is reported at the cap. Laying the cap out in device - * pixels moves it by far less than that. A label that really is that much - * narrower than its cap still fits on one line once mermaid switches it to - * wrapping, so nothing visible changes for it. - */ -function reportedLabelWidth(element: Element, width: number): number { - if (!(element instanceof HTMLElement) || !element.style.maxWidth.endsWith("px")) return width; - if (!insideDiagramLabel(element)) return width; - const cap = Number.parseFloat(element.style.maxWidth); - return Math.abs(width - cap) <= 0.5 / (window.devicePixelRatio || 1) ? cap : width; -} - -/** By local name rather than `closest("foreignObject")`: a selector's case - * rules for SVG names differ between engines. */ -function insideDiagramLabel(element: Element): boolean { - for (let node = element.parentElement; node; node = node.parentElement) { - if (node.localName === "foreignObject") return true; - } - return false; +/** Labels measured against a font that is still loading come back sized for + * the fallback and clip once the real font paints, so a drawing only happens + * synchronously when nothing is loading, and an async one waits. */ +function fontsSettled(): boolean { + return typeof document === "undefined" || !document.fonts || document.fonts.status === "loaded"; } -/** Renders overlap (the live preview and Preview both draw), so the patch is - * installed by the first and removed by the last. */ -let capAwareRenders = 0; -let browserGetBoundingClientRect: Element["getBoundingClientRect"] | null = null; - -async function withCapAwareLabelMeasurement<T>(render: () => Promise<T>): Promise<T> { - if (capAwareRenders++ === 0) { - const original = Element.prototype.getBoundingClientRect; - browserGetBoundingClientRect = original; - Element.prototype.getBoundingClientRect = function (this: Element): DOMRect { - const rect = original.call(this); - const width = reportedLabelWidth(this, rect.width); - return width === rect.width ? rect : new DOMRect(rect.x, rect.y, width, rect.height); - }; - } +async function waitForFonts(): Promise<void> { + if (typeof document === "undefined" || !document.fonts) return; try { - return await render(); - } finally { - if (--capAwareRenders === 0 && browserGetBoundingClientRect) { - Element.prototype.getBoundingClientRect = browserGetBoundingClientRect; - browserGetBoundingClientRect = null; - } + await document.fonts.ready; + } catch { + /* ignore font-api failures and render anyway */ } } @@ -291,9 +96,11 @@ async function withCapAwareLabelMeasurement<T>(render: () => Promise<T>): Promis /* -------------------------------------------------------------------------- */ /** - * One entry per (source, full theme identity). Keyed by the diagram text - * itself, so an editor that re-renders on every keystroke pays for a diagram - * once and a cursor moving in and out of a block costs nothing at all. + * One entry per (fonts, source). Keyed by the diagram text itself, so an editor + * that re-renders on every keystroke pays for a diagram once and a cursor + * moving in and out of a block costs nothing at all. The SVG carries no ids + * (beyond an accessibility title, which gets a fresh prefix per drawing) and no + * palette, so the editor and Preview can show the same string side by side. */ const svgCache = new Map<string, MermaidRenderResult>(); const inFlight = new Map<string, Promise<MermaidRenderResult>>(); @@ -304,62 +111,97 @@ const SVG_CACHE_LIMIT = 60; export type MermaidRenderResult = { ok: true; svg: string } | { ok: false; error: string }; -function cacheKey(source: string, mode: "light" | "dark", themeKey: string): string { - return `${mode}\u0000${themeKey}\u0000${source}`; +function cacheKey(source: string, fonts: DiagramFonts): string { + return `${fonts.text}\u0000${fonts.mono}\u0000${source}`; } -/** A finished render, or null when this diagram has not been drawn yet. Lets a - * widget paint synchronously on re-mount instead of flashing empty. */ -export function peekMermaidSvg( - source: string, - mode: "light" | "dark", - themeKey: string = mode, -): MermaidRenderResult | null { - return svgCache.get(cacheKey(source, mode, themeKey)) ?? null; +/** The two surfaces read one fence differently: Preview keeps the code + * block's trailing newline, the editor widget trims the body. Unnormalized, + * split view drew every diagram twice under two keys, which is the double + * work the shared cache exists to remove (#184). Whitespace around a diagram + * never changes it. */ +function normalizeSource(source: string): string { + return source.trim(); +} + +function remember(key: string, result: MermaidRenderResult): MermaidRenderResult { + if (svgCache.size >= SVG_CACHE_LIMIT) { + const oldest = svgCache.keys().next().value; + if (oldest !== undefined) svgCache.delete(oldest); + } + svgCache.set(key, result); + return result; +} + +let renderSeq = 0; + +function renderOptions(fonts: DiagramFonts, idPrefix: string) { + return { + fontFamily: fonts.text, + fontFamilyMono: fonts.mono, + fontSize: DIAGRAM_FONT_SIZE, + // Only an accessibility title or description carries an id; a fresh prefix + // keeps two diagrams on one page from claiming the same one. + idPrefix: `${idPrefix}-${renderSeq++}`, + // A note is local-first and a diagram is a picture: drawing one never + // fetches an image URL and never navigates. Mermaid ran in its strict mode + // here, which turned off the same two things. + images: false, + links: false, + }; +} + +function failure(error: unknown): MermaidRenderResult { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; +} + +/** A finished render, or null when this diagram cannot be painted right now. + * Lets a widget paint synchronously on re-mount instead of flashing empty. + * Once Pele and this diagram's type have loaded, a diagram that was never + * drawn is drawn here on the spot: rendering is synchronous and takes about a + * millisecond, so typing a new diagram never shows a pending block. */ +export function peekMermaidSvg(rawSource: string, idPrefix = "zen-mermaid-live"): MermaidRenderResult | null { + const source = normalizeSource(rawSource); + const fonts = currentFonts(); + const key = cacheKey(source, fonts); + const cached = svgCache.get(key); + if (cached) return cached; + if (!runtime || !fontsSettled() || !runtime.supports(source)) return null; + try { + return remember(key, { ok: true, svg: runtime.render(source, renderOptions(fonts, idPrefix)).svg }); + } catch (error) { + return remember(key, failure(error)); + } } /** Render to an SVG string, reusing an in-flight render of the same diagram. - * Never throws: a broken diagram is a result the caller can show. */ + * Never throws: a broken diagram is a result the caller can show, and its + * message (which quotes the diagram source) must be shown as text. */ export function renderMermaidSvg( - source: string, - mode: "light" | "dark", - themeKey: string = mode, + rawSource: string, idPrefix = "zen-mermaid-live", ): Promise<MermaidRenderResult> { - const key = cacheKey(source, mode, themeKey); - const cached = svgCache.get(key); - if (cached) return Promise.resolve(cached); + const source = normalizeSource(rawSource); + const peeked = peekMermaidSvg(source, idPrefix); + if (peeked) return Promise.resolve(peeked); + const fonts = currentFonts(); + const key = cacheKey(source, fonts); const running = inFlight.get(key); if (running) return running; const task = (async (): Promise<MermaidRenderResult> => { try { - const mermaid = await loadMermaid(); - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - ...buildMermaidTheme(mode), - }); - // The id must be unique per render: mermaid puts it in the DOM and a - // repeat collides with the diagram already on screen. - const id = `${idPrefix}-${renderSeq++}`; - const { svg } = await withCapAwareLabelMeasurement(() => mermaid.render(id, source)); + const [pele] = await Promise.all([loadPele(), waitForFonts()]); + const { svg } = await pele.renderAsync(source, renderOptions(fonts, idPrefix)); return { ok: true, svg }; } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) }; + return failure(error); } })(); inFlight.set(key, task); return task.then((result) => { inFlight.delete(key); - if (svgCache.size >= SVG_CACHE_LIMIT) { - const oldest = svgCache.keys().next().value; - if (oldest !== undefined) svgCache.delete(oldest); - } - svgCache.set(key, result); - return result; + return remember(key, result); }); } - -let renderSeq = 0; diff --git a/packages/app-core/src/lib/pele-runtime.ts b/packages/app-core/src/lib/pele-runtime.ts new file mode 100644 index 00000000..1067c491 --- /dev/null +++ b/packages/app-core/src/lib/pele-runtime.ts @@ -0,0 +1,12 @@ +/** + * The one module that imports Pele, reached only through the `import()` in + * `mermaid-render`. + * + * It exists so the renderer's async chunk carries a name of ours, + * `pele-runtime-*.js`: the perf harness and the packaged-web browser test + * assert that this chunk is not fetched before a note needs a diagram, and + * Pele's own module names (`lazy`, hashed chunk ids) are too generic to match. + * `pele/lazy` keeps each diagram type in a chunk of its own, loaded the first + * time a diagram of that type renders. + */ +export { PeleError, render, renderAsync, supports } from 'pele/lazy' diff --git a/packages/app-core/src/lib/preview-outline-jump.test.ts b/packages/app-core/src/lib/preview-outline-jump.test.ts index 9e7cac25..fa84848a 100644 --- a/packages/app-core/src/lib/preview-outline-jump.test.ts +++ b/packages/app-core/src/lib/preview-outline-jump.test.ts @@ -222,6 +222,22 @@ describe('previewVisibleSourceLines (what the reader has on screen, #822)', () = expect(previewVisibleSourceLines(null)).toBeNull() }) + it('reads a long list as the item at the top of the view, not as its start (#953)', () => { + // A list from line 5 fills the view; item 37 starts above the top edge + // and its child 38 covers it, while 39 starts inside the view. + const list = [ + { line: 5, top: 0, bottom: 2000 }, + { line: 5, top: 0, bottom: 160 }, + { line: 37, top: 200, bottom: 360 }, + { line: 38, top: 230, bottom: 260 }, + { line: 39, top: 260, bottom: 300 }, + { line: 41, top: 700, bottom: 860 } + ] + expect(previewVisibleSourceLines(renderedPreview(list, 250, 650))).toEqual({ top: 38, end: 41 }) + // Scrolled so item 37 starts exactly at the top: that item, not its child. + expect(previewVisibleSourceLines(renderedPreview(list, 200, 650))).toEqual({ top: 37, end: 41 }) + }) + it('treats the line range as half open', () => { const visible = { top: 5, end: 30 } expect(previewShowsSourceLine(visible, 5)).toBe(true) @@ -250,6 +266,16 @@ describe('previewEditRequestForTarget (double-click in the reading view, #822)', return root } + it('opens the list item that was double-clicked, not the start of its list (#953)', () => { + const root = document.createElement('article') + root.innerHTML = + '<ul data-source-line="5"><li data-source-line="37"><p><strong>Task 9</strong></p>' + + '<ul><li data-source-line="39">Detail <em>9b</em></li></ul></li></ul>' + const item = root.querySelector<HTMLElement>('[data-source-line="39"]')! + item.getBoundingClientRect = () => ({ top: 410 } as DOMRect) + expect(previewEditRequestForTarget(root.querySelector('em'))).toEqual({ sourceLine: 39, blockClientTop: 410 }) + }) + it('resolves inline content to its top-level block and reports where the block is', () => { const root = article() const paragraph = root.querySelector<HTMLElement>('[data-source-line="7"]')! diff --git a/packages/app-core/src/lib/preview-outline-jump.ts b/packages/app-core/src/lib/preview-outline-jump.ts index 63235237..549236db 100644 --- a/packages/app-core/src/lib/preview-outline-jump.ts +++ b/packages/app-core/src/lib/preview-outline-jump.ts @@ -67,6 +67,10 @@ export function previewVisibleSourceLines( if (!previewScrollEl) return null const viewport = previewScrollEl.getBoundingClientRect() let top: number | null = null + // Stamps nest: a list carries its first line and every item inside it its + // own (#953). They come in document order, so a later stamp that still + // covers the top edge sits inside the earlier one, and the innermost wins: + // a long list reads as the item at the top of the view, not as its start. for (const block of previewScrollEl.querySelectorAll<HTMLElement>('[data-source-line]')) { const line = Number(block.dataset.sourceLine) if (!Number.isFinite(line)) continue @@ -75,6 +79,8 @@ export function previewVisibleSourceLines( if (rect.bottom > viewport.top + 1) top = line } else if (rect.top >= viewport.bottom) { return { top, end: line } + } else if (rect.top <= viewport.top + 1 && rect.bottom > viewport.top + 1) { + top = line } } return top == null ? null : { top, end: null } diff --git a/packages/app-core/src/lib/tray-runtime.test.ts b/packages/app-core/src/lib/tray-runtime.test.ts new file mode 100644 index 00000000..a70a72cb --- /dev/null +++ b/packages/app-core/src/lib/tray-runtime.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { traySyncStatusFor } from './tray-runtime' + +// #947: the tray shows a linked vault's sync state, and nothing otherwise. +describe('traySyncStatusFor', () => { + const snapshot = { + vaultName: 'Work', + lastSyncedAt: 1_700_000_000_000, + removedVault: null + } + + it('reports a linked vault in each of its states', () => { + expect(traySyncStatusFor({ ...snapshot, phase: 'ready' })).toEqual({ + state: 'idle', + vaultName: 'Work', + lastSyncedAt: 1_700_000_000_000 + }) + expect(traySyncStatusFor({ ...snapshot, phase: 'syncing' })?.state).toBe('syncing') + expect(traySyncStatusFor({ ...snapshot, phase: 'attention' })?.state).toBe('attention') + expect(traySyncStatusFor({ ...snapshot, phase: 'error' })?.state).toBe('error') + }) + + it('has nothing to say while Cloud is not connected or not linked', () => { + for (const phase of ['hidden', 'disconnected', 'connecting', 'unlinked'] as const) { + expect(traySyncStatusFor({ ...snapshot, phase })).toBeNull() + } + }) + + it('reads a vault that went away as needing the person, as the status bar does', () => { + expect( + traySyncStatusFor({ ...snapshot, phase: 'unlinked', removedVault: { vaultName: 'Work', reason: 'deleted' } }) + ?.state + ).toBe('attention') + }) +}) diff --git a/packages/app-core/src/lib/tray-runtime.ts b/packages/app-core/src/lib/tray-runtime.ts new file mode 100644 index 00000000..b99247e1 --- /dev/null +++ b/packages/app-core/src/lib/tray-runtime.ts @@ -0,0 +1,75 @@ +import type { TraySyncStatus } from '@shared/ipc' +import { create } from 'zustand' +import { useCloudSyncStatusStore, type CloudSyncPhase } from './cloud-auto-sync' + +/** + * The renderer's side of "Keep running in the tray" (#947). The setting lives + * in the main process, which needs it before any window exists; this mirrors + * it so Settings, the palette and the Vim leader can read it synchronously. + */ +interface TrayState { + keepRunningInTray: boolean +} + +export const useTrayStore = create<TrayState>(() => ({ keepRunningInTray: false })) + +export async function loadKeepRunningInTray(): Promise<void> { + const enabled = await window.zen.getKeepRunningInTray?.() + if (typeof enabled === 'boolean') useTrayStore.setState({ keepRunningInTray: enabled }) +} + +export async function setKeepRunningInTray(enabled: boolean): Promise<void> { + const applied = await window.zen.setKeepRunningInTray?.(enabled) + if (typeof applied === 'boolean') useTrayStore.setState({ keepRunningInTray: applied }) +} + +/** Hide this window to the tray. Main refuses while the tray is off, since a + * hidden window would then have no way back. */ +export async function hideWindowToTray(): Promise<boolean> { + if (!useTrayStore.getState().keepRunningInTray) return false + return (await window.zen.hideWindowToTray?.()) ?? false +} + +interface CloudSyncSnapshot { + phase: CloudSyncPhase + vaultName: string | null + lastSyncedAt: number | null + removedVault: unknown +} + +/** What the tray shows for this window's Cloud sync. Only a linked vault has + * a state to report; signed out, connecting or not linked reads as nothing, + * except a vault that went away, which needs the person like the status bar + * says it does. */ +export function traySyncStatusFor(snapshot: CloudSyncSnapshot): TraySyncStatus | null { + const base = { vaultName: snapshot.vaultName, lastSyncedAt: snapshot.lastSyncedAt } + switch (snapshot.phase) { + case 'ready': + return { state: 'idle', ...base } + case 'syncing': + return { state: 'syncing', ...base } + case 'attention': + return { state: 'attention', ...base } + case 'error': + return { state: 'error', ...base } + case 'unlinked': + return snapshot.removedVault ? { state: 'attention', ...base } : null + default: + return null + } +} + +/** Report this window's sync state to the tray whenever it changes. */ +export function startTraySyncStatusReporter(): () => void { + if (!window.zen.reportTraySyncStatus) return () => undefined + let last: string | null = null + const send = (): void => { + const status = traySyncStatusFor(useCloudSyncStatusStore.getState()) + const key = JSON.stringify(status) + if (key === last) return + last = key + window.zen.reportTraySyncStatus?.(status) + } + send() + return useCloudSyncStatusStore.subscribe(send) +} diff --git a/packages/app-core/src/lib/whats-new.ts b/packages/app-core/src/lib/whats-new.ts index d0ddda04..ad27d225 100644 --- a/packages/app-core/src/lib/whats-new.ts +++ b/packages/app-core/src/lib/whats-new.ts @@ -39,6 +39,27 @@ export const CLOUD_ANNOUNCEMENT = { } as const export const WHATS_NEW: readonly WhatsNewEntry[] = [ + { + version: '2.71.0', + highlights: [ + { + title: 'Diagrams in your theme', + body: 'Mermaid diagrams now draw with Pele, about 50 times faster and in your theme’s colours, from the same fences. With live preview on they draw right in the editor, and switching themes repaints them at once.' + }, + { + title: 'D2 diagrams', + body: 'A d2 fence draws a D2 diagram: boxes inside boxes, labelled connections, data models and sequence diagrams, in your theme. The tour note 05c D2 Diagrams has examples to try.' + }, + { + title: 'Keep running in the tray', + body: 'Turn it on in Settings → Appearance → Window, and closing the last window hides it instead of quitting: Cloud keeps syncing and the Quick capture hotkey keeps working. The tray icon brings the window back.' + }, + { + title: 'A fresh demo tour', + body: 'Generate Demo Tour Notes now writes 22 notes, with new ones on task boards, templates, drawings and databases, workflows, and Cloud and the CLI. Regenerate it from the command palette to see what ZenNotes does today.' + } + ] + }, { version: '2.70.0', highlights: [ diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 22824965..95dc13c3 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1581,6 +1581,23 @@ textarea, text-indent: calc(var(--z-list-hanging-indent, 0) * -1); --z-list-guide-color: rgb(var(--z-grey-0) / 0.16); } +/* Each character of the leading whitespace and of a bullet or ordered marker + * takes at least its column count in ch (set inline by cm-markdown-list-indent), + * and so does the bullet drawn over a `-`, so a proportional font, whose space + * is about half a ch, lines the bullet up with its guide and the first line's + * text with the hanging indent (#952). A minimum width: with a monospace font + * they are already that wide. text-indent is inherited, and here it would pull + * the content out of its own box. */ +.cm-editor .cm-markdown-list-line .cm-list-prefix-cell, +.cm-editor .cm-list-boxed-marker .cm-wq-bullet { + display: inline-block; + text-indent: 0; + white-space: pre; +} + +.cm-editor .cm-list-boxed-marker .cm-wq-bullet { + min-width: 1ch; +} .cm-editor .cm-cursor, .cm-editor .cm-dropCursor { border-left: 2px solid rgb(var(--z-accent)); @@ -2463,8 +2480,9 @@ html[data-completed-task-style="gray-strikethrough"] .cm-editor .cm-task-done * text-underline-offset: 2px; } -/* --- Math diagrams (TikZ / JSXGraph / function-plot) ------------------ */ +/* --- Diagrams (TikZ / D2 / JSXGraph / function-plot) ------------------ */ .zen-tikz, +.zen-d2, .zen-jsxgraph, .zen-function-plot { display: flex; @@ -2564,7 +2582,8 @@ html[data-completed-task-style="gray-strikethrough"] .cm-editor .cm-task-done * border-color: rgb(var(--z-bg-3)); color: rgb(var(--z-fg-1)); } -.zen-tikz svg { +.zen-tikz svg, +.zen-d2 svg { max-width: 100%; height: auto; } @@ -2610,7 +2629,8 @@ html[data-completed-task-style="gray-strikethrough"] .cm-editor .cm-task-done * border-radius: 6px; margin: 0; } -.zen-tikz-loading { +.zen-tikz-loading, +.zen-d2-loading { font-size: 11px; color: rgb(var(--z-fg-2)); font-style: italic; @@ -4025,10 +4045,191 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-red)); } -/* Mermaid diagram container */ -/* Mermaid block chrome — adapts to the current theme via CSS vars so - the card never clashes with the active palette. The SVG itself is - themed at render time via `buildMermaidTheme()` in Preview.tsx. */ +/* Mermaid diagrams, drawn by Pele (`lib/mermaid-render.ts`). The SVG paints + every colour through these variables, so a diagram follows the active theme, + a custom CSS theme included, and a theme switch repaints it without drawing + it again. The roles mirror the mermaid themeVariables they replace: nodes on + bg-2 with a bg-3 border, subgraphs on bg-softer, edges in grey-1, and the + chart series in the same order pie slices and git branches used. Fonts are + not set here: Pele writes the font it measured with into the SVG. */ +.pele { + --pele-bg: rgb(var(--z-bg)); + --pele-fg: rgb(var(--z-fg-1)); + --pele-muted: rgb(var(--z-grey-1)); + --pele-line: rgb(var(--z-grey-1)); + --pele-surface: rgb(var(--z-bg-2)); + --pele-surface-alt: rgb(var(--z-bg-softer)); + --pele-border: rgb(var(--z-bg-3)); + --pele-accent: rgb(var(--z-accent)); + --pele-critical: rgb(var(--z-red)); + --pele-error: rgb(var(--z-red)); + --pele-series-1: rgb(var(--z-accent)); + --pele-series-2: rgb(var(--z-blue)); + --pele-series-3: rgb(var(--z-green)); + --pele-series-4: rgb(var(--z-purple)); + --pele-series-5: rgb(var(--z-yellow)); + --pele-series-6: rgb(var(--z-red)); + --pele-series-7: rgb(var(--z-aqua)); + --pele-series-8: rgb(var(--z-fg-1)); +} + +/* D2 diagrams (```d2) paint through theme classes (`fill-N1`, `stroke-B1`, + `color-N2` and so on) that D2 defines in a stylesheet inside each SVG. With + `zen-d2-themed` on the block (a diagram that picked no D2 theme of its own), + these rules outrank that stylesheet and map the classes onto the app theme, + in the roles the Pele rule above uses: text in fg-1, borders and edges in + grey-1, shapes on bg-1 and bg-2, accents in the accent colour. A colour the + author wrote (`style.fill`) is an attribute without a class and stays as + written. A theme switch repaints without drawing again. */ +/* D2 marks a shape with `link:` or `tooltip:` with a badge. Both are switched + off here (a diagram never navigates), so the badge would promise a click that + does nothing. */ +:is(.zen-d2, .cm-d2-block) .d2-svg .appendix-icon { + display: none; +} +.zen-d2-themed .d2-svg > rect:first-child { + /* D2's canvas: let the card or the note show through. */ + fill: transparent; +} +.zen-d2-themed .d2-svg .fill-N1 { + fill: rgb(var(--z-fg-1)); +} +.zen-d2-themed .d2-svg .stroke-N1 { + stroke: rgb(var(--z-fg-1)); +} +.zen-d2-themed .d2-svg .color-N1 { + color: rgb(var(--z-fg-1)); +} +.zen-d2-themed .d2-svg .background-color-N1 { + background-color: rgb(var(--z-fg-1)); +} +.zen-d2-themed .d2-svg .fill-N2 { + fill: rgb(var(--z-fg-2)); +} +.zen-d2-themed .d2-svg .stroke-N2 { + stroke: rgb(var(--z-fg-2)); +} +.zen-d2-themed .d2-svg .color-N2 { + color: rgb(var(--z-fg-2)); +} +.zen-d2-themed .d2-svg .background-color-N2 { + background-color: rgb(var(--z-fg-2)); +} +.zen-d2-themed .d2-svg .fill-N3, +.zen-d2-themed .d2-svg .fill-B1, +.zen-d2-themed .d2-svg .fill-B2 { + fill: rgb(var(--z-grey-1)); +} +.zen-d2-themed .d2-svg .stroke-N3, +.zen-d2-themed .d2-svg .stroke-B1, +.zen-d2-themed .d2-svg .stroke-B2 { + stroke: rgb(var(--z-grey-1)); +} +.zen-d2-themed .d2-svg .color-N3, +.zen-d2-themed .d2-svg .color-B1, +.zen-d2-themed .d2-svg .color-B2 { + color: rgb(var(--z-grey-1)); +} +.zen-d2-themed .d2-svg .background-color-N3, +.zen-d2-themed .d2-svg .background-color-B1, +.zen-d2-themed .d2-svg .background-color-B2 { + background-color: rgb(var(--z-grey-1)); +} +.zen-d2-themed .d2-svg .fill-N4, +.zen-d2-themed .d2-svg .fill-N5 { + fill: rgb(var(--z-bg-3)); +} +.zen-d2-themed .d2-svg .stroke-N4, +.zen-d2-themed .d2-svg .stroke-N5 { + stroke: rgb(var(--z-bg-3)); +} +.zen-d2-themed .d2-svg .color-N4, +.zen-d2-themed .d2-svg .color-N5 { + color: rgb(var(--z-bg-3)); +} +.zen-d2-themed .d2-svg .background-color-N4, +.zen-d2-themed .d2-svg .background-color-N5 { + background-color: rgb(var(--z-bg-3)); +} +.zen-d2-themed .d2-svg .fill-N6, +.zen-d2-themed .d2-svg .fill-B3, +.zen-d2-themed .d2-svg .fill-B4 { + fill: rgb(var(--z-bg-2)); +} +.zen-d2-themed .d2-svg .stroke-N6, +.zen-d2-themed .d2-svg .stroke-B3, +.zen-d2-themed .d2-svg .stroke-B4 { + stroke: rgb(var(--z-bg-2)); +} +.zen-d2-themed .d2-svg .color-N6, +.zen-d2-themed .d2-svg .color-B3, +.zen-d2-themed .d2-svg .color-B4 { + color: rgb(var(--z-bg-2)); +} +.zen-d2-themed .d2-svg .background-color-N6, +.zen-d2-themed .d2-svg .background-color-B3, +.zen-d2-themed .d2-svg .background-color-B4 { + background-color: rgb(var(--z-bg-2)); +} +.zen-d2-themed .d2-svg .fill-B5, +.zen-d2-themed .d2-svg .fill-AA4, +.zen-d2-themed .d2-svg .fill-AB4 { + fill: rgb(var(--z-bg-1)); +} +.zen-d2-themed .d2-svg .stroke-B5, +.zen-d2-themed .d2-svg .stroke-AA4, +.zen-d2-themed .d2-svg .stroke-AB4 { + stroke: rgb(var(--z-bg-1)); +} +.zen-d2-themed .d2-svg .color-B5, +.zen-d2-themed .d2-svg .color-AA4, +.zen-d2-themed .d2-svg .color-AB4 { + color: rgb(var(--z-bg-1)); +} +.zen-d2-themed .d2-svg .background-color-B5, +.zen-d2-themed .d2-svg .background-color-AA4, +.zen-d2-themed .d2-svg .background-color-AB4 { + background-color: rgb(var(--z-bg-1)); +} +.zen-d2-themed .d2-svg .fill-N7, +.zen-d2-themed .d2-svg .fill-B6, +.zen-d2-themed .d2-svg .fill-AA5, +.zen-d2-themed .d2-svg .fill-AB5 { + fill: rgb(var(--z-bg)); +} +.zen-d2-themed .d2-svg .stroke-N7, +.zen-d2-themed .d2-svg .stroke-B6, +.zen-d2-themed .d2-svg .stroke-AA5, +.zen-d2-themed .d2-svg .stroke-AB5 { + stroke: rgb(var(--z-bg)); +} +.zen-d2-themed .d2-svg .color-N7, +.zen-d2-themed .d2-svg .color-B6, +.zen-d2-themed .d2-svg .color-AA5, +.zen-d2-themed .d2-svg .color-AB5 { + color: rgb(var(--z-bg)); +} +.zen-d2-themed .d2-svg .background-color-N7, +.zen-d2-themed .d2-svg .background-color-B6, +.zen-d2-themed .d2-svg .background-color-AA5, +.zen-d2-themed .d2-svg .background-color-AB5 { + background-color: rgb(var(--z-bg)); +} +.zen-d2-themed .d2-svg .fill-AA2 { + fill: rgb(var(--z-accent)); +} +.zen-d2-themed .d2-svg .stroke-AA2 { + stroke: rgb(var(--z-accent)); +} +.zen-d2-themed .d2-svg .color-AA2 { + color: rgb(var(--z-accent)); +} +.zen-d2-themed .d2-svg .background-color-AA2 { + background-color: rgb(var(--z-accent)); +} + +/* Mermaid block chrome: adapts to the current theme via CSS vars so the card + never clashes with the active palette. */ .prose-zen .mermaid { display: flex; flex-direction: column; @@ -4045,6 +4246,11 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite max-width: 100%; height: auto; } +/* The error quotes the offending line with a caret row under it, which only + points at the right column when the text is not centred like the card. */ +.prose-zen .mermaid .zen-diagram-error { + text-align: left; +} /* URL embeds (```embed) — a responsive video/iframe card. `aspect-ratio` is set inline per provider; the iframe fills the box. */ @@ -4438,6 +4644,7 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite color: rgb(var(--z-fg-1)); } .zen-diagram-modal-host .zen-tikz, +.zen-diagram-modal-host .zen-d2, .zen-diagram-modal-host .zen-jsxgraph, .zen-diagram-modal-host .zen-function-plot, .zen-diagram-modal-host .mermaid { @@ -4536,6 +4743,7 @@ html[data-completed-task-style="gray-strikethrough"] .prose-zen li.task-list-ite width: 100%; } .zen-diagram-pan-content .zen-tikz, +.zen-diagram-pan-content .zen-d2, .zen-diagram-pan-content .zen-jsxgraph, .zen-diagram-pan-content .zen-function-plot, .zen-diagram-pan-content .mermaid { diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 4e3b4f68..8ccc5da5 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index bbc496de..25bb51b5 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -33,6 +33,7 @@ import type { ServerSessionStatus, VaultSettings, TikzRenderResponse, + TraySyncStatus, VaultChangeEvent, VaultDemoTourResult, VaultInfo, @@ -514,6 +515,19 @@ export interface ZenBridge { /** Whether the quick-capture window stays pinned on top (won't hide on blur). */ getQuickCapturePinned(): Promise<boolean> setQuickCapturePinned(pinned: boolean): Promise<boolean> + /** Desktop only (#947): whether closing the last window hides it to the + * tray instead. Optional so the web and phone bridges need no stub. */ + getKeepRunningInTray?(): Promise<boolean> + /** Resolves to the setting now in effect. */ + setKeepRunningInTray?(enabled: boolean): Promise<boolean> + /** Hide this window to the tray. Resolves false, and leaves the window + * alone, while the tray is off: a hidden window would be unreachable. */ + hideWindowToTray?(): Promise<boolean> + /** This window's Cloud sync state for the tray; null when there is none. */ + reportTraySyncStatus?(status: TraySyncStatus | null): void + /** Quit the app for real. Linux and Windows have no app menu, so with the + * tray holding the window this is the way out besides the icon's menu. */ + quitApp?(): void renderTikz(source: string): Promise<TikzRenderResponse> mcpGetRuntime(): Promise<McpServerRuntime> diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 5a0b56db..bb8c7cb3 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -174,6 +174,11 @@ export const IPC = { APP_SET_QUICK_CAPTURE_HOTKEY: 'app:set-quick-capture-hotkey', APP_GET_QUICK_CAPTURE_PINNED: 'app:get-quick-capture-pinned', APP_SET_QUICK_CAPTURE_PINNED: 'app:set-quick-capture-pinned', + APP_GET_KEEP_RUNNING_IN_TRAY: 'app:get-keep-running-in-tray', + APP_SET_KEEP_RUNNING_IN_TRAY: 'app:set-keep-running-in-tray', + APP_TRAY_SYNC_STATUS: 'app:tray-sync-status', + WINDOW_HIDE_TO_TRAY: 'window:hide-to-tray', + APP_QUIT: 'app:quit', APP_READ_EXTERNAL_FILE: 'app:read-external-file', APP_WRITE_EXTERNAL_FILE: 'app:write-external-file', APP_MOVE_EXTERNAL_FILE_TO_VAULT: 'app:move-external-file-to-vault', @@ -257,6 +262,18 @@ export type AppUpdatePhase = | 'offline' | 'error' +/** What a window reports about its Cloud sync for the tray icon (#947). Only + * a connected, linked vault has a state worth showing; anything else is + * reported as null and leaves the tray without a sync line. */ +export type TraySyncState = 'idle' | 'syncing' | 'attention' | 'error' + +export interface TraySyncStatus { + state: TraySyncState + vaultName: string | null + /** Epoch milliseconds of the last finished sync, when there was one. */ + lastSyncedAt: number | null +} + export interface CliInstallRequest { /** Main-issued approval for a specific dangling historical shortcut. */ repairToken: string diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index b3212d97..baddf73f 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-domain/src/demo-tour-data.ts b/packages/shared-domain/src/demo-tour-data.ts index a1d614b6..e61a63df 100644 --- a/packages/shared-domain/src/demo-tour-data.ts +++ b/packages/shared-domain/src/demo-tour-data.ts @@ -6,63 +6,67 @@ export interface DemoTourTemplateFile { export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ { path: "inbox/demo/00 Start Here.md", - body: "# Start here: ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 Markdown Basics]]: headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 Code Blocks]]: fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 Tables and Task Lists]]: tables, task metadata, and the vault-wide Tasks view\n4. [[04 Math with KaTeX]]: inline math, block math, aligned equations, and formulas in preview\n5. [[05 Mermaid Diagrams]]: flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b Math Diagrams]]: TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 Callouts and Footnotes]]: callouts, footnotes, highlights, images, and local files\n8. [[07 Wiki Links and Tags]]: wikilinks, tags, backlinks, connections, and search\n9. [[08 Daily Notes]]: daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 Vim Cheat Sheet]]: the app-specific motions, leader flows, folds, and ex commands\n11. [[10 Ideas and Tasks]]: a realistic note that composes multiple features at once\n12. [[11 Workspace, Search, and Views]]: tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 Settings and Keymaps]]: themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 Commands, Help, and Demo Tour]]: command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 Reference Pane and Floating Windows]]: pinned notes, research context, and detached note windows\n16. [[15 Search Backends and Fuzzy Workflows]]: note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n" + body: "# Start here: ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 Markdown Basics]]: headings, emphasis, lists, links, frontmatter, slash commands, and formatting a selection\n2. [[02 Code Blocks]]: fenced code, syntax highlighting, and languages you add yourself\n3. [[03 Tables and Task Lists]]: editable tables, task states, fields, subtasks, and the Tasks list\n4. [[04 Math with KaTeX]]: inline and display math, numbered equations, and Typst as an alternative\n5. [[05 Mermaid Diagrams]]: flow, sequence, state, gantt, and pie diagrams, drawn in your theme\n6. [[05b Math Diagrams]]: TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[05c D2 Diagrams]]: D2 architecture diagrams, with nested containers, data models, and sequences\n8. [[06 Callouts and Footnotes]]: callouts, footnotes, highlights, images, sizes, video embeds, and local files\n9. [[07 Wiki Links and Tags]]: wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n10. [[08 Daily Notes]]: daily, weekly, and monthly notes, quick capture, and date shortcuts\n11. [[09 Vim Cheat Sheet]]: the app-specific motions, leader flows, folds, and ex commands\n12. [[10 Ideas and Tasks]]: a realistic note that composes multiple features at once\n13. [[11 Workspace, Search, and Views]]: home, tabs, splits, panels, comments, archive, trash, and session restore\n14. [[12 Settings and Keymaps]]: themes, CSS overrides, editor options, remappable keys, and `config.toml`\n15. [[13 Commands, Help, and Demo Tour]]: command palette discovery, ex commands, built-in Help, PDF export, and this tour\n16. [[14 Reference Pane and Floating Windows]]: pinned notes, research context, and detached note windows\n17. [[15 Search Backends and Fuzzy Workflows]]: note search, vault text search, find in a note, fzf, and ripgrep\n18. [[16 Tasks Calendar and Kanban]]: the Tasks calendar, Kanban boards, saved filters, and whole notes as tasks\n19. [[17 Templates, Drawings, and Databases]]: templates, Excalidraw drawings, and spreadsheet-like databases\n20. [[18 Workflows]]: repeatable changes across many notes, planned before anything is written\n21. [[19 Cloud, CLI, and AI Agents]]: ZenNotes Cloud, the tray, the `zn` CLI, and assistants over MCP\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading and list folding and outline jumps\n- wikilinks, block links, embeds, tags, backlinks, and the Atlas map\n- quick capture from anywhere, and Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks as a list, a calendar, and Kanban boards\n- templates, Excalidraw drawings, and databases\n- note search, vault text search, and find in a note\n- Mermaid, D2, TikZ, JSXGraph, and function-plot diagrams\n- KaTeX or Typst math\n- slash commands, `@` dates, and text replacements\n- comments on selected text\n- Vim mode, leader hints, hint mode, ex commands, and pane motion\n- themes, CSS overrides, keymaps, and a portable `config.toml`\n- workflows that plan before they write\n- ZenNotes Cloud, the `zn` CLI, and AI assistants over MCP\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n^portable\n\n#demo #reference #tour\n" }, { path: "inbox/demo/01 Markdown Basics.md", - body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://lumarylabs.com)\n- Autolink: <https://lumarylabs.com>\n- Wikilink: [[07 Wiki Links and Tags]]\n- Custom label: [[11 Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 Code Blocks]] for syntax highlighting, [[06 Callouts and Footnotes]] for richer block styles, or back to [[00 Start Here]].\n\n#demo #markdown\n" + body: "# Markdown basics\n\nZenNotes starts with ordinary markdown. The app adds keyboard-first workflows around it, but the source stays portable and readable everywhere.\n\n## Headings\n\n```\n# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n```\n\nHeadings matter for more than styling:\n\n- they show up in the **outline**\n- they can be folded with `zc` and unfolded with `zo`\n- long notes can be searched by heading with `Space p`\n\n## Emphasis\n\n*Italic* with single asterisks, **bold** with double, ***bold italic*** with triple, `inline code` with backticks, ~~strikethrough~~ with tildes, and ==highlight== with double equals.\n\n## Paragraphs and line breaks\n\nA blank line starts a new paragraph.\nA single newline usually stays in the same paragraph.\n\nLeave two trailing spaces when you really want a hard line break. \nLike this.\n\n## Lists\n\nUnordered:\n\n- Apples\n- Bananas\n - Cavendish\n - Plantain\n- Cherries\n\nOrdered:\n\n1. Draft the note\n2. Refine the structure\n3. Ship the change\n\n## Links\n\n- External: [ZenNotes](https://zennotes.org)\n- Autolink: <https://zennotes.org>\n- Wikilink: [[07 Wiki Links and Tags]]\n- Custom label: [[11 Workspace, Search, and Views|workspace guide]]\n\n## Blockquotes and dividers\n\n> Markdown still does a lot with very little.\n>\n> ZenNotes just makes it faster to navigate and work with.\n\n---\n\n## Frontmatter\n\nYAML frontmatter works fine at the top of a note:\n\n```yaml\n---\ntitle: My Note\ndate: 2026-04-16\ntags: [project, research]\npriority: high\n---\n```\n\nZenNotes does not require frontmatter, but features like daily notes, tags, and task defaults can make use of it.\n\n## Slash commands\n\nZenNotes also helps you write these structures faster:\n\n- type `/` at the start of a line or after whitespace\n- choose items like headings, bullets, numbered lists, tasks, callouts, code blocks, tables, math blocks, links, images, and dividers\n- keep typing after `/` to filter the insert menu\n\nThat means markdown stays plain, but you do not have to remember every snippet from scratch.\n\n## Formatting a selection\n\nSelect some text and a small toolbar offers bold, italic, strikethrough, highlight, code, math, link, comment, and a **Turn into** menu that re-types the block. The same actions have shortcuts that work with Vim mode on or off:\n\n| Shortcut | Result |\n| --- | --- |\n| `⌘B` / `⌘I` | Bold / italic |\n| `⌘E` | Inline code |\n| `⌘K` | Link |\n| `⇧⌘S` / `⇧⌘H` | Strikethrough / highlight |\n| `⇧⌘M` | Inline math |\n\nOn Windows and Linux, read `⌘` as `Ctrl`. After typing a formatted word, `Ctrl+.` (`Alt+]` on Windows and Linux) hops the cursor past the closing markers so you can keep writing outside them.\n\n## Text that arrives hard-wrapped\n\nText pasted from a terminal or an email often arrives broken at 80 columns. Put the cursor in the paragraph and press `Ctrl+Q` (`Alt+Q` on Windows and Linux) to join its lines into one, so the editor wraps it to the pane. In Vim mode, `gqip` does the same.\n\n## What to try in this note\n\n- Put the cursor on a heading and fold it.\n- Switch the note between **Edit**, **Split**, and **Preview**.\n- Open the outline with `Space p`.\n- Search for this note with `Space f`.\n\n## What's next\n\nJump to [[02 Code Blocks]] for syntax highlighting, [[06 Callouts and Footnotes]] for richer block styles, or back to [[00 Start Here]].\n\n#demo #markdown\n" }, { path: "inbox/demo/02 Code Blocks.md", - body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and renders them with syntax highlighting in preview and split view.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise<User | null> {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap<String, usize> {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## What's next\n\nSee [[05 Mermaid Diagrams]] for Mermaid fences, [[05b Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" + body: "# Code blocks\n\nZenNotes treats code fences as plain markdown on disk and highlights them in the editor, in Split, and in Preview.\n\n## A fast way to insert them\n\nType `/` and choose **Code block** if you do not want to type the fence manually.\n\n## TypeScript\n\n```ts\nexport interface User {\n id: string\n name: string\n roles: string[]\n}\n\nexport async function fetchUser(id: string): Promise<User | null> {\n const response = await fetch(`/api/users/${id}`)\n if (!response.ok) return null\n return (await response.json()) as User\n}\n```\n\n## Python\n\n```python\nfrom dataclasses import dataclass\n\n@dataclass\nclass Point:\n x: float\n y: float\n\n def distance_to(self, other: \"Point\") -> float:\n return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5\n```\n\n## Bash\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nfor note in inbox/*.md; do\n words=$(wc -w < \"$note\")\n printf \"%6d %s\\n\" \"$words\" \"$(basename \"$note\")\"\ndone\n```\n\n## Rust\n\n```rust\nuse std::collections::HashMap;\n\nfn word_count(text: &str) -> HashMap<String, usize> {\n let mut counts = HashMap::new();\n for word in text.split_whitespace() {\n *counts.entry(word.to_lowercase()).or_insert(0) += 1;\n }\n counts\n}\n```\n\n## JSON\n\n```json\n{\n \"name\": \"ZenNotes\",\n \"productName\": \"ZenNotes\",\n \"version\": \"0.1.0\",\n \"scripts\": {\n \"dev\": \"electron-vite dev\",\n \"build\": \"electron-vite build\"\n }\n}\n```\n\n## Diff\n\n```diff\n- Space /\n+ Space s t\n```\n\n## Plain text\n\n```\nNo language tag, no syntax highlighting.\nUseful for raw config examples or ASCII notes.\n```\n\n## Inline code\n\nUse `inline code` when the snippet belongs inside a sentence.\n\n## Workflow notes\n\n- **Edit** mode is best for writing or refactoring the raw fence.\n- **Split** mode is ideal when you want source on one side and highlighted output on the other.\n- Fenced blocks are ignored by the task scanner, so `- [ ]` inside code stays an example, not a live task.\n- Vault text search can still find matching text inside code fences because they are part of the note body.\n\n## A language ZenNotes does not know yet\n\nIn the desktop app, Settings → Editor → Languages installs a TextMate grammar (a self-contained `.tmLanguage.json` file) for any other language. Pick the fence tag and its aliases, check the sample in the live preview, and install: the same fences then highlight in Edit, Split, and Preview, and the note itself never changes.\n\n## What's next\n\nSee [[05 Mermaid Diagrams]] for Mermaid fences, [[05b Math Diagrams]] for TikZ, JSXGraph, and function-plot, or [[10 Ideas and Tasks]] for how snippets mix with prose and planning in a real note.\n\n#demo #code\n" }, { path: "inbox/demo/03 Tables and Task Lists.md", - body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Mermaid | ✅ | Rendered inside preview and split view. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | File-based | Use any sync tool that watches folders. |\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday: design review\n - [ ] Tuesday: code-freeze prep\n - [x] Saturday: offline\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row to scan every live note across **Inbox**, **Quick Notes**, and **Archive**. From there you can:\n\n- filter by task content\n- filter by note title\n- filter by inline `#tags`\n- filter by priority markers like `!high`\n- press `Enter` or `o` to open the source note\n- press `Space` or `x` to toggle the selected task without leaving the list\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 Callouts and Footnotes]])\n- [x] Math blocks (see [[04 Math with KaTeX]])\n- [x] Mermaid (see [[05 Mermaid Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Screenshots in the tour due:2026-04-25 !med #docs\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives.\n\n#demo #tasks #tables\n" + body: "# Tables and task lists\n\n## Tables\n\nPlain GFM tables. Alignment is controlled with colons in the divider row.\n\n| Feature | Support | Notes |\n| ---------- | :--------: | --------------------------------------------------------- |\n| Headings | ✅ | Fold from the editor gutter and jump via the outline. |\n| Wiki links | ✅ | `[[Title]]` resolves by note name. |\n| Tags | ✅ | Written inline as `#like-this`. |\n| Math | ✅ | KaTeX, inline and display. |\n| Diagrams | ✅ | Mermaid and D2, drawn in Edit, Split, and Preview. |\n| Search | ✅ | Notes by title/path, vault text by fuzzy content search. |\n| Sync | Your pick | Any folder sync tool, or ZenNotes Cloud. |\n\nWith live preview on, a table draws as an editable grid right in the editor: click a cell, or move between cells with the arrow keys (`h`/`j`/`k`/`l` in Vim mode), and right-click a cell (or press `m`) for row and column actions. Drag the right border of a header cell to set that column's width; the note keeps the widths on a hidden line under the table, so Preview and PDF export draw them too. A table that outgrows inline editing can become a database: see [[17 Templates, Drawings, and Databases]].\n\nRight-aligned numbers:\n\n| Quarter | Revenue | Delta |\n| ------: | -------: | -----: |\n| Q1 | $124,300 | +4.2% |\n| Q2 | $131,980 | +6.2% |\n| Q3 | $129,010 | −2.3% |\n| Q4 | $152,407 | +18.1% |\n\n## Task lists\n\nEvery checkbox survives on disk as normal markdown like `- [ ]` and `- [x]`.\n\n## What ZenNotes task parsing supports\n\n### Core checkboxes\n\n- [ ] Open task\n- [x] Completed task\n- [X] Uppercase `X` also counts as completed\n- [/] Started, still live work\n- [-] Cancelled on purpose\n- [>] Forwarded to another note [[10 Ideas and Tasks]]\n\n| Marker | State | How to set it |\n| --- | --- | --- |\n| `[ ]` / `[x]` | Open / done | `⌘L` (`Ctrl+L` on Windows and Linux), or click the box |\n| `[/]` | In progress | \"Mark Task In Progress\" in the palette, or `i` in the Tasks list |\n| `[-]` | Cancelled | \"Cancel Task\" in the palette, or `c` in the Tasks list |\n| `[>]` | Forwarded | type `>` inside the box, or `>` in the Tasks list, then pick a note |\n\nForwarding moves a task to another note and leaves this record behind, linked to where it went; the copy there links home. An in-progress task stays in Today and on the board, while forwarded and cancelled ones collect in groups of their own.\n\n### Different list styles still count\n\n- [ ] Bulleted task using `-`\n+ [ ] Bulleted task using `+`\n* [ ] Bulleted task using `*`\n1. [ ] Ordered task using `1.`\n2) [ ] Ordered task using `2)`\n> - [ ] Blockquoted task lines are parsed too\n\n### Nested tasks\n\n- [ ] Weekly review\n - [ ] Clear inbox to zero\n - [ ] Triage [[10 Ideas and Tasks]]\n - [x] Back up vault\n - [ ] Plan next week\n - [ ] Monday: design review\n - [ ] Tuesday: code-freeze prep\n - [x] Saturday: offline\n\nA parent with subtasks shows how many of its direct children are done as a small chip at the end of its line, here `1/4` on Weekly review. The count is drawn, never written into the file.\n\n### Metadata tokens on the task line\n\n- [ ] Ship the onboarding checklist due:2026-04-18 !high #onboarding #docs\n- [ ] Refresh demo screenshots due:2026-04-22 !med #demo #assets\n- [ ] Clean up seed notes !low #maintenance\n- [ ] Wait for design sign-off @waiting #design\n- [ ] Review vault search UX due:2026-04-30 !high #search #ux\n- [ ] Draft the tour feedback form @project:tour @status:review\n\nThe parser understands these tokens:\n\n| Token | Meaning | Example |\n| ----- | ------- | ------- |\n| `due:YYYY-MM-DD` | ISO due date used for grouping | `due:2026-04-22` |\n| `!high` / `!med` / `!low` | Priority marker | `!high` |\n| `@waiting` | Moves the task into the Waiting group | `@waiting` |\n| `#tag` | Inline task tag, searchable in the Tasks view | `#design` |\n| `@key:value` | Any field you like; each key becomes its own Kanban board | `@project:tour` |\n\n### What the Tasks view does with them\n\n- Tasks with no due date land in **Today**\n- Tasks due today or already overdue also land in **Today**\n- Tasks due in the future land in **Upcoming**\n- Tasks with `@waiting` land in **Waiting**\n- Checked tasks land in **Done**\n- In-progress tasks (`[/]`) keep their place in **Today**\n- Forwarded and cancelled tasks collect under **Forwarded** and **Cancelled**\n- Overdue tasks contribute to the overdue count in the **Today** section\n\n### Filtering and navigation\n\nPress the sidebar **Tasks** row, or run `:tasks`, to scan every live note. Archiving a note retires its tasks along with it, and a note with `tasks: false` in its frontmatter keeps its checkboxes as plain checklists. From the list you can:\n\n- type in the filter box (`/` in Vim mode): it matches task text, note titles, `#tags`, `!high`, and `@key:value` fields\n- press `Enter` or `o` to open the source note\n- press `x` to toggle the selected task, `i` to mark it in progress, `c` to cancel it, or `>` to forward it\n- right-click any task for the same actions, with each one's key shown beside it\n- switch to the Calendar or the Kanban board: see [[16 Tasks Calendar and Kanban]]\n\n### Ignored on purpose\n\nTasks inside fenced code blocks are not parsed, so you can document task syntax safely:\n\n```md\n- [ ] This looks like a task\n- [x] But code fences are ignored by the vault-wide task scanner\n- [ ] That makes examples and snippets safe\n```\n\n### Note-level defaults\n\nYou can also set due date and priority defaults in frontmatter, then override them inline per task:\n\n```yaml\n---\ndue: 2026-05-01\npriority: high\n---\n```\n\nWith defaults like that, a plain line such as `- [ ] Draft roadmap` inherits the due date and priority even without repeating the tokens.\n\n### Rendering checklist\n\nEvery item below is wired up:\n\n- [x] Paragraphs\n- [x] Emphasis: _italic_, **bold**, ~~strike~~\n- [x] Ordered and unordered lists\n- [x] Tables\n- [x] Task lists\n- [x] Blockquotes\n- [x] Footnotes (see [[06 Callouts and Footnotes]])\n- [x] Math blocks (see [[04 Math with KaTeX]])\n- [x] Mermaid (see [[05 Mermaid Diagrams]])\n- [x] D2 (see [[05c D2 Diagrams]])\n- [x] TikZ, JSXGraph, and function-plot (see [[05b Math Diagrams]])\n- [x] Vault-wide Tasks grouping and filtering\n- [ ] Try the Kanban board in [[16 Tasks Calendar and Kanban]] !med #tasks\n\n## Tasks as an app feature\n\nThe Tasks tab is not just a renderer demo. It is a vault-wide operational view for planning and review. Use it when you want one place to see what is due, what is waiting, what is done, and where each task lives, and switch it to the Calendar or the Kanban board when a list is not the right shape. A whole note can be a task too: [[16 Tasks Calendar and Kanban]] covers both.\n\n#demo #tasks #tables\n" }, { path: "inbox/demo/04 Math with KaTeX.md", - body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown while preview and split mode give you readable math output.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode keeps the raw LaTeX visible.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b Math Diagrams]].\n\n#demo #math #reference\n" + body: "# Math with KaTeX\n\nZenNotes renders LaTeX math via KaTeX. The source stays plain markdown, and with live preview on the formulas draw right in the editor; put the cursor in one to edit its source.\n\n## A fast way to insert math\n\nType `/` and choose **Math block** when you want display math without typing the fence from memory.\n\n## Inline math\n\nEuler's identity is $e^{i\\pi} + 1 = 0$. \nThe area of a circle is $A = \\pi r^2$. \nA quadratic has roots $x = \\dfrac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.\n\n## Display blocks\n\n$$\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\, dx = \\sqrt{\\pi}\n$$\n\n$$\n\\frac{\\partial}{\\partial t} \\Psi(x, t) = -\\frac{\\hbar^2}{2m} \\frac{\\partial^2}{\\partial x^2} \\Psi(x, t) + V(x)\\Psi(x, t)\n$$\n\n## Aligned equations\n\n$$\n\\begin{aligned}\n(a + b)^2 &= a^2 + 2ab + b^2 \\\\\n(a - b)^2 &= a^2 - 2ab + b^2 \\\\\na^2 - b^2 &= (a + b)(a - b)\n\\end{aligned}\n$$\n\n## Numbered equations\n\nEach unstarred `equation` environment gets the next number in the note, in the editor and in Preview alike:\n\n$$\n\\begin{equation}\nE = mc^2\n\\end{equation}\n$$\n\n$$\n\\begin{equation}\n\\nabla \\cdot \\mathbf{E} = \\frac{\\rho}{\\varepsilon_0}\n\\end{equation}\n$$\n\n## Matrices\n\n$$\n\\mathbf{A} =\n\\begin{bmatrix}\n 1 & 2 & 3 \\\\\n 4 & 5 & 6 \\\\\n 7 & 8 & 9\n\\end{bmatrix}\n\\qquad\n\\det(\\mathbf{A}) = 0\n$$\n\n## Summations, limits, derivatives\n\n$$\n\\sum_{n=1}^{\\infty} \\frac{1}{n^2} = \\frac{\\pi^2}{6}\n\\qquad\n\\lim_{x \\to 0} \\frac{\\sin x}{x} = 1\n\\qquad\n\\frac{d}{dx} \\ln x = \\frac{1}{x}\n$$\n\n## Probability and finance\n\n$$\nP(A \\mid B) = \\frac{P(B \\mid A) P(A)}{P(B)}\n$$\n\n$$\nC = S_0 \\Phi(d_1) - K e^{-rT} \\Phi(d_2)\n$$\n\n$$\nd_1 = \\frac{\\ln(S_0 / K) + (r + \\tfrac{1}{2}\\sigma^2) T}{\\sigma \\sqrt{T}}, \\qquad d_2 = d_1 - \\sigma \\sqrt{T}\n$$\n\n## Why this matters in ZenNotes\n\n- **Edit** mode with live preview draws each formula in place and shows its LaTeX when the cursor enters it.\n- Type two letters inside a math region to complete Greek letters, operators, arrows, and functions, with a rendered preview of each.\n- Settings → Editor → Math size scales the math against the surrounding text, from 50 to 200 percent.\n- **Split** mode is great when you want source and rendered math side by side.\n- **Preview** mode turns math-heavy notes into something closer to a paper or spec.\n- Vault text search still sees the underlying source, which makes formulas searchable as text.\n\n## Prefer Typst? An alternative math engine\n\nZenNotes can also typeset math with **Typst** instead of KaTeX. Open **Settings ▸ Editor ▸ Math renderer** and pick **Typst**; it applies in both the live editor and the reading view.\n\nTypst reads the same `$…$` and `$$…$$` blocks as **Typst markup**, not LaTeX, so each note's math is written for whichever engine you pick. The formulas here are Typst syntax: with the Math renderer set to **Typst** they render; with **KaTeX** (the default) they show as errors until you switch.\n\nInline: $x^2 + y^2 = z^2$ and $sqrt(a^2 + b^2)$.\n\n$$\nintegral_0^1 x^2 dif x = 1/3\n$$\n\n$$\nsum_(n=1)^oo 1/n^2 = pi^2/6\n$$\n\n$$\nmat(1, 2; 3, 4) quad vec(a, b, c)\n$$\n\n## What's next\n\nWhen the note needs geometry, plotted functions, or figure-quality diagrams rather than equation layout, jump to [[05b Math Diagrams]].\n\n#demo #math #reference\n" }, { path: "inbox/demo/05 Mermaid Diagrams.md", - body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- **Split** mode is usually the sweet spot: raw source on one side, rendered diagram on the other.\n- Diagrams are still searchable because the source fence lives in the note body.\n- If Mermaid syntax breaks, ZenNotes falls back to showing the source block, which makes failures debuggable instead of mysterious.\n\n## What's next\n\nStay in diagram mode with [[05b Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" + body: "# Mermaid diagrams\n\nMermaid fences render inline in ZenNotes, drawn by Pele (pele.run) in your theme's colours and fonts. They are still just markdown code blocks on disk, so you can version them, diff them, and edit them anywhere.\n\nFor TikZ, JSXGraph, and function-plot, see [[05b Math Diagrams]]. For architecture diagrams with nested containers, see [[05c D2 Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `mermaid`.\n\n## Flowchart\n\n```mermaid\nflowchart LR\n A([User types]) --> B{Vim mode?}\n B -- yes --> C[CodeMirror vim keymap]\n B -- no --> D[Standard editing]\n C --> E[Save to .md]\n D --> E\n E --> F([File on disk])\n```\n\n## Sequence diagram\n\n```mermaid\nsequenceDiagram\n autonumber\n actor U as User\n participant R as Renderer\n participant M as Main process\n participant D as Disk\n\n U->>R: Type in editor\n R->>M: writeNote(path, body)\n M->>D: fs.writeFile(...)\n D-->>M: ok\n M-->>R: NoteMeta\n R-->>U: Clean tab title\n```\n\n## State diagram\n\n```mermaid\nstateDiagram-v2\n [*] --> Draft\n Draft --> Review : Submit\n Review --> Draft : Request changes\n Review --> Approved : Accept\n Approved --> Published : Ship\n Published --> Archived : 90 days\n Archived --> [*]\n```\n\n## Gantt chart\n\n```mermaid\ngantt\n title Product roadmap\n dateFormat YYYY-MM-DD\n axisFormat %b %d\n\n section Editor\n Vim motions polish :done, vim1, 2026-03-10, 5d\n Outline panel :done, out1, 2026-03-17, 3d\n Attachments preview :active, att1, 2026-04-15, 7d\n Multi-window sync : mws1, after att1, 5d\n\n section Release\n QA pass : qa1, after mws1, 3d\n Ship :milestone, rel1, after qa1, 0d\n```\n\n## Pie chart\n\n```mermaid\npie title How the day was spent\n \"Deep work\" : 45\n \"Meetings\" : 15\n \"Slack\" : 10\n \"Reading\" : 20\n \"Breaks\" : 10\n```\n\n## Vault map\n\n```mermaid\ngraph TB\n subgraph Lifecycle\n Q[Quick Notes]\n I[Inbox]\n A[Archive]\n T[Trash]\n end\n Q --> I\n I --> A\n I --> T\n A --> I\n T --> I\n```\n\n## Working with diagrams in the app\n\n- With live preview on, a diagram draws in place in the editor; move the cursor into it to get the source back.\n- **Split** mode keeps the source on one side and the diagram on the other while you work on a big one.\n- **Expand** on a diagram in Preview opens it larger, with zoom.\n- Switch themes and every diagram repaints at once. A colour you set yourself with `style`, `classDef`, or `linkStyle` stays as written.\n- Labels are plain text: use `<br>` for a line break and markdown bold or italic; put a formula in a math block beside the diagram.\n- A broken diagram shows the error as text, with a caret under the column that failed, so a typo is easy to find.\n- Diagrams are still searchable because the source fence lives in the note body.\n\n## What's next\n\nStay in diagram mode with [[05b Math Diagrams]] if you want interactive geometry, coordinate figures, or compact function plots.\n\n#demo #mermaid #diagrams\n" }, { path: "inbox/demo/05b Math Diagrams.md", - body: "# Math diagrams: TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 Mermaid Diagrams]]) and KaTeX (see [[04 Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ: figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly: no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph: interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object, with no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"`, which is useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser, so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot: quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math (`Math.PI`, `Math.sin(x)`, etc.), plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve and the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" + body: "# Math diagrams: TikZ, JSXGraph, and function-plot\n\nBeyond Mermaid (see [[05 Mermaid Diagrams]]) and KaTeX (see [[04 Math with KaTeX]]), ZenNotes renders three more diagram types from plain fenced code blocks. Each one shines at a different job.\n\nSwitch to **Preview** or **Split** mode to see them rendered. The source stays plain markdown on disk.\n\n---\n\n## TikZ: figure-quality math diagrams\n\nUse when you want paper-grade vector figures: coordinate systems, geometry, commutative diagrams, automata, trees, plots. The full TikZ + pgfplots toolchain compiles on-device via WebAssembly: no network, no LaTeX install.\n\n### A parabola with axes\n\n```tikz\n\\begin{tikzpicture}\n \\draw[->, thick] (-2.2,0) -- (2.2,0) node[right] {$x$};\n \\draw[->, thick] (0,-0.5) -- (0,4.5) node[above] {$y$};\n \\draw[domain=-2:2, smooth, thick, blue] plot (\\x,{\\x*\\x});\n \\node[blue, above right] at (1.4, 1.96) {$y = x^2$};\n\\end{tikzpicture}\n```\n\n### A triangle with labelled vertices\n\n```tikz\n\\begin{tikzpicture}\n \\coordinate[label=below left:$A$] (A) at (0,0);\n \\coordinate[label=below right:$B$] (B) at (4,0);\n \\coordinate[label=above:$C$] (C) at (1.5,3);\n \\draw[thick] (A) -- (B) -- (C) -- cycle;\n \\draw[dashed] (C) -- ($ (A)!(C)!(B) $) node[pos=0.5, right] {$h$};\n\\end{tikzpicture}\n```\n\n### A small commutative diagram\n\n```tikz\n\\begin{tikzpicture}[node distance=2.2cm, every node/.style={font=\\small}]\n \\node (A) {$A$};\n \\node (B) [right of=A] {$B$};\n \\node (C) [below of=A] {$C$};\n \\node (D) [right of=C] {$D$};\n \\draw[->] (A) -- node[above] {$f$} (B);\n \\draw[->] (A) -- node[left] {$g$} (C);\n \\draw[->] (B) -- node[right] {$h$} (D);\n \\draw[->] (C) -- node[below] {$k$} (D);\n\\end{tikzpicture}\n```\n\n---\n\n## JSXGraph: interactive geometry and plots\n\nUse when you want the diagram to be **draggable** and **live**. Points move, sliders animate, curves reflow. Configuration is a small JSON object, with no JavaScript required.\n\nEach object takes a `type` (the JSXGraph element name) and `args` (the element's constructor arguments). Assign an `id` to reference an object from a later one using `\"@id\"`, which is useful for attaching points to curves, for example.\n\n### Sine wave with a point on the curve\n\nJSXGraph's `functiongraph` evaluates string expressions with its built-in **JessieCode** parser, so write `sin(x)`, `cos(x)`, `x^2`, `exp(x)`, etc. directly (no `Math.` prefix).\n\n```jsxgraph\n{\n \"boundingbox\": [-6.5, 1.6, 6.5, -1.6],\n \"axis\": true,\n \"objects\": [\n {\n \"id\": \"curve\",\n \"type\": \"functiongraph\",\n \"args\": [\"sin(x)\"],\n \"attributes\": { \"strokeColor\": \"#6caedf\", \"strokeWidth\": 2 }\n },\n {\n \"type\": \"glider\",\n \"args\": [1, 0, \"@curve\"],\n \"attributes\": {\n \"name\": \"P\",\n \"size\": 4,\n \"strokeColor\": \"#d35e0c\",\n \"fillColor\": \"#d35e0c\"\n }\n }\n ]\n}\n```\n\nDrag `P` along the curve.\n\n### Unit circle with a labelled point\n\n```jsxgraph\n{\n \"boundingbox\": [-1.6, 1.6, 1.6, -1.6],\n \"axis\": true,\n \"width\": 360,\n \"height\": 360,\n \"objects\": [\n {\n \"type\": \"circle\",\n \"args\": [[0, 0], 1],\n \"attributes\": { \"strokeColor\": \"#945e80\" }\n },\n {\n \"type\": \"point\",\n \"args\": [0.7, 0.7141],\n \"attributes\": {\n \"name\": \"Q\",\n \"fillColor\": \"#6c782e\",\n \"strokeColor\": \"#6c782e\"\n }\n }\n ]\n}\n```\n\n### Two lines and their intersection\n\n```jsxgraph\n{\n \"boundingbox\": [-5, 5, 5, -5],\n \"axis\": true,\n \"objects\": [\n { \"id\": \"A\", \"type\": \"point\", \"args\": [-3, -2], \"attributes\": { \"name\": \"A\" } },\n { \"id\": \"B\", \"type\": \"point\", \"args\": [ 3, 2], \"attributes\": { \"name\": \"B\" } },\n { \"id\": \"C\", \"type\": \"point\", \"args\": [-3, 2], \"attributes\": { \"name\": \"C\" } },\n { \"id\": \"D\", \"type\": \"point\", \"args\": [ 3, -2], \"attributes\": { \"name\": \"D\" } },\n {\n \"id\": \"L1\",\n \"type\": \"line\",\n \"args\": [\"@A\", \"@B\"],\n \"attributes\": { \"strokeColor\": \"#45707a\" }\n },\n {\n \"id\": \"L2\",\n \"type\": \"line\",\n \"args\": [\"@C\", \"@D\"],\n \"attributes\": { \"strokeColor\": \"#c14a4a\" }\n },\n {\n \"type\": \"intersection\",\n \"args\": [\"@L1\", \"@L2\", 0],\n \"attributes\": { \"name\": \"X\", \"size\": 4, \"fillColor\": \"#b47109\" }\n }\n ]\n}\n```\n\nDrag any of `A`–`D` and the intersection follows.\n\n---\n\n## function-plot: quick Cartesian plots\n\nSmallest and simplest of the three. Give it functions, get a plot. Great for calculus-style notes and quick sanity checks.\n\nThe fence body is the options object passed to [function-plot](https://mauriciopoppe.github.io/function-plot/). Expression syntax is standard JavaScript math (`Math.PI`, `Math.sin(x)`, etc.), plus the `x^2` shorthand for powers.\n\n### Several functions on one axis\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"xAxis\": { \"domain\": [-6.28, 6.28] },\n \"grid\": true,\n \"data\": [\n { \"fn\": \"sin(x)\", \"color\": \"#45707a\" },\n { \"fn\": \"cos(x)\", \"color\": \"#c14a4a\" },\n { \"fn\": \"x / 3.14159265\", \"color\": \"#6c782e\" }\n ]\n}\n```\n\n### A derivative annotation\n\nHover the curve and the tangent slope updates live.\n\n```function-plot\n{\n \"yAxis\": { \"domain\": [-2, 8] },\n \"xAxis\": { \"domain\": [-3, 3] },\n \"grid\": true,\n \"data\": [\n {\n \"fn\": \"x^2\",\n \"derivative\": { \"fn\": \"2 * x\", \"updateOnMouseMove\": true },\n \"color\": \"#945e80\"\n }\n ]\n}\n```\n\n### A parametric curve\n\n```function-plot\n{\n \"xAxis\": { \"domain\": [-1.5, 1.5] },\n \"yAxis\": { \"domain\": [-1.5, 1.5] },\n \"grid\": true,\n \"data\": [\n {\n \"graphType\": \"polyline\",\n \"fnType\": \"parametric\",\n \"x\": \"cos(t)\",\n \"y\": \"sin(t)\",\n \"range\": [0, 6.283],\n \"color\": \"#b47109\"\n }\n ]\n}\n```\n\n---\n\n## When to reach for which\n\n| You want… | Use |\n| ---------------------------------------------------------------- | ------------------------------------------- |\n| Paper-grade static figure, TikZ muscle-memory, LaTeX portability | **TikZ** |\n| Interactive geometry, draggable points, geometry theorems | **JSXGraph** |\n| Quick plot of a few functions, minimal config | **function-plot** |\n| Architecture, nested systems, data models | **D2** (see [[05c D2 Diagrams]]) |\n| Flow / sequence / state / gantt / ER diagram | **Mermaid** (see [[05 Mermaid Diagrams]]) |\n| Inline formulas, display equations | **KaTeX** (see [[04 Math with KaTeX]]) |\n\n#demo #math #diagrams #tikz #jsxgraph #function-plot\n" + }, + { + path: "inbox/demo/05c D2 Diagrams.md", + body: "# D2 diagrams\n\nD2 (d2lang.com) is a text-to-diagram language made for architecture: boxes inside boxes, labelled connections, data models, and sequences. A `d2` fence renders inline in ZenNotes, and on disk it stays a plain code block you can version, diff, and edit anywhere.\n\nFor flowcharts, state machines, Gantt charts, and pie charts, see [[05 Mermaid Diagrams]]. For math figures, see [[05b Math Diagrams]].\n\n## A fast way to insert one\n\nType `/` and choose **Code block**, then change the language to `d2`.\n\n## Shapes and connections\n\n```d2\ndirection: right\ncapture: Quick capture {shape: oval}\ninbox: Inbox {shape: cylinder}\nnote: Note\narchive: Archive {shape: stored_data}\ncapture -> inbox: save\ninbox -> note: refine\nnote -> archive: file it\n```\n\n## Containers\n\n```d2\ndirection: right\ndesktop: Desktop app {\n renderer: Renderer\n main: Main process\n renderer -> main: IPC\n}\ndisk: Vault on disk {shape: cylinder}\ncloud: ZenNotes Cloud {shape: cloud}\ndesktop.main -> disk: read and write\ndesktop.main -> cloud: sync\n```\n\n## Data models\n\n```d2\nnotes: {\n shape: sql_table\n path: text {constraint: primary_key}\n title: text\n updated_at: timestamp\n}\nlinks: {\n shape: sql_table\n from_path: text {constraint: foreign_key}\n to_path: text {constraint: foreign_key}\n}\nlinks.from_path -> notes.path\nlinks.to_path -> notes.path\n```\n\n## Sequence diagrams\n\n```d2\nshape: sequence_diagram\nyou: You\neditor: Editor\ndisk: Disk\nyou -> editor: type\neditor -> disk: save the .md file\ndisk -> editor: watcher event\neditor -> you: clean tab title\n```\n\n## Styles and sketch mode\n\n```d2\nvars: {\n d2-config: {\n sketch: true\n }\n}\nidea: Idea\ndraft: Draft {style.stroke-dash: 3}\nnote: Note {style.double-border: true}\nidea -> draft -> note\n```\n\nDiagrams take their colours from your ZenNotes theme, so they repaint when you switch themes. A colour you set with `style` stays as written, and a diagram that names a D2 theme with `theme-id` in `d2-config` keeps that theme.\n\n## Working with D2 in the app\n\n- **Live preview** draws the diagram in the editor; put the cursor in the block to edit its source.\n- **Split** mode shows the source and the diagram side by side.\n- **Expand** on a diagram opens it in a larger view with zoom.\n- A broken diagram shows D2's message with its line and column, so a typo is easy to find.\n- Icons, images, and links are not drawn: a diagram never reaches the network.\n- The D2 compiler loads the first time a note holds a `d2` fence, so that first diagram takes a moment.\n\n## What's next\n\nCompare with [[05 Mermaid Diagrams]], or go back to [[00 Start Here]].\n\n#demo #d2 #diagrams\n" }, { path: "inbox/demo/06 Callouts and Footnotes.md", - body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\nPut `-` right after the type to start a callout collapsed, or `+` to start it expanded, then click its title to open or close it.\n\n> [!example]- Foldable callout\n> Handy for screenshots, logs, and config excerpts that should stay out of the way until you need them.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" + body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`. Type `[!` inside a blockquote to pick a type from a menu instead of remembering the names.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\nPut `-` right after the type to start a callout collapsed, or `+` to start it expanded, then click its title to open or close it.\n\n> [!example]- Foldable callout\n> Handy for screenshots, logs, and config excerpts that should stay out of the way until you need them.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping or pasting a file into the editor copies it into the vault's `assets/` folder and inserts a normal markdown reference to it, so attachments stay together instead of cluttering your notes. Renaming or moving an asset later rewrites every note that points at it.\n\nExample image:\n\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n![ZenNotes demo card](<../../zennotes-demo-card.svg>)\n```\n\n### Image sizes\n\nAn image takes an optional width after a `|`, in both link styles:\n\n```md\n![[assets/diagram.png|300]]\n![A caption|300](assets/diagram.png)\n```\n\nIn the editor, drag the handle on a picture's right edge to resize it; the width is written back as that hint, so Preview, PDF export, and Obsidian all show the same size. In Vim mode, `:imgwidth 480` sets it by number and `:imgw auto` resets it.\n\n### Videos and link cards\n\nPaste a YouTube or Vimeo link on an empty line and a small menu offers **Embed**, **Bookmark**, or the plain URL. An embed is a fence holding the URL, drawn as the video's poster until you click play; a bookmark is drawn as a link card built from the page's title and description.\n\n````md\n```embed\nhttps://www.youtube.com/watch?v=VIDEO_ID\n```\n````\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n" }, { path: "inbox/demo/07 Wiki Links and Tags.md", - body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively.\n\n- Shortest form: [[01 Markdown Basics]]\n- Custom display text: [[11 Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] (opening it offers to create the note)\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- PDFs can open directly into the reference pane\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 Start Here]]\n A --> B[[01 Markdown Basics]]\n A --> C[[02 Code Blocks]]\n A --> D[[03 Tables and Task Lists]]\n A --> E[[04 Math with KaTeX]]\n A --> F[[05 Mermaid Diagrams]]\n A --> G[[05b Math Diagrams]]\n A --> H[[06 Callouts and Footnotes]]\n A --> I[[07 Wiki Links and Tags]]\n A --> J[[08 Daily Notes]]\n A --> K[[09 Vim Cheat Sheet]]\n A --> L[[10 Ideas and Tasks]]\n A --> M[[11 Workspace, Search, and Views]]\n A --> N[[12 Settings and Keymaps]]\n A --> O[[13 Commands, Help, and Demo Tour]]\n A --> P[[14 Reference Pane and Floating Windows]]\n A --> Q[[15 Search Backends and Fuzzy Workflows]]\n```\n\n#demo #reference #search #links\n" + body: "# Wiki links, tags, backlinks, and search\n\nThese features turn a folder of markdown files into a navigable vault.\n\n## Wiki links\n\nPoint at other notes with `[[double brackets]]`. ZenNotes resolves them by note title, case-insensitively. Type `[[` and a picker lists matching notes, images, PDFs, and databases; press `|` on a suggestion to start its display text.\n\n- Shortest form: [[01 Markdown Basics]]\n- Custom display text: [[11 Workspace, Search, and Views|workspace guide]]\n- Missing note: [[A Future Note]] (drawn muted with a dashed underline; following it offers to create the note)\n- A section: [[03 Tables and Task Lists#Task lists]]\n- One block: [[00 Start Here^portable]]\n\nYou can follow links with the mouse or keyboard:\n\n- in Vim mode, put the cursor on a link and press `gd`\n- markdown links and wikilinks both work\n- `gD` creates the note for a missing link at once, without asking\n- standard markdown links like `[text](Note.md)` count as links too, in Connections and everywhere else\n- PDFs can open directly into the reference pane\n\nRename a note and every link to it, across the vault, rewrites itself to the new name, display text and `#heading` parts included.\n\n## Blocks and embeds\n\nEnd any line with a `^block-id` marker to name that block, or put the marker on its own line to name the paragraph above it, then link straight at it with `[[Note^block-id]]`. The last paragraph of [[00 Start Here]] carries `^portable`, which is how the link above reaches it. Typing `^` inside a wikilink lists the target note's ids with their text.\n\nPut `!` in front of a wikilink to embed instead of link. This pulls that one paragraph in, live:\n\n![[00 Start Here^portable]]\n\n`![[Note]]` inlines a whole note in Preview and PDF export, so a master note can gather its sub-notes and export as one document.\n\n## Tags\n\nTags are plain inline text. They start with `#` and become searchable structure.\n\nThis demo folder uses tags like:\n\n- #demo\n- #reference\n- #tasks\n- #vim\n- #search\n- #workspace\n\nThe **Tags** view lets you browse notes matching one or more selected tags in a dedicated main-pane list. Right-click a tag chip there to rename or delete that tag across the whole vault. A tag can nest with `/`, like `#project/alpha`, and the sidebar shows nested tags as a tree.\n\n## Connections\n\nThe **Connections** panel helps you inspect:\n\n- outbound links from the current note\n- backlinks into the current note\n- unresolved link targets that still need a note\n\nThis is especially useful when you are writing specs, research notes, or project docs and want context without leaving the active note.\n\n## Atlas\n\n**Atlas** draws the whole vault as a map: every note a point sized by how linked it is, every wikilink a line, and your top-level folders as regions. Open it from the sidebar, with `Space g` in Vim mode, or with \"Open Atlas\" in the command palette. Press `v` to switch between the flat map and a 3D sky, `1` to `4` for lenses (Structure, Heat, Orphans, Bridges), and `f` to jump to a note by its label.\n\n## Search modes\n\nZenNotes has two distinct searches:\n\n### Note search\n\n- `⌘P` opens the note search palette\n- `Space f` opens the same search in Vim mode\n- this search matches note titles and paths\n\n### Vault text search\n\n- `Space s t` opens vault text search\n- it searches matching text lines across **Inbox**, **Quick Notes**, and **Archive**\n- selecting a result opens the note and jumps to the matched line\n\nVault text search can run on different backends:\n\n- **Auto** prefers `fzf`, then `ripgrep`, then built-in\n- **Built-in** keeps everything inside ZenNotes\n- **ripgrep** and **fzf** can be chosen explicitly\n- custom binary paths can be configured in **Settings**\n- the app shows the resolved runtime backend so you can see what is actually being used\n\n## Graph of this tour\n\n```mermaid\ngraph LR\n A[[00 Start Here]]\n A --> B[[01 Markdown Basics]]\n A --> C[[02 Code Blocks]]\n A --> D[[03 Tables and Task Lists]]\n A --> E[[04 Math with KaTeX]]\n A --> F[[05 Mermaid Diagrams]]\n A --> G[[05b Math Diagrams]]\n A --> G2[[05c D2 Diagrams]]\n A --> H[[06 Callouts and Footnotes]]\n A --> I[[07 Wiki Links and Tags]]\n A --> J[[08 Daily Notes]]\n A --> K[[09 Vim Cheat Sheet]]\n A --> L[[10 Ideas and Tasks]]\n A --> M[[11 Workspace, Search, and Views]]\n A --> N[[12 Settings and Keymaps]]\n A --> O[[13 Commands, Help, and Demo Tour]]\n A --> P[[14 Reference Pane and Floating Windows]]\n A --> Q[[15 Search Backends and Fuzzy Workflows]]\n A --> R[[16 Tasks Calendar and Kanban]]\n A --> S[[17 Templates, Drawings, and Databases]]\n A --> T[[18 Workflows]]\n A --> U[[19 Cloud, CLI, and AI Agents]]\n```\n\n#demo #reference #search #links\n" }, { path: "inbox/demo/08 Daily Notes.md", - body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Agenda\n\n- [ ] Morning: triage [[10 Ideas and Tasks]]\n- [ ] 10:00 design review\n- [ ] 12:00 lunch\n- [x] 14:00 code-freeze prep\n- [ ] Evening reading: Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Add a sample PDF so the reference-pane flow is demonstrated with a real file.\n- [ ] Add more screenshots for the search palette.\n- [ ] Refine the help text for view-specific ex prompts.\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 Tables and Task Lists]]\n- [ ] Review [[12 Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" + body: "---\ntitle: 2026-04-16\ndate: 2026-04-16\ntags: [daily, log, demo]\n---\n\n# Thursday, 2026-04-16\n\n> [!tip] Pattern\n> A daily note is still just a `.md` file. Keep it under `inbox/daily/`, `quick/`, or wherever your vault makes sense. If you name it `YYYY-MM-DD.md`, it sorts chronologically without extra tooling.\n\n## Why daily notes fit ZenNotes well\n\n- they stay file-based and sync-friendly\n- they pair naturally with quick capture\n- they work well with tasks, tags, and links\n- reopening the app restores your tabs, panes, and window bounds, so an active daily workflow is easy to resume\n\n## Daily, weekly, and monthly notes\n\nTurn on daily notes under Settings → Vault → Daily notes, pick a folder and a title pattern, and optionally a template so each day starts pre-filled. Then:\n\n- `Space d` (or `:daily`) opens today's note, creating it if needed\n- `Space w` / `:weekly` and `Space m` / `:monthly` do the same for weekly and monthly notes, once you turn those on\n- `Space c` toggles the calendar panel, which opens by itself beside a daily or weekly note\n- a task written in a daily note is due on that day, so it lands on that date in the Tasks calendar without a `due:`\n- \"Roll Over Unfinished Tasks to Today\" in the command palette moves every unchecked task from past daily notes into today's; a setting beside the daily-note options does it each time you open today's note (off by default)\n\n## Agenda\n\n- [ ] Morning: triage [[10 Ideas and Tasks]]\n- [ ] 10:00 design review\n- [ ] 12:00 lunch\n- [x] 14:00 code-freeze prep\n- [ ] Evening reading: Seeing Like a State, chapter 3\n\n## Quick capture and dates\n\nPress `⇧⌘Space` (`Ctrl+Shift+Space` on Windows and Linux) from any app, or `Space q` inside ZenNotes, and a small always-on-top window appears. The first line becomes the title and the rest the body; `⌘Enter` saves it into Quick Notes and hides the window, and `⌘N` saves and starts the next one.\n\nQuick Notes are for fast capture. From there you can:\n\n- keep the note in Quick Notes\n- move it into Inbox\n- archive it later\n- trash it with confirmation if it is no longer useful\n\nDate helpers are also built in:\n\n- type `@` to insert **Today**, **Yesterday**, or **Tomorrow**\n- type `@date` to pick any other day from a calendar, and `@time` or `@now` for the current time\n- the inserted value is an ISO date like `2026-04-16`\n- ISO dates stay readable, sortable, and easy to search\n\nExamples:\n\n- Review due @today\n- Follow up on search backend docs @tomorrow\n- Closed the previous thread @yesterday\n\n## Log\n\n- Shipped the vault text search backend picker.\n- Updated the demo vault so it covers the current product surface.\n- Verified that session restore brings back the working layout after relaunch.\n\n## Wins\n\n- The same note works in edit, split, or preview mode.\n- Tasks here show up in the vault-wide Tasks view.\n- Links here also show up in Connections.\n\n## Follow-ups\n\n- [ ] Send the design review notes to the team\n- [/] Read chapter 4 of Seeing Like a State\n- [ ] Book the dentist due:2026-04-20\n\n## Notes for tomorrow\n\n- [ ] Carry over open tasks from [[03 Tables and Task Lists]]\n- [ ] Review [[12 Settings and Keymaps]] for any missing personalization features\n\n#daily #log #demo\n" }, { path: "inbox/demo/09 Vim Cheat Sheet.md", - body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⇧⌘N` | New Quick Note |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⌥Z` | Toggle word wrap |\n\nIf you explicitly turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, outline, and connections |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space e` | Toggle sidebar |\n| `Space p` | Open note outline |\n| `Space l f` | Format the active note |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading or list item at the cursor |\n| `zo` | Unfold the heading or list item at the cursor |\n| `zM` | Fold all headings and list items |\n| `zR` | Unfold all headings and list items |\n\n## Links and hint mode\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow wikilink, markdown link, or open/create note under cursor |\n| `f` | Hint mode for clickable targets when not in insert mode |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `Space` / `x` | Toggle selected task in **Tasks** |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit|split|preview` | Switch the current pane mode |\n| `:editmode` / `:splitmode` / `:previewmode` | Direct aliases for note mode changes |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading or list item |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading and list item |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can now be remapped in [[12 Settings and Keymaps]]. Vim mode is the default, but the app no longer hardcodes every sequence forever.\n\n#demo #vim #reference\n" + body: "# Vim cheat sheet for ZenNotes\n\nZenNotes ships with Vim mode on by default. The editor uses CodeMirror Vim bindings, and the app adds its own keyboard-first flows around panes, panels, search, and built-in views. On Windows and Linux, read `⌘` as `Ctrl`.\n\n## Global shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `⌘P` | Search notes |\n| `⇧⌘P` | Open command palette |\n| `⌘N` | New note in the current folder |\n| `⇧⌘N` | New Quick Note |\n| `⇧⌘Space` | Quick capture window, from any app |\n| `⌘,` | Open Settings |\n| `⌘1` | Toggle sidebar |\n| `⌘2` | Toggle connections |\n| `⌘3` | Toggle outline panel |\n| `⇧⌘C` | Toggle comments panel |\n| `⌘4` / `⌘5` / `⌘6` | Edit / Split / Preview |\n| `⌘L` | Toggle the checkbox on the current line |\n| `⇧⌘E` | Export the note as PDF |\n| `⌘.` | Toggle Zen mode |\n| `⌘W` | Close active tab or built-in view |\n| `⇧⌘T` | Reopen the last closed tab |\n| `Ctrl+Tab` | Switch to the previous note |\n| `⌥Z` | Toggle word wrap |\n\nIf you turn Vim mode off, `⌘F` or `Ctrl+F` becomes an extra direct note-search shortcut.\n\n## Pane and panel motion\n\n| Keys | Action |\n| --- | --- |\n| `Ctrl-w h` / `j` / `k` / `l` | Move focus between sidebar, note list, editor panes, and side panels |\n| `Alt+H` / `J` / `K` / `L` | The same, with Vim mode on or off |\n| `Ctrl-w v` | Split right |\n| `Ctrl-w s` | Split down |\n| `[b` / `]b` | Previous / next buffer |\n| `gt` / `gT` | Next / previous tab (`3gt` jumps to the third) |\n| `Ctrl-o` | Jump back in note history |\n| `Ctrl-i` | Jump forward in note history |\n\n## Leader (`Space`) shortcuts\n\n| Keys | Action |\n| --- | --- |\n| `Space o` | Open buffers |\n| `Space f` | Search notes |\n| `Space s t` | Search vault text |\n| `Space p` | Open note outline |\n| `Space e` | Toggle sidebar |\n| `Space v` | Switch vault |\n| `Space h` | Hint mode: labels over every clickable target |\n| `Space t` | New note from a template |\n| `Space i` | Insert a template into this note |\n| `Space q` | Quick capture window |\n| `Space d` / `Space w` / `Space m` | Today's, this week's, this month's note |\n| `Space c` | Toggle the calendar panel |\n| `Space g` | Open Atlas |\n| `Space a` | Open Workflows (once turned on in Settings) |\n| `Space b` | Hide the window to the tray (once turned on in Settings) |\n| `Space r` | Review Cloud sync conflicts, while any are waiting |\n| `Space l f` | Format the active note |\n| `Space l y` | Copy the note as Markdown |\n| `Space l s` | Add the note to Favorites, or remove it |\n| `Space l d` | Turn the table under the cursor into a database |\n| `Space`, then pause | Show leader hints when enabled |\n\nLeader hints can be **timed** or **sticky** in Settings. Sticky mode stays open until you press `Space` again or `Esc`.\n\n## Moving through a note\n\n| Keys | Action |\n| --- | --- |\n| `]]` / `[[` | Next / previous heading (a motion, so `d]]` and `v]]` work) |\n| `gqip` | Join a hard-wrapped paragraph into one line (`gw` keeps the cursor) |\n| `Ctrl+V` | Visual block; `I`, `A`, and `c` edit every row |\n| `\"+p` | Paste from the system clipboard, images included |\n\n## Folding\n\n| Keys | Action |\n| --- | --- |\n| `zc` | Fold the heading, list item, or callout at the cursor |\n| `zo` | Unfold the heading, list item, or callout at the cursor |\n| `zM` | Fold all headings, list items, and foldable callouts |\n| `zR` | Unfold everything |\n\n`Ctrl+Alt+F` and `Ctrl+Alt+U` (`⌘⌥F` and `⌘⌥U` on macOS) fold and unfold with Vim mode on or off.\n\n## Links\n\n| Keys | Action |\n| --- | --- |\n| `gd` | Follow the wikilink or markdown link under the cursor |\n| `gD` | Create the note for the link under the cursor, without asking |\n| `gy` | Copy the link under the cursor |\n\n## Grammar and spelling\n\nWith Harper turned on (Settings → Editor, off by default; it checks on this device):\n\n| Keys | Action |\n| --- | --- |\n| `]s` / `[s` | Next / previous problem |\n| `z=` | Suggestions for the problem under the cursor |\n| `zg` | Add the word to this vault's dictionary |\n| `zG` | Ignore this one suggestion |\n\n## Sidebar, list, and built-in views\n\nWhen focus is in the sidebar, note list, Tasks, Tags, Archive, Trash, or Quick Notes tab:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Move selection |\n| `gg` / `G` | Jump to top / bottom |\n| `Enter` / `l` | Open selected item |\n| `h` | Collapse or move back |\n| `o` | Toggle selected folder |\n| `/` | Filter the current list or view |\n| `m` | Open the context menu for the selected row |\n| `Esc` | Return toward the editor |\n\nView-specific extras:\n\n| Keys | Action |\n| --- | --- |\n| `x` | Toggle the selected task in **Tasks** |\n| `i` / `c` / `>` | Mark in progress, cancel, or forward the selected task |\n| `K` / `J` | Move the selected task up or down |\n| `2` / `3` | Switch **Tasks** to the Calendar or the Kanban board |\n| `F` | Pick a saved Tasks filter |\n| `r` | Restore selected note in **Trash** |\n| `x` / `d` | Permanently delete selected note in **Trash** |\n| `:` | Open the local ex prompt in **Tasks** or **Tags** |\n\n## Preview and connections\n\nWhen focus is in rendered preview or the connections panel:\n\n| Keys | Action |\n| --- | --- |\n| `j` / `k` | Scroll line by line |\n| `Ctrl-d` / `Ctrl-u` | Half-page down / up |\n| `gg` / `G` | Jump to top / bottom |\n| `p` | Peek the selected backlink in Connections |\n| `h` / `Esc` | Back out toward the editor |\n\nDouble-click any passage in Preview to open the editor right there.\n\n## Ex commands\n\nType `:` in normal mode:\n\n| Command | Action |\n| --- | --- |\n| `:w` | Save the active note |\n| `:q` | Close the current tab or built-in view |\n| `:wq` | Save and close |\n| `:help` | Open the built-in manual |\n| `:tasks` | Open Tasks |\n| `:tag foo bar` | Open Tags filtered to `foo` and `bar` |\n| `:trash` | Open Trash |\n| `:daily` / `:weekly` / `:monthly` | Open the periodic note |\n| `:template` | New note from a template |\n| `:e path` / `:edit path` | Open or create a note by vault-relative path |\n| `:new [path]` | Create a new note |\n| `:mv [folder]` | Move the active note, with a folder picker when no folder is given |\n| `:split` / `:vsplit` | Split the current tab down or right |\n| `:bn` / `:bp` | Next / previous tab |\n| `:buffers` / `:ls` | Open the buffer switcher |\n| `:bd` / `:bc` | Close the active tab |\n| `:view edit` / `split` / `preview` | Switch the current pane mode |\n| `:zen` / `:zen on` / `:zen off` | Toggle or force Zen mode |\n| `:format` | Format the active note |\n| `:fold` / `:unfold` | Fold or unfold the current heading or list item |\n| `:foldall` / `:unfoldall` | Fold or unfold every heading and list item |\n| `:filter text` / `:savefilter name` | Filter the Tasks views, or save the filter |\n| `:imgwidth 480` | Resize the image on the cursor line |\n| `:harper on` / `:harper off` | Turn grammar checking on or off |\n| `:unbind action.id` | Remove an action's key entirely |\n| `:whatsnew` | Show what this version brought |\n| `:cmd query` / `:commands` | Run or browse command palette entries |\n| `Tab` on the ex line | Complete commands and supported arguments |\n\n## One more important note\n\nEvery shortcut above can be remapped, or unbound, in [[12 Settings and Keymaps]]. Vim mode is the default, but no sequence is hardcoded.\n\n#demo #vim #reference\n" }, { path: "inbox/demo/10 Ideas and Tasks.md", - body: "# Ideas and tasks: a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should attachment previews appear inline for PDFs by default?\n- [ ] Is the built-in text-search backend fast enough on large vaults when neither `fzf` nor `ripgrep` is available?\n- [ ] Do we expose tag renaming from the UI, or keep it intentionally file-grep first?\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which now opens as a dedicated main-pane list view.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n## Now\n\n- [ ] Add a sample PDF + image to the tour so [[06 Callouts and Footnotes]] can illustrate attachments and reference-pane workflows.\n- [x] Document the Tasks tab behavior in [[03 Tables and Task Lists]].\n- [ ] Collect feedback on [[09 Vim Cheat Sheet]] now that keymaps are configurable.\n- [ ] Confirm the search backend badge is visible enough in the vault text search palette.\n\n## Shipped\n\n- [x] Vault text search can use **Auto**, **Built-in**, **ripgrep**, or **fzf**.\n- [x] Custom binary paths can be configured when `rg` or `fzf` live outside `PATH`.\n- [x] Settings now show the resolved runtime backend instead of only the requested one.\n- [x] Archive and Trash both behave as list-style built-in tabs instead of sidebar dump zones.\n\n## Cross-references\n\n- Tour index: [[00 Start Here]]\n- Search and links: [[07 Wiki Links and Tags]]\n- Workspace guide: [[11 Workspace, Search, and Views]]\n- Settings and keymaps: [[12 Settings and Keymaps]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n\n#demo #tasks #planning #workspace\n" + body: "# Ideas and tasks: a realistic note\n\nThis is the kind of note most real users end up writing: prose, todos, links, snippets, diagrams, and operational context all mixed together. It shows how ZenNotes features compose instead of living in isolated demos.\n\n> [!note]\n> Status as of 2026-04-16. Use this note to test search, outline, connections, comments, Tasks, and split view in one place.\n\n## Open questions\n\n- [ ] Should the reading list become a database? See [[17 Templates, Drawings, and Databases]].\n- [ ] Which recurring notes deserve a template of their own?\n- [ ] Is the monthly tag cleanup worth turning into a workflow? See [[18 Workflows]].\n\n## Working notes\n\n- Quick capture starts in **Quick Notes**, but anything important should graduate into **Inbox**.\n- Cold notes belong in **Archive**, which opens as a dedicated main-pane list view; archiving a note also retires its tasks from the Tasks views.\n- Deleted notes should go through **Trash**, where restore and permanent delete are separated on purpose.\n- If tabs are hidden, `Space o` or `:buffers` becomes the fastest way to recover the current working set.\n\n> [!tip] Discuss a passage\n> Select a sentence and press `⌥⌘M` (`Ctrl+Alt+M` on Windows and Linux) to leave a comment on it. The Comments panel (`⇧⌘C`) keeps each thread beside the note, with replies and resolve, and the note itself stays clean.\n\n## Now\n\n- [/] Merge the duplicate tags from the Tags view @project:cleanup\n- [ ] Move the reading list into a database @project:cleanup\n - [x] Export the old list\n - [ ] Pick the columns\n - [ ] Link each book to its notes\n- [ ] Set up a weekly review template due:2026-04-24 @project:cleanup\n- [-] Migrate the vault to another app (not needed: the files are plain markdown)\n\n## Done\n\n- [x] Turned on daily notes with a template\n- [x] Pinned the style guide in the reference pane\n- [x] Left the vault text search backend on **Auto**\n\n## Cross-references\n\n- Tour index: [[00 Start Here]]\n- Search and links: [[07 Wiki Links and Tags]]\n- Workspace guide: [[11 Workspace, Search, and Views]]\n- Settings and keymaps: [[12 Settings and Keymaps]]\n- Tasks on a board: [[16 Tasks Calendar and Kanban]]\n\n## A snippet I keep forgetting\n\nConverting a buffer to hex in Node:\n\n```ts\nimport { randomBytes } from 'node:crypto'\n\nconst buf = randomBytes(16)\nconsole.log(buf.toString('hex'))\n```\n\nConverting back:\n\n```ts\nconst hex = '01020304abcdef'\nconst buf = Buffer.from(hex, 'hex')\n```\n\n## Rough architecture sketch\n\n```mermaid\nflowchart TB\n subgraph Main\n V[Vault I/O]\n W[Watcher]\n T[Task scanner]\n S[Vault text search]\n end\n subgraph Renderer\n E[Editor]\n SB[Sidebar]\n P[Preview]\n O[Outline]\n C[Connections]\n end\n E <-->|IPC| V\n SB -->|IPC| V\n P -->|IPC| V\n O --> E\n C --> E\n V --> T\n V --> S\n W -->|events| V\n```\n\n## A little math\n\nThe rough cost model people keep re-deriving:\n\n$$\nT \\approx 3 \\cdot t \\cdot \\frac{m}{\\text{bandwidth}}\n$$\n\n## Workflow checklist\n\n- [ ] Try this note in **Edit**, **Split**, and **Preview**\n- [ ] Open the **outline** and jump to \"Workflow checklist\"\n- [ ] Open **Connections** and inspect backlinks\n- [ ] Search for `backend` with `Space s t`\n- [ ] Toggle **Zen mode**\n- [ ] Open Tasks, switch to Kanban, and group by `project` to see this note's tasks as a board\n- [ ] Leave a comment on a sentence above\n\n#demo #tasks #planning #workspace\n" }, { path: "inbox/demo/11 Workspace, Search, and Views.md", - body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" + body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Home\n\nWith no note open, the main pane shows a light home view: quick-create actions, your most recently edited notes, your **Favorites**, and today's open tasks with an overdue count. Favorite a note from its context menu, with `Space l s`, or with \"Add Note to Favorites\" in the command palette; it shows in the sidebar and on Home in the same order.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nEach note remembers its own last mode. Preview opens at the line you were editing, and leaving Preview lands the cursor on whatever you were reading; double-click a passage in Preview to edit it right there. You can switch modes with `⌘4`, `⌘5`, and `⌘6`, from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n- `⌥1` to `⌥9` (`Ctrl+1` to `Ctrl+9` on macOS) jump to a tab by position, and `⇧⌘T` reopens the last one you closed\n- each note keeps its own undo history while the app is open, across tabs and after closing and reopening it; Settings → Editor can keep it after quitting too, like Vim's `undofile`\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\nMoving a note never needs a drag: run `:mv` (or \"Move\" in the note's context menu) and pick a folder, or type it, as in `:mv Projects/Alpha`. Cmd-click (Ctrl-click on Windows and Linux) and Shift-click select several sidebar items at once, and the context menu then acts on all of them.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Comments** (`⇧⌘C`) hold discussion threads anchored to selected text, beside the note rather than in it\n- **Calendar** (`Space c`) is a month view for daily and weekly notes: dots show how much you wrote each day, a mark shows unfinished tasks, and a click opens or creates that day's note\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThese panels stay as you set them while you move between notes. In a narrow pane they first shrink, then the ones you opened longest ago tuck into a slim rail at the right edge.\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n" }, { path: "inbox/demo/12 Settings and Keymaps.md", - body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid anymore. Settings now cover both presentation and behavior.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location\n- inspect the app version\n- see the About section\n- find the Lumary Labs link\n- remember that Settings save automatically on this device\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" + body: "# Settings and keymaps\n\nZenNotes is keyboard-first by default, but it is not rigid. Settings cover both presentation and behavior, and Settings opens on its own search: press `⌘,`, type a few letters, and press Enter to jump to the setting.\n\n## Appearance\n\nFrom Settings you can tune:\n\n- theme family\n- light or dark mode\n- theme variant or contrast\n- dark sidebar treatment\n- the window title bar, which you can turn off on any platform\n- **Keep running in the tray**, so closing the window keeps ZenNotes syncing in the background (see [[19 Cloud, CLI, and AI Agents]])\n- whether PDF exports use your theme or a clean print look\n\nTwo CSS layers go further. A **custom theme** is a whole palette: Settings → Appearance → Custom → New theme creates a folder under `~/.config/zennotes/themes/` with a `theme.css` you edit live. An **override** is a small CSS file that sits on top of any theme to change one or two things, like the accent colour; toggle each one under Settings → Appearance → Overrides.\n\nThe point is to keep the app comfortable for long sessions without changing the underlying note files.\n\n## Editor behavior\n\nKey editor settings include:\n\n- Vim mode on or off\n- leader key hints on or off\n- timed vs sticky leader hints\n- leader hint duration\n- live preview\n- note tabs\n- word wrap\n- PDF behavior in edit mode\n- date-titled Quick Notes\n- the default view mode a note opens in\n- tab size and the indent guides on nested lists\n- how completed tasks look: plain, dimmed, struck through, or both\n- the math renderer: KaTeX or Typst\n- **Text replacements**, which expand short triggers as you type (`->` becomes `→` out of the box)\n- **Grammar and spelling with Harper**, off by default, checked on this device so no text leaves the app\n- keeping each note's undo history after you quit\n\n## Vault text search backends\n\nVault text search can be powered by:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\nYou can also set explicit binary paths for `rg` and `fzf` in case they live outside your normal `PATH`.\n\nZenNotes now shows:\n\n- what tools are available\n- what backend is configured\n- what backend is actually being used at runtime\n\nThat matters because **Auto** can fall back, and explicit backends can also fall back when the configured binary path is missing.\n\n## Typography and layout\n\nYou can tune:\n\n- interface font\n- reading font\n- monospace font\n- editor and preview font size\n- line height\n- reading width\n- editor width\n- centered vs left-aligned content\n- line numbers\n\nThese are workflow settings, not note-format settings. The markdown file stays the same.\n\n## Keymaps\n\nKeymaps are now configurable from inside the app:\n\n- global shortcuts\n- leader sequences\n- pane-prefix motions\n- Vim-specific editor actions\n- list and view navigation\n\nThat means you can remap things like:\n\n- search notes\n- search vault text\n- toggle Zen mode\n- pane movement\n- fold motions\n- leader flows such as `Space s t`\n\nMulti-step sequences are supported, so the keymap system can handle more than single shortcuts. Recording a key that another action already uses names the clash instead of letting two actions share it, **Unbind** removes a key entirely (`:unbind action.id` does the same from the ex line), and **Ignored keys** swallows a key the app should never see, such as the extra key a Kanata or QMK layer sends.\n\n## One file for your preferences\n\nYour preferences (theme, editor, Vim, keymaps, fonts, search backend, and more) are mirrored to a plain-text `config.toml`, at `~/.config/zennotes/config.toml` on macOS and Linux and `%APPDATA%\\zennotes\\config.toml` on Windows. Every setting is listed with its allowed values and every keymap action with its default, so you can change anything without opening the app, and edits apply live. Keep it in git, stow, or chezmoi and the same setup follows you to every machine.\n\n## Vault and About\n\nThe rest of Settings handles the vault and app identity:\n\n- reveal or change the vault location, or give the vault a name of its own\n- point Inbox, Quick Notes, Archive, and Trash at folders of your choosing\n- inspect the app version, with a Version details block to copy into a bug report\n- reopen **What's New** for this version\n- find the website, Discord, and issue tracker links\n\n## Practical advice\n\nIf you are learning the app:\n\n1. keep Vim mode on\n2. enable leader hints\n3. leave search backend on **Auto**\n4. only start remapping after the defaults feel familiar\n\nThat gives you the clearest path through the built-in help, demos, and keyboard flows.\n\nFor a deeper walkthrough of runtime backend selection, fallbacks, and fuzzy content search behavior, see [[15 Search Backends and Fuzzy Workflows]].\n\n#demo #settings #keymaps #reference\n" }, { path: "inbox/demo/13 Commands, Help, and Demo Tour.md", - body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" + body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search Notes…`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Go to Trash`\n- `Open Atlas`\n- `New Drawing`\n- `New Database`\n- `Export Note as PDF…`\n- `What’s New in ZenNotes`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view has eight sections:\n\n- **Start Here**, a short path to getting productive\n- **How-To** recipes for the jobs people repeat most\n- **Concepts**, how the app, your files, and your workflows fit together\n- **Shortcuts**\n- **Vim & Ex**, every ex command and alias\n- **Commands**, the command palette\n- **CLI**, the `zn` command-line tool\n- **Settings**, what each setting does\n\nAfter an update, a What's New window shows what the version brought, once. Reopen it any time with `What’s New in ZenNotes` in the palette or `:whatsnew`.\n\n## Export\n\n`⇧⌘E` (`Ctrl+Shift+E` on Windows and Linux) exports the active note as a PDF. An embedded note (`![[Note]]`) is inlined, so a master note exports as one document, and a collapsed callout prints open. Settings → Appearance → PDF export chooses between a clean print look and your current theme.\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`, with a sample drawing and a sample database beside the notes\n- adds the bundled demo image at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes and their sample files\n- removes the bundled demo image\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n" }, { path: "inbox/demo/14 Reference Pane and Floating Windows.md", @@ -70,7 +74,23 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [ }, { path: "inbox/demo/15 Search Backends and Fuzzy Workflows.md", - body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" + body: "# Search backends and fuzzy workflows\n\nZenNotes has two different search surfaces, and the deeper one can be powered by different backends.\n\n## Two searches, two jobs\n\n### Note search\n\nUse when you want to find a note by title or path:\n\n- `⌘P`\n- `Space f`\n\nThis is the fastest way to jump to a file you already roughly know. When nothing matches, `Shift+Enter` opens a small New note form named after your search, with a folder and tags to pick, so an empty search is two keys away from becoming the note. `Ctrl+D` moves the highlighted note to Trash without closing the palette.\n\n### Vault text search\n\nUse when you want to find matching text inside note bodies:\n\n- `Space s t`\n\nThis searches across note content and jumps directly to the matching line when you open a result.\n\n### Inside one note\n\nIn Edit or Split, a find-and-replace bar searches the note itself, with match-case, whole-word, and regex toggles. Open it with `F3` (on macOS with Vim mode on, `⌘F` works too), and press `F3` again for the next match. With Vim mode on, `/` searches the note as well.\n\n## Backends\n\nVault text search can run on:\n\n- **Auto**\n- **Built-in**\n- **ripgrep**\n- **fzf**\n\n### Auto\n\n`Auto` prefers:\n\n1. `fzf`\n2. `ripgrep`\n3. built-in fallback\n\nThat makes the app adapt to what is installed on the machine.\n\n### Built-in\n\nUse this when you want:\n\n- zero external dependencies\n- predictable behavior across machines\n- a search path that always exists even when no tools are installed\n\n### ripgrep\n\nUse this when you want:\n\n- strong plain-text search performance\n- system-level tooling you may already use outside the app\n- a backend that is familiar to terminal users\n\n### fzf\n\nUse this when you want:\n\n- terminal-style fuzzy matching behavior\n- ranking that feels close to launcher workflows\n- an external backend often used by Vim and Neovim users\n\n## Custom binary paths\n\nIf `rg` or `fzf` are not in your normal `PATH`, ZenNotes lets you point to them directly from Settings.\n\nExamples:\n\n- `/opt/homebrew/bin/rg`\n- `/opt/homebrew/bin/fzf`\n- `/usr/local/bin/rg`\n\nBlank means “use whatever is on PATH”.\n\n## Runtime backend vs configured backend\n\nZenNotes shows:\n\n- what you configured\n- what tools are available\n- what backend is actually being used\n\nThat distinction matters because:\n\n- `Auto` may resolve differently on different machines\n- explicit `ripgrep` or `fzf` settings can still fall back if the binary path is invalid\n\n## Search result behavior\n\nVault text search is designed to be navigational, not just informational:\n\n- results stay keyboard navigable\n- the active row stays in view while you move\n- the matching text is highlighted in the result\n- opening a result moves the cursor to the match in the note\n\nThis makes it feel more like a picker than a grep dump.\n\n## Good habits\n\n- use note search when you know the file\n- use vault text search when you only know the phrase\n- leave the backend on **Auto** unless you have a reason to force one\n- configure explicit binary paths if your tools live outside `PATH`\n\n## Related notes\n\n- [[07 Wiki Links and Tags]] for search in the context of notes, tags, and links\n- [[11 Workspace, Search, and Views]] for where these pickers fit into the app\n- [[12 Settings and Keymaps]] for changing the backend and remapping the shortcut\n\n#demo #search #fzf #ripgrep #reference\n" + }, + { + path: "inbox/demo/16 Tasks Calendar and Kanban.md", + body: "# Tasks calendar and Kanban\n\nThe Tasks view has three shapes: the list from [[03 Tables and Task Lists]], a calendar, and a Kanban board. They all read the same `- [ ]` lines in your notes, so a task moved on the board or rescheduled on the calendar is a change to its markdown, nothing more.\n\nOpen Tasks from the sidebar or with `:tasks`, then switch shapes with the buttons in its header, or with `1`, `2`, and `3` in Vim mode.\n\n## Calendar\n\nThe calendar lays tasks out by due date.\n\n- A task written inside a daily note shows on that note's day, without a `due:`.\n- Type in the box under the grid to add a task to the selected day; it goes into that day's daily note.\n- Drag a task onto another day to reschedule it.\n- From the keyboard, `Tab` picks a task in the day list, `<` and `>` move it a day earlier or later, and `T` moves it to today.\n\n## Kanban\n\nThe board turns tasks into cards in columns. **Group by** decides the columns:\n\n| Group by | Columns |\n| --- | --- |\n| Status | Today, Upcoming, In progress, Waiting, Done |\n| Priority | High, Medium, Low, No priority |\n| Folder | One per note folder |\n| Custom status | The statuses you define, in your order |\n| Any `@field` | One per value of that field, found as you use them |\n\nEvery field you write as `@key:value` becomes a board of its own. The tasks in [[10 Ideas and Tasks]] carry `@project:cleanup`, so grouping by `project` shows them together.\n\nMoving a card writes the change back into the note: dropping a card on In progress marks it `[/]`, and moving it on a field board rewrites its `@field:value`.\n\n| Keys | Action |\n| --- | --- |\n| `h` / `l` | Previous / next column |\n| `j` / `k` | Previous / next card |\n| `g` | Cycle the Group by |\n| `Shift+H` / `Shift+L` | Send the card to the previous / next column |\n| `<` / `>` | Reorder the columns themselves |\n| `Space` / `Enter` | Toggle / open the card |\n\nDrag a card inside its column to put it in your own order, which the board remembers. Set **Order** to Due date (`s` in Vim mode, or `:order due`) and every column sorts by date instead; Manual brings your order back.\n\n### Custom statuses\n\nDefine the columns of the Custom status board under Settings → Tasks → Kanban statuses, or in `config.toml`:\n\n```toml\n[view]\nkanban_statuses = [\"backlog\", \"in_progress\", \"review\", \"done\"]\n```\n\nA task takes its status from an `@status:review` token, or from a `status:` line in its note's frontmatter.\n\n## Filters you keep\n\nThe filter box above all three shapes (`/` in Vim mode, or `:filter`) matches task text, note titles, `#tags`, `!high`, and `@key:value` fields, and it stays put while you switch shapes. Filter `@project:cleanup` with the board grouped by status and you have a one-project board.\n\nA filter worth typing twice is worth saving: press **Save filter…** or run `:savefilter name`. Saved filters become chips under the header, `F` picks one in Vim mode, and the command palette lists each as \"Tasks: name\". They live in `config.toml` under `[saved_filters]`.\n\n## A whole note as a task\n\nA note can be a task by itself. Tag it `task` and keep its details in frontmatter:\n\n```yaml\n---\ntags: [task]\nstatus: in-progress\npriority: high\ndue: 2026-05-01\n---\n```\n\nThe note body holds whatever detail you like, and the note shows up in every Tasks view beside the inline tasks. Create one with **New Task** in the command palette, **New Task in Folder…**, `a` in the Tasks view, or `:newtask Projects/Website`.\n\nChecking a task note off writes `status: done` into its frontmatter. On the Custom status board, a finished task note whose status is one of your columns stays in that column, so a `done` column holds the task notes you finished.\n\n## Not every checkbox is a task\n\nA reading list is a checklist, not a to-do. Put `tasks: false` in a note's frontmatter and its checkboxes stay checkboxes, out of the Tasks views. To leave out a whole folder, right-click it and choose **Exclude from Tasks**.\n\n## Try this now\n\n- Open Tasks, press `3`, and set Group by to `project`\n- Switch to the calendar with `2` and drag a task onto another day\n- Run **New Task** from the palette and watch the note appear on the board\n- Save a filter, then pick it again with `F`\n\n#demo #tasks #reference\n" + }, + { + path: "inbox/demo/17 Templates, Drawings, and Databases.md", + body: "# Templates, drawings, and databases\n\nThree kinds of structure that still end up as ordinary files in the vault: templates for notes you write again and again, Excalidraw drawings, and databases you can edit like a spreadsheet.\n\n## Templates\n\nA template turns a repeated note shape into one keystroke. Open the template picker with `Space t`, `:template`, or **New Note from Template…** in the command palette (`⌥⌘T` on macOS). Pick one, choose the folder, and the note opens with the date filled in and the cursor where the template wants it.\n\nZenNotes ships built-in templates for engineering (ADR, RFC, Bug Report, Postmortem, Meeting Notes, 1:1) and for personal use (Daily Note, Weekly Review, Reading Notes, Journal, Project Kickoff, To-do).\n\nYour own templates are plain markdown in `.zennotes/templates/`, made under Settings → Templates or with **Save Current Note as Template…**. A template can use these variables:\n\n| Variable | Becomes |\n| --- | --- |\n| `{{title}}` | The note's title |\n| `{{date}}` / `{{date:YYYY-MM-DD}}` | Today, in the format you give |\n| `{{time}}` / `{{week}}` | The time, the ISO week |\n| `{{cursor}}` | Where the cursor lands |\n| `{{modified_date}}` | Stays live: always the date the note was last saved |\n\nFor example:\n\n```md\n# {{title}}\n\nMeeting on {{date:EEEE, MMMM d}}\n\n## Notes\n{{cursor}}\n\n## Action items\n- [ ]\n```\n\n`Space i` inserts a template into the note you are in instead of creating a new one, and daily, weekly, and monthly notes can each start from a template of their own.\n\n## Excalidraw drawings\n\nA `.excalidraw` file is a note type of its own: it sits in the sidebar, opens in an Excalidraw editor tab, and saves back as JSON. This folder ships one, embedded here like an image:\n\n![[Tour Sketch.excalidraw|600]]\n\nClick the drawing to open it in its own tab and change it; the embed above redraws when you come back. To make your own, run **New Drawing**, or **Embed New Drawing** to create one at the cursor, or **Embed Existing Drawing…** to pick one. The embed syntax is the same as for images, `![[name.excalidraw]]`, with an optional width.\n\n## Databases\n\nA database is a table you edit like a spreadsheet, kept as plain files: a `<Name>.base` folder holding `data.csv` with the rows and `schema.json` with the field types and views. This folder ships one. Open it from the sidebar or follow this link: [[Reading List]].\n\n- The **Table** view edits cells in place; `h`/`j`/`k`/`l` move between cells and `i` or `Enter` edits one.\n- The **Board** view groups the books by their **Status**, so dragging a card from Queued to Reading changes its status.\n- Fields can be text, number, checkbox, date, select, multi-select, or a link to notes.\n- Press `o` on a row to open it as a full markdown page whose frontmatter mirrors the row.\n- The raw-CSV toggle shows the file underneath.\n\nCreate one with **New Database** in the command palette, or right-click a folder and choose **New database**.\n\n### A table that outgrew itself\n\nPut the cursor in this table and press `Space l d` (or run **Convert Table to Database…**). ZenNotes creates a database from it, every column typed from its contents, and leaves a link in its place. `u` puts the table back.\n\n| Snack | Price | In stock |\n| --- | --- | --- |\n| Almonds | 4.5 | yes |\n| Dark chocolate | 3 | no |\n| Rice crackers | 2.25 | yes |\n\n## What's next\n\nSee [[16 Tasks Calendar and Kanban]] for task boards, or [[18 Workflows]] to change many notes in one run.\n\n#demo #templates #databases\n" + }, + { + path: "inbox/demo/18 Workflows.md", + body: "# Workflows\n\nA workflow turns a vault ritual into a file you run with one key: find the notes tagged `#book`, keep the ones you rated 4 or higher, write them as a table into your reading log, and tag the rest `#someday`. It is opt-in, so nothing here runs until you ask it to.\n\n## Turn it on\n\nWorkflows are off by default. Turn them on under Settings → Workflows, and the **Workflows** view appears in the sidebar, as **Open Workflows** in the command palette, and as `Space a` in Vim mode. **Start tutorial** on the same settings page walks the whole loop hands-on, on a practice folder it creates and then removes.\n\n## What a workflow looks like\n\nA workflow is a plain markdown file in `.zennotes/workflows/`: frontmatter, then one pipeline per line. Each named line is a **wire** carrying a set of notes:\n\n```md\n---\nname: Reading log\ndescription: Keep the reading log in sync\nstatus: draft\ntrigger: manual\n---\n\nbooks = tag #book\ngood = books | where rating >= 4 | sort finished desc\nsomeday = books | where rating < 4\n\ngood | render table title, rating, finished | write-section \"inbox/Reading Log.md\" \"Finished\"\nsomeday | add-tag #someday\n```\n\nThe Workflows view shows the same file as a canvas, with the live count of notes flowing through every wire, and as text. Edit either one; the other follows.\n\n## The pieces\n\n| Kind | Examples |\n| --- | --- |\n| Sources | `all`, `folder inbox`, `tag #book`, `search reading list`, `current`, `selection` |\n| Filters | `where rating >= 4`, `tagged #x`, `in inbox/projects`, `contains TODO`, `since 7d`, `sort`, `limit 25` |\n| Changes | `set status done`, `add-tag`, `remove-tag`, `move`, `rename`, `append`, `apply-template`, `archive`, `trash` |\n| Outputs | `render table`, `write`, `write-section`, `create-each`, `notify`, `clipboard` |\n\nPress `?` in the Workflows view for the full reference, with an example for every step.\n\n## Nothing happens blind\n\n- Press `R` (or **Run**) and a dry run lists every change first, grouped and counted, before anything is written.\n- After you apply, **Undo** puts back every file the run wrote, byte for byte. A run the app never finished is offered back as an undo on the next launch.\n- A workflow has no code steps, no shell, and no network, so one somebody shared with you is safe to read and run.\n- New workflows start as inert drafts; `t` activates one. Importing a workflow is a review, never an install.\n\n## Runs on their own\n\nGive an active workflow a trigger and it runs when you make that change in this app: `trigger: on note-saved`, `on note-created`, `on note-moved`, or `on tag-added`. It sees only the note that changed, there is no confirmation, and the receipt toast carries **Undo**:\n\n```md\n---\nname: File topics\nstatus: active\ntrigger: on note-saved where folder = inbox\n---\n\nall | contains type: topic | move Topics\n```\n\nSettings → Workflows → Event triggers turns them all off on this device.\n\n## Get started\n\nPress `n` in the Workflows view for the recipe gallery: a reading log, inbox triage, a weekly review, open loops, tag cleanup, and more. Each recipe copies into your vault as an ordinary file you can read before you run it. Every active workflow is also a command palette entry, and **Run Workflow…** picks one from anywhere.\n\nSee [[17 Templates, Drawings, and Databases]] for the templates `apply-template` uses, or go back to [[00 Start Here]].\n\n#demo #workflows\n" + }, + { + path: "inbox/demo/19 Cloud, CLI, and AI Agents.md", + body: "# Cloud, CLI, and AI agents\n\nYour notes are files in a folder, so everything in this note is optional: ways to reach the same vault from other devices, from a terminal, and from an AI assistant.\n\n## ZenNotes Cloud\n\nZenNotes Cloud syncs a vault between your computers and the phone apps. Connect from Settings → Cloud, or from **Connect** in the status bar; the status bar then shows when the vault last synced.\n\n- When two devices change the same note at once, nothing is overwritten: sync pauses for that note, a banner says so, and `Space r` (or **Review** in the status bar) walks you through the choice.\n- **End-to-end encryption** (beta) keeps a Cloud vault's notes, attachments, and names unreadable to ZenNotes Cloud. Set it up in Settings → Cloud with a passphrase and a 24-word recovery key, which ZenNotes never receives.\n- **Publish Note** in the command palette puts a public copy of the note on the web. Its link stays the same when you publish an update.\n\n## Keep running in the tray\n\nTurn on Settings → Appearance → Window → **Keep running in the tray**, and closing the last window hides it instead of quitting. A ZenNotes icon stays in the system tray (the menu bar on macOS), Cloud sync keeps going, and the Quick capture hotkey keeps working. Click the icon to bring the window back; its menu also opens Quick capture, shows the sync state, and quits for real.\n\nOn a tiling desktop, where minimise has nowhere to go, `Space b` (or **Hide Window to Tray**) hides the window on purpose, and **Quit ZenNotes** in the palette quits from anywhere.\n\n## A server of your own\n\nPrefer a browser, or a vault on a machine you run? The self-hosted ZenNotes server serves the same app on the web, from a Docker image (`adibhanna/zennotes`) or a standalone binary, with your notes kept as ordinary `.md` files on that machine. The desktop app can open a vault on it too: Settings → Vault → Remote workspace takes the server's address and token.\n\n## The `zn` command-line tool\n\nInstall `zn` once from Settings → CLI, or with **Install Command-Line Tool (zn)** in the palette, and the vault is a terminal away. It reads and writes the same files, so what you do there shows up in the app at once.\n\n```sh\nzn capture \"Call the bank about the card\"\npbpaste | zn capture --tag idea\nzn search \"deadline\"\nzn task list\nzn task watch\nzn open -n ~/notes\nzn tui\n```\n\n`zn task watch` prints each task status change as it happens, for scripts that react to your board, and on macOS and Linux `zn tui` opens a full editor in the terminal. On macOS, the same Settings page installs a Raycast extension for searching and opening notes, and Quick Look previews a note in Finder the way ZenNotes draws it.\n\n## AI agents through MCP\n\nZenNotes includes an MCP server, so an assistant like Claude Code, Claude Desktop, or Codex can search, read, and edit your notes. Settings → MCP sets up each client in one click, and `zn mcp` starts the same server from a terminal.\n\n- The assistant works on the vault the app has open, and follows you when you switch vaults.\n- It can take part in a note's comments: read the threads, reply in them, and resolve them, signed with its name.\n- When it changes a note, ZenNotes keeps what the note said before. A banner names the assistant and **Review changes** shows each change inline to accept or reject; in Vim mode, `]c` and `[c` move between changes, `dp` keeps one, and `do` puts back what it replaced.\n\n## What's next\n\nThat is the end of the tour. Go back to [[00 Start Here]], or open **Help** for the full manual.\n\n#demo #cloud #cli\n" }, ] @@ -79,4 +99,16 @@ export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [ path: "zennotes-demo-card.svg", body: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"720\" viewBox=\"0 0 1200 720\" fill=\"none\">\n <defs>\n <linearGradient id=\"bg\" x1=\"96\" y1=\"72\" x2=\"1120\" y2=\"648\" gradientUnits=\"userSpaceOnUse\">\n <stop stop-color=\"#2B2A27\"/>\n <stop offset=\"1\" stop-color=\"#1B1A18\"/>\n </linearGradient>\n <linearGradient id=\"panel\" x1=\"120\" y1=\"120\" x2=\"1040\" y2=\"600\" gradientUnits=\"userSpaceOnUse\">\n <stop stop-color=\"#34322E\"/>\n <stop offset=\"1\" stop-color=\"#262421\"/>\n </linearGradient>\n </defs>\n <rect width=\"1200\" height=\"720\" rx=\"36\" fill=\"url(#bg)\"/>\n <rect x=\"72\" y=\"72\" width=\"1056\" height=\"576\" rx=\"28\" fill=\"url(#panel)\" stroke=\"#5E5449\" stroke-width=\"2\"/>\n <rect x=\"120\" y=\"118\" width=\"220\" height=\"48\" rx=\"24\" fill=\"#1F1F1D\" stroke=\"#5E5449\" stroke-width=\"2\"/>\n <text x=\"160\" y=\"149\" fill=\"#D8C093\" font-size=\"22\" font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\" letter-spacing=\"5\">DEMO</text>\n <text x=\"120\" y=\"248\" fill=\"#E5CC9C\" font-size=\"70\" font-weight=\"700\" font-family=\"Inter, ui-sans-serif, system-ui, sans-serif\">ZenNotes Demo</text>\n <text x=\"122\" y=\"300\" fill=\"#B7A385\" font-size=\"30\" font-family=\"Inter, ui-sans-serif, system-ui, sans-serif\">Local files, keyboard-first flows, and markdown-friendly structure.</text>\n <rect x=\"120\" y=\"360\" width=\"960\" height=\"188\" rx=\"24\" fill=\"#1F1F1D\" stroke=\"#5E5449\" stroke-width=\"2\"/>\n <rect x=\"160\" y=\"404\" width=\"300\" height=\"18\" rx=\"9\" fill=\"#E5CC9C\" fill-opacity=\"0.9\"/>\n <rect x=\"160\" y=\"440\" width=\"760\" height=\"14\" rx=\"7\" fill=\"#7E715F\"/>\n <rect x=\"160\" y=\"472\" width=\"700\" height=\"14\" rx=\"7\" fill=\"#7E715F\"/>\n <rect x=\"160\" y=\"504\" width=\"620\" height=\"14\" rx=\"7\" fill=\"#7E715F\"/>\n <rect x=\"920\" y=\"392\" width=\"120\" height=\"120\" rx=\"24\" fill=\"#F1954B\"/>\n <path d=\"M965 452h30M980 437v30\" stroke=\"#FFF5E3\" stroke-width=\"8\" stroke-linecap=\"round\"/>\n <text x=\"120\" y=\"606\" fill=\"#8F8474\" font-size=\"20\" font-family=\"ui-monospace, SFMono-Regular, Menlo, monospace\" letter-spacing=\"4\">SEE ALSO: HELP, SEARCH, OUTLINE, TASKS, QUICK NOTES</text>\n</svg>\n" }, + { + path: "inbox/demo/Tour Sketch.excalidraw", + body: "{\n \"type\": \"excalidraw\",\n \"version\": 2,\n \"source\": \"https://zennotes.org\",\n \"elements\": [\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1001,\n \"version\": 1,\n \"versionNonce\": 1002,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"title\",\n \"type\": \"text\",\n \"x\": 160,\n \"y\": 10,\n \"width\": 420,\n \"height\": 35,\n \"text\": \"A note's life in ZenNotes\",\n \"originalText\": \"A note's life in ZenNotes\",\n \"fontSize\": 28,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"#ffec99\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 3\n },\n \"seed\": 1003,\n \"version\": 1,\n \"versionNonce\": 1004,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"quick\",\n \"type\": \"rectangle\",\n \"x\": 20,\n \"y\": 90,\n \"width\": 180,\n \"height\": 80\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1005,\n \"version\": 1,\n \"versionNonce\": 1006,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"quick-t\",\n \"type\": \"text\",\n \"x\": 30,\n \"y\": 115,\n \"width\": 160,\n \"height\": 30,\n \"text\": \"Quick Notes\",\n \"originalText\": \"Quick Notes\",\n \"fontSize\": 24,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"#a5d8ff\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 3\n },\n \"seed\": 1007,\n \"version\": 1,\n \"versionNonce\": 1008,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"inbox\",\n \"type\": \"rectangle\",\n \"x\": 290,\n \"y\": 90,\n \"width\": 180,\n \"height\": 80\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1009,\n \"version\": 1,\n \"versionNonce\": 1010,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"inbox-t\",\n \"type\": \"text\",\n \"x\": 300,\n \"y\": 115,\n \"width\": 160,\n \"height\": 30,\n \"text\": \"Inbox\",\n \"originalText\": \"Inbox\",\n \"fontSize\": 24,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"#b2f2bb\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 3\n },\n \"seed\": 1011,\n \"version\": 1,\n \"versionNonce\": 1012,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"archive\",\n \"type\": \"rectangle\",\n \"x\": 560,\n \"y\": 90,\n \"width\": 180,\n \"height\": 80\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1013,\n \"version\": 1,\n \"versionNonce\": 1014,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"archive-t\",\n \"type\": \"text\",\n \"x\": 570,\n \"y\": 115,\n \"width\": 160,\n \"height\": 30,\n \"text\": \"Archive\",\n \"originalText\": \"Archive\",\n \"fontSize\": 24,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"#ffc9c9\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 3\n },\n \"seed\": 1015,\n \"version\": 1,\n \"versionNonce\": 1016,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"trash\",\n \"type\": \"rectangle\",\n \"x\": 290,\n \"y\": 250,\n \"width\": 180,\n \"height\": 70\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1017,\n \"version\": 1,\n \"versionNonce\": 1018,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"trash-t\",\n \"type\": \"text\",\n \"x\": 300,\n \"y\": 270,\n \"width\": 160,\n \"height\": 30,\n \"text\": \"Trash\",\n \"originalText\": \"Trash\",\n \"fontSize\": 24,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 2\n },\n \"seed\": 1019,\n \"version\": 1,\n \"versionNonce\": 1020,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a1\",\n \"type\": \"arrow\",\n \"x\": 205,\n \"y\": 130,\n \"width\": 80,\n \"height\": 0,\n \"points\": [\n [\n 0,\n 0\n ],\n [\n 80,\n 0\n ]\n ],\n \"lastCommittedPoint\": null,\n \"startBinding\": null,\n \"endBinding\": null,\n \"startArrowhead\": null,\n \"endArrowhead\": \"arrow\"\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1021,\n \"version\": 1,\n \"versionNonce\": 1022,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a1-t\",\n \"type\": \"text\",\n \"x\": 205,\n \"y\": 95,\n \"width\": 80,\n \"height\": 25,\n \"text\": \"refine\",\n \"originalText\": \"refine\",\n \"fontSize\": 18,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 2\n },\n \"seed\": 1023,\n \"version\": 1,\n \"versionNonce\": 1024,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a2\",\n \"type\": \"arrow\",\n \"x\": 475,\n \"y\": 130,\n \"width\": 80,\n \"height\": 0,\n \"points\": [\n [\n 0,\n 0\n ],\n [\n 80,\n 0\n ]\n ],\n \"lastCommittedPoint\": null,\n \"startBinding\": null,\n \"endBinding\": null,\n \"startArrowhead\": null,\n \"endArrowhead\": \"arrow\"\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1025,\n \"version\": 1,\n \"versionNonce\": 1026,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a2-t\",\n \"type\": \"text\",\n \"x\": 475,\n \"y\": 95,\n \"width\": 80,\n \"height\": 25,\n \"text\": \"retire\",\n \"originalText\": \"retire\",\n \"fontSize\": 18,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"dashed\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": {\n \"type\": 2\n },\n \"seed\": 1027,\n \"version\": 1,\n \"versionNonce\": 1028,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a3\",\n \"type\": \"arrow\",\n \"x\": 380,\n \"y\": 175,\n \"width\": 0,\n \"height\": 70,\n \"points\": [\n [\n 0,\n 0\n ],\n [\n 0,\n 70\n ]\n ],\n \"lastCommittedPoint\": null,\n \"startBinding\": null,\n \"endBinding\": null,\n \"startArrowhead\": null,\n \"endArrowhead\": \"arrow\"\n },\n {\n \"angle\": 0,\n \"strokeColor\": \"#1e1e1e\",\n \"backgroundColor\": \"transparent\",\n \"fillStyle\": \"solid\",\n \"strokeWidth\": 2,\n \"strokeStyle\": \"solid\",\n \"roughness\": 1,\n \"opacity\": 100,\n \"groupIds\": [],\n \"frameId\": null,\n \"roundness\": null,\n \"seed\": 1029,\n \"version\": 1,\n \"versionNonce\": 1030,\n \"isDeleted\": false,\n \"boundElements\": null,\n \"updated\": 1,\n \"link\": null,\n \"locked\": false,\n \"id\": \"a3-t\",\n \"type\": \"text\",\n \"x\": 390,\n \"y\": 195,\n \"width\": 150,\n \"height\": 25,\n \"text\": \"recoverable\",\n \"originalText\": \"recoverable\",\n \"fontSize\": 18,\n \"fontFamily\": 1,\n \"textAlign\": \"center\",\n \"verticalAlign\": \"middle\",\n \"containerId\": null,\n \"autoResize\": true,\n \"lineHeight\": 1.25\n }\n ],\n \"appState\": {\n \"gridSize\": null,\n \"viewBackgroundColor\": \"#ffffff\"\n },\n \"files\": {}\n}\n" + }, + { + path: "inbox/demo/Reading List.base/data.csv", + body: "id,Title,Author,Status,Rating,Finished on\ntour-0007,Seeing Like a State,James C. Scott,Reading,,\ntour-0008,The Pragmatic Programmer,Andrew Hunt and David Thomas,Finished,5,2026-03-02\ntour-0009,How to Take Smart Notes,Sönke Ahrens,Finished,4,2026-01-18\ntour-0010,A Philosophy of Software Design,John Ousterhout,Queued,,\ntour-0011,Working in Public,Nadia Eghbal,Queued,,\n" + }, + { + path: "inbox/demo/Reading List.base/schema.json", + body: "{\n \"version\": 1,\n \"idFieldId\": \"tour-0006\",\n \"fields\": [\n {\n \"id\": \"tour-0006\",\n \"name\": \"id\",\n \"type\": \"text\",\n \"hidden\": true\n },\n {\n \"id\": \"tour-0001\",\n \"name\": \"Title\",\n \"type\": \"text\"\n },\n {\n \"id\": \"tour-0002\",\n \"name\": \"Author\",\n \"type\": \"text\"\n },\n {\n \"id\": \"tour-0003\",\n \"name\": \"Status\",\n \"type\": \"select\",\n \"options\": [\n {\n \"id\": \"tour-0013\",\n \"value\": \"Queued\"\n },\n {\n \"id\": \"tour-0014\",\n \"value\": \"Reading\"\n },\n {\n \"id\": \"tour-0015\",\n \"value\": \"Finished\"\n }\n ]\n },\n {\n \"id\": \"tour-0004\",\n \"name\": \"Rating\",\n \"type\": \"number\"\n },\n {\n \"id\": \"tour-0005\",\n \"name\": \"Finished on\",\n \"type\": \"date\"\n }\n ],\n \"views\": [\n {\n \"id\": \"tour-0012\",\n \"name\": \"Table\",\n \"type\": \"table\",\n \"filters\": [],\n \"sorts\": [],\n \"columnOrder\": [\n \"tour-0006\",\n \"tour-0001\",\n \"tour-0002\",\n \"tour-0003\",\n \"tour-0004\",\n \"tour-0005\"\n ],\n \"hiddenFieldIds\": [\n \"tour-0006\"\n ]\n },\n {\n \"id\": \"tour-0016\",\n \"name\": \"Board\",\n \"type\": \"board\",\n \"filters\": [],\n \"sorts\": [],\n \"groupByFieldId\": \"tour-0003\",\n \"boardColumnOrder\": [\n \"Queued\",\n \"Reading\",\n \"Finished\"\n ],\n \"cardFieldIds\": [\n \"tour-0002\",\n \"tour-0004\"\n ]\n }\n ],\n \"activeViewId\": \"tour-0012\"\n}\n" + }, ] \ No newline at end of file diff --git a/packages/shared-domain/src/demo-tour.ts b/packages/shared-domain/src/demo-tour.ts index da7ec394..8b4c59e1 100644 --- a/packages/shared-domain/src/demo-tour.ts +++ b/packages/shared-domain/src/demo-tour.ts @@ -19,3 +19,21 @@ export function currentDemoTourPath(path: string): string { export function isDemoTourStartPath(path: string): boolean { return currentDemoTourPath(path) === DEMO_TOUR_START_PATH } + +/** + * Folders inside the tour folder that the given tour files sit in, deepest + * first. The sample database ships as a `Reading List.base` folder of its own, + * and removing the tour has to take that folder down before the tour folder + * itself can go; an empty one left behind would also keep the tour folder. + */ +export function demoTourNestedDirs(paths: readonly string[]): string[] { + const dirs = new Set<string>() + for (const path of paths) { + let dir = path.slice(0, Math.max(0, path.lastIndexOf('/'))) + while (dir.startsWith(`${DEMO_TOUR_DIR}/`)) { + dirs.add(dir) + dir = dir.slice(0, dir.lastIndexOf('/')) + } + } + return [...dirs].sort((a, b) => b.split('/').length - a.split('/').length || a.localeCompare(b)) +} diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 12fa0e7d..f28257b6 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -99,6 +99,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderToggleFavorite", group: "vim", defaultBinding: "s", title: "Leader note action: toggle favorite" }, { id: "vim.leaderTableToDatabase", group: "vim", defaultBinding: "d", title: "Leader note action: convert table to database" }, { id: "vim.leaderQuickCapture", group: "vim", defaultBinding: "q", title: "Leader: open quick capture" }, + { id: "vim.leaderHideToTray", group: "vim", defaultBinding: "b", title: "Leader: hide window to tray" }, { id: "vim.leaderTemplatePicker", group: "vim", defaultBinding: "t", title: "Leader: new from template" }, { id: "vim.leaderInsertTemplate", group: "vim", defaultBinding: "i", title: "Leader: insert template into note" }, { id: "vim.leaderDailyNote", group: "vim", defaultBinding: "d", title: "Leader: today's daily note" }, diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 09d3ed6d..7fbc6e2d 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.70.0", + "version": "2.71.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/tooling/scripts/perf-desktop-runtime.mjs b/tooling/scripts/perf-desktop-runtime.mjs index cd28aa14..47f0baac 100644 --- a/tooling/scripts/perf-desktop-runtime.mjs +++ b/tooling/scripts/perf-desktop-runtime.mjs @@ -90,7 +90,12 @@ const deferredNormalFlowChunkPatterns = [ /^vendor-markdown-/, /^vendor-highlight-/, /^vendor-d3-/, - // mermaid.core is the entry of the mermaid stack: if any of it loads, this does. + // Pele draws mermaid fences; its runtime chunk loads before any diagram type. + /^pele-runtime-/, + // D2 draws ```d2 fences; its runtime chunk carries the WebAssembly compiler. + /^d2-runtime-/, + // mermaid.core is the entry of the mermaid.js stack (now only Excalidraw's + // Mermaid dialog): if any of it loads, this does. /^mermaid\.core-/, /^vendor-jsxgraph-/, /^vendor-function-plot-/ diff --git a/tooling/scripts/perf-web-runtime.mjs b/tooling/scripts/perf-web-runtime.mjs index 8fcce2e6..a964c0c9 100644 --- a/tooling/scripts/perf-web-runtime.mjs +++ b/tooling/scripts/perf-web-runtime.mjs @@ -68,7 +68,12 @@ const deferredNormalFlowChunkPatterns = [ /^vendor-markdown-/, /^vendor-highlight-/, /^vendor-d3-/, - // mermaid.core is the entry of the mermaid stack: if any of it loads, this does. + // Pele draws mermaid fences; its runtime chunk loads before any diagram type. + /^pele-runtime-/, + // D2 draws ```d2 fences; its runtime chunk carries the WebAssembly compiler. + /^d2-runtime-/, + // mermaid.core is the entry of the mermaid.js stack (now only Excalidraw's + // Mermaid dialog): if any of it loads, this does. /^mermaid\.core-/, /^vendor-jsxgraph-/, /^vendor-function-plot-/ diff --git a/tooling/scripts/test-app-core-browser.mjs b/tooling/scripts/test-app-core-browser.mjs index 72681021..4db02bc1 100644 --- a/tooling/scripts/test-app-core-browser.mjs +++ b/tooling/scripts/test-app-core-browser.mjs @@ -188,7 +188,7 @@ try { await until(() => client.evaluate(`document.querySelector('.cm-content')?.textContent.includes('Read and edit')`), 'editor', 60000) assert.equal(await client.evaluate(`document.querySelector('[data-consumer-selection]').textContent`), path) assert.equal(await client.evaluate(`getComputedStyle(document.querySelector('#root > *')).display`), 'flex', 'Compiled Tailwind styles did not load') - const beforeLazy = requests.filter((url) => /mermaid\.core|typst.*\.wasm|harper.*\.wasm/.test(url)) + const beforeLazy = requests.filter((url) => /pele-runtime|d2-runtime|mermaid\.core|typst.*\.wasm|harper.*\.wasm/.test(url)) assert.deepEqual(beforeLazy, [], 'Heavy features loaded before requested') await client.evaluate(`document.querySelector('.cm-content').focus()`) const modifier = process.platform === 'darwin' ? 4 : 2 @@ -212,6 +212,9 @@ try { await until(() => client.evaluate(`(() => { const button = [...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Preview'); if (!button) return false; button.click(); return true })()`), 'Preview control') await until(() => client.evaluate(`!!document.querySelector('[aria-label="Note preview"]')`), 'Preview mode') await until(() => client.evaluate(`document.querySelector('.prose-zen .mermaid svg') && document.querySelector('.prose-zen .zen-typst-math svg')`), 'lazy Mermaid and Typst render', 60000) + // The deferral check above only means something while the diagram renderer + // really ships as a chunk by this name. + assert.ok(requests.some((url) => /pele-runtime-/.test(url)), 'Mermaid did not render through the pele-runtime chunk') const screenshot = await client.send('Page.captureScreenshot', { format: 'png' }) await writeFile(join(run, 'lazy-features.png'), Buffer.from(screenshot.data, 'base64')) await client.evaluate('window.packageNavigation.goHome()')