e64d67e904069fddcdde63208b2df0210c9e898d
68
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e64d67e904 |
Expire trash after 30 days, and make the deadline something you can see
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m45s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m12s
Trash had no end. A note sat in /trash until someone emptied it by hand, and its attachment BYTES sat on disk the whole time — the pile-up the operator asked about. Nothing purged; there was no scheduler at all. Retention is server-owned: `trash_retention_days` (default 30, 0 = keep forever) in the settings registry, so it lands in admin Settings with no migration and takes effect without a restart. A background sweep started in before_serving does the work. Clients learn about a purge the way they learn about any deletion — as a tombstone on the delta feed. An auto-purge nobody can see coming is data loss on a timer, so the window is now visible: /api/config publishes it, notes carry `deleted_at`, Trash leads with the policy, and each card counts down. The countdown rounds DOWN — saying "1 day left" for a note with ten minutes on the clock is the one error here that actually costs someone a note. Three things this turned up on the way: - `DELETE /api/notes/<id>` hard-deleted the row, leaving no tombstone at all. A permanent delete in the web UI never reached a linked device, which would keep its copy forever and push it back on the next edit. It now purges through the same path as everything else. - The purge left `note_revisions` and `note_link_previews` behind. A revision holds the full body, so the text of a "permanently deleted" note was still sitting in the database. - `deleted_at` now SURVIVES a purge instead of being cleared. It's still true, and it means every query that says "not trashed" excludes tombstones for free — without it a content-less row reads as a perfectly normal active note and shows up on the board as a blank card. Desktop keeps its own clock only when there's nobody else to keep one: the sweep runs at startup on an UNLINKED device and refuses otherwise. A linked client that expired notes on its own schedule could destroy something the server was deliberately keeping, then push that delete upstream. Local policy must never outrank the server's — so it also adopts the server's window for the countdown rather than showing its offline default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
6f35e6e6d8 |
Confirm irreversible deletes, which sync just made far more consequential
The trash model itself was already right and needed no change: notes soft- delete (`trashed` locally, `deleted_at` server-side), Trash is a real view, restore works, permanent deletion is a separate second step only offered on an already-trashed note, `trash()` shows an Undo toast, and nothing auto- purges — trash persists until someone acts. Sync carries all of it: a trashed note syncs WITH its content, and only `purged_at` deletes a client's copy. What was missing is the guard on the irreversible step. "Delete forever" and label deletion were one click, silent, with no confirmation — and M10.7 has changed what that costs. Before, a mis-click lost a note on one machine. Now it pushes a tombstone that deletes it from every linked device, and the local tombstone survives to make sure it gets there. Both guards live in the STORE, not the call sites: NoteCard and NoteEditor both offer delete-forever, and duplicating the copy is how two prompts drift until one of them stops matching what actually happens. The copy names the real consequence — "deleted from every device you sync with" — because that's the part a user cannot infer from a button in a Trash view. The label prompt also says the notes themselves are kept, since that's what people actually worry about when deleting a label. Labels deliberately get a confirmation but NOT a trash of their own. A label is organization, not content; the reversible middle step notes get would be ceremony around something that costs nothing to recreate. Saved-filter deletion already confirmed (AppShell), so these two were the outliers, not a new convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
ed623a7bef |
M10.7d: download attachment bytes into a content-addressed store (task 2107)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m47s
The client half of task 1942's server work. Metadata already rides the delta feed; this fetches the payload so a synced image exists on the device. Blobs are filed under their own sha256, so the same image attached to five notes is stored once and re-downloading it is free — the dedupe the task asks for falls out of content addressing rather than needing bookkeeping. The hash is also the integrity check, applied on the way IN. Bytes that don't hash to what the server advertised are refused rather than filed under a name that lies about them — and because the blob then still counts as missing, the next sync simply tries again. SECURITY: the hash arrives in a server response and becomes a FILENAME, so it is validated as 64 hex characters before touching the filesystem. Without that, a hostile or buggy server could send "../../..." and steer a write outside the blob directory. Tested. A failed attachment never fails the sync. Notes are the primary data and have already landed; aborting here would let one unreachable file block every future sync. Counted, logged, surfaced in the UI as "they'll retry on the next sync", and retried because the blob is still absent. sha2 is pure Rust, so the Windows cross-compile lane pays nothing for it — the constraint recorded in ci-requirements.md. SPLIT, deliberately: this stores the bytes but does NOT yet render them in the webview. That half needs a custom URI scheme or the asset protocol, whose URL form differs by platform (Windows uses http://scheme.localhost/, others scheme://localhost/) — and CI cannot verify webview rendering at all, being headless with no webview. Guessing at it here would ship an unverifiable change on the most fragile lane. Follow-up filed; synced images will show as broken until it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
fe683595df |
M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s
The surface that turns the engine into a feature (rule 27). Desktop-only — the web build IS a server's UI, so a "connect a server" screen there would be nonsense; the route redirects to the board and the nav entry is hidden. UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads with "Working offline on this device — everything works without a server", because a screen that framed the default as a problem would push people into configuring something they may never need. The app is local-first; this is opt-in. Probe before credentials. "Check" shows who actually answered — site name, version, and the M10.6 verdict — before any password or token is typed. An incompatible server is shown in red and the sign-in fields never appear, so you cannot hand a credential to something that can't use it. `degraded` names the missing capabilities rather than staying quiet and letting a feature mysteriously do nothing. Both credential paths, matching the Rust side: email+password (a fresh install has no session to mint a token from) or a pasted device token (for anyone who'd rather not type a password into a desktop app). Secrets are cleared from component state the moment they're exchanged. Disconnect states plainly that the token stays valid server-side and points at Account -> Linked devices, rather than implying a remote revoke that didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason. Push rejections are surfaced verbatim after a sync, never swallowed — a duplicate label name is the realistic case and only a person can resolve it. Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" — it's a revision watermark, not a time, and it doesn't move at all when a sync legitimately finds nothing new, so "synced a moment ago, nothing new" would be indistinguishable from "never synced". Stamped only after BOTH halves of the cycle succeed; a stamp after a partial cycle would claim currency the data doesn't have. Cleared on unlink so a new server can't inherit it. run_cycle now returns the post-cycle status, so the UI updates from one round-trip instead of chasing every sync with a status call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi |
||
|
|
f325402902 |
desktop: robust startup + operation logging (portability troubleshooting)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 32s
There was essentially no logging — useless for proving the app renders across different environments. Add real observability: - tauri-plugin-log -> stdout (so `2>&1 | tee` captures a run) AND a persistent file in the app log dir (grabbable after the fact on any machine). Level Info. - Startup diagnostics: app version, OS/arch, the Linux display/session stack (XDG_SESSION_TYPE, desktop, Wayland/X11, GDK_BACKEND), the WebKit render- hardening vars actually in effect, resolved log + data dirs, DB open/migrate result, and note/label counts. - log_event command + a frontend logEvent() helper: boot line (data source + WebKit user-agent) from main.ts, first-route config/session/destination from the router guard, and — via the bridge invoke() wrapper — every failed Tauri command named with its error, so a broken basic function is self-identifying. Task 2040. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
41cf4b3598 |
desktop M10.5: wire the offline local source; boot to board with no server
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 57s
adapters/local.ts implements the repository seam over the M10.4 Tauri commands via invoke (camelCase args -> the commands' snake_case params); adapters/index.ts now selects local when running in the desktop shell, rest on web. bridge.ts exports invoke for it. This is the commit that resolves the black screen: a fresh desktop launch reads config + session + notes from the on-device SQLite core, so the router's auth gate passes with a synthetic local user and the board renders with zero server and zero account. Account auth, device linking, attachment upload, URL unfurl and file import reject with a "connect a server" message (no offline meaning yet); everything else — board, editor, capture, search, filters, labels, checklists, reminders — works fully offline. desktop.yml also now rebuilds the app on frontend adapter/bridge changes, since the desktop bundle embeds the frontend. Task 1994. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
20cf15c99c |
desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
Extract a typed repository interface (adapters/repo.ts) from the scattered store/view -> api.* calls, backed by adapters/rest.ts (verbatim HTTP mapping) and selected through adapters/index.ts. Every store and the notes-facing views now depend on `repo`, never the HTTP client directly -- the seam the offline local source (M10.5, over Tauri invoke) plugs into next. Behavior-preserving for web: rest.ts maps each semantic method to the exact endpoint the code called before; query-string and multipart building moved out of the stores/views into rest.ts (the one place that knows the URL shape). Client-side logic (reconcile/sort/optimistic reorder/toasts) stays in the stores. GraphView + admin SettingsView keep direct api calls -- out of the offline-core scope (M10.5 is board/editor/capture/search/filter/labels/ checklists/reminders). Task 1992. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
877a6a572f |
M10 (task 2013): integrated AppImage — app self-integration (OOBE + Account toggle)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 26s
CI & Build / Build & push image (push) Successful in 33s
The Linux AppImage can now install itself into the applications menu, so it behaves like an installed app instead of a loose file. - Rust (desktop/src-tauri/src/integration.rs): integration_status / integrate_desktop / unintegrate_desktop commands — detect $APPIMAGE, copy the AppImage to ~/Applications, write ~/.local/share/applications/thoughtsync.desktop + embedded icon, update-desktop-database. Registered in lib.rs. - Frontend: withGlobalTauri exposes window.__TAURI__.core.invoke; desktop/bridge.ts (isDesktop + typed invoke, NO @tauri-apps/api dep -> web bundle unaffected); DesktopIntegrationPrompt (first-run OOBE, remembered) mounted in App.vue; AccountView "Desktop app" add/remove control. All desktop-guarded -> no-ops on web. - desktop.yml: upload the .deb + .AppImage as a run artifact (continue-on-error) so the build is downloadable for hand-testing. Verified by CI: ci.yml (vue-tsc) for the frontend, desktop.yml (cargo + tauri build) for the Rust + AppImage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
44a5466793 |
M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
- BaseModal.vue (new): the backdrop + dialog-panel shell (dimmed fixed overlay, bordered rounded panel, role=dialog, close on Escape + backdrop mousedown). Caller sizes/pads/shadows the panel via `panelClass`, picks start/center `align`, and sets an `ariaLabel` for header-less panels. - LabelsModal, CommandPalette, and the AppShell keyboard-shortcuts overlay drop their hand-rolled backdrop+panel shells and slot their content into BaseModal (~12 lines of overlay boilerplate each → gone). - NoteEditor deliberately keeps its own shell: its backdrop mousedown is drag-guarded and its Esc/⌘-Enter handling is bespoke (unsaved-edit safety), so folding it in would risk regressing the app's core editing surface (rule 28). - AccountView's one device-name field now uses the shared BaseInput. SettingsView is intentionally NOT converted — its rows are a horizontal label+control pattern (checkbox/number/text, direct value mutation), a different shape than BaseInput's vertical form field. Frontend-only; CI vue-tsc is the type/template gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
9c679d0ab9 |
M9 S5: guard the login ?redirect= against open redirect
LoginView handed route.query.redirect straight to router.replace, so a crafted link like /login?redirect=//evil.com (or a backslash variant) could bounce a just-authenticated user off-site. safeRedirect() now only follows an in-app absolute path — a single leading slash, rejecting "//host" and "/\\host" (and anything without a leading slash, i.e. absolute/scheme URLs) → falls back to "/". Frontend-only; CI vue-tsc is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
e1cf63e875 |
M9 S3 (frontend): consolidate local-date helpers into notes/datetime.ts
The created-date range facets built local-day bounds by hand in two places (FilterBar's onTo/toInput, TimelineView's buildQuery) — parse a "YYYY-MM-DD", shift a day for the half-open upper bound, format back. - datetime.ts: parseLocalDate() / addLocalDays() (non-mutating) / formatLocalDay(). - TimelineView: drops its inline localDate() + the +1-day Date math. - FilterBar: drops its inline isoDay() + the setDate(±1) mutations. Behavior-preserving and deliberately NOT unifying output: Timeline still emits UTC (.toISOString()) bounds, FilterBar still emits naive-local "…T00:00:00" strings — only the shared primitives are extracted. (The naive-vs-UTC divergence is a separate backend-datetime-semantics question, flagged for later, not silently changed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
590d3ff2f6 |
M9 S3 (frontend): recall views adopt useNoteList + AsyncState/EmptyState
The Search / Timeline / Reminders views each hand-rolled the same items+loading+error scaffold, retry button, and editor-host glue. - useNoteList(fetcher, fallbackError) (new): the load-a-note-list scaffold (items/loading/error + a load() that never leaves a half-state). Views supply just the fetcher; the refs drive <AsyncState>. - SearchView / TimelineView / RemindersView: adopt useNoteList + useNoteEditor + <AsyncState>/<EmptyState>; drop the local list/loading/error refs, the duplicated retry blocks, and the notes.items-shadowing navigate glue. - GraphView: editor host now via useNoteEditor (openNode/closeEditor/onNavigate collapse to navigate); loading/error via <AsyncState>. Its two empty states keep inline markup/buttons, so they stay custom (not forced into EmptyState). - reminders store: fetchReminders() is the single owner of /api/notes/reminders; both the background poll (check) and RemindersView read through it. Frontend-only; CI vue-tsc is the type gate (no local typecheck, rule 10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
e8e0d86413 |
M9 S2: fold raw multipart fetches into api.postForm
Both notes-store uploads (uploadAttachment, importNotes) hand-rolled the same fetch + resp.json() + !ok error parsing that api.client already does. Add `api.postForm<T>(path, form)`: request() now detects a FormData body and lets the browser set the multipart Content-Type (skipping the JSON header + stringify), reusing the shared error handling — so the two uploads gain network-error handling and the 5xx infra toast they lacked. A too-large import returns 413 (< 500), so it still throws for inline display rather than toasting. DRY: net -13 lines; no raw fetch() remains in the stores. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
18ca4d4db4 |
S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
M9 section S1, commit 4 (frontend). Introduce the shared UI primitives the 7 data views + 5 editor hosts were hand-rolling: - components/AsyncState.vue: the loading / error(+Retry) wrapper (emits `retry`). - components/EmptyState.vue: the centered title/subtitle "nothing here" block. - composables/useNoteEditor.ts: the editing/open/close/navigate glue every editor host duplicated, as one controller (onClose hook + local-list resolution for [[wiki-link]] navigation). BoardView adopts all three: its three hand-rolled loading/error/empty blocks collapse into <AsyncState> + <EmptyState>, and its editor glue into useNoteEditor. The other views + editor hosts adopt these in the Organize (S3) and Auth (S5) sections. Behavior-preserving. Frontend has no local typecheck (rule 10); CI's vue-tsc is the gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
3c78060051 |
Header: use the app icon instead of the "TS" text badge (links home)
The header showed a plain "TS" text badge while the real brand icon (public/icon.svg — the yellow knowledge-graph tile) was only used as the favicon/PWA icon. Swap the badge for the actual icon, and make the logo + name a RouterLink to the board (standard "logo goes home"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
1df70c53bd |
Capture: Enter-to-start + on-board notice; dismiss discards a new note
Live-pass feedback: make the "waiting for input" state visible and starting a note more deliberate, and let dismiss cancel an accidental note. - New notes are now a confirm-to-keep dialog: Esc OR click-away DISCARD a brand-new, not-yet-persisted note (so an accidental keystroke / type-to- compose never litters); Ctrl/Cmd+Enter, the footer button, or Shift+Enter commit it. A compose already persisted by a rich action, and any existing note, still close-and-save on dismiss. The compose footer button is now a filled "Add note" so the save path is unmistakable. - Enter (board, nothing focused) starts a new note; a subtle dashed on-board notice — "Press Enter or start typing to add a note" — makes capture discoverable instead of silent (click it to compose too). Type-to-compose stays as the fast path. - Empty-state copy + shortcuts help updated (Enter/c for a new note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
675b3aa248 |
Capture UX: "+ New" header button + type-to-compose (two-mode board keys)
Live-pass feedback: the wide "Take a note…" bar showed through behind the
compose modal and felt redundant. Per operator choice, remove the bar and
make capture header-button + keyboard driven.
- Removed the board's inline "Take a note…" trigger bar.
- AppShell gains a "+ New" header button (next to search) — navigates to the
board if needed, then opens the compose modal. The `c` shortcut now does
the same (unified with newNote()).
- Type-to-compose: on the board with no card focused, any other single
printable key opens a new note SEEDED with that key (AppShell onKeydown
fall-through, after the reserved / c ? g shortcuts). Seed travels via
ui.composeSeed → NoteEditor's new `initialBody` prop; caret placed at end.
- Two-mode board keyboard (BoardView): RESTING = arrows enter browse, letters
type-to-compose; BROWSING (a card focused) = j/k move, e/x/# act, Enter
opens, Esc exits to resting. ui.boardCardFocused tells the global handler
to stand down while browsing so it doesn't swallow card keys.
- Updated the shortcuts help + the empty-state copy ("Hit + New — or just
start typing").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
f9f1b77d37 |
Unify compose + edit onto one surface (modal-only editor)
Live-pass feedback: compose and edit still felt like different surfaces. They already shared one component (task 1920), but rendered as two frames — an inline in-flow box (compose) vs a modal overlay (edit) — which read as two designs. Per operator choice, make BOTH the modal. NoteEditor is now modal-only (rule 22 — the inline frame is fully removed): dropped the `inline`/`autofocus` props, the collapsed "Take a note" frame, `expanded`, `open()`, `commitInline`, `autoGrow`, and the outside-click commit. Compose vs edit is purely note=null vs a note. Esc / Ctrl+Enter / backdrop / Done all commit-and-close (create in compose, save in edit); Shift+Enter still saves & starts a fresh note in compose (now gated on isCreate, not the frame). Edit-only sections gate on !isCreate. BoardView: the always-expanded inline composer becomes a slim "Take a note…" trigger bar that opens the SAME modal with an empty note; the `c` shortcut does likewise. One surface for capture and editing — and the seam the card→editor grow animation (1914) will hook into. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
d1bc91a54a |
M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
Completes 1908 without Web Push. While the app is open, due reminders now actually surface; recurring reminders + snooze/done are manageable. - reminders store (singleton): polls /api/notes/reminders every 45s while the app is open; each due reminder fires ONCE as a toast (with an "Open" action) and, if the user opts in, a page-context OS Notification — no service worker, no PWA. Silently primes a stale backlog on first load; only announces recently-due ones. AppShell starts/stops it. - Editor reminder section: a Repeat picker (Does not repeat / Daily / Weekly / Monthly / Yearly) + Done (advances a recurring reminder / clears a one-off) + Snooze 1h/1d, shown when a reminder is set. - RemindersView rebuilt as a chronological list: per row a due time + recurrence badge + Done / 1h / 1d, click to open; plus an "Enable notifications" opt-in and a note that background alerts come with native. - Note type gains `recurrence`; notes store setRecurrence / completeReminder / snoozeReminder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
5c045aed63 |
M6 1902b: facet bar + saved views UI (frontend)
The dead-simple facet bar over the board + saved views in the sidebar,
completing task 1902. Filter state lives in the URL query, so a filtered
board is a shareable lens and a saved view is just a link ("one space,
many lenses").
- notes/facets.ts: facetsFromQuery / facetsToQuery / facetCount helpers.
- FilterBar.vue (board only): a "Filters (N)" toggle expanding to text
search + color swatches + label chips + has-reminder / has-attachment /
Lists / Notes toggles + a created-date range; Clear + "Save view".
Each control writes the URL query (router.replace).
- notes store: load(view, label, facets) builds the query; NoteFacets type
+ activeFacets; import reload preserves active facets.
- savedFilters store + sidebar "Views" section (each a query-link, delete
on hover); loaded on mount.
- BoardView derives facets from the query, reloads on facet change (ignores
?open=), and shows a "no notes match these filters" empty state.
- Backend: saved-filter param whitelist keys on `label` (matches the
repeatable ?label= query) so saved views keep their labels. New filter
icon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
69bf04e948 |
M6 1901: URL capture with link-preview unfurl (SSRF-hardened)
Paste a link → fetch its OpenGraph/meta preview (title, description, image,
site) and show a rich card. User-triggered + persisted (never auto-fetches;
cached so it never re-fetches). Opt-in via a new admin setting
enable_url_unfurl (default on, rule 26).
Security (the whole point of this task): a new dependency-free unfurl.py
does the fetch with layered SSRF defenses — http/https only; resolve the
host and reject EVERY non-public address (private/loopback/link-local/
reserved/multicast/unspecified — blocks 169.254.169.254 etc.); connect to
the vetted IP with SNI so DNS-rebinding can't slip through; ≤3 redirects
each re-validated; 5s timeout; 512 KB cap; text/html only; blocking IO in a
worker thread. No server-side image fetch — the og:image URL is loaded by
the browser.
- note_link_previews table (migration 0020), one per (note, url); serialized
inline on notes (+ rides the sync pull feed read-only).
- POST /api/notes/<id>/unfurl {url} (owner-scoped, setting-gated, 502 on
fetch failure); DELETE /api/notes/<id>/previews/<id>.
- enable_url_unfurl exposed in public config so the UI hides the affordance
when disabled.
Frontend: LinkPreview.vue card; editor detects URLs in the body and offers a
"Preview <domain>" chip per un-previewed link (ensureDraft first), renders
preview cards with remove; card shows previews read-only. New link icon;
notes-store unfurl()/deletePreview().
Tests (DB-free): is_public_ip range blocking, validate_url scheme/parts,
extract_preview (OG + <title> fallback + relative-image resolve), endpoint
auth-guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
b5f545f655 |
M6 1900: any-file attachments + audio memos (broaden beyond images)
A note can now carry any file, not just images — PDFs, documents, audio memos, etc. "Dump anything" capture. Backend: - note_attachments.filename (migration 0019) records the original name for download + display. - Upload drops the image-only mime gate: accepts any type, derives the storage extension from the filename, and enforces a DB-backed per-file cap — new setting max_attachment_mb (default 25, rule 25). App body ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits); the per-file cap is the effective attachment limit. - Serve sets Content-Disposition: images inline, everything else downloads with its original (header-sanitized) filename. - Import (native + Keep Takeout) now brings in ANY attachment, not just images — completing the Keep audio-memo gap; preserves filename + sha256. - Attachment metadata (delta feed + REST) carries filename. Frontend: - Editor renders attachments by kind: images inline (thumbnail), audio via an inline <audio> player, any other file as a download chip (paperclip + filename + size). File picker accepts any type; "Attach a file". - Card previews the first image; non-image files show as compact chips. Tests (DB-free): _safe_filename, _attachment_ext, _header_filename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
0ca244be3d |
Sync 5: attachment blob sync — client id + content hash (M8)
Let native clients sync attachment blobs deterministically: - note_attachments gains sha256 (migration 0018, nullable, no backfill). The delta feed's attachment metadata now carries size + sha256 so a client knows exactly which blobs it already has (dedupe) and can verify integrity after download. - Upload accepts an optional client-supplied attachment id (multipart form field), so a file attached offline keeps its identity across sync; re-uploading an id the note already has is an idempotent no-op. The server hashes the stored bytes (sha256) on upload. Download by id already exists (owner/shared scoped). Frontend Attachment type carries the new optional size/sha256. (Still image-only mimes — broadening to any-file is task 1900. Blob sync behavior is operator-verified on deploy.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
3c76b50a9c |
Sync 2: device-token bearer auth + linked-devices UI (M8)
Native clients (Tauri/Android) authenticate sync with a long-lived device bearer token, alongside the existing web session cookie. Backend: - security.py: generate_token() (secrets.token_urlsafe) + hash_token() (SHA-256 — device tokens are already high-entropy, so no slow KDF; keeps per-request bearer auth cheap). Only the hash is stored. - device_tokens table (migration 0016): id, user_id, token_hash (unique), name, created_at, last_used_at. - login_required now accepts `Authorization: Bearer <token>` OR the session cookie. Session path stays DB-free (fast); bearer path looks up the token hash, sets g.user_id, and stamps last_used_at. - Endpoints: POST /api/auth/device-login (public; email+password → token, the native first-link flow), POST /api/auth/devices (session/bearer → token, web "link a device"), GET /api/auth/devices (list), DELETE /api/auth/devices/<id> (revoke). All owner-scoped; token shown once. Frontend: - Per-user (not admin) /account view "Linked devices": create a token (one-time reveal + copy), list devices (name, linked/last-synced), revoke with confirm. Top-bar device icon for all users; devices Pinia store. Tests (DB-free): token hash determinism + uniqueness; device endpoints auth-guard (401 without auth, before DB); device-login input validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
333ab9ce74 |
Import: ThoughtSync-native round-trip + Google Keep Takeout
Complete the export/import pair (task 1907). POST /api/notes/import takes
an uploaded .zip and appends its notes — never overwriting existing ones.
Two formats, auto-detected:
- ThoughtSync export: recognized by its notes.json (app == thoughtsync);
round-trips title/body/color/kind/pinned/archived/remind_at/timestamps/
labels/items and re-attaches image media from the zip.
- Google Keep Takeout: each Keep <note>.json → a note. Maps title,
textContent/listContent (+ checked), labels, Keep color enum (nearest
palette match), isPinned/isArchived, isTrashed (→ trash), created/edited
microsecond timestamps; folds annotation URLs into the body; resolves
attachment filePaths relative to the note's folder.
Imported notes reuse create_note's derivation + reconciliation:
display-title derive, #tag reconcile, [[wiki-link]] rewrite. Explicit
labels attach as manual (via_tag=false); inline #tags reconcile as tags.
Image attachments copied into media storage; non-image types (e.g. Keep
audio) skipped until any-file attachments land.
Frontend: an Import control in the sidebar (next to Export) — hidden file
input + FormData POST + result toast ("Imported N notes (M skipped)"),
reloading the board + labels. New upload icon; notes-store importNotes().
Tests: import auth-guard + pure-helper coverage (_usec_to_dt, _keep_spec
list/text/color/annotation/attachment mapping, _native_spec round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
1417479729 |
Graph: label hubs cluster notes; unlinked notes float by default
Add labels as first-class nodes in the graph so tags act as visual clustering hubs, and let unlinked notes float in the space by default — per operator request (the graph is a light auxiliary lens, not a focal surface). Backend (graph.py): - Emit a label-hub node (id "label:<uuid>", kind "label", #name, label color) for every label attached to a live note. - Emit note -> label membership edges (kind "label") alongside the existing wiki-link edges (now kind "link"). Frontend (GraphView.vue): - Render hubs as larger ringed nodes; membership edges dashed with a slightly longer spring rest so notes ring their hub. - Default showAll (unlinked notes float) to true; add a Show labels toggle (default on) that hides hubs + membership edges. - Click a label hub -> that label's board lens (/label/<id>); note clicks still open the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
cc828559ff |
M6: export — download all your notes as a zip
Data portability / no lock-in (task 1907, export half). GET /api/notes/export streams a zip of the caller's notes: a machine-readable notes.json (notes + labels + items + reminders + attachment refs), a human-readable Markdown file per note (frontmatter + body / checklist), and the attachment media. Sidebar 'Export' link (same-origin GET, session cookie) downloads it. _slugify unit-tested. Import (Google Keep Takeout) is the follow-up increment of this task. Pure backend + small frontend; no migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
efbf981a2a |
M6: version history — note revisions + restore
A note's title+body is snapshotted on each edit that changes either, so an accidental overwrite can be viewed and restored (task 1906). Underwrites 'dump freely, nothing is lost'. Backend: note_revisions table (migration 0014) + NoteRevision model; update_note records a revision of the PRE-edit state whenever title/body changes; GET /api/notes/<id>/revisions (newest 50) and POST /api/notes/<id>/revisions/<rev_id>/restore (snapshots the current state first so restore is itself undoable, then applies the revision with the usual title/body ripple — display name, links, #tags, backlinks). Title+body only in v1. Frontend: a History toggle in the modal editor opens a panel of past versions (timestamp + preview) with per-row Restore. Store gains fetchRevisions/restoreRevision. Migration 0014 runs on deploy; DB behavior operator-verified (no Postgres CI lane). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
47974f62ee |
M7: editor keyboard + one-click card toolbars (live-pass fixes)
From the unified-editor live pass (task 1922): - Esc now returns to the base Notes board from any view (AppShell); an open editor consumes Esc first (its handler stops propagation) so it closes before navigating. - Ctrl/Cmd+Enter = finish & close in BOTH frames — it already closed the modal editor; now it also commits+collapses the board composer (matches the email/chat 'send' convention). Shift+Enter still saves & starts a new note. - Fix: the inline composer committed on document mousedown, collapsing and shifting the board before a click landed — so clicking another card's pin/archive/trash took two clicks. Commit on click (bubble phase) instead; the target's action fires first, then the composer closes. Pure frontend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
261d8fcac5 |
M6: lightweight markdown rendering on note cards
Render a small Markdown subset in the read view — headings (#..###), bullet/ordered lists, blockquote, fenced code, and inline **bold** / *italic* / `code` — alongside the existing [[wiki-links]] (task 1905). Capture stays plain text; only the card render is formatted. Hand-rolled dependency-free parser (notes/markdown.ts) rendered as Vue vnodes (MarkdownText/MarkdownInline), never v-html, so there is no HTML-injection surface — matches the no-heavy-dep ethos. Headings require '# ' (space), so a #tag (no space) stays plain text and is never mistaken for a heading; the two coexist. Replaces LinkedText (links-only) on the card; LinkedText deleted. Pure frontend — no backend/DB/migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
bb33d5c495 |
M7: unify compose + edit into one NoteEditor (DRY)
The 'Take a note' composer (QuickAdd) and the note editor were two components with duplicated field logic and unequal capabilities — composing lacked [[ links, labels, images, reminders. Merge them into a single NoteEditor with two frames: inline on the board (compose), modal on a card (edit). Same fields, styling, [[ autocomplete and toolbar in both (task 1920). Compose is now a draft that persists on first real action: on commit-with-content, or on the first label/image/reminder/checklist use (ensureDraft) — so no empty-note litter. Rich controls light up once there's content; note-lifecycle actions (pin/archive/trash, links/backlinks) stay in the modal. notes.create() now returns the created note (for the draft id). QuickAdd.vue deleted; BoardView renders <NoteEditor inline>. Pure frontend — no backend/DB/migration. Sets up the editor<->card morph (task 1914). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
ae0c748507 |
M6: label management — usage counts + merge
Extends the existing label manager (create/rename/color/delete) with the two missing maintenance tools (task 1904), so the label list stays clean — which matters more now that #tags mint labels automatically. Backend: GET /api/labels returns a per-label note count (one grouped query); new POST /api/labels/<id>/merge moves the source label's notes onto a target and deletes the source (repoint via delete+reinsert to avoid mutating the composite PK; preserves via_tag; dedupes notes already on the target). Body #tags are NOT rewritten, so a tag-sourced label re-mints on next edit if its #tag text remains — a documented nuance. Frontend: LabelsModal shows each label's note count and a 'merge into…' picker; also restores the previously-missing close (x) and merge icons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
95b0e30fc7 |
M6: browse notes by creation date (Timeline lens)
A temporal recall path — find a note by WHEN it was captured, not just what it contains (task 1903, first of the M6 recall items). Backend: list_notes gains an optional created_at range (created_after / created_before, half-open interval) + sort=created; also lays groundwork for the richer-search facets (task 1902). New _parse_iso_dt helper with a DB-free unit test. Frontend: a Timeline view (sidebar nav + 'g t' + command palette) grouping active notes newest-first into local-time buckets (Today / Yesterday / Earlier this week / this month / Month YYYY), plus an optional From/To date filter. Built as a lens on the same NoteCard masonry, consistent with the existing Reminders/Search views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
c4914fe587 |
m4.5: inline #tags — hashtags in a note become labels (two-way sync)
Fast, cross-device labelling: type #groceries in a note and it becomes the "groceries" label. The body is the source of truth for tag-labels; manual picker labels stay independent (rule 28 — additive). - note_labels.via_tag (migration 0013) marks tag-sourced attachments. - parse_tags(): #tag at start-of-body or after whitespace, needs a letter (so #2024, URL #frags, mid#word are ignored). unit-tested. - _reconcile_tags() on create + body-update: attach labels for current #tags (find-or-create, case-insensitive), detach tag-labels whose tag was removed; never touches manual (via_tag=false) rows. - label picker (set_note_labels + editor onLabelsChange) now preserves tag-labels on save, so a picker action can't strip a label the #tag still mandates. - serialize via_tag; card/editor chips render tag-labels as "#name", and the editor hides the × on them (remove by editing the tag text). - LabelPicker builds manual NoteLabels (via_tag:false). Fourth and final item of M4.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
80324fba3b |
m4.5: lists first-class — create a checklist straight from quick-add
Checklist notes existed (M2) but could only be made by creating a text note and toggling it in the editor — so they were undiscoverable. Now the board's quick-add can make one in one shot. - create endpoint accepts kind + items: POST /api/notes with kind:"list" and items:[...] creates a checklist note and its items atomically. A list note is non-empty when it has a title or ≥1 item. - quick-add gets a checklist toggle (checkbox icon): flip it and each body line becomes an item on save; placeholder switches to "One item per line"; resets to a plain note after close. - notes store create() accepts kind + items; parse_list_items() helper (trims, drops blanks) with a unit test. - title placeholders now read "Title (optional)" in quick-add too. Third item of M4.5. Display/editing of checklists was already built in M2; this closes the creation gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
0ae02858f7 |
m4.5: content-aware [[ linking — autocomplete searches note body
The [[ autocomplete only matched note names, so you could only link a note you could name. Now it searches note NAME *and* body, so you can link by recalling any phrase. - new GET /api/notes/link-search?q= — owner-scoped, non-trashed; substring ILIKE on display_title OR body; ranked name-first, then name-prefix, then recency; empty q returns recent notes as suggestions. Deterministic (no semantic/AI search); the FTS index still powers the heavier /search. LIKE wildcards in q are escaped. - editor [[ autocomplete now calls link-search (debounced 120ms) instead of filtering the cached titles index; excludes the note itself; inserts the matched note's display name as [[Name]]. - unit tests for the LIKE-escaping + a link-search auth guard. Second item of M4.5; builds on the display_title work (every note has a name to link to). Command-palette content search is a natural follow-on, left out to keep this focused. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
2b6a353666 |
m4.5: titles optional — every note gets an auto display name
Capture starts in the body, so forcing a title feels odd and body-only notes had no name — which made them unlinkable. Fix both: persist a display_title = explicit title if set, else the note's first non-empty body line (deterministic, no AI). The title field stays optional. - migration 0012: notes.display_title (NOT NULL, best-effort backfill; the app recomputes precisely on next save) - derive_display_title() helper, set on create + update - drive the /titles index, backlinks, graph edges + node labels, and [[wiki-link]] resolution off display_title so body-only notes are nameable, findable (command palette / [[ autocomplete), and linkable - rename-repoint generalized: inbound [[Old Name]] links now survive a name change via the first body line too, not just an explicit title - unit tests for the derivation (explicit wins, first non-empty line, blank/empty, length cap) - frontend: display_title on the Note type; title field placeholder now reads "Title (optional)" First item of M4.5 (frictionless input & recall); unblocks the linking work. Card rendering unchanged (no first-line duplication). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
1b6eb0053b |
m4: perf pass — lazy-load note images
Light, audit-first perf pass (task 1870): - lazy-load + async-decode the card thumbnail and editor attachment images so an image-heavy board / large note doesn't eagerly fetch and decode every off-screen image up front Audit found the rest already in good shape and left as-is: all routes are code-split via dynamic import(); the titles index (palette, [[-autocomplete, wiki-link resolution) is cached in its store and only fetched once; board refetches are scoped to real view/label changes with no double-load on mount. (Server-side thumbnail generation — cards load full-res uploads — is a larger, separate feature, noted for later, out of scope for a light pass.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
5e225075f3 |
m4: drag-to-reorder polish — grip handle + drop affordances
Refine the masonry drag interaction (task 1869) so reordering reads as intentional instead of an accidental, feedbackless jump: - explicit grip handle (top-left, hover-revealed, board views only) gates dragging — a plain click or text-select no longer starts a drag - source card dims while dragging; the card under the pointer shows a brand ring + slight lift, so the drop position is clear before release - proper move cursor via dataTransfer effectAllowed/dropEffect - dragleave uses a relatedTarget guard so the target ring doesn't flicker over child elements - clear drag state on dragend (BoardView clears the tracked source even when a drag is cancelled off-target) - add a "grip" icon to the shared Icon set Native HTML5 DnD stays library-free; it remains a desktop/mouse affordance (touch reorder is the M5 Android client's domain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
3e9b6095dd |
m4: PWA install — manifest, icons, service worker
Make ThoughtSync installable ("Add to Home Screen") without going
offline-first (the Android app is the real offline client, M5):
- web app manifest (name, icons incl. maskable + SVG, standalone, theme)
- generated PNG icon set + apple-touch-icon + favicon, from committed
SVG sources (a linked-thoughts constellation on the brand tile)
- minimal service worker: installable shell only — caches just an
offline fallback page, never the app shell / hashed assets / API, so
data stays fresh and deploys never serve a stale shell
- register the SW in main.ts (progressive enhancement; failures ignored)
- index.html: manifest/icon links, apple-mobile meta, description
- backend: register the .webmanifest MIME type so it serves as
application/manifest+json
- README: note that install needs a secure context (HTTPS/localhost)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
|
||
|
|
b7b37e5e1a |
m4: settings-UI polish (dirty tracking + load-error state)
Save is disabled unless something actually changed (dirty = current values vs. the last loaded/saved snapshot); "Saved." clears the moment you edit again, and a quiet "No unsaved changes" shows at rest. A failed load now renders an explicit error + Retry instead of a blank form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
545f946754 |
m4: per-view error states (board / search / graph / reminders)
Each data view now catches a failed load and shows an explicit error message with a Retry button, instead of falling through to a misleading empty state. Pairs with the global error toast: the toast says something failed, the view says which and offers a retry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
d3bb091f73 |
m4: global API-error surface (no more silent failures)
The api client now catches network failures and non-JSON bodies robustly, and routes unexpected errors (offline / 5xx) to a global toast — 4xx stay with the caller so forms keep their inline messages. Toast actions are now optional (undo toasts keep their button; error toasts are message-only), and ToastHost moved from AppShell to the app root so toasts show everywhere, including the login screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
ecbdd12ba9 |
m4: mobile nav — hamburger + slide-in drawer
Below sm the sidebar had no replacement (nav was inaccessible on phones). The sidebar aside is now a responsive drawer: fixed + off-canvas with a hamburger toggle and backdrop on mobile, static in-flow on sm+ (same markup, no duplication). Closes on nav-click, backdrop, Esc, or route change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
894dd4ac42 |
graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
Backend /api/graph now returns ALL non-trashed notes as nodes, each with its first label's color; edges unchanged. GraphView reworked: drag a node to reposition it (pins to cursor + reheats the sim), pan the background, wheel- zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by default), a Reset view button, and nodes filled by label color. Click (a press without a drag) still opens the note; re-fetches on view open. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
d570939253 |
ui: capture polish — auto-grow quick-add + per-card color popover
QuickAdd body textarea auto-grows to fit content (min ~3 rows, capped with scroll) instead of a fixed 3 rows. NoteCard's hover toolbar gains a color dot that opens a swatch popover to recolor the note in place (outside-click closes it; listener attached only while open). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
9baa9217c7 |
ui: undo toast after trash / archive
A global ToastHost (mounted in the app shell) shows a transient toast with an Undo action, driven by the ui store (showToast/dismiss/runAction, 5s auto-dismiss). The notes store fires it centrally: trash → Undo restores; archive → Undo unarchives — so it works from card, editor, and keyboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
f321c2bf70 |
labels: per-label color
Give labels a color (migration 0011 adds labels.color, server_default 'default' so existing labels keep the neutral chip). The PATCH endpoint now updates name and/or color; note serialization carries each label's color. Frontend: a swatch picker per label in the Edit-labels modal, colored chips on cards + in the editor (LABEL_CHIP_CLASSES), and a color dot on each sidebar label. Reuses the note color vocabulary. (Graph node coloring rides this in the graph-liveliness task.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
9be8920362 |
links: clickable [[wiki-links]] on cards
LinkedText now makes each [[link]] segment interactive: click (or Enter when focused) resolves the title and opens the target note, creating it first if it doesn't exist. Reuses the palette's /?open=<id> open path. @click.stop / keydown.stop keep the surrounding card's click-to-open from also firing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |
||
|
|
2a06f7499a |
links: [[ autocomplete in the note editor
Typing `[[` in a text note's body opens a title-suggestion menu from the titles index. Arrow keys move the selection, Enter/Tab inserts `[[Title]]` and places the caret after it, Esc dismisses just the menu (kept from bubbling to the dialog's Esc-close), mouse hover/click also select. The menu appears only while the caret sits inside an unclosed [[… token. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm |