From 04a97ea27f0d065514330b5b2b9546ee325dfd8b Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:06:48 -0700 Subject: [PATCH 01/10] spec: add palette long-title fix proposal --- .../fix-palette-long-title/proposal.md | 19 +++++++++++++++++++ .../changes/fix-palette-long-title/tasks.md | 6 ++++++ 2 files changed, 25 insertions(+) create mode 100644 openspec/changes/fix-palette-long-title/proposal.md create mode 100644 openspec/changes/fix-palette-long-title/tasks.md diff --git a/openspec/changes/fix-palette-long-title/proposal.md b/openspec/changes/fix-palette-long-title/proposal.md new file mode 100644 index 0000000..db7264d --- /dev/null +++ b/openspec/changes/fix-palette-long-title/proposal.md @@ -0,0 +1,19 @@ +# Change: Keep the action label readable for long note titles in the palette + +## Why +Note-scoped commands in the command palette render the note title as a breadcrumb +prefix before the action label, for example "My very long note title > Pin note". +The title and the action shared a single ellipsized line, so a long title consumed +the whole row and pushed the action label out of view. The user could no longer tell +which action a row would run. + +## What Changes +- Render the palette title region as a flex row so the action label keeps its width. +- Give the note-title and folder prefix its own max-width and ellipsis, so a long + title truncates on its own while the separator and action stay fully visible. +- Keep the breadcrumb separator, the leading icon, and the action label from shrinking. + +## Impact +A presentational change confined to `src/lib/components/CommandPalette.svelte` (one +markup wrap plus CSS). No command logic, store, or Rust changes. The folder breadcrumb +shown on searched leaves benefits from the same prefix cap. diff --git a/openspec/changes/fix-palette-long-title/tasks.md b/openspec/changes/fix-palette-long-title/tasks.md new file mode 100644 index 0000000..196f2af --- /dev/null +++ b/openspec/changes/fix-palette-long-title/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Wrap the action title in its own element so it can stay non-shrinking +- [x] Make the title region a flex row and cap the prefix with its own ellipsis +- [x] Keep icon, separator, and action label from shrinking +- [x] Confirm svelte-check and Vitest stay green From b6ce6eb5a7e9e3e93c4e4eb465bca0b47fbafa6d Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:06:48 -0700 Subject: [PATCH 02/10] fix(palette): keep the action label readable for long note titles Note-scoped commands show the note title as a breadcrumb prefix before the action. A long title used to consume the row and hide the action. The title region is now a flex row, the prefix takes its own max-width and ellipsis, and the separator, icon, and action label no longer shrink. --- src/lib/components/CommandPalette.svelte | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index 090545f..82d797c 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -151,7 +151,7 @@ onmousemove={() => (active = i)} > - {#if cmd.icon}{/if}{#if cmd.prefix}{cmd.prefix}{/if}{#if cmd.parent && cmd.parent !== currentParent}{findCommand(commands, cmd.parent)?.title}{/if}{cmd.title} + {#if cmd.icon}{/if}{#if cmd.prefix}{cmd.prefix}{/if}{#if cmd.parent && cmd.parent !== currentParent}{findCommand(commands, cmd.parent)?.title}{/if}{cmd.title} {#if cmd.isActive?.()} @@ -255,18 +255,34 @@ .cmd-icon { margin-right: 8px; color: var(--accent-text); + flex-shrink: 0; } + /* A flex row so the action label keeps its width while a long note-title + prefix takes its own ellipsis instead of clipping the whole line. */ .cmd-title { + display: flex; + align-items: baseline; + min-width: 0; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } .cmd-parent { color: var(--text-tertiary); + max-width: 16ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 1; } .cmd-crumb { margin: 0 6px; color: var(--text-tertiary); + flex-shrink: 0; + } + .cmd-action { + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .cmd-meta { display: flex; From 114663e50f343ce11ecd7c192c567c29e7134edb Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:11:43 -0700 Subject: [PATCH 03/10] spec: add native titlebar theming proposal --- .../feat-titlebar-theme-sync/proposal.md | 20 +++++++++++++++++++ .../changes/feat-titlebar-theme-sync/tasks.md | 6 ++++++ 2 files changed, 26 insertions(+) create mode 100644 openspec/changes/feat-titlebar-theme-sync/proposal.md create mode 100644 openspec/changes/feat-titlebar-theme-sync/tasks.md diff --git a/openspec/changes/feat-titlebar-theme-sync/proposal.md b/openspec/changes/feat-titlebar-theme-sync/proposal.md new file mode 100644 index 0000000..ff17fea --- /dev/null +++ b/openspec/changes/feat-titlebar-theme-sync/proposal.md @@ -0,0 +1,20 @@ +# Change: Native titlebar follows the in-app dark/light theme + +## Why +The library window keeps the native macOS titlebar, but the theme store never tells +macOS when the resolved light/dark variant changes. The titlebar and traffic lights +stay locked to whatever the system appearance was at launch, so switching to a dark +theme under a light system (or the reverse) leaves a mismatched bright titlebar above +a dark app. + +## What Changes +- Add a thin `set_window_theme` command that sets the library window's native theme + (Light or Dark) through Tauri's window API. +- Expose it as `setWindowTheme` in the IPC client. +- Sync it from the theme store whenever the resolved variant changes, alongside the + existing vibrancy sync, so every theme or mode change repaints the chrome. + +## Impact +Three small touch points: one Tauri command, one IPC wrapper, and one private sync +method on the theme store. The core crate is untouched. A no-op off macOS and in browser +dev. The borderless capture window has no native chrome and is left alone. diff --git a/openspec/changes/feat-titlebar-theme-sync/tasks.md b/openspec/changes/feat-titlebar-theme-sync/tasks.md new file mode 100644 index 0000000..d795922 --- /dev/null +++ b/openspec/changes/feat-titlebar-theme-sync/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add the set_window_theme Tauri command and register it in the handler +- [x] Add the setWindowTheme IPC client wrapper +- [x] Sync the window theme from the theme store on every resolved-variant change +- [ ] Confirm the native titlebar flips live when toggling light/dark in the running app From af9708ba55a24dde841f7211b24a0ac5f7a26141 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:11:44 -0700 Subject: [PATCH 04/10] feat(window): sync the native titlebar to the in-app theme The native macOS titlebar stayed on the launch-time system appearance even after switching to a dark or light theme in the app. A thin set_window_theme command sets the library window's native theme, and the theme store calls it whenever the resolved variant changes, next to the existing vibrancy sync. --- src-tauri/src/lib.rs | 19 +++++++++++++++++++ src/lib/api/client.ts | 4 ++++ src/lib/stores/theme.svelte.ts | 16 +++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f715b5c..bfd1be4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -335,6 +335,24 @@ fn set_window_vibrancy(app: AppHandle, material: Option) { } } +/// Match the native library window's theme (titlebar and traffic-light treatment) +/// to the in-app light/dark variant. Tauri maps this to the window's OS appearance, +/// so the chrome follows the active theme instead of the launch-time system setting. +/// An unknown variant is a no-op; the borderless capture window has no native chrome +/// and is left alone. +#[tauri::command] +fn set_window_theme(app: AppHandle, variant: String) { + use tauri::Theme; + let theme = match variant.as_str() { + "light" => Theme::Light, + "dark" => Theme::Dark, + _ => return, + }; + if let Some(win) = app.get_webview_window("library") { + let _ = win.set_theme(Some(theme)); + } +} + // ---- theme file sharing ---- // Thin byte I/O for portable `.intheme.json` theme files. The open/save dialog // runs in JS via the dialog plugin; Rust only reads/writes the chosen path, so @@ -796,6 +814,7 @@ pub fn run() { hide_capture, open_library, set_window_vibrancy, + set_window_theme, export_theme_file, import_theme_file, export_note_file, diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 444e03c..8abc1cd 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -97,6 +97,10 @@ export const openUrl = (url: string) => call("open_url", { url }); // window. A no-op off macOS; the material string is one of theme MATERIAL_KEYS. export const setWindowVibrancy = (material: string | null) => call("set_window_vibrancy", { material }); +// Match the native library window theme (titlebar / traffic-light treatment) to +// the in-app variant. A no-op off macOS; the capture window is left alone. +export const setWindowTheme = (variant: "light" | "dark") => + call("set_window_theme", { variant }); // ---- theme files ---- // Byte I/O for portable .intheme.json files; the open/save dialog runs in JS. diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index a7cfd08..0885350 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -3,7 +3,7 @@ // writes tokens to :root via apply. Persists to the existing settings KV so // the choice survives restarts and is shared with the capture window. -import { getSetting, setSetting, setWindowVibrancy } from "$lib/api/client"; +import { getSetting, setSetting, setWindowVibrancy, setWindowTheme } from "$lib/api/client"; import { applyTheme } from "$lib/themes/apply"; import { BUILTIN_THEMES, DEFAULT_THEME_ID } from "$lib/themes/builtin"; import { validateTheme } from "$lib/themes/validate"; @@ -90,6 +90,7 @@ class ThemeStore { if (font) document.documentElement.style.setProperty("--font-body", font.value); } void this.#syncVibrancy(); + void this.#syncWindowTheme(); } /** Ask the OS to render (or clear) the active theme's window material, and @@ -110,6 +111,19 @@ class ThemeStore { else delete document.documentElement.dataset.vibrancy; } + /** Match the native window chrome (titlebar, traffic lights) to the resolved + * light/dark variant, so the macOS decorations follow the in-app theme rather + * than the launch-time system appearance. Library window only; a no-op in + * browser dev or off macOS, where the native call is unavailable. */ + async #syncWindowTheme(): Promise { + if (location.pathname.startsWith("/capture")) return; + try { + await setWindowTheme(this.resolvedVariant); + } catch { + // Best-effort; nothing to clean up if the native call is unavailable. + } + } + setTheme(id: string): void { if (!this.allThemes.some((t) => t.id === id)) return; this.activeId = id; From 792cdef0c9c1135d78c8f834830bb739b72d3914 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:15:11 -0700 Subject: [PATCH 05/10] spec: add list Tab indentation proposal --- .../changes/feat-list-tab-indent/proposal.md | 19 +++++++++++++++++++ .../changes/feat-list-tab-indent/tasks.md | 6 ++++++ 2 files changed, 25 insertions(+) create mode 100644 openspec/changes/feat-list-tab-indent/proposal.md create mode 100644 openspec/changes/feat-list-tab-indent/tasks.md diff --git a/openspec/changes/feat-list-tab-indent/proposal.md b/openspec/changes/feat-list-tab-indent/proposal.md new file mode 100644 index 0000000..a519411 --- /dev/null +++ b/openspec/changes/feat-list-tab-indent/proposal.md @@ -0,0 +1,19 @@ +# Change: Tab and Shift+Tab nest list items in the editor + +## Why +The editor had no Tab handling for lists, so there was no way to create a sublist or +un-nest one with the keyboard. Nesting is a basic outlining need: pressing Tab on a list +item should indent it into a sublist, and Shift+Tab should outdent it back. + +## What Changes +- Add a pure `listIndentChanges` helper that computes the indent/outdent change set for + every list line a selection touches. +- Bind Tab and Shift+Tab in the editor (before the default keymap) to apply it: Tab nests + by two spaces, Shift+Tab un-nests by up to two. Bullet (-, *, +) and ordered (1.) + markers are recognized. +- Leave non-list lines to the editor's existing default Tab behavior. + +## Impact +One new pure module with unit tests and one keymap in the editor. Ordered lists are not +renumbered (markdown renderers handle nesting). Always on, no new setting. CodeMirror +remaps the selection through the line-anchored changes so the caret stays with its text. diff --git a/openspec/changes/feat-list-tab-indent/tasks.md b/openspec/changes/feat-list-tab-indent/tasks.md new file mode 100644 index 0000000..0d624f8 --- /dev/null +++ b/openspec/changes/feat-list-tab-indent/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] Add a pure listIndentChanges helper (bullet + ordered, multi-line selection) +- [x] Unit-test indent, outdent, non-list, mixed, and multi-line cases +- [x] Bind Tab / Shift+Tab in the editor before the default keymap +- [ ] Confirm nesting and un-nesting work in the running editor From f091c8dcd44147ccbb9cf0974f1f66b3b92106f2 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:15:11 -0700 Subject: [PATCH 06/10] feat(editor): nest list items with Tab and Shift+Tab Pressing Tab on a bullet or ordered list line now indents it into a sublist, and Shift+Tab outdents it. A pure listIndentChanges helper computes the line-anchored change set (unit-tested) and the editor binds Tab / Shift+Tab to it before the default keymap. Non-list lines keep the default behaviour. --- src/lib/components/Editor.svelte | 19 ++++++++++++ src/lib/list-indent.test.ts | 49 +++++++++++++++++++++++++++++++ src/lib/list-indent.ts | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/lib/list-indent.test.ts create mode 100644 src/lib/list-indent.ts diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 8930d81..f86f959 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -20,6 +20,7 @@ import { formatEdit, type FormatKind, type Sel } from "$lib/markdown-format"; import { activeMarks, type ActiveMarks } from "$lib/markdown-active"; import { wysiwygExtension, wysiwygTheme, setPreviewMode } from "$lib/wysiwyg"; + import { listIndentChanges } from "$lib/list-indent"; let { value = "", @@ -85,6 +86,12 @@ { key: "Mod-e", run: () => { applyFormat("code"); return true; } }, { key: "Mod-Shift-k", run: () => { applyFormat("link"); return true; } }, ]), + // Tab nests a list item (and Shift-Tab un-nests it). On a non-list line + // both return false so the default Tab handling is untouched. + keymap.of([ + { key: "Tab", run: (v) => applyListIndent(v, false) }, + { key: "Shift-Tab", run: (v) => applyListIndent(v, true) }, + ]), keymap.of([...defaultKeymap, ...historyKeymap]), // GFM base so ~~strikethrough~~ parses (the highlight + active-state // detection both rely on Strikethrough nodes existing). @@ -151,6 +158,18 @@ }); view.focus(); } + + // Nest (outdent = false) or un-nest (outdent = true) the list lines the + // selection touches. Returns false when nothing is an indentable list line so + // CodeMirror falls back to its default Tab handling. CM remaps the selection + // through the line-anchored changes, so the caret stays with its text. + function applyListIndent(v: EditorView, outdent: boolean): boolean { + const { from, to } = v.state.selection.main; + const changes = listIndentChanges(v.state.doc.toString(), from, to, outdent); + if (changes.length === 0) return false; + v.dispatch({ changes, scrollIntoView: true }); + return true; + }
diff --git a/src/lib/list-indent.test.ts b/src/lib/list-indent.test.ts new file mode 100644 index 0000000..9678235 --- /dev/null +++ b/src/lib/list-indent.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { listIndentChanges } from "./list-indent"; + +describe("listIndentChanges", () => { + it("indents a single bullet line", () => { + expect(listIndentChanges("- item", 2, 2, false)).toEqual([{ from: 0, insert: " " }]); + }); + + it("indents ordered list items", () => { + expect(listIndentChanges("1. first", 0, 0, false)).toEqual([{ from: 0, insert: " " }]); + }); + + it("returns no changes for a non-list line", () => { + expect(listIndentChanges("just a paragraph", 3, 3, false)).toEqual([]); + }); + + it("indents a line that is already nested, deeper", () => { + expect(listIndentChanges(" - nested", 5, 5, false)).toEqual([{ from: 0, insert: " " }]); + }); + + it("outdents an indented bullet by two spaces", () => { + expect(listIndentChanges(" - item", 4, 4, true)).toEqual([{ from: 0, to: 2 }]); + }); + + it("does not outdent a top-level bullet", () => { + expect(listIndentChanges("- item", 2, 2, true)).toEqual([]); + }); + + it("removes only the available leading space when outdenting one space", () => { + expect(listIndentChanges(" - item", 3, 3, true)).toEqual([{ from: 0, to: 1 }]); + }); + + it("indents every list line a multi-line selection touches", () => { + const doc = "- a\n- b\n- c"; + expect(listIndentChanges(doc, 0, doc.length, false)).toEqual([ + { from: 0, insert: " " }, + { from: 4, insert: " " }, + { from: 8, insert: " " }, + ]); + }); + + it("skips non-list lines inside a mixed selection", () => { + const doc = "- a\nplain\n- c"; + expect(listIndentChanges(doc, 0, doc.length, false)).toEqual([ + { from: 0, insert: " " }, + { from: 10, insert: " " }, + ]); + }); +}); diff --git a/src/lib/list-indent.ts b/src/lib/list-indent.ts new file mode 100644 index 0000000..b7f118b --- /dev/null +++ b/src/lib/list-indent.ts @@ -0,0 +1,50 @@ +// Pure list-indentation helper for the editor's Tab / Shift+Tab keys. Computes +// the change set that nests (Tab) or un-nests (Shift+Tab) every list line a +// selection touches, anchored at line starts so CodeMirror remaps the selection +// for us. Kept free of CodeMirror types so it is testable against a plain string. + +/** One CodeMirror-style change: insert at `from`, or delete the range [from, to). */ +export interface IndentChange { + from: number; + to?: number; + insert?: string; +} + +/** Two spaces per indent level, matching the editor's existing list markers. */ +const INDENT = " "; + +/** A bullet (-, *, +) or ordered (1.) marker with its leading whitespace. */ +const LIST_RE = /^(\s*)([-*+]|\d+\.)\s+/; + +/** + * Indent (`outdent` false) or outdent (`outdent` true) every list line the range + * [from, to] touches, by one level. Non-list lines in the range are left alone. + * Returns an empty array when no touched line is an indentable list item, so the + * caller can fall back to the default Tab behaviour. + */ +export function listIndentChanges( + doc: string, + from: number, + to: number, + outdent: boolean, +): IndentChange[] { + const blockStart = doc.lastIndexOf("\n", from - 1) + 1; + const nextNl = doc.indexOf("\n", to); + const blockEnd = nextNl === -1 ? doc.length : nextNl; + + const changes: IndentChange[] = []; + let lineStart = blockStart; + for (const line of doc.slice(blockStart, blockEnd).split("\n")) { + const m = LIST_RE.exec(line); + if (m) { + if (!outdent) { + changes.push({ from: lineStart, insert: INDENT }); + } else { + const remove = Math.min(INDENT.length, m[1].length); + if (remove > 0) changes.push({ from: lineStart, to: lineStart + remove }); + } + } + lineStart += line.length + 1; // + 1 for the newline that split() dropped + } + return changes; +} From e0c9ec9bf925de1b8ac89b22bec597ab6791bbe9 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:20:19 -0700 Subject: [PATCH 07/10] spec: add settings wiki and Contexting copy proposal --- .../feat-settings-contexting/proposal.md | 24 +++++++++++++++++++ .../changes/feat-settings-contexting/tasks.md | 8 +++++++ 2 files changed, 32 insertions(+) create mode 100644 openspec/changes/feat-settings-contexting/proposal.md create mode 100644 openspec/changes/feat-settings-contexting/tasks.md diff --git a/openspec/changes/feat-settings-contexting/proposal.md b/openspec/changes/feat-settings-contexting/proposal.md new file mode 100644 index 0000000..6a7ba9e --- /dev/null +++ b/openspec/changes/feat-settings-contexting/proposal.md @@ -0,0 +1,24 @@ +# Change: Settings wiki navigation and the Contexting copy format + +## Why +The settings view shipped as a sidebar with a single About tab and an empty placeholder. +Two needs push it forward: settings should grow without becoming a long scroll, and the +app needs a foundation for AI features. The answer is a wiki-style settings shell, a +landing grid of categories that open focused sub-pages, plus a first new category, +Contexting, that controls what copying a note hands to other tools. + +## What Changes +- Replace the settings sidebar with a landing grid of category cards; each card opens a + focused sub-page with a breadcrumb back to the grid. Escape steps back to the grid + before closing the view. +- Keep the existing About page; drop the empty Default New Tab placeholder. +- Add a Contexting category: a user-editable copy template with {title}, {tags}, {date}, + and {content} placeholders, a live preview, and the list of available variables. +- Add a "Copy note as context" command to the palette that renders the template for the + selected note and writes it to the clipboard. +- Persist the template to the settings KV, and render it from a pure, tested module. + +## Impact +A new pure module (contexting-format) with tests, a small rune store, one palette command, +and a rewritten SettingsView. Clipboard uses the WebView's navigator.clipboard, so no new +native dependency or capability is added. The core crate is untouched. diff --git a/openspec/changes/feat-settings-contexting/tasks.md b/openspec/changes/feat-settings-contexting/tasks.md new file mode 100644 index 0000000..0842fd7 --- /dev/null +++ b/openspec/changes/feat-settings-contexting/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Add a pure renderTemplate module with {title}/{tags}/{date}/{content} and tests +- [x] Add a contexting rune store persisting the template to the settings KV +- [x] Add the "Copy note as context" palette command (clipboard write) +- [x] Rewrite SettingsView as a wiki grid with About and Contexting sub-pages +- [x] Init the contexting store on app start +- [ ] Confirm in the running app: edit the template, the preview updates, and the copy fills variables From ac2ea71b79014ac4dcdb8bb61c2e3dfd50948f95 Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:20:19 -0700 Subject: [PATCH 08/10] feat(settings): add wiki navigation and the Contexting copy format Replace the settings sidebar with a landing grid of category cards that open focused sub-pages with a breadcrumb back. Add a Contexting category whose editable template wraps a note with {title}, {tags}, {date}, and {content}, with a live preview. A new 'Copy note as context' palette command renders the template for the selected note and writes it to the clipboard, seeding the app's AI features. Rendering lives in a pure, tested module. --- src/lib/commands.ts | 13 + src/lib/components/SettingsView.svelte | 345 +++++++++++++++++-------- src/lib/contexting-format.test.ts | 40 +++ src/lib/contexting-format.ts | 32 +++ src/lib/stores/contexting.svelte.ts | 38 +++ src/routes/+page.svelte | 2 + 6 files changed, 364 insertions(+), 106 deletions(-) create mode 100644 src/lib/contexting-format.test.ts create mode 100644 src/lib/contexting-format.ts create mode 100644 src/lib/stores/contexting.svelte.ts diff --git a/src/lib/commands.ts b/src/lib/commands.ts index e99bd6e..1c18be9 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -5,6 +5,7 @@ import { library } from "$lib/stores/library.svelte"; import { theme } from "$lib/stores/theme.svelte"; +import { contexting } from "$lib/stores/contexting.svelte"; import { exportTheme, importTheme } from "$lib/themes/share"; import { BODY_FONTS } from "$lib/themes/fonts"; import type { Command } from "$lib/command-filter"; @@ -88,6 +89,18 @@ export function buildCommands(): Command[] { prefix: notePrefix, run: () => (n.isDeleted ? library.restoreSelected() : library.deleteSelected()), }, + { + id: "note.copyContext", + title: "Copy note as context", + group: "Notes", + prefix: notePrefix, + run: async () => { + library.flushPendingEdits(); + const note = library.selected; + if (!note) return; + await navigator.clipboard.writeText(contexting.render(note, library.selectedTags)); + }, + }, ); } diff --git a/src/lib/components/SettingsView.svelte b/src/lib/components/SettingsView.svelte index d67f967..3fc7024 100644 --- a/src/lib/components/SettingsView.svelte +++ b/src/lib/components/SettingsView.svelte @@ -1,6 +1,13 @@ -
- + + {/if} + -
- {#if activeTab === "about"} -
- InstantNotes -

InstantNotes

- {#if appVersion} - v{appVersion} - {/if} -

Instant capture, organized knowledge.

+ {#if page === "home"} +
+ {#each CATEGORIES as cat (cat.id)} + + {/each} +
+ {:else} +
+ {#if page === "about"} +
+ InstantNotes +

InstantNotes

+ {#if appVersion} + v{appVersion} + {/if} +

Instant capture, organized knowledge.

-
-
- Version - {appVersion || "—"} -
-
-
- Platform - macOS · Apple Silicon -
-
-
- Source - +
+
+ Version + {appVersion || "-"} +
+
+
+ Platform + macOS · Apple Silicon +
+
+
+ Source + +
-
+ {:else if page === "contexting"} +
+

Contexting

+

+ The template behind "Copy note as context" in the ⌘K palette. Wrap the note + however a tool or model expects; this is the seed for InstantNotes' AI features. +

+ + + - {:else if activeTab === "default-new-tab"} -
-

Default New Tab

-

- Configure what happens when you create a new note. -

-
-

Options for default content, templates, and focus behavior will appear here.

+
+ {#each TEMPLATE_VARS as v} + {v} + {/each} +
+ + Preview +
{preview}
-
- {/if} -
+ {/if} +
+ {/if}
diff --git a/src/lib/contexting-format.test.ts b/src/lib/contexting-format.test.ts new file mode 100644 index 0000000..4891307 --- /dev/null +++ b/src/lib/contexting-format.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { renderTemplate, DEFAULT_TEMPLATE } from "./contexting-format"; + +const note = { title: "Groceries", body: "milk\neggs", updatedAt: "2026-06-18T10:00:00.000Z" }; + +describe("renderTemplate", () => { + it("fills title and content", () => { + expect(renderTemplate('{content}', note, [])).toBe( + 'milk\neggs', + ); + }); + + it("renders tags as space-separated hashtags", () => { + expect(renderTemplate("{tags}", note, [{ name: "food" }, { name: "todo" }])).toBe("#food #todo"); + }); + + it("renders empty tags as an empty string", () => { + expect(renderTemplate("[{tags}]", note, [])).toBe("[]"); + }); + + it("falls back to Untitled for an empty title", () => { + expect(renderTemplate("{title}", { ...note, title: "" }, [])).toBe("Untitled"); + }); + + it("fills a non-empty date", () => { + expect(renderTemplate("{date}", note, []).length).toBeGreaterThan(0); + }); + + it("leaves unknown placeholders untouched", () => { + expect(renderTemplate("{title} {unknown}", note, [])).toBe("Groceries {unknown}"); + }); + + it("default template wraps content and metadata", () => { + const out = renderTemplate(DEFAULT_TEMPLATE, note, [{ name: "food" }]); + expect(out).toContain(''); + expect(out).toContain("tags: #food"); + expect(out).toContain("milk\neggs"); + expect(out).toContain(""); + }); +}); diff --git a/src/lib/contexting-format.ts b/src/lib/contexting-format.ts new file mode 100644 index 0000000..fd9b962 --- /dev/null +++ b/src/lib/contexting-format.ts @@ -0,0 +1,32 @@ +// Pure rendering for the Contexting copy template: turns a note plus its tags +// into a wrapped string a tool or model can consume. Kept free of runes so it is +// unit-testable and shared by the store, the copy command, and the settings +// preview. This is the first of a planned set of context/AI helpers. + +import type { Note, Tag } from "$lib/api/types"; + +/** Default wrap: an XML-ish envelope carrying the note's metadata and body. */ +export const DEFAULT_TEMPLATE = `\ntags: {tags}\n{content}\n`; + +/** Placeholders the template understands, surfaced in the settings UI. */ +export const TEMPLATE_VARS = ["{title}", "{tags}", "{date}", "{content}"] as const; + +/** + * Fill {title}, {tags}, {date}, {content} from a note and its tags. Tags render + * as space-separated #hashtags to match how they are written in the editor; + * {date} uses the note's last-updated date in the local format. Unknown + * placeholders are left untouched. + */ +export function renderTemplate( + template: string, + note: Pick, + tags: Pick[], +): string { + const values: Record = { + title: note.title || "Untitled", + tags: tags.map((t) => `#${t.name}`).join(" "), + date: new Date(note.updatedAt).toLocaleDateString(), + content: note.body, + }; + return template.replace(/\{(title|tags|date|content)\}/g, (_, key: string) => values[key] ?? ""); +} diff --git a/src/lib/stores/contexting.svelte.ts b/src/lib/stores/contexting.svelte.ts new file mode 100644 index 0000000..6d04841 --- /dev/null +++ b/src/lib/stores/contexting.svelte.ts @@ -0,0 +1,38 @@ +// Contexting (Svelte 5 runes): the user-editable template for copying a note as +// LLM-ready context. Persisted to the existing settings KV like the editor and +// theme stores. Rendering logic lives in the pure contexting-format module. + +import { getSetting, setSetting } from "$lib/api/client"; +import type { Note, Tag } from "$lib/api/types"; +import { DEFAULT_TEMPLATE, renderTemplate } from "$lib/contexting-format"; + +const KEY_TEMPLATE = "contexting.copyTemplate"; + +class ContextingStore { + copyTemplate = $state(DEFAULT_TEMPLATE); + + #loaded = false; + + async init(): Promise { + if (this.#loaded) return; + this.#loaded = true; + try { + const t = await getSetting(KEY_TEMPLATE); + if (typeof t === "string" && t.length > 0) this.copyTemplate = t; + } catch { + // Settings are best-effort; keep the default template silently. + } + } + + setTemplate(t: string): void { + this.copyTemplate = t; + void setSetting(KEY_TEMPLATE, t); + } + + /** Render the active template for a note and its tags, ready for the clipboard. */ + render(note: Note, tags: Tag[]): string { + return renderTemplate(this.copyTemplate, note, tags); + } +} + +export const contexting = new ContextingStore(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9660990..14c87ee 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,6 +18,7 @@ import { library } from "$lib/stores/library.svelte"; import { updater } from "$lib/stores/updater.svelte"; import { editorPrefs } from "$lib/stores/editor.svelte"; + import { contexting } from "$lib/stores/contexting.svelte"; let appVersion = $state(""); let paletteOpen = $state(false); @@ -27,6 +28,7 @@ onMount(() => { void library.init(); void editorPrefs.init(); + void contexting.init(); void getVersion().then((v) => (appVersion = v)); updater.start(); // Tray "Check for Updates…" opens the panel and runs a manual check. From 3995cab5728507fa47efd42a7c9651bd404261cf Mon Sep 17 00:00:00 2001 From: jamubc <150970140+jamubc@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:38:27 -0700 Subject: [PATCH 09/10] feat: build and release for windows and linux Extend the bundle targets, release matrix, and updater manifest to all three desktop platforms, gate the macOS-only shell behavior, and make shortcut labels and font stacks platform-aware. --- .github/workflows/build.yml | 47 +++++++++++++++++++ .github/workflows/release.yml | 41 +++++++++++++---- README.md | 48 ++++++++++++++------ package.json | 3 ++ rust-toolchain.toml | 4 ++ scripts/tauri-dev.mjs | 55 ++++++++++++++--------- src-tauri/src/lib.rs | 60 +++++++++++++++++++------ src-tauri/tauri.conf.json | 7 +-- src-tauri/tauri.macos.conf.json | 35 +++++++++++++++ src/lib/app.css | 6 +-- src/lib/commands.ts | 3 +- src/lib/components/BulkActions.svelte | 3 +- src/lib/components/FormatToolbar.svelte | 15 ++++--- src/lib/components/NoteList.svelte | 5 ++- src/lib/components/SettingsView.svelte | 3 +- src/lib/components/WelcomeScreen.svelte | 5 ++- src/lib/platform.ts | 17 +++++++ src/lib/themes/fonts.ts | 10 +++-- 18 files changed, 287 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 rust-toolchain.toml create mode 100644 src-tauri/tauri.macos.conf.json create mode 100644 src/lib/platform.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5da4f30 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +# Cross-platform build gate: every PR and push to main must compile, type-check, +# and pass tests on all three shipping platforms. `tauri build --no-bundle` runs +# the full production pipeline (frontend build, per-platform config resolution, +# release compile) without bundling, so no signing secrets are needed here. +name: Build + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + strategy: + fail-fast: false + matrix: + platform: [macos-latest, ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install Tauri system dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + + - run: npm ci + + - run: npm run check + + - run: npm test + + - run: cargo test --workspace --manifest-path src-tauri/Cargo.toml + + - run: npm run tauri -- build --no-bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3754985..f296663 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,8 @@ -# Push a v* tag and GitHub builds, signs, and publishes the release, -# including the latest.json manifest the in-app updater polls. +# Push a v* tag and GitHub builds every platform and assembles a draft release, +# including the latest.json manifest the in-app updater polls. The three matrix +# jobs upload into one draft; tauri-action merges each platform into latest.json, +# so the draft must stay unpublished until all jobs finish. Publish after the +# smoke test to ship. # Requires repo secrets: TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD. name: Release @@ -12,8 +15,15 @@ permissions: jobs: release: - # macos-latest runners are arm64; we ship Apple Silicon only. - runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + # macos-latest runners are arm64; macOS ships Apple Silicon only. + - platform: macos-latest + - platform: ubuntu-22.04 + - platform: windows-latest + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 @@ -28,6 +38,12 @@ jobs: with: workspaces: src-tauri + - name: Install Tauri system dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + - run: npm ci # Release notes = the CHANGELOG section for this tag. tauri-action writes @@ -35,6 +51,7 @@ jobs: # in-app updater shows real "What's new" text, not install boilerplate. - name: Extract release notes id: notes + shell: bash run: | version="${GITHUB_REF_NAME#v}" notes="$(awk -v v="$version" ' @@ -50,7 +67,13 @@ jobs: printf '%s\n' "$notes" echo "" echo "---" - echo "First install: download the \`.dmg\`, open it, and drag InstantNotes to Applications. macOS blocks the first launch of this unnotarized build — run \`xattr -d com.apple.quarantine /Applications/InstantNotes.app\` or use System Settings > Privacy & Security > \"Open Anyway\". Existing installs update in place from inside the app." + echo "First install on macOS: download the \`.dmg\`, open it, and drag InstantNotes to Applications. macOS blocks the first launch of this unnotarized build: run \`xattr -d com.apple.quarantine /Applications/InstantNotes.app\` or use System Settings > Privacy & Security > \"Open Anyway\"." + echo "" + echo "First install on Windows: download and run the \`-setup.exe\` installer. SmartScreen flags the unsigned build: click \"More info\", then \"Run anyway\"." + echo "" + echo "First install on Linux: download the \`.AppImage\`, make it executable (\`chmod +x\`), and run it." + echo "" + echo "Existing installs update in place from inside the app." echo "__NOTES_EOF__" } >> "$GITHUB_OUTPUT" @@ -62,8 +85,10 @@ jobs: with: tagName: ${{ github.ref_name }} releaseName: "InstantNotes ${{ github.ref_name }}" - # Publish on a successful build so a forgotten draft can't silently - # block delivery. Pushing a v* tag is the deliberate ship gate. - releaseDraft: false + # Draft while the matrix assembles: publishing early would expose a + # latest.json missing the platforms still building. Publishing the + # smoke-tested draft is the ship gate. + releaseDraft: true includeUpdaterJson: true + updaterJsonPreferNsis: true releaseBody: ${{ steps.notes.outputs.body }} diff --git a/README.md b/README.md index 46a8529..bcdf0ce 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,47 @@ # InstantNotes -Instant notes for macOS. Capture, organize, and search your thoughts. +Instant notes for macOS, Windows, and Linux. Capture, organize, and search your thoughts. InstantNotes is a desktop notes app built around fast capture and a focused library. Save a thought from anywhere with a global shortcut, then organize and retrieve it without being forced into a folder system. ## Features -- **Instant capture**: a lightweight capture panel summoned from the system tray or via `Cmd+Shift+N`, with drafts preserved if dismissed +- **Instant capture**: a lightweight capture panel summoned from the system tray or via a global hotkey (`Option+Space` on macOS, `Ctrl+Shift+Space` on Windows and Linux), with drafts preserved if dismissed - **Focused library**: a two-section sidebar (All Notes and Workspaces) over a note list and editor, with pinned notes floated to the top and a status filter for archived and trashed notes - **Workspaces**: named collections that group related notes; a note can live in many workspaces, and deleting a workspace never deletes its notes - **Full-text search**: SQLite FTS5 search over titles and bodies with ranked results, using plain-language queries with no search syntax to learn -- **Command palette**: a `Cmd+K` palette for running actions and switching themes, with arrow-key navigation and recents; search reaches into sub-menus (typing a theme name jumps straight to it), and the Themes sub-menu applies each theme live so you can preview as you arrow through +- **Command palette**: a `Cmd+K` (`Ctrl+K`) palette for running actions and switching themes, with arrow-key navigation and recents; search reaches into sub-menus (typing a theme name jumps straight to it), and the Themes sub-menu applies each theme live so you can preview as you arrow through - **Tags, not folders**: lightweight labels, including tags extracted from `#inline` text - **Local and private**: all data stored locally in SQLite; note content never appears in logs or diagnostics ## Installation -InstantNotes runs on macOS (Apple Silicon). Download the latest `.dmg` from the [releases page](../../releases), open it, and drag InstantNotes to Applications. +Download the latest build for your platform from the [releases page](../../releases). The builds are unsigned, so each OS asks for a one-time confirmation on first launch; the in-app updater applies later versions without any of it. -The app is not notarized, so macOS blocks the first launch with an "Apple could not verify" message. Clear the quarantine flag and it opens normally from then on: +### macOS (Apple Silicon) + +Download the `.dmg`, open it, and drag InstantNotes to Applications. The app is not notarized, so macOS blocks the first launch with an "Apple could not verify" message. Clear the quarantine flag and it opens normally from then on: ```sh xattr -d com.apple.quarantine /Applications/InstantNotes.app ``` -Alternatively, after the blocked first launch, open System Settings, go to Privacy and Security, scroll down, and click "Open Anyway". On macOS 14 and earlier, right-click the app and choose Open instead. This is a first-install step only: the in-app updater applies later versions without any of it. +Alternatively, after the blocked first launch, open System Settings, go to Privacy and Security, scroll down, and click "Open Anyway". On macOS 14 and earlier, right-click the app and choose Open instead. + +### Windows (x64) + +Download and run the `-setup.exe` installer. SmartScreen flags the unsigned build: click "More info", then "Run anyway". + +### Linux (x64) + +Download the `.AppImage`, make it executable, and run it: + +```sh +chmod +x InstantNotes_*.AppImage +./InstantNotes_*.AppImage +``` + +The app lives in the system tray; on desktops without tray support (such as stock GNOME, which needs the AppIndicator extension), use the in-window File menu to quit and the library window to work. To build from source instead, see [Development](#development). @@ -32,9 +49,11 @@ To build from source instead, see [Development](#development). ### Prerequisites -- macOS -- [Rust](https://rustup.rs/) (stable) -- Node.js 20+ +- macOS, Windows, or Linux +- [Rust](https://rustup.rs/) via rustup (the version is pinned by `rust-toolchain.toml`) +- Node.js 22+ +- Linux only: the [Tauri system dependencies](https://v2.tauri.app/start/prerequisites/#linux) (webkit2gtk 4.1 and friends) +- Windows only: the Visual Studio Build Tools with the C++ workload ### Run the app @@ -59,7 +78,7 @@ npm test # frontend unit tests (Vitest) npm run tauri build ``` -Produces an `.app` bundle and `.dmg` under `src-tauri/target/release/bundle/`. Without an Apple Developer ID the bundle is ad-hoc signed and not notarized, so downloaded copies require the first-launch steps described under [Installation](#installation). Builds made locally on your own machine are not quarantined and open normally. +Produces the platform's bundles under `src-tauri/target/release/bundle/`: an `.app` and `.dmg` on macOS, an NSIS `-setup.exe` on Windows, and an `.AppImage` on Linux. The builds are unsigned (macOS is ad-hoc signed, not notarized), so downloaded copies require the first-launch steps described under [Installation](#installation). Builds made locally on your own machine open normally. ### Release (with self-update) @@ -69,17 +88,18 @@ The app checks GitHub Releases for updates on launch and every 6 hours, via `lat # 1. Add a "## [X.Y.Z]" section to CHANGELOG.md describing the release. # 2. Bump every version file in lockstep: npm run bump X.Y.Z -# 3. Commit, tag, and push; .github/workflows/release.yml builds, signs, and -# publishes the release on a successful build (no draft step to forget). +# 3. Commit, tag, and push; .github/workflows/release.yml builds macOS, Windows, +# and Linux in a matrix and assembles a draft release with a merged latest.json. git commit -am "chore: bump version to X.Y.Z" git tag vX.Y.Z && git push origin vX.Y.Z +# 4. Smoke test the draft's artifacts, then publish the draft to ship. ``` -`npm run bump` updates package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and src-tauri/Cargo.lock together (the `instantnotes-core` crate versions independently). Pushing a `v*` tag is the deliberate ship gate. +`npm run bump` updates package.json, src-tauri/tauri.conf.json, src-tauri/Cargo.toml, and src-tauri/Cargo.lock together (the `instantnotes-core` crate versions independently). Publishing the smoke-tested draft is the deliberate ship gate; the draft stays invisible to the in-app updater until then. CI signs the updater artifact with the minisign key stored in the repo secrets `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and the app verifies downloads against the matching public key in `tauri.conf.json`. If the secret is ever lost, generate a new keypair with `npm run tauri signer generate`, update both the secret and the pubkey, and ship one manual release so installs can cross over. -For a fully local release without CI, build with `TAURI_SIGNING_PRIVATE_KEY` set, run `./scripts/make-update-manifest.sh`, and upload the dmg, `InstantNotes.app.tar.gz`, and `latest.json` with `gh release create`. Release downloads must be publicly reachable for the in-app check to work. +For a fully local release without CI (macOS-only fallback: `make-update-manifest.sh` writes just the `darwin-aarch64` entry), build with `TAURI_SIGNING_PRIVATE_KEY` set, run `./scripts/make-update-manifest.sh`, and upload the dmg, `InstantNotes.app.tar.gz`, and `latest.json` with `gh release create`. Release downloads must be publicly reachable for the in-app check to work. ### Project structure diff --git a/package.json b/package.json index b62cdf9..b6b5335 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.6.2", "description": "InstantNotes — instant capture, organized knowledge", "type": "module", + "engines": { + "node": ">=22" + }, "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..69cf9ba --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +# One toolchain everywhere: rustup reads this on every cargo invocation, locally +# and on all three CI runners, so builds cannot drift between machines. +[toolchain] +channel = "1.91.1" diff --git a/scripts/tauri-dev.mjs b/scripts/tauri-dev.mjs index 0af30b7..5876b0f 100644 --- a/scripts/tauri-dev.mjs +++ b/scripts/tauri-dev.mjs @@ -38,33 +38,48 @@ function killPid(pid, label) { } } -// 1) Kill any running dev binary by PID (never signal this script itself). -for (const line of sh("ps -axo pid=,args=").split("\n")) { - if ( - line.includes("target/debug/instantnotes") && - !line.includes("tauri-dev.mjs") && - !line.includes("node ") - ) { - killPid(line.trim().split(/\s+/)[0], "stale dev instance"); +// 1 + 2) Stale-process cleanup needs ps/lsof, so it runs on macOS and Linux +// only. On Windows the single-instance plugin still reloads the surviving +// webview (lib.rs), so a re-run is stale-frontend-safe, just not force-fresh. +if (process.platform !== "win32") { + // 1) Kill any running dev binary by PID (never signal this script itself). + for (const line of sh("ps -axo pid=,args=").split("\n")) { + if ( + line.includes("target/debug/instantnotes") && + !line.includes("tauri-dev.mjs") && + !line.includes("node ") + ) { + killPid(line.trim().split(/\s+/)[0], "stale dev instance"); + } } + + // 2) Free the Vite dev port so a fresh server is used (not a stale one). + const onPort = sh("lsof -ti tcp:1420").trim(); + if (onPort) for (const pid of onPort.split("\n")) killPid(pid, "stale dev server on :1420"); } -// 2) Free the Vite dev port so a fresh server is used (not a stale one). -const onPort = sh("lsof -ti tcp:1420").trim(); -if (onPort) for (const pid of onPort.split("\n")) killPid(pid, "stale dev server on :1420"); +// 3) Launch fresh, pointed at the real notes DB (the installed app's +// app_data_dir for this platform, matching Tauri's path resolver). +const appDataRoot = + process.platform === "darwin" + ? join(homedir(), "Library", "Application Support") + : process.platform === "win32" + ? (process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")) + : (process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")); +const dbPath = join(appDataRoot, "com.instantnotes.app", "instantnotes.db"); -// 3) Launch fresh, pointed at the real notes DB. -const dbPath = join( - homedir(), - "Library/Application Support/com.instantnotes.app/instantnotes.db", -); -const tauriBin = existsSync("node_modules/.bin/tauri") - ? "node_modules/.bin/tauri" - : "tauri"; +const binName = process.platform === "win32" ? "tauri.cmd" : "tauri"; +const localBin = join("node_modules", ".bin", binName); +const tauriBin = existsSync(localBin) ? localBin : binName; const child = spawn( tauriBin, ["dev", "--config", "src-tauri/tauri.dev.conf.json"], - { stdio: "inherit", env: { ...process.env, INSTANTNOTES_DB_PATH: dbPath } }, + { + stdio: "inherit", + env: { ...process.env, INSTANTNOTES_DB_PATH: dbPath }, + // .cmd shims only execute through a shell. + shell: process.platform === "win32", + }, ); child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfd1be4..5009fde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -605,8 +605,11 @@ pub fn run() { // After an in-place update, refresh the cached app icon once. refresh_icon_cache_if_updated(&dir); - // macOS app menu bar. The Edit submenu is required for Cut/Copy/Paste - // to work in the WebView. + // App menu bar. The Edit submenu is required for Cut/Copy/Paste to + // work in the WebView on every platform. The application submenu + // (Services, Hide, Hide Others) is a macOS convention with no + // Windows/Linux equivalent, so off macOS its Settings and Quit + // entries live in the File submenu instead. let settings_item = MenuItem::with_id( app, "settings", @@ -614,6 +617,7 @@ pub fn run() { true, Some("CmdOrCtrl+,"), )?; + #[cfg(target_os = "macos")] let app_submenu = SubmenuBuilder::new(app, "InstantNotes") .about(None) .separator() @@ -641,11 +645,19 @@ pub fn run() { true, None::<&str>, )?; - let file_submenu = SubmenuBuilder::new(app, "File") - .item(&new_note_item) - .separator() - .item(&export_item) - .build()?; + let file_submenu = { + let builder = SubmenuBuilder::new(app, "File") + .item(&new_note_item) + .separator() + .item(&export_item); + #[cfg(not(target_os = "macos"))] + let builder = builder + .separator() + .item(&settings_item) + .separator() + .quit(); + builder.build()? + }; let edit_submenu = SubmenuBuilder::new(app, "Edit") .undo() .redo() @@ -655,9 +667,14 @@ pub fn run() { .paste() .select_all() .build()?; + #[cfg(target_os = "macos")] let app_menu = MenuBuilder::new(app) .items(&[&app_submenu, &file_submenu, &edit_submenu]) .build()?; + #[cfg(not(target_os = "macos"))] + let app_menu = MenuBuilder::new(app) + .items(&[&file_submenu, &edit_submenu]) + .build()?; app.set_menu(app_menu)?; app.on_menu_event(|app, event| match event.id().as_ref() { "settings" => { @@ -677,7 +694,11 @@ pub fn run() { // Tray menu - the app's permanent presence. Dev builds use ⌥⇧Space so // they never fight an installed release for the system-wide ⌥Space hotkey. - let capture_accel = if cfg!(debug_assertions) { + // The tab-separated hint only renders reliably in the macOS status + // menu; other platforms surface the hotkey in the welcome screen. + let capture_accel = if !cfg!(target_os = "macos") { + "New Capture" + } else if cfg!(debug_assertions) { "New Capture\t⌥⇧Space" } else { "New Capture\t⌥Space" @@ -744,14 +765,25 @@ pub fn run() { }) .build(app)?; - // Global shortcut: ⌥Space toggles the capture panel. In dev builds use - // ⌥⇧Space instead - the system-wide ⌥Space is exclusive, so a dev build - // and an installed release (same hotkey) would otherwise silently collide. + // Global shortcut: ⌥Space toggles the capture panel on macOS. Windows + // reserves plain Alt+Space for the system window menu, so Windows and + // Linux use Ctrl+Shift+Space. In dev builds add one more modifier - + // the hotkey is exclusive, so a dev build and an installed release + // (same hotkey) would otherwise silently collide. use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; - let shortcut = if cfg!(debug_assertions) { - Shortcut::new(Some(Modifiers::ALT | Modifiers::SHIFT), Code::Space) + let shortcut = if cfg!(target_os = "macos") { + if cfg!(debug_assertions) { + Shortcut::new(Some(Modifiers::ALT | Modifiers::SHIFT), Code::Space) + } else { + Shortcut::new(Some(Modifiers::ALT), Code::Space) + } + } else if cfg!(debug_assertions) { + Shortcut::new( + Some(Modifiers::CONTROL | Modifiers::SHIFT | Modifiers::ALT), + Code::Space, + ) } else { - Shortcut::new(Some(Modifiers::ALT), Code::Space) + Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space) }; if let Err(e) = app.global_shortcut().register(shortcut) { // Content-free log per SEC-001; conflict fallback UI is an M4 item. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 02a63e5..9daa115 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,8 +20,7 @@ "height": 640, "minWidth": 720, "minHeight": 480, - "visible": true, - "transparent": true + "visible": true }, { "label": "capture", @@ -49,7 +48,9 @@ "active": true, "targets": [ "app", - "dmg" + "dmg", + "nsis", + "appimage" ], "icon": [ "icons/32x32.png", diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..1336b4c --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "label": "library", + "title": "InstantNotes", + "url": "/", + "width": 980, + "height": 640, + "minWidth": 720, + "minHeight": 480, + "visible": true, + "transparent": true + }, + { + "label": "capture", + "title": "InstantNotes Capture", + "url": "/capture", + "width": 560, + "height": 160, + "resizable": false, + "decorations": false, + "transparent": true, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "visibleOnAllWorkspaces": true, + "center": true, + "visible": false, + "closable": false + } + ] + } +} diff --git a/src/lib/app.css b/src/lib/app.css index 88023d7..224d370 100644 --- a/src/lib/app.css +++ b/src/lib/app.css @@ -4,11 +4,11 @@ Themes live as data in src/lib/themes/; do not hard-code palettes here. */ :root { - --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; - --font-mono: ui-monospace, "SF Mono", Menlo, monospace; + --font-ui: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; /* Resolved font slots (theme decides whether each is ui or mono). */ --font-body: var(--font-ui); - --font-meta: ui-monospace, "SF Mono", Menlo, monospace; + --font-meta: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --bg: #161617; --bg-sidebar: #1c1c1d; diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 1c18be9..90b0887 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -8,6 +8,7 @@ import { theme } from "$lib/stores/theme.svelte"; import { contexting } from "$lib/stores/contexting.svelte"; import { exportTheme, importTheme } from "$lib/themes/share"; import { BODY_FONTS } from "$lib/themes/fonts"; +import { modKey } from "$lib/platform"; import type { Command } from "$lib/command-filter"; export type { Command } from "$lib/command-filter"; @@ -61,7 +62,7 @@ export function buildThemeCommands(): Command[] { /** Build the current command set. Reads store state, so call on palette open. */ export function buildCommands(): Command[] { const commands: Command[] = [ - { id: "note.new", title: "New note", group: "Notes", shortcut: "⌘N", run: () => library.newNote() }, + { id: "note.new", title: "New note", group: "Notes", shortcut: `${modKey}N`, run: () => library.newNote() }, ]; if (library.selected) { diff --git a/src/lib/components/BulkActions.svelte b/src/lib/components/BulkActions.svelte index 087a240..f29b174 100644 --- a/src/lib/components/BulkActions.svelte +++ b/src/lib/components/BulkActions.svelte @@ -1,5 +1,6 @@