Commit Graph
45 Commits
Author SHA1 Message Date
bvandeusen d77a79859c server: hand out the Android client this server syncs with (2726)
CI & Build / Python tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 5m32s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 8m8s
A self-hoster should not need an account on someone else's forge to get the app
for their own notes. The Fabled-Git instance is private — which is why
`install.sh` already cannot fetch for anyone but the operator — so a release page
is no use as a distribution point. The server holding the notes is something the
person already trusts and already reaches.

It also keeps the pair in step by construction. Client and server negotiate a
sync protocol version before linking, so a server that also serves the client
cannot hand out a phone it is unable to talk to.

**Two files, and both must be present**: `thoughtsync.apk` and a
`thoughtsync-android.json` sidecar carrying `{version_name, version_code, size,
sha256}`. The sidecar exists because an APK keeps its version in a binary AXML
manifest, which Python cannot read and which is not worth putting `aapt` on a
Quart server to reach. CI writes it beside the APK, where the values are already
known — including the digest, computed over the same bytes it uploads, so a
phone can tell a truncated download from a complete one before handing it to the
installer. Not a trust anchor; the signature is that.

**Under DATA_DIR, not baked into the image.** Baking charges ~55 MiB to every
self-hoster including everyone who never touches Android. `/var/thoughtsync` is
already the mounted volume that holds attachments, so a build dropped there
survives container recreation.

**Absence is an ordinary state, not an error.** No APK means the key is absent
from `/api/config` — absent rather than null, so a client testing for it cannot
confuse "this server has no client" with "this server predates the field" — the
web UI hides the card instead of offering a button that 404s, and the metadata
route answers 404. A server whose owner does not use Android is not misconfigured.

**A mismatched pair also counts as no client.** If the sidecar's recorded size
does not match the file on disk, the two did not arrive together; serving one
build while advertising another is worse than serving none, because the phone
would compare versions against a promise the bytes do not keep. That makes the
copy order in docs/android-distribution.md load-bearing, and it is written down
there: APK first, sidecar last.

**The version is public, the bytes are not.** An updater has to be able to ask
"is there something newer?" cheaply and before it has done anything; 55 MiB is
not for anyone who can reach the port. `login_required` already accepts either a
session cookie or a device bearer token, so the browser and a linked phone both
work with no second auth path.

The Android lane now publishes both files to the same rolling `dev` release the
desktop bundles use, reusing `publish-release.sh` — its nullglob asset list was
already built for several jobs in separate workspaces publishing to one release,
which is exactly this. Signed builds only: publishing an unsigned APK would offer
people something they cannot install over what they already have.

Nine tests, DB-free like the rest of the suite — this lane runs no Postgres, so
the advertisement is asserted through `advertisement()` rather than through
`/api/config`, whose other half needs a database. Both routes ARE exercised,
because neither opens a session.
2026-08-20 20:11:32 -04:00
bvandeusenandClaude Opus 5 641999de58 frontend: reminders becomes a lens, and cards can clear a reminder (task 1913)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 46s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android (Tauri) / Android APK (debug) (push) Successful in 3m41s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m2s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Reminders was the last surface still reading as its own page — a bespoke row list
rather than the board's cards. It is now the same NoteGrid as every other lens.

The reason it wasn't already is that the list was a TRIAGE surface: one tap for
Done, 1h, 1d. Cards had none of that, so converting naively would have turned each
of those into open-act-close. Reminder upkeep is exactly the "maintenance must
stay dead simple or people stop coming back" case from the north star, so making
it three times more work to look tidier would have been a bad trade.

So the actions moved onto the card instead, shown wherever a note carries a
reminder — the board included. That turns out to be the better place for them
anyway: seeing something due while browsing and clearing it there is useful
outside the reminders lens. Always visible rather than hover-revealed, because a
finger cannot hover and these are the primary action on a due note; .chip-btn
takes the same coarse-pointer sizing rule as .icon-btn.

The card acts on the store directly, which the board picks up through reconcile.
The reminders lens fetches its own list, so it needs telling — hence the
reminder-changed event, which exists only for hosts that hold a list of their own.

Also carried recurrence (↻) onto the card. It was shown only in the reminders
list, so unifying would have silently dropped it; a repeating note now reads as
repeating on the board too. And the container went max-w-2xl → max-w-6xl, since a
narrower column would have reintroduced the different-page feeling the cards just
removed.

RemindersView is ~40 lines lighter for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:48:28 -04:00
bvandeusenandClaude Opus 5 c8c8ec4b4e frontend: the shell names the active lens (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 15s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m57s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Android (Tauri) / Android APK (debug) (push) Successful in 3m37s
Which lens you're looking at is a property of the space, not of a page you
navigated to — so the name now sits in the bar that never moves, beside the app
name, and stays put while everything beneath it re-filters.

It replaces three per-view <h1>s that each sat in a different place with slightly
different markup (timeline, reminders, graph) and, more to the point, were absent
entirely on the board and in search — the two lenses people spend the most time
in had no name at all. A label lens is named by the label itself, because
"Groceries" is what the user came looking for and "Label" tells them nothing.

Shown at every width rather than hidden on small screens, which was my first cut
and would have been a regression: deleting the per-view titles while hiding the
shell one leaves a phone with no lens name anywhere, and Android is a peer surface
now. Below `sm` the app name is already hidden, so the lens name simply takes the
space it vacates — you know which app you're in; what you need is which lens.

The h1s on Settings, Sync, Account, Login and Register are untouched: those routes
render outside the shell entirely, so they have no chrome to be named by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:49:27 -04:00
bvandeusenandClaude Opus 5 67b9ea2938 frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
"The same board, re-filtered" has to be literally true to read as true. The
column classes were copy-pasted into five places — the board's pinned and other
sections, its non-board branch, search, and timeline — so a lens could drift from
home by a single edit. One already had: the FLIP reflow from 1914 landed on the
board's three grids and left search and timeline popping. NoteGrid is now the only
file that knows how the masonry is laid out or how it moves, and search and
timeline gained the motion by adopting it.

It takes activeId rather than an index. The board splits its notes across two
grids, so index-based focus made the call site do offset arithmetic
(focusedIndex === pinnedNotes.length + i) against a list the grid didn't own.

The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board,
archive, trash and label all render the same BoardView; keying the transition on
the route would remount it, blanking the board and refetching — exactly the
page-change feeling this is meant to remove. Unkeyed, Vue transitions only when
the component TYPE changes (board to search to timeline to graph), and moving
between the board's own lenses stays an in-place reflow that NoteGrid animates.
The two behaviours fall out of one rule rather than needing to be special-cased.

Out is quicker than in because mode="out-in" makes the durations additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:39:49 -04:00
bvandeusenandClaude Opus 5 18a58fb5da frontend: the board glides and the editor grows from its card (task 1914)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 1m0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android (Tauri) / Android APK (debug) (push) Successful in 4m25s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m8s
Two of M7's motion targets. prefers-reduced-motion was already in place from the
1999 pass and gates both of these for free.

FILTERED REFLOW. The three card grids become TransitionGroups sharing one
transition name, so "how the board moves" is defined once in CSS rather than
three times in markup. Vue's TransitionGroup does the FLIP itself — measure
before, measure after, transition the difference away — so no animation
dependency, which the task called for.

Leavers are deliberately NOT pulled out of flow with position:absolute, the usual
TransitionGroup trick. This masonry is CSS multi-column, and an absolutely
positioned child escapes its column to the container's origin: a note would fly
diagonally across the board on its way out. Keeping leavers in flow costs a small
settle when the element is finally removed, so the leave is the shortest of the
three durations.

EDITOR CONTINUITY. useNoteEditor.open() is the one place that knows which card
was clicked, so that is where the card's on-screen centre is captured; the editor
panel then scales from that point. Deliberately not a true shared-element morph:
scaling by the real card-to-panel ratio distorts the text on the way, and a card
is often a third of the modal, so an honest ratio reads as a zoom rather than a
transition. The task sanctioned a good-enough scale/position tween; this is that.

A point rather than a rect, because nothing needs the card's size and a point
survives the card being filtered away while the editor is open. Consumed on read,
so a compose — which has no card — cannot inherit the origin of whatever was
edited before it and grow from an arbitrary corner.

The animation lives inside NoteEditor rather than in the five views that render
it: the leave has to finish BEFORE the host unmounts, so the component owns its
own visibility and tells the host when it is done. visible starts true with
`appear`, because the panel lives inside that v-if and would not exist to measure
otherwise. The origin is measured with offsetLeft/offsetTop rather than
getBoundingClientRect — enter-from has already applied scale(0.94) by then, so
the bounding rect is of the shrunken panel and the origin would land off by a few
pixels. Offsets are layout geometry and ignore transforms.

Durations are 140-220ms. The brief is continuity, so a card should read as having
moved, not as having performed.

NOT verified: motion is a visual property and there is no frontend test lane, no
device, and no app run here. vue-tsc proves it compiles. Whether it FEELS right
is an operator live pass, which is what M7's own verification section asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:55:21 -04:00
bvandeusenandClaude Opus 5 be0eb94225 frontend: reorder cards with Pointer Events so touch can do it at all (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m12s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Native HTML5 drag-and-drop never fires from touch — the API predates it and was
never wired to it — so on a phone reordering did nothing whatsoever, and the grip
that starts it was hover-gated on top of that. Pointer Events cover mouse, touch
and stylus on one code path instead of two.

The awkward part is hit-testing. Native DnD routed dragover/drop to whatever was
under the cursor, so each card learned on its own that it was the target. A
captured pointer sends every move to the element that captured it, so the dragged
card has to hit-test for itself and publish the result where the other cards can
see it — hence the shared refs in useCardDrag. It reads the DOM via
elementFromPoint rather than tracking geometry because the board is a CSS masonry:
visual order isn't derivable from model order, and cards reflow as the column
count changes. Asking the browser what is actually under the finger is the only
answer that stays true.

Capture is what makes the gesture survive crossing a card boundary; touch-action:
none claims it from the browser's scrolling; a 6px threshold keeps a tap from
becoming a drag; and pointercancel is handled so a system interruption leaves no
half-set state.

The parent contract is unchanged apart from `drop` now carrying the target's ID
rather than its note — the dragged card finds its target in the DOM, so an id is
all it can know without a second lookup. BoardView keeps its own tracking of what
was picked up; that it now duplicates the composable's draggingId is real, and
noted for the DRY pass rather than expanded into here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:39:37 -04:00
bvandeusenandClaude Opus 5 659237ccc6 desktop: the empty board explains where your notes live (task 1999)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 25s
A fresh desktop install has no login — auth_me returns a synthetic local user so
the shared router's guard resolves — but nothing said so. You landed on a bare
board with no way to tell whether the app was storing your thoughts on this
machine, waiting for a credential, or quietly shipping them somewhere.

The start state is the empty board itself, not a welcome modal or an onboarding
gate. The product exists to take a thought in under a second; spending that
second on a dialog taxes the one thing it is for. It also means there is no
"seen it" flag to persist, migrate, or let drift out of step with reality — the
message retires itself the moment a first note exists, which is exactly when it
stops being true that you have nothing here.

Shown only when the app is unlinked: offering to connect a server to someone who
already has one is noise. The status read is best-effort and never awaited, so
the board renders at full speed regardless; if it fails we keep showing the
offline copy, which is the honest reading of "we know of no server".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:37:29 -04:00
bvandeusenandClaude Opus 5 2cfe049f9c sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:31:06 -04:00
bvandeusenandClaude Opus 5 d6734cf7a0 desktop: in-app updates, two channels, signed, fed by fixed-tag releases
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 30s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Update manifest (push) Has been skipped
There was no in-place update anywhere. The app never checked, downloaded or
applied anything, and the only published release predates the whole sync arc —
so `install.sh` would hand out a build with no sync in it. Installing from
per-run CI artifacts, which is what's been happening, is not something an
updater can point at: ephemeral, auth-gated, no stable URL.

Two channels, switchable in the app: `stable` follows tagged releases, `dev`
follows every green push.

The feed is a Fabled-Git release asset, not a ThoughtSync server route. This
reverses the lean recorded in task 1998, and the reason matters — a
server-hosted feed can only reach a desktop that has linked a server, and
local-first-with-no-server is the whole premise. An unlinked install has to be
able to update itself.

Each channel reads a `latest.json` on a release whose TAG NEVER MOVES.
That's forced, not stylistic: Forgejo has no /releases/latest/download/<asset>
route (verified — it 404s with no redirect), so "newest" cannot be named in a
URL. `dev` carries the rolling bundles; `stable` is a pointer release holding
only the manifest, whose URLs aim at the versioned release's assets, so nothing
is duplicated.

The manifest is written by a third job that runs after both bundle jobs. They
build in separate workspaces and neither can see the other's output, but one
manifest has to describe both platforms — generating it inside either job would
silently omit the other, and a missing platform reads to a user as "no update
available" rather than as a broken feed. It reads what actually landed on the
release, so it can never advertise a bundle that failed to upload.

Signing is gated on the secret existing, in the script rather than an `if:`
(the secrets context isn't reliably available to step conditions). No key means
no updater artifacts and no publish: a feed the app would refuse to verify is
worse than no feed, because it looks like it works. CI stays green until the
key lands.

On Linux the updater can only replace an AppImage — a deb or pacman install is
owned by its package manager and must never be overwritten underneath it. The
app detects that case up front and says so, instead of failing halfway through
with a permissions error nobody can read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
2026-07-26 19:03:12 -04:00
bvandeusenandClaude Opus 5 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
2026-07-26 16:20:13 -04:00
bvandeusenandClaude Opus 5 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
2026-07-26 00:43:34 -04:00
bvandeusenandClaude Opus 5 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
2026-07-26 00:30:37 -04:00
bvandeusenandClaude Opus 4.8 20cf15c99c desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 29s
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
2026-07-24 22:31:00 -04:00
bvandeusenandClaude Opus 4.8 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
2026-07-24 13:24:20 -04:00
bvandeusenandClaude Opus 4.8 44a5466793 M9 S5 (frontend): shared BaseModal for the standard modals + BaseInput in Account
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 27s
- 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
2026-07-23 21:58:59 -04:00
bvandeusenandClaude Opus 4.8 9c679d0ab9 M9 S5: guard the login ?redirect= against open redirect
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-23 21:48:41 -04:00
bvandeusenandClaude Opus 4.8 e1cf63e875 M9 S3 (frontend): consolidate local-date helpers into notes/datetime.ts
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-23 21:40:13 -04:00
bvandeusenandClaude Opus 4.8 590d3ff2f6 M9 S3 (frontend): recall views adopt useNoteList + AsyncState/EmptyState
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
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
2026-07-23 21:36:24 -04:00
bvandeusenandClaude Opus 4.8 18ca4d4db4 S1: frontend infra — AsyncState/EmptyState + useNoteEditor, adopted in BoardView
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-23 19:40:37 -04:00
bvandeusenandClaude Opus 4.8 1df70c53bd Capture: Enter-to-start + on-board notice; dismiss discards a new note
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 33s
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
2026-07-23 14:11:11 -04:00
bvandeusenandClaude Opus 4.8 675b3aa248 Capture UX: "+ New" header button + type-to-compose (two-mode board keys)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-23 13:59:23 -04:00
bvandeusenandClaude Opus 4.8 f9f1b77d37 Unify compose + edit onto one surface (modal-only editor)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 30s
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
2026-07-23 12:57:03 -04:00
bvandeusenandClaude Opus 4.8 d1bc91a54a M6 1908b: foreground reminder delivery + recurrence/snooze UI (frontend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 29s
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
2026-07-23 11:58:44 -04:00
bvandeusenandClaude Opus 4.8 5c045aed63 M6 1902b: facet bar + saved views UI (frontend)
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 37s
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
2026-07-23 08:45:56 -04:00
bvandeusenandClaude Opus 4.8 3c76b50a9c Sync 2: device-token bearer auth + linked-devices UI (M8)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 33s
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
2026-07-22 22:59:52 -04:00
bvandeusenandClaude Opus 4.8 1417479729 Graph: label hubs cluster notes; unlinked notes float by default
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 35s
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
2026-07-22 21:16:16 -04:00
bvandeusenandClaude Opus 4.8 bb33d5c495 M7: unify compose + edit into one NoteEditor (DRY)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 4s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 40s
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
2026-07-22 14:05:00 -04:00
bvandeusenandClaude Opus 4.8 95b0e30fc7 M6: browse notes by creation date (Timeline lens)
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 1m2s
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
2026-07-22 12:26:35 -04:00
bvandeusenandClaude Opus 4.8 5e225075f3 m4: drag-to-reorder polish — grip handle + drop affordances
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 26s
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
2026-07-21 08:33:29 -04:00
bvandeusenandClaude Opus 4.8 b7b37e5e1a m4: settings-UI polish (dirty tracking + load-error state)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 27s
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
2026-07-21 00:13:17 -04:00
bvandeusenandClaude Opus 4.8 545f946754 m4: per-view error states (board / search / graph / reminders)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 27s
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
2026-07-21 00:10:40 -04:00
bvandeusenandClaude Opus 4.8 894dd4ac42 graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 35s
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
2026-07-20 22:40:28 -04:00
bvandeusenandClaude Opus 4.8 4c72dfea0b keyboard: board card navigation (j/k/arrows + actions)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 34s
Focused-card selection on the board: j/k or arrows move it, Enter opens,
e archives, # pins, x trashes the focused card (actions gated off the Trash
view). The selection is a brand ring on the card; it scrolls into view and
clamps as the list/view changes. A window keydown listener on BoardView,
ignored while typing, on a button/link, or while the editor modal is open.
Added the card keys to the ? cheat-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:18:28 -04:00
bvandeusenandClaude Opus 4.8 b24316843e keyboard: command palette (Cmd/Ctrl+K)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
Fuzzy quick-open overlay: type to jump to any note by title (titles index)
or run a command — New note, Go to Board/Graph/Reminders/Archive/Trash, and
Open Settings (admins). Arrow keys move the selection, Enter activates, Esc
closes; Cmd/Ctrl+K toggles it (works even while typing).

Selecting a note navigates to the board with ?open=<id>; BoardView watches
that query and opens the editor (fetching the note if it isn't loaded), then
clears the param. Added to the ? cheat-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 11:05:50 -04:00
bvandeusenandClaude Opus 4.8 4bd4ce5a1f keyboard: global shortcuts + ? cheat-sheet
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s
App-shell keydown dispatcher (active only inside the authed shell):
- c        new note (jump to board / reopen composer via a ui-store signal)
- /        focus the search box
- g b/g/r  jump to Board / Graph / Reminders (two-key, 800ms window)
- ?        open a keyboard-shortcuts cheat-sheet overlay
- Esc      blur a focused field / close the cheat-sheet

Ignored while typing in an input/textarea/contenteditable (except Esc).
New stores/ui.ts carries the compose signal; QuickAdd exposes open(); the
board reopens the composer on the signal when already on the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 10:23:44 -04:00
bvandeusenandClaude Opus 4.8 c57982d910 M3 reminders: notes.remind_at + Reminders view
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 32s
- Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at
  (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed);
  serialize includes remind_at.
- Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder;
  editor datetime-local picker + clear; card reminder chip (overdue = red);
  sidebar Reminders entry + /reminders view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:12:14 -04:00
bvandeusenandClaude Opus 4.8 ad006ccb58 M3 graph view: /api/graph + force-directed SVG
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 28s
- graph blueprint: GET /api/graph resolves note_links to target notes by
  normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
  of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
  cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
  editor with link navigation). Sidebar Graph entry + route; empty state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:05:45 -04:00
bvandeusenandClaude Opus 4.8 2d72dcc7cb M3 wiki-links: [[links]] + backlinks
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 38s
- Migration 0009: note_links (source_id, target_norm). Parse [[...]] from body on
  create/update and rewrite the source's links. GET /api/notes/titles (owner
  {id,title} index for client-side resolution); GET /api/notes/<id>/backlinks.
- Frontend: titles store; LinkedText renders [[Title]] styled on cards; editor
  shows Links (outgoing, resolve/create-on-click) + Linked-from (backlinks),
  clicking navigates the editor to the target note (board + search).
- notes store: fetchOne, createTitled. DB-free link-parser tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-20 08:00:08 -04:00
bvandeusenandClaude Opus 4.8 339cc5c2d2 M2 drag-reorder: notes.position + reorder API + native DnD
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 30s
- Migration 0008: notes.position (int). Board orders pinned -> position ->
  updated_at; new notes created at top (max position + 1). POST /api/notes/reorder
  assigns positions from the given order (owner-scoped).
- notes store: position on Note, position-aware sort, optimistic reorder().
- NoteCard reorderable (native HTML5 draggable + dragstart/drop); BoardView moves
  the dragged note before the drop target and persists.

Note: drag on a CSS-columns masonry has imperfect during-drag visuals (columns
reflow); order persists correctly. Candidate for a polish pass / layout tweak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:28:43 -04:00
bvandeusenandClaude Opus 4.8 ffc008bf4d M2 search: Postgres FTS backend + top search bar + results
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 31s
- Migration 0005: generated tsvector column (title A + body B) + GIN index on
  notes; GET /api/notes/search?q= (websearch_to_tsquery, ts_rank, ACL-scoped,
  excludes trash), labels merged into results.
- Persistent AppShell layout (parent route + <RouterView> children) so the new
  top search box keeps focus across board/search/label navigation.
- SearchView (debounced live search from the shell → /search?q=, results masonry,
  no-match empty state); BoardView/SearchView render inside the shared shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 22:03:19 -04:00
bvandeusenandClaude Opus 4.8 2046600a95 M2 labels frontend: sidebar shell + label filter, chips, picker, manage
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 34s
- AppShell: persistent left sidebar (Notes · labels · Archive · Trash) + top bar
  (site name, admin Settings, sign out); BoardView now renders inside it.
- labels store (list/create/rename/delete); Note gains labels[]; notes store
  gains setLabels + label-aware reconcile + /api/notes?label= loading.
- /label/:id route → label-filtered board.
- LabelPicker (tag a note, create-on-the-fly) in the editor; label chips shown
  on cards and in the editor; LabelsModal to create/rename/delete labels.
- api client PUT; new icons (note/tag/pencil/plus/check); nav-link styles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 21:56:14 -04:00
bvandeusenandClaude Opus 4.8 fdaf5c370c QoL: Shift+Enter save-and-continue + autofocus quick-add on landing
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 42s
- Shift+Enter in the quick-add title/body saves the current note and immediately
  starts a fresh one, staying focused for rapid capture. Plain Enter is still a
  newline (body) / title→body (title). Placeholder hints the shortcut.
- Landing on the board auto-opens + focuses the note input (QuickAdd autofocus
  prop, passed from BoardView) so you can start typing on arrival.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 21:31:48 -04:00
bvandeusenandClaude Opus 4.8 68f7b260de M1.5 frontend: admin Settings UI + config store + register gating
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 31s
- config store (public /api/config: site_name, allow_registration, version),
  loaded in the router guard; site name drives the board header.
- session User gains is_admin.
- /settings route with requiresAdmin guard + admin-only gear link in the header;
  SettingsView renders grouped typed fields (text/number/toggle), saves via
  PATCH /api/settings with saved/error feedback, refreshes public config.
- Register link hidden + /register route blocked when registration is closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 18:28:56 -04:00
bvandeusenandClaude Opus 4.8 0f604f9a26 M1: masonry board UI — quick-add, note cards, editor, views
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 30s
- notes Pinia store (load/create/pin/archive/color/trash/restore/delete) with
  view-aware reconcile; api client patch/del methods.
- colors.ts palette (10 keys → light/dark card + swatch tints).
- QuickAdd (collapsed → expand, title/body/color, click-outside/Esc to save),
  NoteCard (color tint, click-to-edit, hover action bar), NoteEditor modal,
  ColorPicker, inline Icon set (no icon dep).
- BoardView rewrite: Notes/Archive/Trash routes, CSS-columns masonry, Pinned +
  Others sections, per-view empty states, sign-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 16:08:53 -04:00
bvandeusenandClaude Opus 4.8 bf3d403648 M0: Vue 3 + TypeScript frontend — auth views + authed board shell
- Vite + Vue 3.5 + Pinia + vue-router + Tailwind (brand accent #F5C518),
  dark-mode aware, deterministic package-lock.json for `npm ci`.
- Session store (fetchMe/login/register/logout) over a credentials:'include'
  fetch client; router guards (requiresAuth / guestOnly) with lazy /me resolve.
- BaseButton + BaseInput primitives (focus rings, loading, error states).
- LoginView, RegisterView, and an authed BoardView shell with an empty state
  for the M1 masonry board — all at v1 polish (rule 24).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 13:17:38 -04:00