1a413733478f279e2ca502e52b4c8a93a3d76bd6
119
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
729d0dadf1 |
editor: collect the refund — the web editor autosaves on an idle pause
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
#2971's engine work was already done and its benefit was never taken up here. Both engines coalesce revision snapshots to one per editing session — `src/thoughtsync/revisions.py::should_snapshot` and `store.rs`'s namesake, the server's applied on the PATCH path AND in `sync.py`, with four integration tests covering it. So a write has cost a write, not a write plus a revision, for some time. But this editor still wrote only on `close()`. That save-on-close existed BECAUSE writes were expensive; with the reason gone, all that was left was the cost — a tab closed mid-paragraph lost the paragraph, which is the one thing a notes app must not do. Android already debounces (`BoardViewModel`); the shared Vue editor did not, so web and desktop kept paying for a trade that had been cancelled. Now: a 1s idle pause writes. EDIT MODE ONLY, deliberately. In compose, `dismiss` discards a note that was never persisted so an accidental keystroke or a type-to-compose never litters the board. An autosave there would create the row and quietly take that behaviour away. Materialising a compose on first keystroke is a separate decision (#2967), not a side effect of this one. Three details that decide whether it is safe rather than merely present: * `flush` returns without writing while a save is in flight, so an autosave landing there would silently drop everything typed since that save began. It RE-ARMS instead of skipping. * Errors are swallowed and retried on the next pause. An autosave that interrupts typing with a message is worse than one that waits, and `close` still surfaces a real failure where the person is looking. * The timer is cancelled by `close`, by `dismiss` and on unmount, so nothing fires through a component during its leave animation or after it is gone. Checked and found harmless rather than assumed: `notes.reconcile` replaces the store's item but never touches `useNoteEditor`'s `editing` ref, so the `watch(() => props.note)` that calls `setBody` does not fire on a save. Were that not true, autosaving would have reset the field and the caret every second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
6c0153be1e |
desktop: a global hotkey opens a small window to write in, now with its lockfile
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m5s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m22s
Restores |
||
|
|
10ea15bef0 |
Revert the desktop hotkey: a new crate needs a Cargo.lock this machine cannot write
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m7s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`42e06da` added `tauri-plugin-global-shortcut` to Cargo.toml without updating
Cargo.lock, and every cargo invocation in CI passes `--locked`. Both desktop
jobs failed on the same line before compiling anything:
error: cannot update the lock file ... because --locked was passed
So this says nothing about whether the code is right — clippy never ran. The
gate did exactly its job.
There is no Rust toolchain on this workstation (rule 10 — CI verifies), and a
lockfile is the one artifact CI is deliberately forbidden to generate. Hand-
writing the entries is not a real option: it needs the exact checksum and the
whole transitive tree, and a wrong checksum fails harder than a missing one.
Reverted rather than left red, because a red `dev` blocks everything behind it
and the Android half of #1899 is green and unaffected at
|
||
|
|
42e06da576 |
desktop: a global hotkey opens a small window to write in, and nothing else
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 5s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 35s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build & push image (push) Successful in 35s
The other half of #1899. Press the combination anywhere and a 520x220 window arrives over whatever you were doing; type, Ctrl/Cmd+Enter, it is gone. The board never comes forward, which is the whole point — bringing the app up to write one line is the friction this removes. ## There is no default shortcut, deliberately A global shortcut is the one setting here that can collide with software this app knows nothing about. Any default is a key combination taken away from something on somebody's machine, silently, at install time. So the feature is OFF until a combination is chosen, and choosing one is how it turns on. CommandOrControl+Shift+N is offered as a one-click suggestion, never applied on the user's behalf. ## Stored and live are reported separately `CaptureShortcut` carries both `shortcut` and `registered`, because they genuinely disagree: a combination another app grabbed first is saved and does nothing when pressed, and on Wayland a compositor may refuse global grabs outright. Saying only "your shortcut is X" would be a lie with a keystroke attached, so the settings row says "saved but isn't active — something else is holding it". `capture_shortcut_set` registers BEFORE storing, so a combination the system refuses is never written down as though it worked. Registration at startup is best-effort and logged: a shortcut that worked when it was chosen can be taken by something installed later, and the app must still open. ## Two windows, one database, no shared store The capture window runs a second copy of the frontend with its own Pinia stores, so a note saved there is invisible to the board until it is told. It is told — `capture_done(saved)` emits to `main`, and BoardView reloads. The emit failing is cosmetic (the note is already in SQLite) so it is logged, not raised. The window is opened at `index.html?capture=1` rather than at `/capture` because the bundled assets are served as FILES: a path with no file behind it 404s in the production build while routing fine under the dev server. The router turns the query into the route. It is hidden rather than closed on the way out, and it keeps its text. A capture interrupted by something more urgent is still there on the next press, which is what makes Escape safe to press. A failed save also keeps the window open holding the text — hiding it would throw away the only copy of something just written in order to report a problem you could retry your way out of. ## Where the setting lives Rule 25 says a tunable belongs in the UI, and this one has to be. It sits in the desktop's Sync screen beside the update channel, not in admin Settings: that screen is the SERVER's and bounces on desktop anyway, while this is a property of one installation on one machine. Persisted with the same `store::set_pref` the update channel uses. No @tauri-apps/api dependency was added — everything routes through `invoke` and the `withGlobalTauri` global, as the rest of the bridge does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
193dfb9e94 |
tags: renaming onto an existing tag merges them, and the older row survives
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.
I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.
That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.
Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.
* `store::rename_label` and the server's PATCH now implement one rule.
THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
one that already held the name", so that renaming A→B and B→A land on
the same survivor; otherwise the outcome depends on which way round
someone typed it, and two devices tidying the same pair disagree about
which id still exists. Ties go to the incumbent, so it stays
deterministic.
* The core reuses `merge_labels` rather than reimplementing the move. That
is the only place that knows to mark every affected NOTE dirty before
the delete cascades the membership rows away, which is what makes a
merge reach the server at all.
* The server's rename and its `/merge` route now share one `_merge_into`
helper, for the same reason.
* Both server lookups became case-INSENSITIVE, matching every client. The
create path is included: it was the one actually minting the unstorable
pair, so fixing only the rename would have left the door open.
* The web asks before merging, naming both note counts. A merge cannot be
undone by repeating it and is now reachable by a typo in a text field —
the same reasoning as the delete confirmation in #2116. The confirmation
lives in the shared store, so the desktop gets it too; the FFI does not
ask, because that belongs to the surface with a person in front of it.
* The web store detects the merge from the LIST, not the response: the
survivor may be the row we asked to rename, so an unchanged id proves
nothing.
Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.
Also fixes a straggler from
|
||
|
|
8c7553d619 |
copy: the product says "tags" now, and the schema keeps saying Label
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
Two words for one concept cost real comprehension: over a single exchange
the operator concluded that auto-tagging did not exist (it does, in
`derive.rs`) and that a tag-management view did not exist (it does,
`LabelsModal.vue`). The `#` is how most of these get made, so the `#` wins
the noun.
User-visible strings only, on all three surfaces plus the server's errors.
`Label`, `NoteLabel`, `via_tag`, `label_id`, the tables, `/api/labels` and
the FFI names are all untouched — renaming those touches migrations and the
wire format to buy nothing a reader can see.
Two of these were more than a find-and-replace:
* Android's `label_from_tag` said "from #tag", sitting beside a chip that
already renders as `#name`. Once every one of them IS a tag that hint is
circular. What it actually tells you is that the note's BODY owns this
one — which is why it alone has no remove cross — so it now says "from
the text".
* The web's empty state said "No labels yet — create one above" while
Android's already mentioned the `#` route. The web now says it too. That
is the exact fact the operator did not have.
The paired `aria-label`s went with their `title`s; a screen reader saying
"label" while the tooltip says "tag" is the same confusion with a smaller
audience.
Left alone deliberately: `json_error("invalid label")` and
`"label_ids must be a list"` in `notes/__init__.py` name the `?label=` query
parameter and the `label_ids` request field. Those are wire surface, not the
word a person reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
|
||
|
|
ef418a8c92 |
buttons: one definition of the shape, worn by a <button> and by an <a>
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in
|
||
|
|
fd1e4ae487 |
downloads: five clients, and the page leads with the one that fits you
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all the server held. Step 3 baked in four more, so the single card had to become a section — and five artifacts is exactly where a downloads page turns into a table of filenames and stops being a product. So it LEADS with what fits the machine asking, from the user agent, and keeps the rest quiet but visible. A wrong guess costs nothing: nothing is behind a disclosure and every other client is one click away. Linux gets all three at once, because the UA says "Linux" and nothing about dpkg or pacman — there is no better answer available. They are named for the distro rather than the package format, since a person knows which system they run and not necessarily which packaging it uses. The AppImage carries one clause of its own: it is 95 MB against 3, and it is also the only bundle that updates itself in place. Both facts belong to the same decision. macOS and iOS lead with nothing and say so. There is no build for either, and "There's no macOS build yet" is the difference between deliberate and broken. The version renders `unknown` rather than blank, and the download stays offered — not knowing which build it is, is not a reason to withhold it. Two things this did NOT do, both deliberate: The task asked for a Tauri case — do not offer the desktop app to someone already running it. That case cannot be reached: `/account` redirects to the board in the desktop app (requiresServer, router/index.ts), because device tokens are a server-side concept. A branch for it would be dead code. `.btn-link` mirrors BaseButton's declarations rather than replacing them. BaseButton is a <button> and cannot carry an href, and unifying the two would have put every button in the app into an operator pass that CI cannot check — for a cosmetic gain. The comment names the pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c |
||
|
|
f992439588 |
version: every surface can say which build it is, and two of them were lying
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
Note 3127 §5 removed version tags, so an artifact's self-report is now the only
answer to "which build is this?" — and nothing exists to contradict it when it
is wrong. Three surfaces gain a dim build line: the foot of the web rail, the
login screen, and the foot of Sync on Android.
The login screen because "I can't sign in" is a bug report like any other, and
requiring an account to read a build number withholds it from exactly the people
who can't get past that page. `/api/config` is already public.
Two of the values it was going to show were wrong, which is the part worth
knowing about.
The DESKTOP reported `env!("CARGO_PKG_VERSION")` from `config_get` and from the
startup log. `cargo tauri build --config '{"version": ...}'` overrides
tauri.conf.json, not Cargo's own metadata — so both read the literal `0.2.0` in
Cargo.toml, on every build ever shipped. They now read a display version baked in
by the lane through `option_env!`, hoisted to the crate root because two readers
of one fact is how this repo keeps producing 2181-2183. Not the ordering key
either: `1.0.<minutes>` is the opaque value Tauri's updater compares and must
never be shown to a person, and `update.rs` still reads it because a comparator
is exactly what it is (rule 149).
The SERVER fell back to `__version__` when APP_VERSION was absent, so a server
run from a checkout reported `0.2.0` — a real-looking version naming no build
anybody could obtain. `__init__.py` already asserted the honest answer was
"APP_VERSION being missing, which app.py already handles"; it did not, and a
comment claiming a behaviour two files away is how that stayed true-sounding.
Now an explicit "unknown", with the packaging version left where "unknown" is
not a legal value.
Android reads the INSTALLED package's versionName rather than BuildConfig, so it
reports what is actually on the phone.
Everything renders "unknown" rather than blank when it cannot say. A blank looks
like a layout bug; a plausible default cannot be caught by anything.
build.rs gets `rerun-if-env-changed` for the baked value: cargo does not track an
`option_env!` variable on its own, and the desktop lane having no cache today is
what makes that easy to forget the day one is added.
|
||
|
|
fa89da1fab |
notes: color leaves the model, the wire and all three surfaces
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of this field: a card is one neutral surface per theme, and the only coloured thing on a board is a tag. What was left was a column written by a picker and read by nothing. Rule 22 — the old path comes out completely. No flag, no fallback, no "override if set". Server: the column, the `?color=` facet, the create/update/serialise paths, the sync assignment, the front-matter line, and Keep's colour map. Alembic 0029 drops it and sweeps `"color"` out of stored saved-filter params — a view that silently filtered on a field the app no longer has would return nothing and never say why. That sweep is Python, not `params::jsonb - 'color'`, because Postgres has no try-cast and one malformed blob would abort a migration that is running over somebody's saved views. `NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on the model that lost one is an invitation to put the column back; labels still name a colour, so the vocabulary belongs where the normalizer already is. Core: the field, the facet, the `NoteCreateInput`, and every read and write in store/push/pull. Local schema v9 drops the column and does the same saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and `NoteDraft.color` with it. Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule, the FilterBar colour row, the facet in the query round-trip, and the colour half of the editor's baseline-and-save. Android: the `ColorSheet`, the `Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`. ## The protocol: v4, and the floor deliberately stays at 3 Checked against `compat.rs` and the push handler rather than trusting the `#[serde(default)]` annotation, because the v2 precedent points the other way: v2 dropped `kind` and `title` and DID raise both floors, on the rule that dropping a field a client sends and expects back is breaking. `color` fails the second half of that test. A v3 client reading a v4 note gets `"default"` from its own serde default and draws the colour it derives locally — the board it drew yesterday. A v3 client pushing `color` has the key ignored, since `_assign_note_fields` reads its payload key by key and never validates the shape. Neither direction errors and neither shows anything wrong. `title` was the note's NAME; this is a field that no longer renders. So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both floors stay at 3. `docs/sync.md` carries the reasoning and the per-version history, and its push example is brought back in line — it still listed `title`, `kind` and `items`, all gone before this. Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:` imports fine, the key simply read past. Old exports must still import. #3041 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
13a88179b8 |
tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.
`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:
inline, on the card as a chip, on its own fill
light `-800` 7.09 - 15.13 6.37 - 12.01 (was 4.52 - 8.23)
dark `-300` 9.45 - 14.23 8.23 - 11.88 (unchanged)
The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.
That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.
`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.
Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.
`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.
Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.
Step 2 of M315. #3149
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b91091caca |
cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
A card's fill stops being a function of the note. One neutral per theme on all three surfaces — white in light, neutral-900 in dark, which is what the palette's `default` always was and what both editors already used, so this is a collapse onto a surface everything already had rather than a colour anybody has to like. Measured, against "not the same color as their background but close to it": card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text 17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest number on the card — the edge and the shadow separate it from the board, so a fill that separated on its own would make it a panel. Deleted, since the card was their only consumer: `derivedFill` / `hslHex` and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`, `noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css, `chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` / `noteIsStrong` / `firstLabelColor` in NoteTint.kt, and `resolvedNoteColor` / `noteColorIsChosen`. `tintHash` and `derivedTint` STAY, against the plan: a label with no colour of its own still derives one from its name, and that path was never the one that failed. The mirrored pair and its fixture survive intact. The editor follows the card, and its Done button takes the brand — the board's compose FAB is the app's existing statement of "affirmative action here", where Material's default secondaryContainer is a baseline colour this theme never sets. The colour picker is left in place, doing nothing, for exactly one step: removing it here would leave `note.color` written by nothing and read by nothing, which is a worse intermediate than a control that visibly does nothing. #3041 takes the field and the picker together. Step 1 of M315. #3148 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f50204a98b |
editor: detekt counts returns, so the promotion guards collapse into one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of them the same `return this`. Collapsed into a null-or-task guard and a `changed` flag, which says the contract more plainly anyway: the list comes back untouched unless something was actually promoted. Mirrored in blocks.ts even though nothing lints it there. The two files are kept line-by-line alike on purpose, and letting them drift on shape is how the next person stops trusting that reading one tells you the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1a49ae7ea9 |
editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the state and nothing reads the body again — every edit travels the other way, through `joinBlocks`. So a marker typed by hand stayed literal text on screen until the note was closed and reopened, even though it was already a real item in storage and the card was already drawing a checkbox for it. The editor was the only place that disagreed with itself. (#3024) On BLUR, and only the block being left. There is no good moment to convert while someone is typing: re-splitting on a keystroke moves the caret out of the word being written, and converting the instant `- [ ]` is complete does it before the item has any text. Blur is the one moment the person has demonstrably finished with the block. `promotingTasks` / `promoteTasks` return the SAME list when there was nothing to promote, and both call sites compare by identity. Without that, every blur would re-key every field below it — including the blur that fires on first composition, before a field has ever held focus. Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror of EditorBlock.kt, and the reason that mirror is worth keeping is that the two editors behave identically. Fixing one would spend its whole value. Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only case where this changes the body rather than just how it is drawn, and exactly what reopening the note already did. No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it needs the native library and cannot run in the JVM lane. No existing Android test touches the core for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d9e5753dc2 |
board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
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 / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
23fd2da91e |
The tag goes at the top of the card, where it gets looked at
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link previews. On a tall note that puts the one thing saying what a note IS below the fold of a glance — and a board is scanned, not read. "Which of these is about the thing I am looking for" should be the first thing the eye lands on. Above the body rather than beside it: the body's first line is the note's NAME (M13 steps 3 and 4), and a chip floated next to it would compete with the thing that identifies the note. A row of its own costs one line, and only on notes that carry tags. Both surfaces, same order. Does not depend on tag lifting, which is a much larger change — see the task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3d490bb6f3 |
HSL lightness is not luminance — the dark floor was too low
The unit test I added with the generated fills failed on its first run, on exactly the claim it was written to check, so it earned its keep immediately. The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a ramp starting at the card surface and climbing could not end up below it. That confuses HSL lightness with luminance. At one fixed lightness the eye sees very different brightnesses by hue, because green carries 71% of the luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it was supposed to match, which on the board reads as a hole rather than as variety — the opposite of what the whole change is for. Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue clears the card surface. The range now measures 1.11-1.71 against the board against the old 1.06-1.54, so the floor is back where the shipped ramp had it and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6 against 3.0. 338 distinct dark fills. Two things about the test are worth keeping. It asserts on LUMINANCE rather than on the lightness that was put in — a test of the input would have agreed with the bug and passed. And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught this one by luck. 40,000 covers all 2160. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d3cc7bcd4 |
Nine tints that looked like three — generate the fill instead
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is why a board of them reads as one card repeated: "I only see 3 colors ... it looks like a monolithic wall." Two causes, and the second one is mine. NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's fill says nothing at all — it only has to keep the board from repeating. Those are different jobs and tying them together capped the second at nine values for a board that will hold hundreds. ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill to the same lightness — deliberately, so each would read as a card against the board. But the eye separates by lightness first, so nine hues at one lightness are one card nine times. Hue alone was never going to carry it at that darkness. So an untagged note's fill is now generated from its id rather than looked up: hue anywhere on the circle, one of six lightness levels, saturation fixed. 324 distinct fills in dark and 193 in light, against nine. Separation between fills goes from a 1.03 ceiling to 1.42. Varying lightness is only SAFE because the card has its own grey edge now. While the fill was the card's only boundary it could not afford to drift toward the board; the edge bought that freedom, one commit before it was needed. Saturation is the one dial the hash never touches — variety comes from hue and lightness, loudness would come from saturation. Dark starts a hair under `neutral-900` and climbs, so no note is ever darker than a plain card. Light runs from white down past the board. Body text measures 8.7 at worst against the 4.5 it needs; the meta row 5.1 against 3.0. DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it is binary64; a Kotlin Float is binary32, so the two would round differently near a channel boundary and a note would be one byte off between the phone and the browser. Nobody would ever file that — they would see two colours that are "sort of the same" and never work out why. The web half cannot be executed here at all (no node on this machine), so the Kotlin fixture test is the only place the two implementations are ever compared. It now pins eight generated values as well as the hash, plus the properties that actually matter: that lightness varies, that nothing sinks below the card surface, and that body text stays clear of AA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ae2053d2ed |
Give the cards an edge again — one grey, not ten hues
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m20s
The border was never the problem; a border that carried COLOUR was. It said exactly what the fill already said, at 1.56-2.09 against that fill where the fill managed 1.03-1.05 against the board — the loudest element on every card was redundant with the quietest. A line that varies by colour is content and competes with the fill. A line that never varies is structure and does not. So the edge comes back, and it comes back as a constant in NoteCard rather than a column in the palette. Uniformity is the feature, and putting it where the palette cannot reach it is how that stays true. light #b8b8b8 1.57-1.98 against all twenty card fills dark #404040 1.58-1.73 Matched, not eyeballed: both land at ~1.6-1.7 against the card they edge, so the edge reads with the same authority in either theme. Dark is `neutral-700` — what the `default` card's border always was, one entry's value promoted to the rule for all of them. Light sits between `neutral-300` and `neutral-400` because neither lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads as a wireframe. Rejected on measurement: a translucent black/white edge, which is the tidier way to write it and self-adjusts per card. A border composites over the card's own fill, so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal one. Hue-coded edges are the thing being removed. The shadow steps back to what it was for — depth, not the boundary. Web returns to `shadow-sm`; Android's 2dp drops to 1dp, matching it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
47f108c9c8 |
The border was the thing making every note look the same
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.
Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.
What replaces it differs by theme, because elevation does.
Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
board — a 1.04 contrast that can only read as a card by sitting above one.
The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
2dp.
Dark cannot use one, black on near-black. So the subdued fills moved onto
the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
baked, rather than the same hue at 0.25 over the near-black board. An
untagged card now sits where the plain white card always sat (1.11-1.14
against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
than before — chroma 7-17 where the old ramp had 10-23.
Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).
The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.
Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fe18aaa956 |
The contrast pass, and the invisible chip it found
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed — 10 hues x 2 themes x 2 weights, dark ones composited over the board the way Compose and CSS both do, against the text actually drawn on a card (neutral-700/300 body, neutral-500/400 meta). Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33 to 7.11 against 3.0. Every combination passes AA with room to spare, so the two ramps step 3 introduced need no adjustment. That is the boring half. THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and is drawn at that hue's `-100` — which is exactly what the chip uses as its fill. Measured contrast between the chip and the card it had itself coloured: 1.00 in light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On every tagged note the tag name had stopped reading as a chip and become loose text, and nothing about step 3 looked wrong while writing it. Fixed with an EDGE rather than a different fill. A fill can collide with any card colour and chasing that would need the chip to know what it is sitting on; a border in the chip's own foreground reads against any background and needs no plumbing. Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49 and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying REQUIRED information, and a chip's information is its text — passing AA at 8:1 or better on every card here. The number and the reasoning are both in the source so the judgment can be overruled rather than rediscovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
988e1d3f00 |
A note's colour is its first tag's colour, at a heavier weight
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 20s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m41s
Android / Kotlin + Rust (APK) (push) Failing after 3m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m7s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Four #todo notes on the operator's board in four different colours, because the tint was derived per-note-id and ignored tags entirely. Now a tagged note wears its first tag's colour, so notes that share a tag share a look. TWO WEIGHTS, NOT ONE RAMP. The operator, seeing step 1: "the tints look the same as the chosen colors". They did — there was only one ramp. `strong` is not a second decision, it IS whether the colour was chosen: a tag (or, until step 5, the picker) means somebody said what this note is, while a derived tint only means the board should not be a wall of white. The two weights move in OPPOSITE directions per theme, because that is where each has headroom. The operator asked whether the tint could go lighter instead of the tagged end going darker; in dark mode that is the better half of the answer, so the derived end drops to a quarter opacity — closer to the board, which gives the light body text MORE contrast rather than less. Light mode has nowhere to go below `-50` without being white again, so there the gap opens by deepening the chosen end to `-100`. No hex was transcribed for any of it. `-100` is already in NoteTint.kt as every hue's `lightChipBackground`, and the dark weights are the existing `-950` fill re-alphaed, so the only two numbers that have to agree by hand are the alphas. Copying ten more Tailwind values from memory is exactly how this mirror would have drifted. `default` is marked not tintable — it is the ABSENCE of a colour, there is no emphatic version of it, and re-alphaing its opaque neutral fill would have made every draft card translucent. Borders untouched: the fill is the signal, moving both muddies the edge. Resolution order is explicit pick, then first tag, then the id hash. First tag because it is the one you control by typing; manual labels count the same as #tags because nobody can tell which kind they made by looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6fbee27f9c |
A tag with no colour of its own derives one from its name
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as `Label(owner_id=…, name=name)` with no colour, so it takes the column default — which means tag-driven note colour, built on top, would have left the board exactly as grey as it was. Four #todo notes in the operator's screenshot, four different colours, because the tint is per-note-id and ignores tags entirely. DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan wanted a hashed colour written wherever a label is born, and named the risk in its own body: `find_or_create_label` is "easy to miss, and it is the common one", because most tags are born from typing `#grocery`, not from a management screen. Deriving has no mint points to miss, needs no backfill for the tags that already exist, and reuses the hash and the fixture the notes already have. The cost is that renaming a tag recolours it. That is defensible — the name IS the tag — and an explicitly picked colour is still stored and still wins, so tag colours stay editable exactly as asked. Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are one tag and must not be two colours. All five places a label's colour is drawn now resolve the same way — the card chip, the editor chip, the drawer's tag list, and the management modal's dot and swatch ring. The modal's ring follows the resolved colour rather than the stored one, so opening the picker highlights what you can already see instead of nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6f173b166b |
Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks, so it was the colour of every note except the two the operator had coloured by hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today as "all the existing notes are the same dull color". The ask was "random subdued colors", but random is the one thing it must not be. A tint rolled at render time would differ between the phone and the browser and change on every reload. FNV-1a over the note's id is deterministic, identical on every surface, needs no column and no migration, and a note keeps its colour for life — which is what "random" meant here. Two implementations, deliberately mirrored, same discipline as the checklist grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin the fixture; the TypeScript half carries the same four ids and hashes as a comment because the frontend has no test runner at all — its whole CI lane is `vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over. A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved. Hashing the empty string would give every draft one shared tint and then change it on save anyway — two surprises where one will do. An explicitly-picked colour still wins. The picker is on its way out (milestone 309 step 5) but it has not gone yet, and a hand-coloured note changing under the operator would read as data loss. First of five steps toward colour coming from tags. This one stands alone: no storage change, nothing removed, and the board stops being white today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
96a6f6e691 |
web: the editor draws the checklist too
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s
2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.
Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.
markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.
Two places the web can do better than Compose, and does:
* Backspace at the start of an empty item removes it. A browser sends a real
keydown for Backspace; an Android soft keyboard sends an IME delete that never
surfaces as one, which is why that surface only has Enter-on-empty.
* Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
guessing a row count that is wrong the moment a line wraps.
KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
|
||
|
|
3cab054684 |
web: task lines render as checkboxes where they sit in the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept working across the last two commits — but it was rendering every list TWICE: once as literal `- [ ] milk` bullets inside the body, and again as the separate NoteChecklist block underneath. This is the commit that makes the body the only place a checklist appears. markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would otherwise swallow the marker and leave the brackets showing, the same ordering reason `code` is matched before emphasis in INLINE_RE. Each item carries its ordinal across the WHOLE document, because that is what an item's id means everywhere else now; counting per block would have made the second list's checkboxes toggle the first list's items. The card's preview clamp is why that ordinal is safe there: it only ever drops lines from the end, so a visible item's index is the same whether or not the body was truncated. MarkdownText emits a toggle rather than reaching for the store. Ticking a box rewrites a line of someone's note, and a renderer used in several places should not be the thing deciding that is allowed — the card passes `toggleable` and wires it, a read-only render does not and the boxes are inert. Not wrapped in a <label> either: on a card the text is the note's own words and clicking it opens the note, so only the box toggles. In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at the caret. That makes it the one toolbar action needing no persisted note to hang anything off — ensureDraft is gone from it, and it works on an empty compose box the moment it opens. Enter on a task line continues the list, and on an EMPTY one clears the marker; without that second half a list would be impossible to get out of. Indent and bullet are carried over rather than normalised, because continuing someone's `*` list with a `-` is an edit they did not ask for. NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the repository seam the REST routes and Tauri commands both implement, not the old path. CI cannot check any of this beyond types — there are no frontend tests, only vue-tsc. It wants a real browser pass. |
||
|
|
09b5f874b6 |
Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the envs, we need the security values to be in the UI."* Overrules the call I made yesterday, and rule 25 is on your side — I argued deployment-topology, but the operator has to be able to SEE what protects them, and reading a container's environment is not seeing. Six new settings in a **Security** group: trusted proxy hops (default 1), the per-account and per-address sign-in limits with their shared window, and the sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate limits are no longer hardcoded constants. **The hard part was keeping the throttle cheap.** It consults these BEFORE opening a database connection — deliberately, because a refused attempt is meant to cost nothing, and the hop count is needed to know who is even asking. A query per attempt would undo both. So there is a small cache seeded from the registry defaults (the app works with no database at all, which is what the DB-free unit lane relies on), loaded at boot, and refreshed on every settings save — the same live-update contract `session_ttl_days` already had. `SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so a saved number applies to the next attempt instead of the next deploy. **Bounds are rejected, not clamped.** A hop count of 99 would trust anything a caller sent; a sign-in limit of 0 would lock every account out permanently. Both now fail validation with a message naming the range, and the number input carries min/max so the browser objects first. Silently storing a different number than the one typed is how somebody ends up believing a protection is set to something it is not. `MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself rather than the app from a caller, and there is no operator judgment to apply. Two integration tests, because the whole point is the round trip: a dangerous value refused, a legitimate one reaching the cache the throttle reads and persisting; and every Security row reaching the admin payload with bounds and a description that explains itself. |
||
|
|
7033995975 |
search is a facet on the board, not a place you go
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The server has always been able to do that — `GET /api/notes` composes `?q=` with `?label=` and the rest into one AND-ed query. The frontend never reached it. The header search box navigated to `/search`, and that view called a DIFFERENT endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the one screen you landed on when you searched was the one screen where you could not narrow by tag. Tag filtering lived on the board's FilterBar, which is where you weren't searching. Two search boxes, two endpoints, and only the hidden one did what tags are for. Now the header box writes `?q=` into the board's URL beside whatever labels are already there, and stays on the lens you're in — searching while looking at Trash searches Trash. The box READS from the URL rather than holding its own copy, so it stays in step with the Filters panel's Clear and with a saved view opened from the sidebar. Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search` and both adapter implementations, and the `notes_search` Tauri command whose only caller was the adapter entry. FilterBar loses its own "Search text…" input — it was the same facet, hidden behind a collapsed panel, duplicating a box that is always on screen. Filters now does what its name says: narrowing. The header does searching. `core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and has its own search surface — which has the same no-tag-filter gap the web just lost, and deserves the same fix on its own terms rather than as a rider here. |
||
|
|
de72d27bd4 |
URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a single URL, and to be a compact slot on the bottom of the note when the URL is inline. We also need to support multiple URLs in a single note."* Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)` — so several URLs per note has worked at the storage layer all along. What was missing was that it needed a button, had one size, and drew that size in the wrong place. **Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs and fetches them on a background task AFTER the note is committed. Capture speed is the product: an unfurl is a five-second timeout against a host nobody controls, and a note has to persist the instant someone stops typing. Scheduled from create, from a body edit, and from a synced push — so a linked desktop or Android client gets previews too, on its next pull. An unlinked one has no server to ask and simply has none, which is the honest consequence of being offline. Safe to call on every save: it re-reads what's cached and does nothing when nothing is new. Capped at five URLs per note, silent on every failure (a link that won't fetch isn't an error the person needs — the note is fine, the link is still there), and it re-checks before storing, so a slow fetch can't resurrect a preview for a URL that was deleted while it was in flight. **Two presentations.** A note whose body is nothing but a URL renders as its preview and nothing else — printing the raw URL under a card that already says where it goes is saying the same thing twice, badly. Until the fetch lands, or if it never does, the URL stands in, so the card is never blank. Anything else gets a compact strip. **And the strip moved.** Previews were rendered ABOVE the body, which put a stranger's headline where the note's own first line should be — worse now that the first line IS the note's name. They sit at the foot of the card now, under the note's own words. The editor's "Preview example.com" button is gone with the manual path; removing an unwanted preview stays, and stays editor-only. Nine tests: three on detection (order, dedupe, sentence-punctuation trimming, non-http rejection) in the unit lane, and three in the integration lane for what only a real database shows — the upsert landing on the right row, a second pass fetching nothing, and a preview NOT being stored for a URL that left the body. |
||
|
|
c99cbb3e14 |
cards: clamp the web note preview, as Android always has
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M13 step 4 asked for no bold first line, and step 3 already delivered that — removing `note.title` took the card's <h3> and the Android editor's bold field with it. What step 4 also asked for, and hadn't been done, was the other half: "be willing to spend something small on legibility that isn't weight on the first line." The web card rendered the entire body. Android has always clamped to eight lines (`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on the web and pushed the rest of the board off it — a real asymmetry between two surfaces that are supposed to be peers. It matters more without a title. The first line used to be what your eye caught; with one weight throughout, an unbounded card is just a wall, and the note beside it is the one you were actually looking for. Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and behaves unreliably around the block elements MarkdownText emits (lists, quotes, fenced code). Doing it before the parse is deterministic, matches Android's semantics exactly, and skips parsing a body the card was never going to show. |
||
|
|
924ddb20db |
notes: saveEdit still asked for a title
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s parameter type still declared `title`, so the editor's call — correctly no longer passing one — didn't match. I gated Rust locally and not the frontend. Both are now in ci-requirements, including WHY the frontend one has to be `npm run build` rather than `vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed template sails past it and fails `vite build` in a different workflow, which is exactly how the stray `</div>` got two commits away from where it was written. |
||
|
|
95aa10c2c3 |
Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME stays — search results, export filenames and the command palette all need one — but nothing is typed into it any more. `display_title` is now the first non-empty line of the body, falling back to the first checklist item. That fallback is what step 2 bought, and the reason this could not go first: a checklist had no body to be named from, so the title was its only name. Now every note has a body, and a note that is only a checklist is named by its first item. Gone everywhere: the column and note_revisions.title (0026), the field on the core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7), `normalize_title`, the wire field, the FFI record and `NoteEdit::Title` / `ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and the Android title field in both the compose sheet and the editor. **The search vector had to be rebuilt, not just left alone.** `notes.search_vector` is a STORED GENERATED column whose expression names `title` — Postgres refuses to drop a column another generated column depends on. It is dropped and recreated over `display_title` at weight A, which keeps the original intent: a note's NAME ranks above the rest of its body. **An imported title becomes the note's first body line.** Keep notes carry one, and so does any ThoughtSync export taken before this. Dropping it would silently lose text someone wrote; folding it in puts it exactly where a name now lives, so the note arrives named as it was. Skipped when the body already opens with that line, so re-importing an export this code produced doesn't stack duplicates. Two smaller things fell out. The Android editor loses its bold first field — one weight throughout, because the first line is the note's name but not a different KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s justification comment moved to `ClearRemindAt`, which is now the surviving example of why NoteEdit is a list rather than a struct of options. Protocol note corrected to say what actually shipped: v2 is "no kind, no title", one bump for the pair. Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests all green before pushing. It caught four things — orphaned serde attributes where fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview still has one), nine retention fixtures inserting a dropped column, and four rustfmt diffs. |
||
|
|
33e9278975 |
Fix three breaks the removals left behind
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** ( |
||
|
|
c46a4a7709 |
A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
|
||
|
|
ad21eac5bc |
editor: drop the adapter import that went with backlinks
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
CI & Build / Build & push image (push) Failing after 22s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 30s
Desktop (Tauri) / Update manifest (push) Skipped
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and `repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs with noUnusedLocals, so one stale import failed the whole shared-frontend build and took both desktop lanes down with it (TS6133). My local sweep checked for dangling *references*; it never checked the inverse, that every import still has one. It does now, across all fifteen files that removal touched — `repo` was the only one. |
||
|
|
bc22f8e249 |
Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes
|
||
|
|
982d24c83b |
links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
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 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge. Renaming it broke every inbound link — and the fix that shipped for that (task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]` text inside the body of every note that linked to the renamed one. That works while an explicit title exists to hold still. It stops being defensible the moment a note's name is just its first body line, which is where M13 is going: fixing a typo in your opening sentence would silently edit other notes' words, with nothing to opt out to. So this lands first, before the title comes out, and that window never ships. `note_links` gains `target_id`, bound when the link is written. `target_norm` stays and is what an UNRESOLVED link carries — linking to a note that doesn't exist yet is a supported way to create one, so a link has to be able to name a target that isn't there. Resolution reads the id, falling back to the name only where nothing was bound, which is what lets a forward link connect the moment its target appears. `_claim_unresolved_links` then binds it, so the fallback is a transitional state rather than a permanent one. `_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them touches link rows only: a note's text is never modified by something happening to a different note. The client can no longer resolve links for itself, and that is the point. It used to look `[[text]]` up in a client-side name index, which only held together BECAUSE renaming rewrote the text everywhere. Now the written text can name something the target is no longer called, and only the server holds the binding — so each note serializes its resolved links (`norm`, `id`, and the target's name as it stands NOW). A renamed note reads correctly everywhere it is linked from, without a single body having been edited. Unresolved links are simply absent and fall through to the create-on-click affordance that already existed; so does the offline desktop store, which derives links at query time and has no binding to send. The name-fallback join is owner-scoped everywhere it appears. Bound ids were resolved owner-scoped when written, but matching on display_title alone would have let two users who each have a note called "Groceries" see the other's id and name through an unresolved link (rule 47). The new behaviour is all SQL and this suite runs without a database, so the dead helpers' tests are removed rather than replaced. This repo has no integration lane to hold that ground — noted, not papered over. |
||
|
|
16f86bef93 |
web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device — correct as far as it went (task 2697: a finger cannot hover, and the pill is the only way to pin or archive), but they were still absolutely positioned, so they sat ON the note's own title. A card reading "thought sync tauri app" rendered as "ught sync tauri app" with the grip parked over the first three characters, and the four-icon pill covering the right half of the first line. Placement is now CSS's decision. One element each, two placements: where a pointer can hover they lift out of flow into the floating top-corner pills they have always been; where nothing can hover they stay in flow as a footer row, which cannot overlap anything by construction. Keyed on hover rather than width, for the same reason `.hover-reveal` already is — a narrow window on a laptop still hovers, a wide tablet still doesn't. The colour popover moved inside the action set so it follows it, and opens into the card from either end. The header was sharing one phone-width row between a menu button, the logo, the lens name, a search field and four icons; everything in it was truncated, the lens down to "N…" and the search box to an empty pill. It wraps now, so search takes its own line below sm, and account / settings / sign-out move into the drawer where there is room to name them rather than guess at a glyph. One input, moved by CSS — duplicating it would have meant two `searchInput` refs and a `/` shortcut that focuses the wrong one. Also closes the other half of task 2706, which was waiting on a device to look at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding that makes it safe (sides on body, top on the sticky header, bottom on the board and the drawer), and `100dvh` behind an @supports so the app box follows the visual viewport when the keyboard opens instead of the layout viewport. Both halves in one change, as that task insisted. And the composer no longer tells a phone to "Press Enter". |
||
|
|
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.
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
f5837cd985 |
frontend: hover-revealed controls stay put where hovering is impossible (task 2697)
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m36s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
A finger cannot hover, and on a note card the hover toolbar is the only way to pin, colour or archive — so on a phone those notes could not be acted on at all. Same for deleting a checklist item, a saved view, an attachment or a preview. Marked rather than rewritten inline: one `.hover-reveal` class on the five elements and a single rule that says what it is for. The `group-hover:` reveal stays in the markup because the trigger differs per component (named groups); only the fallback is shared. `@media (hover: none)` asks the device directly, which is more honest than inferring from viewport width — a narrow window on a laptop still hovers, and a large tablet still doesn't. It sits after the Tailwind directives so it beats the opacity-0/pointer-events-none utilities on source order without !important. Tap targets follow the same shape: p-1.5 around an 18px icon lands near 30px, which is fine for a cursor and too small for a thumb. Bumped to 44px on coarse pointers only, so desktop chrome doesn't inflate. The drag grip is deliberately NOT revealed yet. Reordering still uses HTML5 drag-and-drop, which never fires from touch, so showing the handle would only promise something that does nothing. It comes with the pointer-events rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e7ee16c6cf |
frontend: dialogs keep focus, and a skip link past the chrome (task 1999)
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m14s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 5s
BaseModal declared role="dialog" aria-modal="true" and then enforced none of it. Focus never moved into the panel, so Escape — handled ON the panel — did nothing at all in LabelsModal, the integration prompt and the shortcuts modal. Only the command palette escaped correctly, and only because it happens to focus its own input. Tab walked straight out of the dialog into the page that aria-modal had just told assistive tech was inert, and closing dropped focus to <body> so the next Tab restarted from the top of the document. All three are one contract, so it lives in BaseModal rather than in each of the four callers: focus in on open, Tab trapped, focus restored to the opener. The panel takes tabindex="-1" so it can hold focus itself when it wraps nothing focusable. CommandPalette's input focus still wins, because a child's mounted hook runs before its parent's. The skip link is the other half. The header and sidebar are a dozen-odd tab stops that repeat on every navigation, and a keyboard user walked all of them again to reach their notes. <main> takes tabindex="-1" as well, because several browsers scroll to a bare anchor without moving focus to it — which would have made the link look like it worked while leaving the next Tab back at the top. The rest of the audit came back clean: no click handlers on non-focusable elements, and all 30 focus:outline-none uses already pair with a focus-visible ring. M3.5's keyboard pass held up; the gaps were in focus management, not styling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6646a64fb |
desktop: remove two dead ends from the shell, and stop the launch flash (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 12s
CI & Build / Build & push image (push) Successful in 39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Sign out was a trap on the desktop, not an action. It nulls the synthetic local user and redirects to /login, but the offline adapter rejects every sign-in with "there's no account to sign in to" — so the only way back into your own notes was to restart the app. There is nothing to sign out of; the notes are on this machine either way. Linked devices was a quieter version of the same thing: it lists the tokens a SERVER has issued to native clients, and the desktop is one of those clients, so offline the list is always empty and issuing a token rejects. Its actual relationship with a server already has a home at /sync. Also hid the account name, which named a login the app doesn't have. /account is now blocked in the router too, not merely hidden — the mirror of the existing requiresDesktop guard — so a typed URL or a restored history entry can't reach the dead end either. Deliberately not applied to /login and /register: bouncing those on desktop would loop against the requiresAuth guard whenever a session is missing. The launch flash is the window painting before the webview does, showing the platform default white through the gap — worst on a dark-mode desktop, and widened by the software rendering we force on Linux. Set from the live system theme rather than app.windows[].backgroundColor, because that config carries one static colour and either choice would fix half of users while introducing the same flash for the other half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3a1496e5fa |
frontend: honor prefers-reduced-motion, and let frontend work reach the desktop
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Two halves of the same gap. The app had no reduced-motion handling at all — the setting appeared nowhere in the frontend — and the desktop build didn't rebuild on frontend changes, so shared UI work shipped to the web and silently never reached the desktop app. The CSS guard is global and blunt so it catches every Tailwind `transition` already scattered through the components, and catches M7's motion work without each new component having to remember. Near-zero durations rather than `none`, so transitionend/animationend still fire and nothing waiting on them hangs. useReducedMotion covers what CSS can't reach: JS-driven motion, where the honest response to the preference is no animation at all rather than a faster one. It's reactive because the setting can change while the app is open. The path filter was narrowed to the adapter/bridge directories against a "~20-40 min" build cost recorded in the header. Measured runs are 4-5 minutes, so that cost isn't there, and the frontend is compiled into the binary by generate_context! — any part of it changing means the shipped desktop app is stale. Desktop, web and Android are peer surfaces on one quality bar, so shared frontend work has to reach all of them by construction rather than by whichever directory it happened to touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
659237ccc6 |
desktop: the empty board explains where your notes live (task 1999)
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> |
||
|
|
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> |