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/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 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 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 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 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/bump-version.mjs b/scripts/bump-version.mjs index 7ea67a1..75e7486 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -1,6 +1,6 @@ -#!/usr/bin/env node // Bump the app version in every file that must stay in lockstep, in one shot, -// so a release can never ship with the manifests disagreeing. +// so a release can never ship with the manifests disagreeing. Run via +// `npm run bump` (no shebang: Windows vitest cannot parse the file with one). // // npm run bump 0.6.0 // 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 f715b5c..5009fde 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 @@ -587,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", @@ -596,6 +617,7 @@ pub fn run() { true, Some("CmdOrCtrl+,"), )?; + #[cfg(target_os = "macos")] let app_submenu = SubmenuBuilder::new(app, "InstantNotes") .about(None) .separator() @@ -623,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() @@ -637,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" => { @@ -659,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" @@ -726,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. @@ -796,6 +846,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-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/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/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 e99bd6e..90b0887 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -5,8 +5,10 @@ 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 { modKey } from "$lib/platform"; import type { Command } from "$lib/command-filter"; export type { Command } from "$lib/command-filter"; @@ -60,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) { @@ -88,6 +90,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/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 @@
diff --git a/src/lib/components/FormatToolbar.svelte b/src/lib/components/FormatToolbar.svelte index d7505b0..86666c0 100644 --- a/src/lib/components/FormatToolbar.svelte +++ b/src/lib/components/FormatToolbar.svelte @@ -1,5 +1,6 @@ -
- + + {/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 {modKey}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/components/WelcomeScreen.svelte b/src/lib/components/WelcomeScreen.svelte index e00f63c..e154a03 100644 --- a/src/lib/components/WelcomeScreen.svelte +++ b/src/lib/components/WelcomeScreen.svelte @@ -1,6 +1,7 @@