Skip to content

feat: the vault, whiteboards, and the graph (0.9.0) - #54

Open
jamubc wants to merge 73 commits into
mainfrom
0.9.0-pre
Open

jamubc wants to merge 73 commits into
mainfrom
0.9.0-pre

Conversation

@jamubc

@jamubc jamubc commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Why

InstantNotes keeps notes in a SQLite library that only InstantNotes can read. That is fast and safe, but it asks the user to trust a single opaque file with everything they write, and it makes the library a dead end: nothing else can open it, and nothing carries it to another machine. 0.9.0 answers that directly with a Vault, a plain Markdown folder the app keeps in step with the library, readable in any editor with the app closed.

The release then widens what a note can be. A note has been one thing since 0.1.0, a Markdown document, so anything spatial had to live outside the app. Whiteboards make a note a canvas without making it a second class of object: a board is still a note, still searchable, still tagged, still exported. Graph answers the companion question, which is what the library looks like as a whole rather than as a list.

The rest is debt this growth exposed. Images accumulated with no way to reclaim the space. The two halves of the app named error codes and events in two places that could silently drift apart. Updates arrived as a modal dialog that interrupted the user to ask a question they could not act on yet.

What Changes

  • Vault: a one-way export, then a live mirror. Every note becomes a Markdown file carrying its tags and Spaces, attachments come along, trashed notes move to a trash folder, and "Check vault" confirms each file matches its note. Schema v5 tracks the mirror. The app writes the folder and does not read it back yet, so the app stays the place you edit.
  • Whiteboards: a note can be a freeform Excalidraw canvas. Schema v4 adds the surface mode, v6 stores a board in the vault as a standard .excalidraw file beside its note. Text on a board stays searchable and its #tags still tag the note. Creating one is now visible in the note list rather than only in the File menu and the command palette.
  • Graph: a sidebar view drawing notes, tags, and Spaces and the links between them, entered around the note you have open, with click-through to a note, a tag filter, or a Space.
  • Image lifecycle: deleting a note for good deletes the images only it used, and Settings > Images reports and removes images no note references. Images held by notes in Trash or Archive are always kept.
  • Update as a place: the update dialog becomes an Update Space at the top of the sidebar holding the target version, the current one, the size delta, the release notes as a readable note, and the install button. Nothing blocks the window and nothing has to be dismissed.
  • Settings: a dashboard front page with live library counts, plus Images, Editor, Links, Vault, and Feedback pages.
  • One registry per language: error codes and event names each live in exactly one place per half of the app, with a test holding the two halves equal.
  • Fixes: a newer-schema library shows a message instead of aborting at launch, pre-release whiteboard libraries open again, per-note editor state no longer leaks between notes, Linux window corruption, and the release script's Windows path guard. The dashboard's "What's new" also reads the changelog on a Windows checkout, where the file arrives as CRLF.

ADDED Requirements

Requirement: the vault mirrors the library without owning it

The app SHALL keep every non-deleted note as a Markdown file in the chosen folder, carrying its tags and Spaces, and MUST treat the SQLite library as the source of truth. A vault write failure MUST NOT fail the note write that triggered it.

Scenario: a note edit reaches the folder

  • GIVEN a live vault configured at a folder the user chose
  • WHEN the user edits a note and stops typing
  • THEN the matching Markdown file is rewritten a moment later with the new body, its tags, and its Spaces, and the note is saved in the library whether or not that file write succeeded

Requirement: a whiteboard is a note, not a separate object

A note SHALL be either a document or a whiteboard, and a whiteboard MUST remain subject to every behavior a note has: search, tagging, Spaces, trash, and export. Converting a document to a board MUST carry its text onto the canvas.

Scenario: searching for text written on a board

  • GIVEN a whiteboard note with the word "harbour" written in a text element and the tag #ports on the canvas
  • WHEN the user searches for "harbour"
  • THEN the whiteboard note appears in the results and is reachable under the #ports tag

Requirement: a whiteboard can be created from the note list

The note list SHALL offer creating a whiteboard from its create control, so a board can be started without knowing a keyboard shortcut or opening the command palette. The plain create action MUST continue to create a document in one click.

Scenario: starting a board from the list

  • GIVEN the note list toolbar
  • WHEN the user opens the create control's menu and chooses New whiteboard
  • THEN a new whiteboard note is created and opened on its canvas, and the menu shows the Cmd+Shift+N shortcut that does the same thing

Requirement: removing images never removes one still in use

Deleting a note permanently SHALL delete only the images no surviving note references. Images referenced by a note in the Trash or the Archive MUST be kept, because those notes can be restored.

Scenario: emptying the Trash with a shared image

  • GIVEN two notes embedding the same image, one deleted permanently and one in the Archive
  • WHEN the permanent deletion runs
  • THEN the image file remains on disk, because the archived note still references it

MODIFIED Requirements

Requirement: a newer library is refused without crashing

Opening a library whose user_version is past the last migration this build knows SHALL surface a message telling the user to update. The app MUST NOT abort at launch.

Scenario: opening a library from a newer build

  • GIVEN a library written by a later version of InstantNotes
  • WHEN the app launches against it
  • THEN the app stays open and explains that the library needs a newer version, instead of terminating

Requirement: an update informs rather than interrupts

An available update SHALL be presented as a Space in the sidebar and MUST NOT open a modal dialog or require the user to dismiss a reminder. Installing MUST remain the user's explicit action.

Scenario: an update arrives while the user is writing

  • GIVEN the user is typing in a note when an update is found
  • WHEN the updater reports the new version
  • THEN an Update Space appears at the top of the sidebar with a green marker and the editor keeps focus, with nothing to dismiss

Requirement: the dashboard reads the changelog on every platform

The bundled changelog SHALL parse identically whichever line ending the checkout produces. A CRLF copy MUST yield the same release, sections, and items as an LF copy.

Scenario: a Windows checkout renders What's new

  • GIVEN a working copy where CHANGELOG.md was checked out with CRLF endings
  • WHEN the Settings dashboard looks up the running version's release notes
  • THEN it finds the section and lists its bullets, exactly as it does on macOS and Linux

Verification

  1. Build gates, mirroring .github/workflows/build.yml:
    1.1 npm run check passes: 610 files, 0 errors, 0 warnings.
    1.2 npm test passes: 54 files, 490 tests.
    1.3 cargo test --workspace --manifest-path src-tauri/Cargo.toml passes: 220 tests, 1 ignored.
    1.4 cargo clippy --workspace --all-targets --manifest-path src-tauri/Cargo.toml -- -D warnings is clean.
    1.5 cargo fmt --all --check --manifest-path src-tauri/Cargo.toml is clean.
    1.6 All three platform jobs pass, including windows-latest. That runner had never run against this branch before the PR opened, and it caught two defects: the CRLF changelog parsing above, and an export-path test whose fixtures assumed a Unix absolute path.
  2. Migrations, which run against real user libraries:
    2.1 A 0.8.0 library (schema v3) opens and applies v4, v5, and v6 with every note intact.
    2.2 A library touched by a pre-release whiteboard build opens instead of being refused, and its boards render as whiteboards.
    2.3 A library at a schema past v6 shows the update message and does not abort the app.
  3. Vault behavior:
    3.1 Turning the vault on writes every note, and "Check vault" reports no mismatch.
    3.2 Editing a note updates its file; trashing one moves the file to the vault's trash folder.
    3.3 A whiteboard writes a .excalidraw file beside its note.
  4. Whiteboards:
    4.1 The note list create control makes a document on click and offers New whiteboard from its menu, as does a right-click on the control.
    4.2 Cmd+Shift+N and the File menu still create a board, and the palette still converts a note.
    4.3 Text written on a board is returned by search, and a #tag on the canvas tags the note.
  5. Release metadata:
    5.1 package.json, src-tauri/tauri.conf.json, and src-tauri/Cargo.toml all read 0.9.0. The core crate versions independently by design and stays at 0.8.0, per scripts/bump-version.mjs.
    5.2 CHANGELOG.md dates 0.9.0 as 2026-09-25 and the dashboard's "What's new" finds that section for the running version.

jamubc added 30 commits July 11, 2026 23:31
Post-0.8 batch addressing accumulated user feedback.

Added:
- Settings front page is now a dashboard: live library stats (notes, tags,
  Spaces, attachments, capture readiness, trash) and the installed version's
  release notes parsed from the bundled changelog.
- Images settings page with copy-in vs link-original storage, a preview height
  control, and attachment location, count, and size. An Image button in the
  editor toolbar inserts from a file honoring the storage mode; linked files
  render through a narrow per-file asset scope rather than widening it.
- Editor settings page with a "show exact save time" toggle; the exact save
  time, to the minute, is always available on hover over any note date.
- Contexting image handling with an absolute-path default, so copying a note as
  context can hand a tool the real image path, keep the reference, or drop it.
- In-app feedback: a Feedback page that saves a local record and opens a
  prefilled GitHub issue, backed by a submit_feedback command.

Fixed:
- Stray caret when switching notes: each note now loads a clean editor state, so
  no caret lingers in a note being viewed, and undo no longer reaches into the
  previously open note.
- Links set to open with Cmd/Ctrl+Click now show the pointer cursor while the
  modifier is held, so they read as clickable.

Verified: svelte-check clean, vitest (298) green, cargo test green, build ok.
Only the bump-version half of 0decff9; its NoteEditor hunk cleaned up a
duplication introduced by the whiteboard merge and is not needed here.
Default WEBKIT_DISABLE_DMABUF_RENDERER=1 on Linux in both the app entry
point and the dev script, without overriding an explicit setting; the
WebKit compositor otherwise renders a blank or corrupted window on many
Linux systems. README gains Linux prerequisites and Arch/CachyOS build
notes.
SEQUENCE.md defines the order work lands in and the definition of done that
gates each unit. It records that everything between v0.8.0 and this branch is
unfinished: ten streams, none of them decided, including a whiteboard backend
that migration v4 already has a column for.

feat-portable-vault-sync carries the vault and sync design, blocked on that
decision.
The branch held ten unfinished streams, seven of them entangled in the already
pushed 44bea94, so those are finished in place as four grouped changes. The
whiteboard was local-only and comes off cleanly onto feat/note-whiteboard,
taking migration v4, three runtime deps, and four defects out of the release.

Sequencing it after the vault makes its sidecar an additive file type instead
of a user-data migration.
Settle the props on SegmentedRow and ToggleRow, give them the keyboard and
focus behavior their roles promise, and move the row shell each of them was
carrying a copy of into one PrefRow component. SegmentedRow is a real radio
group now: the selected segment is the only tab stop, the arrow keys move and
wrap, and Home and End reach the ends.

The Links page hand-rolled both controls and now uses them, which is what
proves the API holds before three more settings pages depend on it.
Describe library_stats and every field it returns, and say where each number
comes from: seven counts out of the store, and the two attachment numbers off
the filesystem, where a missing directory reports zero rather than failing the
whole call.

Cover the two changelog states the dashboard has to render, an undated heading
and a version with no section at all, and correct the release note to the tiles
that actually ship.
Tick the tasks that are done and write down what was settled: the frozen row
API, why it carries no error state, and where the row shell now lives. The one
task left needs a running app, so it says so.
cargo test, npm test, and npm run check all pass clean. The live
dashboard-number check and the archive step stay open for jam to verify
in the running app.
The "Insert image..." toolbar action hand-wrote the same
attachments/<name> markdown that editor/images.ts already builds for
paste and drop. Reuse that helper so the two insertion paths produce
their reference from one place instead of two copies that happen to
match.
…exting

SettingsImages.svelte (210 lines) and the imagePrefs store it reads from
were both untested. imagePrefs.init() gates a single settings read
behind a private flag, which breaks Svelte's effect context under
vi.resetModules() + dynamic import, so it's covered separately from the
mounted component: images.svelte.test.ts drives init/persistence/clamping
directly, SettingsImages.test.ts covers what the page renders and
persists.

Also extends contexting-format.test.ts: a linked image (an absolute
path, not an attachments/ reference) now has explicit coverage across
keep, absolute, and strip, alone and alongside a copied image.
import_image_file, allow_image_file, and open_attachments_folder were
missing from the attachments section. Also notes how link mode's
absolute-path reference relates to the vault portability requirement,
which feat-portable-vault-sync's serializer will need.
…mage-handling

Resolves the open decision feat-portable-vault-sync deferred to this
unit: link mode survives as a live-editing convenience, and the vault
serializer materializes a copy into attachments/ at flush time rather
than carrying the vault's portability guarantee on a path that can
break on another device. Recorded in tasks.md with the reasoning, since
the vault serializer reads it.

Every task in the unit is done and its suites are green, so the change
moves to openspec/changes/archive/.
An earlier truncated terminal read missed the 22-test migration_tests
module. The real count was always 92 Rust tests, not 70.
feedback.jsonl accumulates every submission with nothing surfacing it
to the user, unlike attachments, which already had "Open attachments
folder". Add open_feedback_log, mirroring that command: it reveals the
file in the OS file manager rather than opening it, creating an empty
file first if none exists yet. Wire a "Reveal saved feedback" button
and a line on the Feedback page stating nothing is pruned automatically.

Also fixes the send() error handling: a GitHub hand-off failure after
a successful local save used to report "Couldn't send feedback", which
is wrong since the feedback was saved. The two failures are now
distinguished, and the draft only survives a local-save failure.
Only the title was capped; a long feedback body had no limit, and
open_url hands the result to the OS shell, whose URL length tolerance
is tighter than a browser address bar in some environments. The
message is now truncated past 1500 characters with a note that the
full text is saved locally; the diagnostics block is never truncated.
SettingsFeedback.svelte (185 lines) was untested. Covers category
switching, empty-body rejection, submit success, a local-save failure
that keeps the draft, a GitHub-hand-off-only failure that still clears
it, and the reveal-log button. feedback.test.ts gains coverage for the
new body-length cap, including that diagnostics survive truncation.
The Feedback section was missing entirely. Documents FeedbackInput's
shape and states plainly that no note content ever reaches this path.
…app-feedback

Recorded: no pruning (a submission is bytes, not the megabytes
attachments needed a different answer for), but revealed, since
invisibility was the real gap the proposal called out. Reasoning is in
tasks.md.

Every task in the unit is done and its suites are green, so the change
moves to openspec/changes/archive/.
Neither existing suite exercised loadDoc's fix for the stray caret and
undo-reaches-into-the-previous-note bug: library.svelte.test.ts's
"undo" coverage is workspace-delete undo, and kernel.test.ts's
"caret" coverage is fold behavior over a headless state. Mounts the
real component with a real EditorView in jsdom, recovers it via
EditorView.findFromDOM, and switches notes on the same instance via
rerender rather than a fresh render, which would mount a new view and
pass trivially either way.

Verified by temporarily reverting loadDoc to reuse state across notes
and confirming the test fails against it before restoring the fix.
editorPrefs.init() reads three settings, then assigns all three
unconditionally once the read resolves. A setter called after init()
started but before it resolved (setShowExactTime, setZoom,
toggleToolbar) got silently overwritten back to the old persisted
value moments later. Guard each field with a #touched set so a live
change always wins over a slower stale read.

Found via a test asserting exactly this sequence in
editor.svelte.test.ts (6 tests), which also covers init()'s
read/default/error-fallback behavior directly, since a component
mount can't drive a #loaded-guarded singleton's read path without
breaking Svelte's effect context under vi.resetModules().
SettingsEditor.svelte (57 lines, one toggle) had no test.
The cursor feedback for the two link-open modes that need Cmd/Ctrl
(preview + modclick, and edit mode) depends on ModKeyCursor toggling
cm-mod-held correctly; linkMarkClass's existing tests only proved
which modes lack cm-link-clickable; the fallback mechanism itself was
untested. Covers meta/ctrl detection, release, window blur, and
cleanup on destroy so a stale modifier state can't leak into the next
note.
Every task is done: the missing regression test exists and was
verified to actually catch the regression, SettingsEditor and
editorPrefs are tested (surfacing and fixing a real race in the
process), and the modifier-held cursor mechanism is covered across
all three link-open modes. Suites are green, so the change moves to
openspec/changes/archive/.
The prior commit's git add ran against both the new and old
(already-moved, nonexistent) paths in one call; git appears to reject
the whole add when one pathspec doesn't match, so it silently staged
only the earlier git-mv snapshot rather than the fully-edited content
sitting in the working tree. Same content this unit's task list
always described; only the commit that carried it was wrong.
Opening a database with a user_version past the last known migration
returned an error, but the setup hook let it flow into build().expect(),
which panics inside a call that cannot unwind and aborts the whole
process. Give AppError a distinct SchemaTooNew variant so setup can
catch it before that expect, show a message telling the user to
update, and exit cleanly instead.
The pinned 1.91.1 channel had no rust-analyzer component available,
so VS Code's extension fell back to a bundled version too new for the
toolchain to satisfy.
…gs-dashboard

Verified against dev-0.9.0.db, a real v3-schema library, after the app's own
pre-migration backup made that possible without touching the drifted-to-v4
primary database. All counts matched.
Every task is done: the frozen row primitives, the dashboard counts
confirmed live against real data, the attachments and capture-readiness
gaps closed, and the changelog view's edge cases covered. Suites are
green, so the change moves to openspec/changes/archive/.
Dates the changelog and adds the Linux and release-script fixes that
were already done but had no changelog line of their own.
A note can now be a whiteboard: its canvas lives in surface_data and its body
holds the text written on the board, so search and inline tags keep working.
Converting is one-way, only a whiteboard holds a canvas, and an auto title
freezes on convert since the body moves with the drawing. List rows leave the
canvas out, since a board can hold pasted images.
A whiteboard's note file now carries kind: whiteboard, and its canvas is a
standard .excalidraw file beside it with the same name, openable in
Excalidraw. The canvas moves, trashes, and deletes with its note under the
mirror's ownership rules, tracked by a hash that migration v6 adds, and Check
vault compares it by content. A one-time export writes it too.
Whiteboards return from the pre-release branch with its four defects fixed.
Board edits ride the same save queue as typing, so a stroke survives a note
switch, trash, or quit. The board follows the app theme, and converting a
note lays its text onto the board instead of hiding it. New Whiteboard sits in
the palette and the File menu, Export writes a board as .excalidraw, and the
board's keys stay the board's. Excalidraw loads on first use, with its fonts
served locally.
Pin the fix for the defect that kept whiteboards off 0.9.0: a stroke still waiting on the canvas reaches the save queue when the board closes, flushes, or switches. Also cover conversion carrying text onto the board, the board's text and fingerprint, and the keys a board keeps.
Add contentKind and surfaceData to the notes commands, note that update_note's reply omits the canvas, and list the File menu events and the .excalidraw export.
Describe what a whiteboard stores and why its body is the text on the board, and how migration v6 carries its canvas file through the vault mirror.
Archive feat-note-whiteboard with how each of the four pre-release defects was fixed, add the Whiteboard Notes requirement, mark unit 12 done, and close the vault's stage 3 whiteboard gate.
An image stays while anything references it as attachments/<name>: a note in
any state, a whiteboard's canvas, or the capture draft. The store re-checks
each candidate itself before removing it, and takes the live vault's copy only
while it is unchanged.
Deleting notes for good now also removes the images only they used, under the
same store lock as the delete so no save can start using one mid-cleanup.
Settings > Images reports how many stored images no note uses and removes them
on request, leaving anything added in the last hour alone.
Cover the unused count and size, the disabled state when every image is in use, and that removal happens only after the confirm.
Add unused_attachments and remove_unused_attachments, and what a permanent delete now removes and keeps.
Archive feat-attachment-cleanup with why removal happens at permanent delete and in an explicit sweep rather than in the background, add the Image Cleanup requirement, and mark unit 11 done.
Live notes, every tag and Space, and one link per membership, read from the
existing tables each time it is asked for. Nothing about the graph is stored.
A Graph place in the sidebar lays the library out with a seeded force layout,
so it draws the same way every visit, and settles it across frames so a large
library never freezes the view. Hovering lights a note's neighborhood, and
choosing a node opens the note, filters by the tag, or opens the Space. It
opens around the note you have open.
Pin that the layout is deterministic, keeps known nodes nearly still on refresh, and settles in steps to the same place; that the view offers every node as a button and routes each kind; and that every other place leaves graph mode.
Archive feat-graph-view with its decisions (tags and Spaces as edges, a sidebar place, nothing stored), add the Graph View requirement, and mark unit 13 done.
Several Tauri projects share the default 1420 dev port, so running
InstantNotes alongside another one collides. Give this project its
own port (1422) across vite.config.js, tauri.conf.json,
tauri.dev.conf.json, and the tauri-dev script so multiple projects
can run side by side.
Move @tauri-apps/plugin-updater from 2.10.1 to 2.12.0, window-vibrancy
from 0.6 to 0.8, and rusqlite from 0.32 to 0.40, along with the
resulting lockfile churn in package-lock.json and Cargo.lock.
Replace the update-available modal with a synthetic Update Space in
the sidebar: a green asterisk that holds two notes, the version you
are going to and the one you are coming from, the install button, and
the release notes as a readable note. It removes the old 'remind me
later' snooze in favor of a Space you can simply ignore, and the
welcome pill and tray check now open it directly.
Introduce src-tauri/src/error.rs, src-tauri/src/events.rs,
src/lib/api/error-codes.ts, and src/lib/api/events.ts as the single
source of each language's error codes and event names, plus
src/lib/api/contract.test.ts to hold the two halves equal. Replace
roughly 40 hand-rolled code: literals and 12 bare event-name literals
with references into the registries. This is behaviour-preserving: it
comes from docs/CODE_SMELL_AUDIT.md phases 0-4 and 6, and the
VALIDATION code it removes was latent and never user-visible, since
the paths that emitted it surface e.message directly rather than
going through friendlyMessage.
The unit landed on 0.9.0-pre on 2026-09-24 with every task checked, but no
entry was ever made in SEQUENCE.md, so the file that says what a release
contains left it out. It takes 13a by landing order and the boundary
registries move to 13b.

Add a test that fails when an archived change records landing on a release
branch and the ledger does not name it, since nothing was checking.
…resolved

Dependabot's three grouped PRs were closed rather than merged: the cargo group
was superseded by 6197b97, and the npm and github-actions groups are stale and
belong to the dependency baseline unit. Unit 6 now says so, and corrects its
own claim that the github-actions group is CI only, since tauri-action v0 to v1
is the action that builds and publishes the release artifacts.

The two whiteboard safety refs are deleted, with the evidence that 0.9.0 holds
everything they carried. Also clears the em dashes this file had collected.
A whiteboard could only be created from the File menu or the command
palette, so the feature was invisible to anyone reading the UI: the
toolbar's one create button always made a document.

The button becomes a split control. The + still makes a document in one
click, and a chevron beside it opens the existing context menu with both
kinds, each labelled with its keyboard shortcut so the menu teaches the
keys rather than replacing them. Right-clicking the control opens the
same menu.

ContextMenu grows an optional shortcut hint per item and an optional
anchor element. Presses on the anchor no longer count as outside the
menu, so the control that opened it can own the toggle instead of the
menu closing on press and reopening on the click.
Set the release date to 2026-09-25, the day it is cut. The Whiteboards
entry now names the note list's new create button as the first way to
start a board, since that is the one a reader will see without knowing
the shortcut.
@jamubc
jamubc requested a review from a team as a code owner September 26, 2026 02:10
@jamubc
jamubc requested a review from Shubin123 September 26, 2026 02:10
A Windows checkout converts CHANGELOG.md to CRLF, and the parser split
it on "\n" alone, so every line kept a trailing carriage return. The
bullet pattern ends in `(.+)$` with no `\s*`, and `.` does not match a
carriage return, so no bullet matched and the Settings dashboard showed
an empty "What's new" on Windows. Split on either ending instead, which
fixes it for every pattern rather than one.
The new CRLF tests replaced "\n" to make their fixture, which on a
Windows checkout turned the already-CRLF file into "\r\r\n" and tested
an input no checkout produces. Convert from either ending instead, so
the fixture is the same on every platform.
The export path test hardcoded "/tmp/a.md". On Windows a leading
separator is not an absolute path without a drive prefix, so every
fixture was rejected for being relative before its extension was
considered, and the test failed there. Build the paths from temp_dir(),
which is absolute on every platform, and keep one explicit relative
case for the check it is meant to cover.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant