The label picker and the editor's tag row each wrote the same Text: the
label_from_tag string, labelSmall, onSurfaceVariant, 8dp start padding.
Chips.kt's FromTextMark() is now both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web compared or keyed tag names with name.toLowerCase() at five sites:
the labels store's merge check, LabelPicker's create check, NoteCard's
colour map, and colors.ts twice. Android had three, plus TagsScreen's rename
dialog using equals(ignoreCase = true). Each now calls foldTag(name), which
says what the core's derive::fold and the server's labeling.named say.
Android's rename dialog now predicts a merge the same way the core decides
one. Per-character ignoreCase and full lowercasing disagree on letters like
İ, whose lowercase is two characters. LabelPicker's search filter keeps
its own lowercasing: that is search, not identity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite's lower() folds ASCII only, so a device could hold "Café" and
"CAFÉ" as two tags while the server held one. derive::fold (to_lowercase)
is now the one comparison. It is registered as the deterministic SQL function
fold() on every connection (schema::migrate), and find_or_create, the rename
clash, the pull clash and the unique index all use it. derive's push_unique
used eq_ignore_ascii_case and now folds the same way.
v14 merges pairs a device already holds before rebuilding the index. The
older row survives, as in rename_label. Memberships move with via_tag kept,
affected notes go dirty, and the merged-away row is a pending delete. It is
written out rather than calling store::merge_labels so the migration doesn't
depend on store code that later versions may change.
rusqlite gains its "functions" feature (operator-approved, 2026-10-08).
grammar.json gains "#café and #CAFÉ" -> ["café"], which all three
implementations run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web: one counted(n, one, other) helper. The sync screen's three "(s)"
messages (attachments that didn't download, changes refused, files that
didn't upload) now say "1 file" / "2 files". The six hand-written ternaries
(SyncView x2, ImportNotes, GroupList, ShareDialog, LabelsModal) use it too.
Android: tags_count was two strings chosen in code, and is now <plurals>.
The delete confirmation read "It is on 1 notes … removed from all of them"
and now has a one form of its own.
Operator decision on #5371, 2026-10-08.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8849 watched the gate reject: the APK job failed, "Build the server
image" was skipped, ci.yml 8850 stood down, nothing was dispatched, and
:dev still resolves to package version 11662 (pushed by 8848). This push
checks the green direction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preference 172. ScrollSentinel watches the end of a cursor-paged list and
asks for the next page a screen early. It asks once per cursor, keeps going
when a page lands without pushing the end off screen, and stops on a failure
until Try again (an observer re-firing on a failure is a request loop). A
failed older page now gets its own error beside the retry, instead of
replacing the list's load error.
Adapted from fabledstash's ScrollSentinel (snippet 3760).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reverted by the next commit. Expect: build fails, server-image skipped,
no ci.yml dispatch, :dev unmoved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server-image ran under always(), so a failed core check or APK build still
dispatched ci.yml. Run 8839 published :<sha> for 0ecdbfe while its APK lane
(8835) was red. Rule 177: nothing publishes on red. With always() dropped,
the dispatch needs rust and build to succeed. A skipped decide still skips
it, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fb95c9b moved the foreground latch into ui/ForegroundTransitions.kt, but
MainActivity lives one package up and imports what it uses from ui by name;
run 8835's compileDebugKotlin stopped on the two unresolved calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rename and setColor each found the label and replaced it, keeping the count
the single-label PATCH doesn't recompute; keepingCount(updated) is that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShareDialog's Share and NoteEditor's Add note / Close each spelled out the
same compact button, smaller than .btn. .btn-compact is its shape and
.btn-compact-primary its brand fill; Close keeps its neutral colours beside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8830's Linux bundle sat on a rust-lld link whose 17 threads were all
parked on futexes until the runner's own 30-minute cap ended it. verify, the
Linux and Windows builds get 20 minutes (warm runs take ~4, and a cold one
compiles the tree in about two), the manifest 5. Kept under the runner's cap
so these are the limits that fire.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five test modules wrote the same db() over memory_conn, and portable.rs
unwrapped it inline; local::test_db() is that. Four pull/push tests wrote the
same label INSERT; local::seed_label(conn, id, name, dirty) is it. Both are
cfg(test), beside memory_conn, as wire::sample_note is beside wire::Note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SettingsView drew secret and plain text settings as two inputs differing only
in type, autocomplete and the saved-value placeholder; one input binds those.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
enable and pushSoon each set the CONNECTED constraint and the 30-second
exponential backoff; B.online() on WorkRequest.Builder sets both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_notes.py and derive.rs each kept the same six lines of prose that look
like a checklist item. Four were already task_lines cases in grammar.json;
the other two (empty brackets, no bullet) join them, so the web suite now
tests them too, and both hand lists go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The source and channel fieldsets were the same markup over different options.
updateChoices holds each set with its legend, radio name and when it is
offered, and one v-for draws them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
revoke_self and revoke_device each loaded the caller's DeviceToken, deleted
it, committed and audited DEVICE_UNLINKED; _unlink_device(which) is that,
owner-scoped as before, with each route keeping only how it names the row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AccountView's device-token reveal was OneTimeLink's markup and copy handler
again. OneTimeLink becomes OneTimeSecret (value, note, noun = link | token),
and the account page shows a new token through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegisterView and ResetPasswordView each wrote the min-length check and its
message; config.passwordRefusal(password) is that check, the web's side of
auth._password_refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Web: GroupList's Person was adapters/repo's Member field for field, and
AccountList's Account was the session store's User; both now import them.
The bridge's Identity stays, as the Tauri mirror of the core's struct.
Server: auth._serialize_user and accounts' _serialize_account wrote the same
four fields. serialize.serialize_user is serialize_person plus is_admin, and
the account list adds created_at to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Destination carried its English title as a field, and NoteTint its colour
name; every other word on screen is a string resource. Destination.title()
now reads nav_notes/reminders/archive/trash (a tag's destination keeps its
name), and NoteTint.label is a @StringRes. Error fallbacks raised in view
models and the install receiver stay as constants: they are made outside
composition, with no Context to read a resource from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sharing.test.ts round-tripped shared=with_me, which facets.test.ts already
does with every facet; its unknown-value case moves there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notes/__init__ re-exported 17 names so it could stand in for the old single
module. Only sync.py and test_notes.py used that, and each now imports from
the module that defines the name; __all__ and the nine imports __init__ held
only for it go. sync.py also drops sa_delete, func, live and NoteLabel,
unused since 8eff5f6 (#5382). The integration test's patch on
inkwell.notes.schedule_unfurls stays: the routes call it from there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useNoteList no longer names a Search view; the desktop adapter's M10.7 plan
gave way to sync under the local core; local.ts's sharing comment sat above
settings; AccountList and password_resets still said there was no mail path;
NoteEditor kept an orphan checklist-flag comment, a textarea comment from
before blocks, and the link-preview comment above the file picker; the
serialize docstring's growth plan is now what it holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- notes/datetime.ts formatLocalDay had no caller; removed.
- style.css set body in two consecutive blocks; they are one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update.rs read_marker(): read the installer's marker file, parse it, and
log one that doesn't parse. adopt_installer_channel and
adopt_installer_server each wrote that out; they already shared adopt().
normalize_server's doc now says why it stays apart from
compat::normalize_base_url: one tidies what a person types, the other
refuses anything odd in what a script wrote. The tauri.conf updater
endpoint stays: read_source already documents that it is never consulted,
and removing it is a config change CI would be the first to try.
rustfmt --check is clean in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lib.rs MAIN_WINDOW: the "main" window label that the reminder worker,
the capture window and the shell each wrote, 5 sites in all.
- lib.rs now_ms(): the epoch-milliseconds clock that the reminder worker
and autosync's cycle stamp each computed. LastCycle.at_ms becomes i64,
the same number on the wire.
- integration.rs applications_dir(): the XDG launcher directory that the
entry path and the desktop-database refresh each built.
rustfmt --check is clean in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LogTag.kt: the four tags the app logs under. Eight files each declared
one of them as a string.
- SNOOZE_HOUR/SNOOZE_DAY sit beside EditorAction.SnoozeReminder; the
notification's snooze uses SNOOZE_HOUR instead of its own 60.
- Reminders.at reads through ui.epochMillis, the parse the card and the
overdue check already use.
Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorBanner.kt Banner(tintKey) { … } is the rounded, palette-tinted row
that ErrorBanner (red) and UpdateBanner (blue) each built. TintChip moves
to Chips.kt with CHIP_RADIUS, which keeps NoteCard.kt under detekt's
function count. Checked with ktlint and detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detekt's SpreadOperator flagged trimEnd(*TRAILING_PUNCTUATION) from
1c4bf56. trimEnd { it in TRAILING_PUNCTUATION } trims the same characters
without copying the array. Checked with detekt in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- BoardScreen TitleAndBody: the title plus quieter body that the empty
board and the store-unavailable screen each wrote.
- NoteCard TintChip: the one-line palette chip that the reminder and
shared-by chips each drew in full.
- TagsScreen Swatch: the colour dot that the tag row (tappable) and the
colour picker each built.
Kept: the label chips. Each uses its tag's ink and a bigger shape, not
the card's chip pair. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TrashedNoteItems, PinItem and ArchiveItem (EditorChrome.kt) are the rows
that the editor's overflow and the board's long-press menu each built: a
trashed note's restore and delete-forever, and the pin and archive
toggles. Each menu keeps its own order and its owner-only rows. Checked
with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Note.manualLabelIds (NoteAccess.kt): the tags attached by hand, which
the label picker, the chip's remove button and the board's create-label
each filtered out of note.labels.
- sharerName(note): who shared a note, or "Someone", which the shared-by
line and the card's chip each spelled out.
Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt BackButton(onClick, label): the IconButton + ArrowBack that the
tags, sync and editor top bars each built. Each keeps its own spoken
label. Checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panel.kt Hint(text, modifier): bodySmall in onSurfaceVariant. That is the
secondary line that the sync pairing form, the sync screen, the update card
and the share sheet each wrote as a full Text(...) at 14 sites. Sites that
add more than a modifier (the link preview's two-line clamp) stay as they
are. So does ShareSheet's own Muted, which is bodyMedium.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Panel.kt ConfirmDialog (moved from TagsScreen, where it was private):
the delete-forever dialog, the sync disconnect and the tag dialogs all
ask through it now.
- strings.xml: editor_cancel and tags_cancel were both "Cancel"; they are
one cancel string.
- NoteAccess carries the core's permission string (wire). The access
lookup, the share sheet's choices and the board's draft read it from
there instead of writing "owner"/"edit"/"view" again.
- hashtag(name): the #-prefixed tag name that TagsScreen, the card and the
editor chips each wrote.
Formatted and checked with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ui/Failure.kt: Throwable.shownAs(fallback) and FALLBACK_ERROR. The
core's own message, else a fallback, which Share, Tags and Sync each
defined privately and Board and Update wrote inline.
- The five view-model factories use lifecycle's viewModelFactory { initializer }
instead of an unchecked-cast object each.
- BoardViewModel.beginSitting: the editingSession bump the four editor
openings each spelled out.
The fallback line stays an English constant, as it was: view models hold no
Context to read strings.xml. Formatted with ktlint in the CI image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- desktop/bridge.ts listen<T>(): the no-op-off-desktop event listener
that onSynced, onCaptured and onReminderDue each wrote.
- size.ts roughSize(): one decimal below 10, none above, for the client
download sizes (MB) and the storage line (GB).
- BaseModal's title prop draws the heading row and close button that
LabelsModal and ShareDialog each built.
- SyncView: switchChannel and switchSource share recheck(); the four
update-card radios render from two option lists.
Kept: the board's and the shell's is-typing guards (they ignore different
elements), the drawer's nav beside the palette's commands (one is a laid-
out list with tags between its entries, the other a command list), the
status pills (each colours differently), and plurals (the copy is the
operator's call, raised in #5371).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accounts, groups, invites and activity each wrote the same first load:
clear the error, try, put the server's reason or a fallback in error,
and stop loading. useLoad (composables/useAction.ts) is that, and its
load() is also the retry.
Kept: ShareDialog and Settings. Their load sets loading back to true on a
reload, which the lists never did, so moving them would change what a
retry shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ebbe4a6 converted the wrong try block in InviteList: the edit matched from
load()'s try down to revoke()'s catch, which left load() half-converted.
load() goes back to its own try/catch/finally. revoke() uses
toastOnFailure, as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
composables/useAction.ts: toastOnFailure runs an action and toasts the
server's reason or a fallback; useAction adds the busy flag a button
waits on; useRowAction keeps the id of the row whose action is running.
Import, export, the menu entry, the integration prompt, sign out
elsewhere, the account reset link, the group actions (whose local act()
it replaces), invite revoke and sync disconnect each wrote that
try/catch/finally out.
Kept: AccountView's device revoke. It shows a fixed message rather than
the server's reason, and moving it would change the text. ShareDialog
shows its errors inline, not in a toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16ab4a1's APK lane stopped at ktlint (chain-method-continuation) on the
one-line chain 1c4bf56 wrote. Reformatted with ktlint --format in the CI
image; no code change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in, register, forgot and reset each wrote the same page: a centred
column, the app icon, a title, a subtitle, the form and a footer link. That
is now components/AuthLayout.vue, with the title as a prop and the
subtitle, the form, the footer and anything after it as slots. The footer
links wear the new .text-link class. Markup and classes are unchanged, so
the pages render as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
style.css gains the classes the views spelled out in full: .section-label
(17 sites), .hint (20), .form-error (13), .alert-error (5), .row-card (5),
.list-empty (5), .field (5), .page-shell (3), and the small row action
.btn-sm (4) / .btn-sm-danger (3). Only exact runs moved, so nothing renders
differently; spacing a site adds beyond a run stays a utility beside it.
Kept: the Reminders and Timeline small buttons. They carry no text colour
and inherit it, so putting them on .btn-sm would recolour them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- common.detach: the hold-a-reference-until-done task start that mailer and
unfurl_queue each wrote.
- common.expired_before: the retention window, now shared by trash and the
audit log (it moves out of retention.py, which imports audit).
- responses.too_many: the 429 with Retry-After from the credential throttle
and the client-download throttle.
- share_sync.revoke_lost: revoke whoever could see a note before and no
longer can, after a share or a group goes.
- serialize.serialize_person: a member as the directory, a share and a
group listing show them.
- groups_api._get_group: the path-id lookup four group routes wrote.
- settings.apply_session_ttl: the session lifetime set at boot and on save.
Kept: the attachment-id claim check (one query; each caller answers an id it
already holds differently), the strict UUID-list parses in reorder and
set_note_labels (distinct error messages), and the checklist-items loops
(one line each).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notes.helpers._fetch_note is the parse-id, not-purged, gated select that
_get_owned, _get_visible and _get_editable each wrote out, and note_visible
is the viewer's visibility predicate that five note reads spelled in full.
labeling.named is the case-insensitive live-name match that tags, labels
(create and rename) and the sync push each wrote; how two tag names compare
is now said in one place (#5385 will change it there). sync._landed is the
flush, read-back-the-revision and reply that four push paths ended with.
The REST routes' commit-and-serialise tails stay: each is two lines, and
whether a route refreshes the row first differs by route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
common.normalize_email is the trim-and-lowercase that sign-up, sign-in,
reset and invite each wrote inline. auth._unauthenticated is the 401 the
six signed-out paths returned, and auth._password_refusal is the
minimum-length check that register, reset and change repeated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The purge wrote out its own try/unlink/log next to unlink_media, which says the
same thing. It couldn't import it: unlink_media lived in the notes package, which
imports retention. unlink_media moves down to storage.py, the module about what
attachments occupy, and the purge, the delete route and sync all call it.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
common.iso is the server's one way to put a datetime on the wire, yet the JSON
export and the importer's Markdown frontmatter wrote out the
x.isoformat() if x else None idiom it replaces, seven times. Same output.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
responses.py is the app's one JSON error shape, yet invites, accounts, the SPA
fallback and the test-email route still built jsonify({"error": ...}) by hand,
and two parsed path ids with their own try/uuid.UUID. They now use json_error,
not_found and parse_uuid. The download limiter's 429 stays for F13, with its
Retry-After twin.
DRY pass #2, batch 4, F10 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server's MIN_PASSWORD_LEN was 8, and the web wrote 8 out five times: two
checks and three placeholders. The constant moves beside the other policy numbers
in settings.py (auth.py imports it), /api/config serves it as
min_password_length, and the config store hands it to Register, Reset and
Account. 8 stays only as the fallback until the config answers.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server unfurls what detect_urls finds, trailing .,;:!? trimmed, and files the
preview under that. The web and Android cards looked a lone link's preview up
under body.trim(), punctuation included, so a note reading
"https://example.com/a." never showed its card.
- grammar.json gains a urls section: what the server finds in a body, and the
link a lone-link note is filed under.
- The web's rule moves out of NoteCard into notes/links.ts loneUrl(); Android's
LinkPreviewRow gets the same loneUrl(); both trim like the server.
- The server and web suites run the cases; Android's JVM test pins them by hand,
as it does the tint.
Fixes#5399. DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web already checked its tint keys against grammar.json; the server's
NOTE_COLORS, which normalize_color accepts, had no such guard. It is now the
fixture's hues plus "default". The core has no palette to check: it stores
whatever the UI (which only offers palette keys) or the server sends.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
daily/weekly/monthly/yearly was written out in the server (REMINDER_RECURRENCES),
the core (recur::RECURRENCES), the web editor's <option>s and Android's picker,
with nothing holding them together. grammar.json now has a recurrences list; the
server, core and web suites each check theirs against it, and the web's options
come from notes/recurrence.ts rather than the template. Android's picker pins the
list by hand with its localised labels, as it does the tint: its JVM tests do not
read the fixture.
DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server and the core each derived display_title and disagreed twice: the
server cut it at 200 characters and the core didn't, and the server split lines
with splitlines(), which also breaks on a lone \r or a U+2028, where the core and
every other reading of the grammar split on \n alone.
- grammar.json gains a display_titles section: blank lines, markers, an empty
item, \r\n, a lone \r, U+2028, and a 201-character line of 'é' (the cut is
characters, not bytes).
- derive::display_title and DISPLAY_TITLE_CAP are the core's half, moved next to
strip_marker. The server splits on "\n". Both suites run the cases.
Behaviour: a device now names a note with a first line over 200 characters the
way the web always has, and the server names a note containing a lone \r or a
U+2028 the way devices always have.
Fixes#5398. DRY pass #2, batch 3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pull's note() and push's server_note each wrote out all nineteen fields of a
wire::Note. wire::sample_note(id, revision) is that note; push's version changes
only the body and attachments, by struct update.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blobs, store and portable each built a BlobStore in a temp directory their own
way: pid+tag twice, a uuid once. blobs::scratch(tag) is that, with a counter, so
two tests can never share a directory even if they pick the same tag. The
desktop's and ffi's temp-dir helpers stay, one per crate: sharing them would
need a test-util feature on the core crate.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven test modules each built a migrated in-memory store by hand: open, migrate,
and (in sharing) wrap it in a Db. local::memory_conn() is that, and
open_in_memory uses it too. Each module's db() is now one line, and the schema,
Connection and Mutex imports it needed are gone.
DRY pass #2, batch 2, F9 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Formatting only. portable::instant (from F5), and the ffi's link and unlink
(F2/F3), were laid out by hand without a toolchain; rustfmt splits each chain.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Db::conn is documented as the one way to take the lock, yet five production and
test sites reached past it with db.0.lock(): the startup summary, the desktop's
trash sweep and config_get, and tests in sharing and update. All five now call
conn().
DRY pass #2, batch 2, F8 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"owner"/"edit"/"view" and the entity names "note"/"label"/"attachment"/"preview"
were literals at about thirty sites across store, pull and push, including match
arms whose spelling had to agree with the rows a different module wrote.
models::access and models::entity now name them, and push names its two ops.
SQL text keeps its literals; Rust-side comparisons and writes read the constants.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
schema::migrate was thirteen hand-copied blocks of "if version < N, apply,
stamp N". The versions are now a STEPS list (SQL, or code for v8). migrate walks
the list, applies each step a store hasn't had and stamps it. A new version is a
new entry at the end; there is no block to copy.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reminders and due_reminders each wrote out "owner's, not trashed, has a time".
The predicate is now one constant both queries read, carrying the reason a
shared note's reminder is not ours.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rename UPDATE appeared twice: once for a merge's survivor and once for a
plain rename. The branch now picks which row is renamed, and one UPDATE follows.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
should_snapshot and snapshot_revision each wrote out the SELECT that note_body
already is.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has_pending listed the same four predicates as pending_fingerprint (dirty notes,
dirty labels, pending deletes, unsent uploads) in a second query. It is now
pending_fingerprint(..)?.is_some(). Counting where LIMIT 1 would do costs nothing
on a local store.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"application/octet-stream" was written out in the blob server, its test, the
store's normalize_mime and the wire default. All four now read OPAQUE_MIME; the
schema's SQL column default names the same string and says so.
DRY pass #2, batch 2, F7 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven calls each spelled out send, describe the transport error, map a 401, and
refuse anything else as unexpected_status; two read the server's {"error"} words
the same way. send_raw (transport + a 401 whose meaning the caller names), send
(and anything but success is unexpected) and server_reason now hold those steps.
Each call keeps only what is its own: device_login's and fetch_identity's 401
wording, the release's 404 = none, sharing's 404 and refusal reason, upload's
retry split.
DRY pass #2, batch 2, F6 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical
order chronological. It was spelled out ten times as
to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies
(store, pull). local::iso(t) and local::now() now hold it; store, pull, engine
and portable call them.
DRY pass #2, batch 2, F5 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.
DRY pass #2, batch 1, F4 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.
DRY pass #2, batch 1, F3 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.
The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.
DRY pass #2, batch 1, F2 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.
DRY pass #2, batch 1, F1 (#5372).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
installUpdate, and a failed channel or source switch, all fell back to "The
update check failed." Each now names what failed. A check that runs after a
successful switch keeps its own message.
Fixes#5387.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.
Fixes#5386.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each accepted any integer. A session length of 0 expired every session at once,
the admin's own included; an attachment limit above the 64 MB body ceiling
allowed files no request could carry, and a negative one refused every upload.
- session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and
max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope.
- MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum
both read.
- A value stored before its bounds existed reads as the nearest bound.
Fixes#5384.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.
Fixes#5383.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.
- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
name back and #grocery can be made again, on the web or from a device.
Fixes#5382.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.
The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-ins and failed sign-ins, accounts created and sign-ups refused,
password changes, resets and reset links, devices linked and unlinked,
invites made and revoked. Each is kept in `audit_events` with the address
it came from, for `audit_retention_days` (Settings → Security, 90 by
default, 0 keeps them forever), and listed newest first for admins under
Settings → Activity. The retention loop deletes older events.
`audit.record` writes in its own session, so a refusal is kept even when
the request's transaction rolls back. A failure to record is logged and
swallowed, never the reason a sign-in fails. A throttled attempt (429) is
not recorded: a row per refused request would make each request in a
flood cost a database write. Throttle trips stay in the app log.
Also: the storage-limit test puts `storage_quota_gb` back afterwards,
since settings outlive the per-test truncate.
#2939 §5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2939 §4. max_attachment_mb capped one file, so any account could fill the
volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for
no limit) caps an account's total. That total is every attachment on every
note the account owns, trash included, since trashed files stay on disk until
emptied. Admins are exempt.
storage.upload_refusal is now the one check every upload makes: per file, then
per account. It is used by:
- the web upload route;
- the sync PUT, which judges the declared Content-Length before reading the
bytes;
- the importer, which learns the room left up front and refuses the whole
archive if its attachments don't fit (nothing is committed).
Over the limit is answered 507 Insufficient Storage, not 413. The core treats
a 4xx as a permanent refusal it never retries, and a 5xx as worth another try.
So a file refused for want of room syncs by itself once space is freed. The
cost is that an over-limit device re-sends that file each cycle until then.
GET /api/auth/storage returns used and limit, and the Account page shows it as
a Storage row ("1.2 GB of 5 GB used").
docs/public-hosting.md drops the quota gap and gains a section on the limit.
Its Android paragraph still said a public http:// address was only warned
about; since 1dd6fc1 it is refused, and the paragraph now says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scribe #3884. The dev channel's release tag moved from `dev` to `dev-rolling`
on 2026-09-10. Since then, the manifest job has also written latest.json to the
old `dev` release, so that desktop apps installed before the move could update
across. The operator has had two desktop installs and is fine reinstalling,
so the bridge goes. The old release and tag are deleted next, through the forge.
- desktop.yml: the BRIDGE_TAG=dev export is removed.
- write-manifest.sh: the TEMPORARY bridge block is removed.
- ci-requirements.md: the "Transitional" paragraph becomes a note that the tag
is gone, and that an app installed before 2026-09-10 reinstalls with
install.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 12, as the operator chose on 2026-10-08: the token
is encrypted, and Android backup stays on.
The core:
- Adds a TokenSeal trait in sync/state.rs, with set_sealed_link and
open_token.
- A sealed token is stored as "sealed:<value>".
- A plain token, stored before this change or while sealing failed, is sealed
in place on its next read.
- A sealed token that won't open is dropped, and the server address and cursor
are kept, so the app reads as unlinked and asks to sign in again. That is
what happens after Android restores the app onto another phone.
- The desktop passes no seal and keeps storing the token as before.
The FFI:
- Exports TokenSeal as a uniffi foreign trait (seal_token / open_token, null
rather than an exception).
- Requires it in Inkwell's constructor, so there is no moment a token could be
stored unsealed.
- Routes credentials(), unlink() and store_link() through it.
Kotlin:
- KeystoreTokenSeal is AES-GCM under an Android Keystore key, using the
SealedBox framing from Minstrel's KeystoreSessionVault (Scribe snippet #5025),
with no new dependency.
- SealedBoxTest checks the framing on the JVM.
allowBackup stays true, and the manifest says why. An unlinked phone's notes
exist only on the phone, and the backup is their one other copy. The backup
carries a token nothing can open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 4. Either action signs the account out of every
other browser and unlinks every device. The browser that made the change stays
signed in.
- POST /api/auth/password needs the current password. A wrong one returns 403,
not 401, so this browser doesn't read as signed out, and it counts against the
sign-in throttle. A short new password returns 400.
- POST /api/auth/sign-out-elsewhere does the same sign-out without a password
change. Called from a device, it keeps that device linked.
- _sign_out_elsewhere moves session_epoch on and deletes device tokens. The
reset route now uses it too, keeping no device.
- The page is renamed from "Linked devices" to "Account", in the router title
and both nav entries. Its sections are Linked devices, Password (one short
line, then the form) and Sessions (a single "Sign out everywhere else" row in
the device rows' style), per preference 188: one line each, no paragraphs.
- docs/public-hosting.md says how sessions end, and why a browser session isn't
listed the way a device is: it is a signed cookie, ended by moving the epoch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 13, as the operator chose on 2026-10-08.
The check lives in the shared core, so the desktop and Android both get it.
compat::cleartext_allowed decides from the address text alone, with no DNS
lookup. It allows https:// always. It allows http:// to private, loopback,
link-local and CGNAT IPs (CGNAT covers Tailscale), to fc00::/7, fe80::/10 and
::1, to single-label names, and to LAN suffixes (.local, .lan, .home.arpa,
.ts.net and others).
The refusal runs in two places:
- probe, so linking stops before a password or token is sent;
- the top of run_cycle, so a device linked before this change stops syncing
with a message telling it to re-link, instead of sending its token on
every cycle.
The server is unchanged and never forces HTTPS (rule 94). Plain http:// on
a LAN links and syncs as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 8 (Scribe #5113), the operator's choice of
setup window over a setup code.
Before this, whoever reached /register first on an empty server became
its admin. On a fresh server at a public address, that could be a
stranger, and a new DNS name is found within minutes.
Now the first registration is refused once 30 minutes have passed since
the server started (create_app records STARTED_AT). A restart opens the
window again. It is a constant rather than a Setting, because there is no
admin yet to change one. Once an account exists it no longer matters, so
existing servers are unaffected. public-hosting.md says so, and two
integration tests cover both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Family idea #5105, practice 9 (Scribe #5113). The body cap was already
there (MAX_CONTENT_LENGTH, 64 MiB). For timeouts, read from hypercorn
0.18's source:
- --keep-alive goes from 600 to 120. It is also the header timeout:
hypercorn marks a connection busy only once a whole request has
arrived, so a client dribbling headers was allowed ten minutes per
connection. 120 stays above Traefik's 90s backend idle timeout, so the
proxy never reuses a connection this server just closed.
- No --read-timeout, deliberately. It bounds every socket read, including
the whole of a streaming download while the client sends nothing, so it
would cut off an APK fetched slowly over mobile data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quart's send_file marks every file it sends Cache-Control: public. The app
download is behind a login, so a shared cache or proxy could have kept one
account's copy and handed it to anyone. send_artifact now marks it
private, as the attachment route already does. Family idea #5105,
practice 11 (Scribe #5113).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three of the idea's practices this project still owed (Scribe #5118):
Practice 3, CI fails on the wrong signer. The signing step printed the
certificate and went on. It now fails unless the APK has exactly one
signer and that signer is the release certificate (SHA-256 408a5835…,
pinned from run 8753). The steps that publish come after it in the same
job, so a wrongly signed build is never staged or published.
Practice 6, app downloads are throttled and carry a sha256 ETag.
- The download route counts per account and answers 429 with
Retry-After past the limit. The limit is a new Settings → Security
value, "App downloads per account per hour" (default 30), live like the
sign-in limits.
- The ETag is the sidecar's sha256, not Quart's mtime-and-path, so a
phone resuming a download across a redeploy is not told its partial
copy is stale. Quart's own ETag and conditional handling are off, and
the route runs the conditional pass after setting the ETag, so Range
and If-Range are judged against the content.
Practice 9, the update offer and debug builds.
- The install-permission notice re-reads the grant each time the app
comes back, as ReminderNotice does. Read once, it stayed up after
someone granted the permission in Settings and came back.
- A debuggable build says it can't update itself and checks for nothing.
Android would refuse the release-signed APK over a debug signature
anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).
The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
an exception, which the board shows as an error, not an app exit. Phones
have always had unwind, because debug unwinds, so this keeps what they
do.
Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.
Scribe #2810.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board was one column below 640px, so on a phone it read as a list
while the app showed two. It is now two columns from the smallest width,
with an 8px gap there (16px from sm up). Two columns at 16px on a 390px
screen would leave ~170px cards. NoteCard's bottom margin, the vertical
half of the gap, tightens with it.
NoteGrid is the only place the board's columns are defined, so board,
search, timeline and reminders all change together.
Fill order is untouched. CSS columns fill top to bottom, so note #2 sits
under #1 rather than beside it, unlike Android. That is left until it has
been looked at on a phone, as #2950 recommends.
Scribe #2950.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As on the web, the search box is now one more facet on the board you are
looking at, rather than a separate unfiltered search. "These words, in
notes tagged grocery" works: on the main board the text is sent to the
core together with the Filters sheet's tags, attachment and shared
switches, and the core ANDs them in list_notes. Archive, Trash and a
tag's view take the text alone. A search typed on Reminders, which is not
a board view, moves to the main board, as the web does.
The Filters chip stays while you search; it was hidden before, on the
mistaken claim that the web hides its filters too. Drag-to-reorder stays
off during a search, since a filtered subset can't be renumbered against
notes it can't see.
store::search and the FFI's search_notes had no other callers, and are
removed. They also searched archived notes and ignored pinning, which the
board's query does not.
Scribe #2942.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8731 failed checkAarMetadata: Coil 3.6.x requires compileSdk 37, and
it pulls in Compose 1.12, which requires AGP 9.1. This project is on
compileSdk 36 and AGP 9.0.1. Coil 3.5.0's AARs ask for 36, and its Compose
(JetBrains 1.11.1) is within this project's BOM (Compose 1.11.2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card draws the linked page's og:image in a strip down its left edge,
cropped to the card's height: 96dp full, 48dp compact, matching the web's
w-24 and w-12. The image is remote, so the phone fetches it from whatever
host the link points at, exactly as a browser does for the web card. The
operator chose that parity (Scribe #3307).
The image is Coil 3's AsyncImage, with OkHttp as its fetcher. Coil's disk
cache means a card scrolled past twice costs one download. When there is
no image, or it fails to load, nothing is drawn rather than an empty box,
and the card is the text card it was before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milestone 325 step 6 (Scribe #3254).
The server publishes its AppImage in the updater's own format at
/api/client/linux-appimage/update.json: the ordering key as `version`, the
signature, and an absolute download URL built on the host that was asked,
so the token the updater attaches goes nowhere else. Unsigned platforms and
a server with no AppImage 404.
The desktop's update source is now Fabled-Git (and its channel) or one
server:
- `read_source` is the one reader. The installer's `install-server` marker
feeds the `update_server` pref once per new value, exactly as the channel
marker feeds its pref; tauri.conf.json's endpoint is never consulted.
- From a server, the check and the download carry the sync link's token
when the app is linked to that same server. Without one the update shows
and says to link rather than offering a button that 401s.
- A server with no build says so. A 404 is "up to date" only on the forge,
where it means an unpublished channel.
- Sync → App updates offers the source once there is a server to offer (the
chosen one, or the linked one), and only shows the channel for the forge.
The trust anchor does not move: whatever the source, the updater verifies
the AppImage against the public key built into the app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Milestone 325 step 5 (Scribe #3253).
curl -fsSL https://notes.example.com/install.sh | sh
The server serves the installer at /install.sh with its own address written
into it (installer.py). Settings → Public address when set, the request's own
address otherwise. The substitution is one variable, given a value that has
passed a strict shape check, and the script checks it again; an address that
cannot pass makes the route refuse rather than serve a script pointed
elsewhere. `public_url` joins the live settings cache so the route needs no
database.
From a server, the script:
- resolves each Linux bundle from the public /api/client/<platform>, and
builds the download URL from the platform id rather than reading it from
the reply;
- asks for a device token (from the terminal, since stdin is the script),
or takes TS_TOKEN, and sends it from a file rather than the command line;
- checks the sha256 the server published before anything installs;
- revokes a prompted token once the download is done;
- records `install-server` for the updater (step 6) instead of the channel.
The forge path is unchanged, and stays the default for the copy the forge
serves. The Account page's downloads card shows the one-line command
whenever the server holds a Linux client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8693's detekt failed both on CyclomaticComplexMethod (17 and 15 against
15) after the filters and drag-to-reorder landed.
- BoardState now carries `filterable` and `reorderable`, so the board's
rules for when the filter row shows and when a card can be carried live
with the state they read instead of as boolean chains in the screen.
- NoteCard's contents (tags, body, links, attachments, reminder, sharing)
move to their own CardContents composable, leaving NoteCard the gestures,
the frame and the menu.
No behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Filters: a chip under the search bar opens a sheet with Has attachment, Shared
with me and tags (a note must carry all of them), applied as they are tapped,
with a count on the chip and a Clear beside it. Only the main board filters and
search spans everything, as on the web; opening another view starts it
unfiltered, a deleted tag drops out of the filters as it does from the lens, and
an empty filtered board says so.
Reorder: hold a card, then move it. The hold is the long press that opens the
card's menu, which closes as the card starts to move; lifting without moving
leaves the menu as before, and moving before the hold is a scroll. Cards trade
places live and the drop writes the order through the core's reorder, newly
exposed over the ffi as reorder_notes. Only on the plain main board, and only on
the same side of the pinned line, since the store sorts pinned first.
#5313.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vue/test-utils and jsdom as dev dependencies, jsdom chosen per file with the
vitest environment comment so the existing unit tests stay on node. The dialog's
repo.shares is faked; the tests cover offering everyone, sharing with a person
and a group, changing and removing a share (and the board's shared flag that
follows), a refused share keeping the choice, the load retry, and the
single-person instance. #5313.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration lookup read the job's id from /etc/hostname, which holds only
while the runner leaves the hostname as the container id. Steward's fix (#5104)
reads it from the /etc/hostname bind mount's path in /proc/self/mountinfo and
falls back to the hostname, so one recipe now serves every repo (#5313).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It proved the APK gate (#5237): run 8667 failed at the core's tests, skipped
the APK job and still dispatched the server image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The step added in 42db4cd ran cargo on the Android image, which has OpenSSL
only for the Android targets; the host build died at openssl-sys (run 8663)
before reaching a test. The core's clippy and tests are now a 'rust' job on
ci-tauri, the image desktop's verify runs them on, and the APK job needs it.
The server-image dispatch moves to its own job: it was a step inside the APK
job, and a skipped job runs no steps, so failing core checks would have
silently stopped the server image too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
android.yml never ran cargo, so a core test that failed in desktop.yml's
verify job stopped the desktop installers and not the APK, which links the
same core through android/ffi (#5237). The Kotlin + Rust job now runs clippy
and the tests for inkwell-core and inkwell-ffi before anything is assembled
or published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8653 failed at 'alembic upgrade head' with a password error: two
integration jobs were on the runner at once, and the name=integration
filter took the other one's database (#5312). The lookup is now scoped to
this job's GITEA-ACTIONS-TASK-<id>- prefix, read from the job container's
own name, and requires exactly one match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The board was Fixed(2) at every width, so a tablet showed two wide columns
where the web shows three or four (#5311). The column count now follows the
web's NoteGrid breakpoints on the window's width: three from 1024dp, four
from 1280dp. A phone keeps two.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 18 of the audit follow-through (#5180), on the operator's decisions.
Inkwell is for capture and recall (note 2897), and these three duplicated a
surface that does the job already:
- Saved views. They lived only on the web; the desktop kept its own set that
never synced, and Android had none. Tags in the drawer already give
one-click recall. Gone from the server (routes, model, migration 0038 drops
the table), the core (store functions, schema v13 drops its table), the
desktop commands, the web adapters, the drawer's Views list and the
"Save view" link.
- The "Has reminder" facet. The Reminders page lists them, sorted by due.
- The FilterBar's "Created" range. Timeline is the date lens and keeps the
created_after/created_before query it builds from local days, which also
retires the UTC/local-day disagreement between the two (B3).
An old link that still carries the removed keys opens the plain board; a
web test pins that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179). Ten comment blocks narrated how the code got here:
milestone numbers, earlier values, the operator's verdict on an old design.
Each now says what the code does and why, and the history stays in git,
Scribe and docs/sync.md. The protocol-version comment in sync.py points at
docs/sync.md's policy section, which already lists every bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, web half).
- NoteActions: share, pin, archive and trash (restore and delete forever
when trashed), as both the card and the editor offer them. The editor's
history toggle goes in its slot, and it closes on `acted`.
- ReminderActions: the Done / 1h / 1d chips on the card and in the editor,
which are now the same chips.
- PageHeader: the back-to-board header that Settings, Sync and Linked
devices each wrote out, now with a `back` icon from the shared set.
- notes/datetime: formatShortDateTime (was formatReminder and the editor's
revLabel) and formatDateTime (the two `fmt` copies).
- notes/colors: labelDotClasses (the sidebar's and the tag manager's
labelDot).
- Drawer links use exact-active-class instead of route.name ternaries.
- BoardView binds its three grids from one gridBinds object.
- Settings goes through repo.settings (rest, plus a local adapter that
answers "needs a server") and shows load failures through AsyncState.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, core and desktop half).
- Every `db.0.lock().map_err(|e| e.to_string())?` (about 50 sites in core and
the desktop) is now `db.conn()?`. The few sites that deliberately handle
a poisoned lock differently, and the tests, keep their own spelling.
- push::Change derives Default, so its four constructors name only the
fields they set.
- store: list_notes, reminders, titles and search share notes_where (ids
from a query, each loaded through load_note). Labels share
LABEL_SELECT/label_row, and saved filters share
SAVED_FILTER_SELECT/saved_filter_row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5179, server half).
- auth: login and device-login share _check_credentials (dummy hash for a
missing account, throttle bookkeeping, the failure log line) and
_bad_credentials.
- settings uses common.coerce_bool. Its private copy differed only in
treating a non-string as its truthiness, which the shared one now does.
- notes: create and import share helpers.top_position.
- auth, settings_api, sync and client_dist return errors through
responses.json_error / not_found, and parse ids with parse_uuid.
- sync: push replies are built by _result(id, entity, status, **extra).
Already merged by earlier steps, so nothing to do here: attachment storage
(store_attachment), the preview upsert (only unfurl_queue writes one now),
and _serialize_note (delegates to _serialize_notes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the audit (#5178). Each was unreachable from every client:
- Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>,
the Tauri commands, the store, rest and local adapters, the core's
add_item/delete_item, set_item_text and remove_item, and the FFI exports.
Adding, rewording and removing an item are body edits in every editor. The
checked toggle stays, and its rewriter is simpler without the drop branch.
- Manual unfurl: POST /unfurl and its adapters. Previews arrive in the
background after a save (unfurl_queue).
- The /api/config `android_client` key, android_release() and the
APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android.
- users.email_verified and users.avatar_path (migration 0037). Nothing set
the first or read the second; the SMTP reset never checked verification.
- derive::extract_tags (only tests used it; the shared fixture now runs
through extract_tag_spans), the unused check and link icons, and the
unused editor_add_item string.
- The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are
read, so nothing stored carries the old one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Share sheet lists groups after people, shows a group share as its name and
how many are in it, and shares through the FFI's ShareTarget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Share dialog lists people and groups in one picker and shows a group share
as its name and member count. Settings gains a Groups section for the admin:
create, rename, delete, and add or remove people.
The core client reads the directory's groups and group shares (ShareTarget:
a member or a group); the desktop command takes user_id or group_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/api/groups (admin) creates, renames and deletes groups and adds or removes
members. The member directory lists every group, and a share may name a
group_id instead of a user_id; a note's shares answer with `member` or `group`.
A note shared with a group reaches whoever is in it now, so membership is what
the feed follows: joining grants each of the group's notes to the new member's
devices, and leaving (or the group being deleted) revokes them unless a direct
share or another group still reaches that person. recipients() never counts the
note's own owner, who may sit in a group it is shared with.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card's long-press menu and the editor's overflow now open on shared notes
with Pin and Archive; Labels, Share and Move to trash stay the owner's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schema v12 adds notes.state_at: a recipient's own pin, archive and order are
stamped there instead of on updated_at, which stays the text's time. Push sends
them only to a server advertising `shared_state` (push::Accepts), a view share
included; the first pull at that level starts the feed over once so held copies
drop their owner's pins. The client speaks protocol 7 and lists `shares` and
`shared_state` among the features a server may lack.
The web card and editor offer pin, archive and drag on shared notes; share and
trash stay the owner's, and the board's trash key skips notes you don't own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A note_user_state row per (note, recipient) holds what used to be the owner's
columns as far as anyone else could tell. The board filters and orders through
the viewer's own state; PATCH and reorder write it for a note shared at any
level; the feed's revision for a shared note is the later of the note's and the
caller's row, so a recipient's pin reaches their devices and no one else's.
Push takes the three with their own `state_at` stamp (protocol 7,
`shared_state`), so pinning a copy whose text is behind never makes that text
win over the owner's edit. A body is only stamped as an edit when it changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The editor's menu has Share…, which opens a sheet of who the note is shared
with and lets you add someone at view or edit, change it, or stop sharing;
unlinked, it says sharing needs a server. A note shared to view opens
read-only; at edit only its text can change. Cards say who shared a note
("From Robin") or that yours is shared, and a view-only note's boxes don't
tick.
Also: two core store tests used unwrap_err on a Result<Note>, which needs
Note: Debug; they use err().expect() now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core pulls with shares from a server offering them (protocol 6): a note
says how it is held (owner, edit, view) and who shared it, and a revoked note
leaves the device. The first such pull starts the feed over once, so notes
shared before this build arrive. The store refuses what a share doesn't allow
(view: everything; edit: anything but the text), push sends only the text of
someone else's note, and their notes stay out of trash, reminders and
reordering. Unlinking drops them.
The Share dialog's calls go to the linked server over the device token, as
Tauri commands and through the FFI. The desktop now offers Share and "Shared
with me"; unlinked, the dialog says sharing needs a server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The change feed answers `?shares=1` with every note the caller can see, each
saying how it is held, plus a `revoked` list of notes that left them. Granting
a share moves the note past the recipient's cursor; ending one, or the owner
deleting the note, leaves a revocation on the same cursor. A recipient at edit
may push the note's text, and nothing else. Protocol 6, feature `shares`;
opt-in, so the floor stays at 3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The operator asked for self-service reset over SMTP. It reuses #5173's
password_resets table, /reset-password page, one-hour single-use token and
sign-out-everywhere.
- Settings (rule 25, not env): a new Email group (SMTP server, port,
encryption as a choice, username, password, from), General → Public
address, and Security → Reset emails per account. The registry gains
`choices`, `secret` (the value is never sent back, `is_set` says one is
saved, an empty save keeps it) and `url` (http(s), trailing slash
stripped).
- mailer.py: stdlib smtplib on a worker thread, 20 s timeout,
starttls | tls | none. mail_settings() is None until a server, a sender
and the public address are set. Links are built from the public address
because the Host header can be forged.
- POST /api/auth/forgot-password: the same answer at the same speed for
any address. The link is made and mailed off the request (send_later).
It is throttled like a sign-in per visitor address, and capped per typed
email by reset_emails_per_account; past the cap it answers the same and
sends nothing.
- POST /api/settings/test-email: mails the admin with the saved settings
and shows the server's error if it fails.
- Public config `password_reset_by_email`. Sign-in shows "Forgot
password?" only then, linking to a new /forgot-password page.
- docs/public-hosting.md: an "Email and forgotten passwords" section.
Tests: the secret stays server-side; emailed link → reset; the same
answer for unknown addresses; the cap; test email success and failure;
validation units. #5266.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ACL has gated every read since M0, but nothing could write a share.
Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
only. Sharing again with the same person changes the permission
(ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
_get_owned. A view share's write is a 404 like a stranger's (#1984). An
editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
never gets the owner's labels, and a #tag an editor types files under
the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
limited to view and edit, an index for "shared with me".
Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
edit, change or remove existing shares, with loading, error and empty
states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
shows none of it (#5175 brings sharing there).
Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_devices signed a fake account into a session and relied on
login_required answering without the database. Since 3dd0b44 the session
path reads the account's epoch, so the fake account hit an unreachable
database and 500ed. The routing property (the static /devices/self rule beats
/devices/<device_id>) is now asserted on the URL map, and the view's 400 for a
web session is an integration test with a real account. #5173.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There is no mail path, so a forgotten password needed a hand on the database
(#2939 §2). Settings → People lists the accounts; Reset password makes a link
that works once within an hour, shown once for the admin to hand over. Making
another link for the same account closes the earlier one.
Using it (/reset-password) sets the password, deletes the account's device
tokens, and moves users.session_epoch on. Sessions are signed cookies the
server can't delete, so each now carries the epoch it signed in under and
login_required reads the account's epoch by primary key. A cookie from before
this has no epoch and reads as 0, the starting value, so the upgrade signs
nobody out. A deleted account's session now stops working too.
The one-time link reveal moves out of InviteList into OneTimeLink, and the
link-building into router/links.ts, shared by invites and resets.
Migration 0033. #5173.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now adding a second person meant re-opening registration to the
whole internet while they signed up (#2939 §1). An admin now makes an
invite in Settings: a link that works once, expires (7 days by default,
1 to 30), and can be pinned to one email address. Only the token's hash
is stored, so the link is shown once.
POST /api/auth/register takes `invite`. Redemption is one conditional
UPDATE inside the transaction that creates the account, so two people
racing one link can't both get in, and a taken email leaves the invite
unused. Every refusal says "invalid or expired invite". The register
page reads ?invite= and opens even while registration is closed.
Refs #5172
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Rust worker reads due reminders from the local store every 15 s and
announces each occurrence once: always to the main window as a toast, and
as a system notification (tauri-plugin-notification) when that window
isn't focused. The page no longer polls on the desktop; its Notification
went nowhere in WebKitGTK and its timer stopped with the window. The
Reminders page says what each surface actually does.
Core gains store::due_reminders, compared by instant, not by string.
Refs #5171
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Export and Import were a link to the server and a reject("needs a server") on the
desktop. Both now run in the core with no server:
- core/src/local/portable.rs builds the same zip the server writes (notes.json,
a Markdown file per note, each attachment this device holds) and reads either
export marker or a Google Keep Takeout zip, with the server's decompression
budget and an all-or-nothing transaction. Export saves to Downloads (no new
plugin) and the sidebar says where; Import takes the archive as raw IPC bytes.
- core/testdata/portable.json pins the format for both copies: the server runs
its Keep and native readers against it (test_portable_fixture.py) and checks
its real export's keys (test_integration.py); the core runs the same cases.
- Found on the way: both importers skipped a Keep note that is only a photo as
"empty". It now imports, on the server and in the core.
- New dependency, approved: `zip` (deflate only) plus `flate2` on its pure-Rust
backend, both already in the lockfile.
The AppImage applications-menu toggle moves from Account, which the desktop
never shows, to the Sync page; the first-run prompt now says so.
errorMessage (#5236) replaces the hand-rolled `.error ?? …` / `.message ?? e`
reads at the remaining catch sites, so a desktop failure shows its real reason.
Task #5170.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The phone downloaded every attachment and drew none of them, so a photo note
looked empty. Now:
- Cards show a note's first image and name its other files; the editor shows
every image at full width and every file as a row. Tapping one opens it in
whatever app handles its type (a cache copy under its real name, through a
FileProvider that serves only those copies). Each can be removed, and a file
the server refused says why under it.
- The editor's toolbar has an Attach button (any type, several at once). Files
are stored on the phone straight away and upload on the next sync that
reaches a server, through the core's step-6 path. Link previews show in the
editor too, and can be dismissed.
- Share → Inkwell accepts one or several images, with or without a caption,
finishing #1899's deferred image/* target.
- The FFI gains add_attachment, delete_attachment, delete_preview and
blob_path. The sync summary counts uploads and failed uploads.
Images decode at the size they are drawn (BitmapFactory sampling plus EXIF
rotation, small LRU cache), so no image library is added. Files over 50 MB are
refused on the phone before they are read whole into memory.
Task #5169.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A literal list went stale the moment attachment_sync was added (run 8513), the
same way pinned version numbers did at v2 — for a reason unrelated to what the
test checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop could not create an attachment at all, and a removed attachment or
dismissed preview came back on the next pull. Now:
- core: add_attachment keeps the bytes in the blob store and queues the row
(schema v10: attachments.uploaded / upload_error). Push uploads it once its
note has landed. A refusal that retrying won't fix (too large, id clash, hash
mismatch) is recorded on the file and not re-sent every cycle; the editor
shows it.
- core: removing a synced attachment or dismissing a preview leaves a tombstone
in pending_deletes; push sends it as an `attachment`/`preview` delete, and a
pull while it waits doesn't put the row back. A pull also keeps files still
waiting to upload instead of replacing them wholesale.
- server: PUT /api/sync/attachments/<id> (raw body, sha256-checked, idempotent,
size-capped) and child deletes in push, which apply regardless of LWW and
answer noop for rows the caller can't see. One store_attachment helper for
the upload route, the importer and sync. Protocol 5, feature attachment_sync;
the client sends neither to a server without it.
- server: migration 0031 makes a link preview's insert/delete bump its note, so
background-fetched previews and web dismissals reach linked devices.
- desktop: Attach and paste-image work offline (raw-bytes IPC command).
- SVG is served as a download by the desktop blob scheme too (as #1981 did for
the web), and drawn as a file chip on both.
- autosync: drop the catch_unwind; release builds abort on panic, so it only
ever worked in debug builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now the only caller of the sync engine was the "Sync now" button. A worker
thread now owns every cycle (the button's included, so two never overlap):
- launch: one cycle as the app opens;
- edit: every 10s it reads a fingerprint of the pending set and sends when that
moved. A fingerprint rather than "anything pending", because a rejected change
stays pending and would otherwise be resent every tick forever;
- timer: a pull every 5 minutes with nothing to send;
- focus: at most once per 30s.
Failed automatic cycles back off (doubling from 10s to 5 minutes). A panicking
cycle counts as a failed one rather than ending the thread. Every cycle is
emitted as inkwell://synced: the board reloads when the pull changed something,
and the Sync screen shows the last automatic failure. No final push on quit;
the launch cycle sends whatever was left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8478 passed every test lane and then failed `Build & push image`. The
Dockerfile's frontend stage copies only frontend/, and `npm run build`
type-checks with tsconfig.json, which covered grammar.test.ts. Its import of
../core/testdata/grammar.json doesn't exist inside that stage. Nothing was
published: the build is the publish and it stopped.
tsconfig.json now excludes `*.test.ts`. The new tsconfig.test.json extends it
with the tests included, and ci.yml's typecheck lane runs that one, so the
tests are still type-checked before anything ships.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared fixture went red on the server (run 8468: 5 failed), because the three
tag rules disagreed:
- core and web: any non-tag character counts as a boundary, so `(#todo)`
and `end.#tag` are tags, and so is the `/#section` of a pasted URL;
- server: only whitespace counts, but `#1st` and `#_x` are tags.
A note's labels could therefore change every time it synced.
All three now share the strict rule: start of line or whitespace, then a
letter, then letters, digits, `_` and `-`. Nothing becomes a tag that wasn't
already one everywhere, and URL anchors stop becoming labels on desktop and
Android. The server's existing `http://x/#nope` test already expected this.
derive.rs's boundary, markdown.ts's lookbehind and tags.py's regex change
together; `_is_tag` goes because the regex now requires the letter. The
fixture flips `(#todo)`, `end.#tag` and its lift case, and adds the URL case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The checklist grammar and the #tag rule are implemented three times (derive.rs,
checklist.py/tags.py, markdown.ts), and the tag colour twice (colors.ts,
DerivedTint.kt). Only Rust and Kotlin had tests. core/testdata/grammar.json now
holds one set of cases (task lines, rendered items, tags, standalone-tag lifts
and the tint hashes), and every suite reads it.
- web: vitest, a dev dependency approved for #5166, with `npm test`.
grammar.test.ts runs the fixture, and titles.test.ts pins #5165's palette fix.
- ci.yml runs the web tests in the job the image build needs. desktop.yml's
verify job runs them too, because the installers embed this frontend and
can't see ci.yml's verdict (rule 177).
- core: derive.rs reads the fixture. server: tests/test_grammar_fixture.py.
- Android keeps its hand-written tint values; its doc now points at the fixture.
The server is expected red here, on purpose. tags.py only takes a tag after
whitespace and lets it start with a digit or `_`, while the core (the
definition) takes any non-tag boundary and needs a letter. So `(#todo)` is a
label on the phone and plain text on the server. The fix follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The palette's note list loaded once per session behind a `loaded` flag, and
the `reload()` that would have cleared it had no caller. So a note written
after the first open couldn't be found by name until the page reloaded
(#5165, audit B4). The list is now fetched again on every open, and the last
list stays visible meanwhile. The input takes focus before the fetch, and a
failed fetch keeps the old list instead of breaking the palette.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A body edit is a sequence: keep a revision, rename the note, lift #tags,
commit, queue link previews. It was written out in PATCH, the item routes,
restore and sync push, and the copies had drifted. Now they all call
`notes/body.py: write_body`, which says how the old text is kept ("session",
"always" for restore, "never" for a new note) and returns whether the text
changed. The routes commit through `_commit_note`, which queues previews after
the commit.
Fixes, both red on 1a2f71e (run 8441):
- restoring a revision queues previews for its links (#5164, audit B5);
- a pushed note keeps the client's edit time when a standalone #tag is lifted.
The lift's extra flush used to let `onupdate` stamp the server clock over it.
Sync push also queues its previews after the batch commits, not mid-batch,
where a fast fetch could look for a note that wasn't committed yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 8437 failed `verify` on purpose, and the Linux build, the Windows
installer and the update manifest all reported skipped. This removes the red
step, so this push is the other direction: a green verify still publishes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integration tests for the edit sequence at each door: create, PATCH, ticking an
item, restoring a revision and sync push. Two fail on today's code, on purpose:
- restoring a revision never queues link previews, so a restored link stays a
bare URL (#5164, audit B5);
- a pushed note whose standalone #tag gets lifted stores the server's clock as
its edit time instead of the client's, because the lift's second flush lets
the column's onupdate overwrite it.
The fix follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy, the workspace tests and rustfmt move out of the Linux `build` job
into their own `verify` job, and both publishing jobs need it. Before this,
`windows` needed only `decide`, so on run 8411 a red clippy stopped the Linux
lane while the Windows installer built and published to dev-rolling (#5184,
rule 177).
This commit also carries a deliberately failing step at the end of `verify`.
It is the red half of the proof: both publishers must report `skipped`. The
next commit removes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both bugs were caught red by the tests in 732fd7a (run 8418: 5 failed, 147
passed) before this fix.
- load_note reads columns by NAME. Dropping `color` (fa89da1) shifted every
column after it and the two timestamps were missed, so created_at showed the
last edit and updated_at showed the trash time — null on any live note. Only
the read was wrong; nothing stored is, so no data needs repairing.
- The board's text facet binds its pattern once for its one placeholder. It
pushed it twice after the title column went (95aa10c), and rusqlite refused
every query with InvalidParameterCount — every desktop search failed.
Android searches through store::search and was never affected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7bc8e04 never got as far as running them: clippy's cloned_ref_to_slice_refs
rejected four `&[x.clone()]` slices, and the file wasn't rustfmt-formatted.
Still tests only — the expected RED is the two timestamp tests and the
text-search tests, ahead of the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
store.rs had none, which is how two bugs reached desktop and Android unseen.
These exercise every board facet, the timestamps, tags, revisions, items,
trash, reminders and label merges against a real migrated schema.
Two are expected RED on this commit, on purpose, so CI shows they catch what
they were written for:
- each_timestamp_comes_from_its_own_column / a_new_note_carries_both_timestamps:
load_note reads created_at and updated_at one column too far right since
fa89da1 dropped `color`.
- text_search_*: the text facet binds its LIKE pattern twice for one `?`,
left over from title+body (95aa10c).
The fix follows in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login and register screens still drew a hard-coded "TS" tile. They now
use /icon.svg, the same mark the shell's header shows.
Browser tabs took index.html's static <title> and never changed it, so every
tab read the same, and some browsers showed the URL instead. usePageTitle,
mounted once in App.vue, sets "<page> · <site name>". Routes outside the shell
name themselves with meta.title. Board lenses use the lens name the header
already shows, now in useLensName so the tab and the header read from one
place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 6 of milestone 481. README, docs/*, ci-requirements.md, the desktop and
Arch READMEs, alembic.ini, .gitignore, the frontend package name, the service
worker's cache name (its activate handler deletes any cache by another name, so
the old one is cleaned up), and the Android names in the release body.
What still says thoughtsync does so on purpose (Scribe note 5071):
- the desktop data crossover (crossover.rs) and its startup log
- the old-export import marker
- the "Upgrading from ThoughtSync" block in .env.example, and compose's pointer
to it
- the packages being retired: deb conflicts/replaces thought-sync, pacman
thoughtsync and thoughtsync-desktop
- the Android signing keyAlias, which names a key in the existing keystore
- history: shipped alembic migrations, and the test-binary hashes that
ci-requirements.md records from 2026-08-18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 4 of milestone 481 (Scribe note 5071: a full rename).
- namespace and applicationId com.fabledsword.inkwell; the Kotlin package moves
with them, and ktlint re-sorted the imports the rename reordered (checked
locally with CI's ktlint 1.4.0 and detekt 1.23.7, both clean)
- uniffi: class Inkwell in com.fabledsword.inkwell.core, InkwellApplication,
InkwellTheme, Theme.Inkwell, log tags, prefs and work names, client agent
inkwell-android
- the lane publishes inkwell.apk / inkwell-android.json; fetch-clients,
guard-forward, publish-release and write-manifest read the same names
A new applicationId is a new app. The old ThoughtSync app keeps its own store
and stays installed beside it. Notes cross over by syncing, and the old app is
then removed by hand.
Kept: the signing keyAlias is still "thoughtsync". It names the key inside the
existing keystore, and the key, and so the certificate, are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 5 of milestone 481. Replaces the linked-notes constellation, which had been
stale since note links were dropped (alembic 0024). The colour scheme stays.
packaging/icons.py draws the mark once and renders every variant from it: the
rounded tile (web, desktop), the maskable full-bleed web icon, and the Android
adaptive foreground. The detail (shaft, vane splits, glint) is cut out of the ink
with a mask rather than painted on in yellow, because the Android foreground is
now transparent and its alpha is also the themed-icon silhouette. The old
foreground was the opaque maskable tile, which a themed icon would have drawn as
a solid square.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 3 of milestone 481 (Scribe note 5071: a full rename).
- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
`inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
installer, release titles, desktop asset names in fetch-clients.sh
The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.
Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:
- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
thoughtsync-named release assets, because the lanes that publish them are
renamed in steps 3 and 4
- exports are written with app "inkwell"
Two deliberate exceptions, both because data rides on them:
- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
rename are backups. Tested both ways
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThoughtSync is renamed Inkwell ("Fabled Inkwell" in full; Scribe note 5071).
This is step 1 of milestone 481: every string a person reads in the running
apps. Identities installed clients depend on are deliberately untouched — the
Tauri productName (it derives the .deb Package: field), identifier and binary
name, applicationId, X-ThoughtSync-* headers, the export's app marker, env vars,
module and crate names.
- web: title, PWA manifest (name "Fabled Inkwell", short_name "Inkwell"),
offline page, icon labels, build labels, prompts, notification title
- server: site_name default, import error, link-preview User-Agent
- 0030: a stored site_name of exactly the old default follows the rename. The
Settings page saves every key, so most servers hold "ThoughtSync" without an
admin ever having chosen it; a name they typed is left alone
- desktop: window title, default device name, local-mode site name, log line
- android: app_name and the strings that name the app
- core: probe and compatibility messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7296889 lengthened `/dev/latest.json` to `/dev-rolling/latest.json` in
each_channel_has_its_own_fixed_feed, which pushed the assert past the
line width. `cargo fmt --all --check` failed the Linux desktop job (run
6358) after Clippy and the tests had passed, so that build, its publish
and the manifest job never ran. Layout taken verbatim from the diff
rustfmt printed; no behaviour change.
Scribe #2184.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
The rolling dev release lived on a tag named `dev`, beside the branch
named `dev`. Once a clone had fetched tags, `git push origin dev` failed
with "src refspec dev matches more than one" (Scribe #2184, note #3042),
and every session had to know to spell out refs/heads/dev.
The channel is still `dev` everywhere a person sees it: the app's
setting, `install.sh --channel dev`, the stored pref. Only the release
tag moves, to `dev-rolling`, matching roundtable-android. `stable` has no
branch to collide with and keeps its name.
- packaging/channel-tag.sh is the one channel -> tag mapping CI reads:
the publish steps in android.yml and desktop.yml, the manifest job,
fetch-clients.sh and guard-forward.sh. guard-forward exits 2 on an
unmapped channel instead of fetching an empty URL and passing.
- update.rs and install.sh carry their own copy because neither can run
it; update.rs gains a test that no channel feed is named like a branch.
- tests/test_channel_tag.py runs the script: no tag is a branch name,
dev is exactly dev-rolling, an unknown channel fails with no output.
- publish-release.sh titles the release "ThoughtSync dev (rolling)", so
the tag name does not leak into what people read.
TEMPORARY bridge: desktop apps installed before this have
.../download/dev/latest.json compiled in. The dev manifest job sets
BRIDGE_TAG=dev, and write-manifest.sh writes the same latest.json to
the old `dev` release. Its URLs name dev-rolling assets, so those apps
update once into a build that reads the new tag. The bridge, and the old
release and tag, are removed once installed apps have crossed over.
Until then the push still needs the explicit refspec, as
ci-requirements.md now says.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
The Android APK upload and both desktop bundle uploads (Linux and
Windows) went through the bvandeusen fork mirror, with comments saying
stock upload-artifact throws GHESNotSupportedError on this hostname. That
stopped being true when the runner moved to gitea/runner 3.x, which
edits the refusal out of the action bundle; stock upload v4-v7 and
download v4-v8 were proven on 2026-09-10 (Scribe spike #3843) and the
same swap is verified on four other repos.
Artifact names, paths, if-no-files-found: error and the no
continue-on-error stance are unchanged. ci-requirements.md now says
stock v7 and keeps what is still true: @v3 uploads are invisible.
Scribe snippet #2271, milestone 395.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
`standard:chain-method-continuation` on `LinkPreviewRow.kt:83`. The `.border(…)`
call took three arguments across four lines, and the `.padding(…)` after it then
began a line with a `.` — which the rule only accepts glued to the closing
paren, `).padding(…)`.
Issue #3110 hit this same rule in `NoteCard.kt` and recorded the fix: do not
write the multiline element. Naming `shape`, `padH` and `padV` first collapses
`.border` back to one line and removes the duplicated RoundedCornerShape at the
same time, which is better than what ktlint was willing to accept.
Also did what #3110's verification note says to do rather than fixing only the
line the linter named: scanned every Kotlin file this branch touched for the
same shape — a multiline chain element followed by a `.` on a new line — and
found no others. ktlint reports one violation and stops, so a second would have
cost another full Android lane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
The web and desktop have shown link previews since #2898; the phone showed the
raw address. The data was already on the device — `Note.previews` is populated
by the core and carried through the FFI — and nothing under `app/src/main` read
the field.
The three presentation rules are copied from `NoteCard.vue` rather than
re-decided, so the same note reads the same way on every surface:
* A note that is NOTHING but a URL renders as its preview and nothing else.
Printing the address under a card that already says where it goes is saying
the same thing twice, badly.
* Links mentioned INSIDE a note get a compact strip at the FOOT of the card.
Above the body would put a stranger's headline where the note's first line
should be; the web learned that in M13.
* Several stack.
`LONE_URL` mirrors the web's `LONE_URL_RE` including the tolerated whitespace —
if the two regexes disagree, one note reads as a card here and a paragraph
there.
Falling back to the URL is deliberate in all three of the cases that produce no
preview: not a lone URL, not unfurled yet, or never unfurlable. A note written
on the phone and not yet synced is permanently in the middle one, because the
unfurl is server-side (`unfurl_queue.py`) and arrives on a later pull — so that
state has to look deliberate, and showing the link does.
No unfurl fetch was added here, and none should be: a phone fetching OG tags
would be a second SSRF-hardened fetcher on the surface least able to afford the
call.
## No image, and that is a question rather than an omission
`LinkPreview.image_url` is a REMOTE third-party address — the web renders it
straight from whatever host the link points at. Matching that here would have
this app fetch images from arbitrary hosts, on a phone, on possibly metered
data, and would make it the first image loading anywhere in this client: there
is no loader, no cache, and not one `Image(` in the whole app today. That is a
decision about privacy and data use, not a rendering detail, so the text card
ships and the image is asked about rather than assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
Search for something, long-press a hit, Move to trash: the snackbar said it
happened and the card sat there until the query next ran. Reachable from the
editor's overflow too — both go through `mutate`.
`mutate` kept the existing list whenever a search was running, with the
reasoning recorded in place: search results are the answer to a query, not a
live view, and running the BOARD query underneath them would replace the hits
with the whole board.
That is right about the board query and wrong about the note. A hit that no
longer matches has left the answer, not just moved within it — pinning one and
watching it not re-sort is fine; trashing one and watching it stay is not.
So the search is re-run instead of the destination loaded. The results are
still the answer to the query, just a current one, and it costs one local
SQLite query — the same argument the surrounding comment already makes for
reloading the board.
Creating a note while searching still leaves the list alone: a new note that
does not match the query has no business appearing in its results.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
#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
`-D warnings` failed the Linux lane on `constant SUGGESTED is never used`, and
it was right — the suggestion is implemented in `bridge.ts` as
SUGGESTED_CAPTURE_SHORTCUT, and nothing in Rust ever read the copy here.
Deleted rather than exposed through a command. This side accepts any
combination the OS will take; picking one to put in front of someone as a
starting point is a UI decision, and a constant here would only be a second
copy of a string one layer reads and the other does not.
Worth noting what this run DID prove, since the previous one proved nothing:
the lockfile gate passed and the Windows job built the NSIS installer end to
end. So `tauri-plugin-global-shortcut`'s handler signature — the thing I could
not verify without a toolchain — is correct, and the feature compiles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
Restores 42e06da, which was reverted only because Cargo.lock had not been
updated for the new crate and every cargo invocation in CI passes `--locked`.
Both desktop jobs failed on that line before compiling anything, so nothing
about the code had been judged.
The lockfile was generated in CI's own `ci-tauri:1.97` image — one container,
`cargo fetch`, nothing built. `cargo fetch` and NOT `generate-lockfile`: the
latter re-resolves from scratch and would have churned versions across the
whole workspace to add one dependency. The diff is 67 insertions, zero
deletions, six packages — tauri-plugin-global-shortcut plus global-hotkey,
x11rb, x11rb-protocol, xkeysym and gethostname. Nothing existing moved.
The feature itself, unchanged from 42e06da:
Press the combination anywhere and a small window arrives over whatever you
were doing; type, Ctrl/Cmd+Enter, gone. The board never comes forward.
There is no default shortcut on purpose — any default is a key combination
taken away from something else on somebody's machine, silently, at install
time. CommandOrControl+Shift+N is offered as a one-click suggestion.
Stored and live are separate fields because they disagree: a combination
another app holds is saved and does nothing when pressed, and a Wayland
compositor may refuse global grabs outright. `capture_shortcut_set` registers
before storing, so a refused combination is never written down as if it worked.
The window hides rather than closes and keeps its text, so an interrupted
capture is still there next press — which is what makes Escape safe. A failed
save keeps it open too, rather than discarding the only copy of something just
written in order to report a retryable problem.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
`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 c8318c3. The work is
intact in 42e06da and comes back with `git revert 5e0c...` once the lockfile
exists — nothing here needs rewriting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
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
Capture without opening the app first — the input half of #1899. Two ways in:
the share sheet from anywhere, and the text-selection toolbar in any app's
text field.
## The note is created, not pre-filled
The obvious build is "open the editor on a draft holding the shared text".
That silently loses it. `NoteEditorScreen`'s flush is guarded by
`bodyText != note.body`, so a draft handed the text already has nothing to
save — share a link, press back without typing, and it is gone. Which is
exactly the shape of a share: the common case is walking away.
So `captureShared` makes the row first and opens the editor on the real
note. A share has already said "keep this"; creating it is what honours
that, and back then leaves a saved note rather than a decision.
## launchMode="singleTop"
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own intent,
which is why `onNewIntent` already worked there. A share intent is built by
the OTHER app and nothing here can add a flag to it, so the activity has to
declare it. Without that, every share while the app was running would stack a
second MainActivity — a second view model, a second board, and a back press
landing on a stale copy of the same app.
## Subject and text, both
A browser sends EXTRA_SUBJECT as the page title and EXTRA_TEXT as the URL.
Keeping both makes the note read as its title, because the core names a note
by its first line — the difference between a board you can scan and a column
of identical links. `distinct` because plenty of senders put the same string
in both.
The extras are removed on read, like the reminder's note id and for the same
reason: the activity keeps its launch intent, so without consuming them a
rotation would replay the share and mint the note again.
## Not included: images
`image/*` is deliberately absent from the filter. Nothing in this app can
create an attachment — the core has `delete_attachment` and no counterpart,
and the FFI exposes neither. Declaring the mime type would put ThoughtSync in
front of people in the share sheet for a job it cannot do, and fail after
they had already chosen it. Adding it needs an attachment-creation path
through the core, the FFI and sync, which is its own piece of work.
The desktop half of #1899 — a global hotkey — is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
`standard:import-ordering`. The two new imports were inserted by anchoring on
`com.fabledsword.thoughtsync.ui.SyncScreen`, which looked like the right
neighbour and is not — `SyncState` and `SyncViewModel` both sort after it, so
Tags* wedged into the middle of the Sync block.
Moved below `SyncViewModel`. Every import block in the five files this branch
touched is now confirmed sorted, not just the one ktlint happened to reach
first — it reports one violation and stops, so a second would have cost
another full Android lane.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
Android could list tags and mint new ones. It could not rename, recolour,
delete or merge one — and since the per-note colour picker was removed with
2949, tag colour is the ONLY colour control in the product, which meant an
Android-only session had no way to change any colour anywhere.
A destination reached from the drawer, not a modal. The web's LabelsModal is
a modal because a desktop can float one over the board; on a phone this is a
place you go to tidy up, and a full screen is what that is.
The manage entry is an action ON the drawer's Tags header rather than a row
in it, so it cannot be mistaken for a sixth lens. The header now renders even
when there are no tags: this screen is where you make the first one, and
hiding the way in until one exists is a door that only appears once you are
already inside.
## The two calls this needed
RENAME and MERGE deliberately do not follow the same rule, and the screen
says so rather than hiding it.
* A rename that lands on an existing name merges, older survives (3324).
That path is accident-prone — it is a text field, and a typo reaches it —
so it needs a rule that cannot depend on which way round it was typed.
The screen catches the collision against the LIST, not from what the core
returns: the survivor may be the tag being renamed, so an unchanged id
afterwards proves nothing. Then it asks before merging.
* An explicit merge keeps its direction. Here the person is choosing, and
the direction IS the intent — folding #grocery into #groceries is a
decision, and overriding it with age would refuse the thing they asked
for. The price is that the direction has to be unmissable, so the body
names the tag that stops existing and every row offered is the survivor.
Delete quotes the note count, because "it is on 40 notes" is a different
decision from "delete this tag?". The count comes from `list_labels`, the
only call the core populates one on. It also says that a tag written as #tag
in a body comes back on that note's next edit — deleting the row cannot
un-write the word, and that is better said than discovered.
## The board had to learn something
`Destination.WithLabel` holds an id, and deleting or merging a tag the board
is currently LOOKING at would strand it on a lens that queries a row which no
longer exists — permanently empty, escapable only via the drawer. So
`loadLabels` became `refreshLabels`: public, and it drops back to Notes when
the current lens is gone. A failed listing deliberately does NOT trigger that
fallback — "I could not read the tags" is not evidence that this one went.
Reused rather than rewritten: `ErrorBanner` (the board and editor already
share it), `MenuItem` from Panel.kt (it closes the menu before acting so a
dialog cannot open under a hanging menu), `PlainTextField`, and the
`NOTE_TINTS` palette — the screen consumes it and does not fork a copy.
`default` stays in the palette on purpose: a tag with that colour gets a hue
derived from its name, so it means "let it pick", and removing it would leave
no way back to that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
Both new assertions fit well inside the 100-column limit and both were still
rejected. The governing setting is `fn_call_width` (60), applied to a macro's
argument list: `survivor.id, older.id, "the older row is the one that
survives"` is 62 characters, so rustfmt breaks it and pairs the two values on
one line with the message beneath.
The neighbouring `assert_eq!(survivor.name, "Grocery", "spelled the way the
caller asked")` was accepted at 59 characters of arguments, which is the
same rule agreeing rather than a different one.
rustfmt's own output, pasted back. Second time this lane has caught the same
class of thing in one session — the other was a method chain, budgeted at 60
by `chain_width`. Recorded so the next person reaches for the 60, not the 100.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
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 8c7553d — the delete confirmation still said
"the label".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
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
`core/src/local/store.rs` implements all seven label operations. The uniffi
object exposed three of them, so Android could attach tags to a note and
mint new ones, and could do nothing else with them ever.
The four additions are pure passthrough, because reading the store showed
both of the things #2963 said to check rather than assume are already
handled there:
* The note count exists. `Label` carries `count: Option<i64>` and
`list_labels` computes it per row, excluding trashed notes — which is
the number a delete confirmation should show. The single-label returns
all end in `load_label` and leave it `None` on purpose, so a screen must
read counts from the LIST and never from an operation's result.
* Sync is free. `rename_label` and `set_label_color` set `dirty = 1`;
`remove_label` records a pending delete; `merge_labels` records one for
the source AND marks every note that carried it dirty before the delete
cascades the membership rows away, because push sends `label_ids` per
note.
So no store change, no sync change, no count plumbing — the binding only.
One divergence found and documented rather than fixed: renaming a tag onto
an existing name is a 409 on the server (`labels.py:94`) and a silent
duplicate in the local store. The desktop has always had this, calling the
same `store::rename_label`; Android now inherits it. Deciding which side is
right belongs with the screen (#2964), not with the binding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
`cargo fmt --all --check` failed the desktop lane on one hunk in the new
`client_headers_identify_app_and_protocol` test. Clippy and every test
passed; only the formatter objected.
rustfmt splits `client_headers()[0].1.starts_with(..)` across lines because
an index followed by a tuple field followed by a call is a three-element
chain, and it will not keep one on a single line inside a macro argument
regardless of width. This is rustfmt's own output, pasted back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
`client_headers()` built `thoughtsync-desktop/{CARGO_PKG_VERSION}`, and both
halves were wrong.
This crate is compiled into the Android app as well as the desktop one, so
every phone in the field announced itself as a desktop. And CARGO_PKG_VERSION
here is the CORE crate's version — a number no build stamps and no user has
ever seen — where the thing a reader of that header wants is the app's own
build (note 3127 §5: with no version tags, the artifact's self-report is the
only answer to "which build is this?").
The core cannot know either value, so the host says them. `set_client_agent`
is a OnceLock the desktop fills in `run()` and Android fills in
`ThoughtSyncApplication.onCreate`, before anything can sync. A host that never
introduces itself sends `thoughtsync-unidentified/unknown` rather than a
plausible default: nothing reads this header today, which is exactly why a
wrong value could sit in it for months — the first person to look at a server
log is the first who could catch it, and only if what they see is obviously a
host that never said who it was.
Android's version comes from the INSTALLED package, through a new
`Context.installedVersionName()` that the foot of the Sync screen now shares.
One answer to "which build is on this phone", so the line a person quotes in a
bug report and the line in the server's log cannot disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
The download links added in fd1e4ae carried their own copy of BaseButton's
class list, because BaseButton is a <button> and cannot hold an href — and a
download must be an anchor, so the browser's own download manager gets the
3-95 MB transfer instead of a blob this app would have to hold in memory.
A copy is not a solution to that; it is two primary buttons that look alike
until someone changes one. So the shape moves to `.btn` + `.btn-primary` /
`.btn-ghost` in the components layer, where both elements can wear it, and
neither owns it.
The `disabled:` variants stay on BaseButton. An anchor has no :disabled, so
they were never shared and pretending otherwise would put a rule in the
shared definition that only one of its two users can ever match.
Verified there is exactly one shape to unify and no third copy: `px-4 py-2.5`
appears in three other files and all three are something else (a toast, a
dashed quick-add affordance, a retention notice). The smaller brand buttons in
AppShell and NoteEditor are a different size, which is a size-variant question
and not this one. And exactly one call site passes a class to BaseButton —
`shrink-0` — which cannot conflict with anything the shape declares.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
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
`fetch-clients.sh` wrote `"version_code": %s` unquoted, which was right when the
only ordering key in sight was Android's integer. The desktop's is Tauri's
`1.0.<minutes>`, and unquoted that is not valid JSON at all — so `json.loads`
raised on all four generated sidecars and the server advertised nothing. A silent
zero, not an error: `_read` treats a malformed sidecar as "no client here", which
is right for a corrupt drop-in and indistinguishable from this.
Caught by running the real fetch against the live dev channel and feeding the
result to the real resolver, rather than by reading the printf.
Also makes `_resolve` wrap BOTH candidate roots in Path(). Only the first was, and
the asymmetry fails the same quiet way: a str `/` str raises TypeError, `_read`
catches it, and a perfectly good directory reads as empty.
The whole pipeline now resolves end to end against the live channel — five of five
platforms, every sidecar valid JSON, one human-readable version across all of them
with each artifact keeping its own comparator type:
android 2026.08.30.1711 code=3504552 57.6 MB
linux-appimage 2026.08.30.1711 code='1.0.3504551' 95.3 MB signed
linux-deb 2026.08.30.1711 code='1.0.3504551' 3.3 MB
linux-pacman 2026.08.30.1711 code='1.0.3504551' 2.7 MB
windows 2026.08.30.1711 code='1.0.3504551' 2.6 MB
The precedence test wrote `version_code=300` for all five platforms and compared
the desktop's against the int it wrote. It comes back as `"300"`, because the
module preserves each platform's own comparator type instead of flattening both
to int — which is the behaviour the change it was testing had just introduced.
A `coded()` helper now says which shape to expect and why, and the assertion runs
over every non-Android platform rather than spot-checking `linux-deb`. The test
caught a real inconsistency in itself precisely because it compared against a
concrete value rather than round-tripping what it wrote.
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product
being complete costs (rule 23): a self-hoster gets a working app for their machine
from the server holding their notes, with no account on a forge that is private.
The AppImage is not optional within that — it is the only bundle that can replace
itself in place, so a server without one cannot serve in-app updates to anybody.
`packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names
and sidecars `client_dist.py` reads. It never fails: a platform with nothing
published means the server advertises nothing for it and the UI hides that
download, and eight fetches must not become eight ways to redden a green lane.
THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been
wrong the easy way. The obvious shortcut is `version.sh display desktop` in the
image job — it has the checkout. But this commit may not be the commit the channel
is serving: a push touching only `src/` does not rebuild the desktop, so the
channel still holds an older build and a locally-derived version would describe
those bytes with this commit's number. `client_dist.py`'s size check could not
catch it, because size IS measured from the real file — it would sail through and
lie about the version alone. So `write-manifest.sh` now publishes
`thoughtsync-desktop.json` beside `latest.json`, from the same two values in the
same breath, and only size/sha256 are measured at bake time.
Which needed the prune's keep-list, or the sidecar would have been uploaded and
deleted again in the same run — a fixed name is self-limiting, which is exactly
why that list exists.
`version_code` is NOT uniformly an integer, and coercing it was a leftover from
the days when Android was the only platform. Android's must stay a JSON number:
`ClientRelease` in core declares it `i64` and a string fails to deserialize on
every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` —
the value its updater actually compares — and `int()` would have rejected every
desktop sidecar CI writes. The table now says which is which, and tests pin both
directions.
Also retires the comment above the fetch step, which claimed the APK came from
"always the rolling dev release" and mentioned `:<version>` images. M314 step 3
made the channel conditional in the code directly below it, and step 6 removed
version-shaped image tags entirely.
Verified against the live dev channel before pushing: the Android half resolves
and exits 0, the desktop half degrades with a warning because the sidecar does not
exist yet, and all five constructed bundle filenames return 200.
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.
Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.
Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.
THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.
PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.
The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.
The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
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.
It existed for one window: `stable` was a manifest-only pointer at whatever `v*`
tag had last been cut, and `stable` is the DEFAULT channel, so without the
fallback `curl … | sh` was broken for everyone between step 3 landing and the
first merge to `main`. That merge happened (`b6673c6`), and `stable` now holds
its own signed bundles at 1.0.3503145 — AppImage, deb and pacman, all resolving
by the one lookup both channels share.
Kept as a fallback it stops being a safety net and becomes a mask: the branch
only runs when `stable` has no bundles, which from here on means something is
broken, and chasing a `v*` release instead of saying so is the wrong answer.
The header now says the transition is finished and that neither channel should
be special-cased again, because the shape of that code invites re-adding it.
The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.
`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.
What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.
The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
Step 7 of M314, the last one. Rule 22 — the old path comes out completely.
## A release stops building
`desktop.yml` no longer triggers on `v*`, and its two `Publish release` steps
are gone. `ci.yml` lost its tag trigger in step 6. So a tag now reaches exactly
one lane: the new `release.yml`, which builds nothing.
That is not a simplification for its own sake. The merge to `main` already
published everything a user can receive — `:latest` + `:<sha>`, both channel
feeds, the updater manifest. A tag rebuilding that source produces identical
artifacts under identical names and re-pushes `:<sha>` with different bytes,
which rule 145 forbids even when they match.
## So what a release is FOR
The changelog (note 3127 §5). Two halves to "what am I running", and the
version answers only the first: which build is this (the footer, /api/config,
the APK's versionName) and what is in it that was not in the one I ran last
month (nothing, until now).
`packaging/release-notes.sh` derives it from git rather than a hand-maintained
CHANGELOG, which drifts into recording what someone MEANT to ship. Capped at 60
entries with the omitted count stated — the first dated release spans 181
commits since `v0.1.0`, and a truncated list that does not say it is truncated
is a lie.
It publishes through `publish-release.sh` rather than making its own API calls,
for the create-or-PATCH-on-409 path: a fixed-tag release that only ever POSTs
keeps whatever body its first run wrote, which is #2182, and reimplementing that
correctly in a second place is how it comes back.
## Retired
`MANIFEST_TAG` and the whole branch behind it. It let the manifest live on a
`stable` pointer release while the bundles sat on a versioned one — a split step
3 removed when `stable` started holding its own bundles. Nothing had passed it
since; a parameter that can only ever receive its own default is a branch nobody
exercises and a comment that goes stale, and its stale text was still telling
readers the installable builds live on the versioned releases.
`desktop/src-tauri/Cargo.toml`'s version and `thoughtsync/__init__.py`'s both
now say out loud that they are not shipped values. The Cargo one carries the
history worth keeping: the old scheme took its base from that line, so `0.2.<run>`
on dev outranked a bare `0.2.0` on main, and the remedy was "remember to bump the
minor before tagging" — documented in a comment, enforced nowhere. #2183 is what
that looked like in the field. **That ritual is now formally dead**, and this is
the deliberate act of killing it rather than a side effect.
## Still there on purpose
`install.sh`'s transitional stable fallback. It cannot go until `main` has
published to `stable` at least once, and that is gated on an operator request.
Removing it now would break the DEFAULT install channel.
#3147
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rule 145 promises every push to `main` publishes a `:<sha>`, so any production
commit is addressable without a release ceremony. `ci.yml`'s `paths:` filter
quietly broke that: a commit touching only docs never triggered the lane, so
that commit had no image and no sha tag.
Pre-existing — the filter has always been there — but it is rule 145's guarantee
and step 6 is where the tag set is being made to match the rule, so it is this
step's to close.
Confirmed live on a0c789b: a docs-only push produced two runs, both client lanes
skipping correctly, and NO image at all.
The server image now always builds. It is the cheap one — ~15 seconds against 6
and 9 minutes for the clients, which is exactly why they skip and it does not —
and always building is what keeps `python:3.12-slim` fresh on something that can
face the internet. That is also why §4's base-image tension does not bite this
project: the artifact it would apply to is the one that never skips.
#3146
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`:<git-sha>` is on `main` only now — a sha tag per dev push was a rollback
target nobody had ever pulled — and `:<version>` never existed as an image tag
after rule 145 was narrowed. Both were still documented.
`docs/android-distribution.md` also said `:dev`, `:latest` and `:<version>` all
ship a client, which is now two-thirds true and misses the more useful fact: the
channel IS the image you run, so a stable server serves a stable client. Worth
saying because until step 3 it was hard-wired to the dev release on every branch
and did the opposite.
This push is also the skip-if-exists verification. It touches neither client's
file set, so both `decide` jobs should report the channel already serving the
current version and skip a 6- and a 9-minute build — while the guard still runs
on that path (§6.3).
#3146
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 6 of M314. Two changes that only make sense together.
## The image tag set rule 145 mandates
dev push -> :dev
main push -> :latest + :<sha>
a v* tag -> nothing; the trigger is gone
`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.
No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.
## Skip-if-exists, adapted, because §4 assumes a registry §5 removed
Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.
So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.
The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.
## The server always builds, deliberately
Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.
That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.
## The guard runs on the skip path
It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.
## Two defects found while wiring this
`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.
`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.
#3146
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 5 of M314, note 3127 §6.3. Everything else in this milestone derives a
number and trusts it; this compares the derived value against what the channel
is actually serving and fails the lane if it went down.
Too-low is the unrecoverable direction: every installed client reports "up to
date" forever, and no later build fixes it until one climbs back above the bad
number. #2183 and #2993 are both that symptom.
## Two hazards, two mechanisms
A shallow clone is now tested DIRECTLY, in `version.sh`, via
`--is-shallow-repository`. The empty-result guard only caught the case where
nothing matched — and run 4796 showed the worse one, where a partial match
returned a real six-days-stale answer. Asking the question outright costs no
network and covers artifacts with nothing published to compare against.
`guard-forward.sh` handles the rest: a squash or rebase merge rewriting the
committer date, a rebuild of an older commit, and clock skew between runners.
## The comparison is per artifact, and the operator differs
desktop derived >= published commit time, so equality is the ORDINARY
no-change case and `<=` would fail every
build that changed nothing
android derived > published build time, so equality means two builds in
one minute — and Android refuses to install
an APK whose versionCode does not RISE
The server is deliberately unguarded: nothing compares its version, `:latest`
moves regardless, and rule 145 removed the version tags that would be the
published list. A too-low value there is a wrong date in a footer, not a
stranded client. It still gets the shallow-clone check.
## Proved to fire, not assumed
Cloned the repo, checked out a commit eight back, ran the guard against the
LIVE dev feed:
at the tip derived 1.0.3502151, published 1.0.3502151 -> pass
eight back derived 1.0.3501535, published 1.0.3502151 -> FAILS
android tip derived 3502171, published 3502152 -> pass
stable derived 1.0.3502151, published 0.2.0 -> pass
That last row is worth keeping: stable still advertises the bare `0.2.0` from
the old Cargo.toml scheme, so the transition orders upward on BOTH channels,
not just the one being exercised.
A channel with nothing published passes rather than failing — otherwise the
first publish to a new channel could never happen.
The guard runs BEFORE the build in all three lanes, so a bad derivation costs
seconds rather than a five-minute compile and a publish to undo.
`compare` is exposed as an explicit mode so the ordering is testable without a
network and inspectable without a push — 16 cases including `1.0.9 < 1.0.10`,
which a string compare gets exactly backwards.
#3145
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`85ead4d` changed `packaging/version.sh` — the script that decides what every
artifact claims to be — and the desktop and Android lanes did not run at all.
Only CI & Build fired, and only because it happens to watch `tests/**`.
So the fix in that commit is unverified on exactly the two lanes whose bug it
was fixing.
`version.sh` lists `packaging` in all three file sets; the workflows' `paths:`
filters did not. Two places holding one decision, with one of them updated —
the failure this subsystem keeps producing (#2181-2183, and again in step 3
where `install.sh` still expected stable's bundles on a versioned release).
The script's own header already warned about this: "a change here that is not
mirrored there means a lane that does not fire — check both." Written, then
not followed, in the same commit.
Step 6 removes the duplication for real by replacing these filters with
skip-if-exists. This is the stopgap until then, and it says so at each site.
#3144
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three failures on c504433, two root causes, and the interesting one is that
`git log -- <paths>` resolves pathspecs against the CURRENT DIRECTORY.
Callers run from wherever suits them: the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest job from
the root. So one push produced THREE versions:
desktop build 1.0.3494522 <- six days stale
pacman packager 1.0.3502131
manifest job 1.0.3502131
The build's pathspec had matched `desktop/src-tauri/Cargo.toml` — a real file
— so git answered with the newest commit touching THAT. Non-empty, so the
shallow-clone guard could not fire; the manifest then found no bundle matching
its own answer and the lane went red two steps from the cause. The Android job
failed loudly in the same run only because ITS pathspec happened to match
nothing from `android/`. Same bug, luckier symptom.
The script `cd`s to `git rev-parse --show-toplevel` before doing anything now,
and the test asserts every artifact answers identically from four directories.
## And a third instance of the trap that bit yesterday
The unit test caught it: `version.sh display nope` printed "unknown artifact"
to stderr and then answered `2026.08.28.0900` with exit 0. `paths_for` is
reached through `$(paths_for "$1")`, so its `exit 2` ended the subshell,
returned an EMPTY pathspec — and an empty pathspec matches everything.
That is now three occurrences of one mistake in one file: the shallow-clone
guard on `key` (emitted `1.0.-26297280`, exit 0), the same guard on `display`
(which failed only because `date` then choked on the empty string), and this.
Each was found by a different mechanism and none by reading the code. The
artifact is validated in the parent shell now, and the file says so where the
next guard would be written.
Both tests assert on STDOUT as well as the exit code. The exit code alone
passed for `display nope` while stdout carried a lie.
#3144
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 4 of M314. `desktop/packaging/build-version.sh` was one generator feeding
the desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`, so a
Kotlin-only commit re-versioned the desktop and a Rust-only commit
re-versioned the phone. Note 3127 §3 cites this repo as its example of that
failure. It is replaced by `packaging/version.sh` — one definition of HOW to
derive, three file sets, and the sets in one place.
Lives at the repo root rather than under desktop/, because it serves three
artifacts now and a shared thing filed under one consumer ends up owned by it.
## Two values, and the clock chosen per value (§2)
desktop key 1.0.<minutes since 2020-01-01> commit time
desktop display 2026.08.28.0900 commit time (#3181 shows it)
android versionName commit time
android versionCode <minutes since 2020> BUILD time
server version 2026.08.28.0900 commit time, no ordering key
Every human-readable version in the repo is now one shape. The two exceptions
are not version names at all — they are bare monotonic integers a comparator
reads and nobody quotes.
The desktop needs a separate key because Tauri parses `latest.json` with the
semver crate and `2026.08.28.0900` fails it twice (four segments, and `08` is a
leading zero). `1.0.` and not `0.0.`: the minor has to clear the installed
`0.2.466` line or every dev user is stranded on "up to date" permanently.
Android's code comes from BUILD time while the desktop's key comes from COMMIT
time, deliberately. Android hard-fails a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
its key must be monotonic by construction; the desktop merely declines to offer
an update, which a guard can catch.
## The bug this found in itself
The shallow-clone guard `exit 1`-ed inside a function called as `$(...)` —
which ends the SUBSHELL, not the script. `display` still failed, but only
because `date` then choked on the empty string. `key` printed the error to
stderr, emitted `1.0.-26297280`, and exited ZERO.
That is precisely the failure the guard exists to prevent: a too-low version on
a green lane, and too-low is the direction you cannot recover from. It resolves
into a global in the parent shell now. The test is parametrized over both
requests, because one path was covered and the other was broken in exactly the
way the covered one was meant to rule out.
## Also
`fetch-depth: 0` on every job that derives — four of them, and only ci.yml's
gate had it. Depth-1 is silently wrong rather than loudly broken (§6.1).
The file sets include each artifact's BUILD RECIPE (its workflow, and
`packaging/`). A workflow file is not shipped, but change a Gradle flag and the
bytes change while the source does not — and once step 6 skips a build whose
version already exists, that serves the OLD artifact on a green run.
The base images are deliberately NOT resolved at derive time: that is an
external lookup, which §7's corollary forbids. `Dockerfile` is already in the
server's set, so pinning `FROM` by digest in step 6 puts the base inside the set
for free.
`build-version.sh` is deleted, its last consumer (the pacman packager) moved
over, and the one finding worth keeping out of its header — why not a `-dev.N`
prerelease — is preserved in the successor.
#3144
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing
sufficient for a user to receive the build" — and here it was not. The
desktop and Android lanes BUILT on main and published nothing: `Publish
release` was gated on `refs/tags/v*`, the channel publishes on
`refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel
moved only when somebody cut a tag, which made a `v*` tag load-bearing
rather than the optional bookmark the model wants.
Both channels are rolling fixed-tag releases now. `dev` from dev, `stable`
from main, same machinery — `publish-release.sh` already took RELEASE_TAG,
`write-manifest.sh` already pruned, and both already PATCHed a stale
description on 409 (#2182). This is wiring, not new mechanism.
## The defect this carried
`ci.yml`'s "Fetch the Android client to bake in" read
`releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked
in the dev APK — `:latest` included — so a stable server served a
dev-channel client to anyone who downloaded it from there. That has nothing
to do with versioning; it is fixed here because this is the step that
finally gives `stable` an APK to point at.
It also means Android needs no channel machinery of its own. The APK is
served FROM the image, so the channel is already a property of which image
you run — note 3127 §7's "nothing to hand off" shape, arrived at here by
accident. One branch-conditional line, not a second channel in
`client_dist.py` as this milestone first assumed.
## The break this nearly shipped
`install.sh --channel stable` read the version out of `stable/latest.json`
and then fetched `releases/tags/v<version>` for the bundles — correct while
stable was a manifest-only pointer, and broken the moment stable holds its
own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for
everyone between this commit and the first merge to main.
Both channels are one lookup now: fetch the fixed-tag release, install what
is on it. A transitional fallback covers the window where `stable` still
has no bundles, marked for deletion in step 7 — without it the default
channel is broken for however long it takes to merge, and that window is
gated on an operator request rather than on this lane.
## The two writers problem
`stable`'s manifest was written by tag builds. It is written by main now,
and the tag path stops writing it — two writers for one channel is a race
with no winner worth having. A `v*` tag still writes its own versioned
manifest; its build consequence goes entirely in step 7.
Also corrected: `update.rs`'s header still described stable as following
`v*` tags. Nothing in that file moved — it only ever read
`<channel>/latest.json` — but the comment was a lie, and it is the file
somebody reads to understand the feed.
#3143
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cargo fmt --check`. Deleting `color:` from these two NoteDraft literals left
the line after it one level too deep — the sort of thing a formatter exists to
catch and an eye does not.
#3041
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two CI failures from the colour removal, both mine.
`a_fresh_database_reaches_v8` asserted the version the migration no longer
stops at. Renamed to say what it actually guards — the LATEST version — so the
next migration updates a number instead of a name that has quietly become
wrong.
While there, two tests the migration deserved and did not have. One asks
SQLite whether `notes.color` is gone rather than reading a row back, because a
SELECT that omits the column passes either way; it also asserts `labels.color`
is still there, since getting that wrong would take every tag's colour with it.
The other seeds three saved views and checks the sweep: one loses its colour
key and keeps its query, one without the key is untouched, and one holding
text that is not JSON at all comes out unchanged rather than NULL.
Writing that third case is what found a real bug in the migration. The guard
was `json_valid(params) AND json_extract(params, '$.color') IS NOT NULL`, which
is the obvious way to write it and is a trap: SQLite does not promise to
short-circuit AND, so `json_extract` can be evaluated against the very rows
`json_valid` was there to exclude — and on malformed input it does not return
NULL, it RAISES, which would have aborted the whole migration over one corrupt
blob. It is a LIKE now, which is total over any text.
The ktlint failure is a doubled blank line where `EditorAction.SetColor`'s
branch used to be.
#3041
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
`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>
`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>
Found by the Scaffold audit #2951 asked for. Three Scaffolds exist; the editor
and the sync screen both consume the IME inset, and the board consumed nothing.
`enableEdgeToEdge()` makes the manifest's `adjustResize` a no-op on API 30+, so
nothing resizes for the keyboard unless the app asks — and
`ScaffoldDefaults.contentWindowInsets` is systemBars, which the IME is not part
of. The Scaffold positions the FAB and the snackbar host from that value, so
with the search field focused both sat under the keyboard.
Not theoretical, and newly load-bearing: `3f0eef1` put an UNDO on the trash
snackbar, so the one control you could not reach was the one that takes back a
note you did not mean to throw away — reachable by searching, long-pressing a
hit and trashing it.
`union` rather than `add`: the navigation bar and the IME are the same edge,
not two stacked ones, and adding them would inset twice under a keyboard that
already covers the nav bar. Set once on the Scaffold rather than per-slot, so
the content column shrinks with it and the board's cards stay above the
keyboard instead of scrolling under it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures on `3f0eef1`, both ktlint, both mine.
`chain-method-continuation`: a multiline element in a Modifier chain wants the
next `.` glued to its closing paren — `).background(…)`. Every other multiline
chain element in this codebase happens to be LAST in its chain, so nothing had
exercised the rule before. `combinedClickable` is now a named `opening`
modifier applied with `.then(…)`, which keeps the chain single-line per element
and reads better than the shape ktlint was asking for.
`no-unused-imports`: lifting the delete-forever dialog into Panel.kt took the
last use of `Text`, `stringResource` and `R` out of NoteEditorScreen.kt with
it. I had checked AlertDialog and TextButton and stopped there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trash existed on Android and was three interactions deep — open the note,
tap the overflow, Move to trash — with nothing at all on the board itself.
The operator's read of that was not "the actions are in the editor"; it was
"there are no long hold context menus in the app I have no way to delete
notes." (#2946)
The card now takes `combinedClickable` and raises a DropdownMenu holding the
same items as the editor's overflow, in the same words, from the same string
resources, dispatching the same `EditorAction`s through the same
`BoardViewModel.onEditorAction`. A note has one vocabulary of things you can
do to it, and reusing the exhaustive dispatcher means the board cannot grow a
parallel one that drifts.
Gated on `note.trashed` rather than on the board's destination — the same
reading the editor uses for read-only, and the only one that survives
Reminders and search, which both mix piles.
Trash gets an UNDO snackbar rather than a confirmation. A long press is a
gesture you can make by accident, so the mistake worth designing for is the
one nobody meant to make, and a dialog only helps someone paying attention in
the moment they were not. Delete forever keeps its dialog; that one does not
undo.
`MenuItem` and the delete-forever dialog move to Panel.kt now that two
surfaces raise them, so there is one place for the close-before-acting order
and one wording of the consequences.
Colour is deliberately not in this menu, though #2946 suggested it:
`note.color` and its picker come out in #3041, so a swatch row here would be
building the one control already known to be leaving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`cargo fmt --all --check`, the only failing gate on d9e5753 — clippy, all 148
tests and every other lane were green. The line was 96 characters, under the
100 max_width, but a chain is held to `chain_width` (60% of it).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Step 2 rewrote the notes already on disk and, because the sync_revision
trigger fires, every client pulls them. So this is not about existing notes.
It is about the ones typed from now on.
Without it: you type `#todo` on its own line, the core stores it as written,
and a second later the push comes back and the text disappears under you.
Offline it never lifts at all until you reconnect. Two surfaces disagreeing
about what a note says is the thing this codebase mirrors rules to avoid.
`lift_standalone_tags` in derive.rs is the mirror of `split_body_tags`, case
for case, with the same two guards — a fenced line is code and is never
touched, and a note that is nothing but tags keeps its text.
ONE SCANNER, not two. `extract_tags` is rewritten over the same `line_tags`
the lift uses, so the two cannot disagree about what a tag is. Line-by-line
changes nothing, since a line start and a `\n` are both boundaries, and the
existing tag tests still pin it.
Char indices rather than byte offsets for the spans, because they are used to
cut the tags back out of the line and a byte offset can land mid-codepoint.
`sync_tags` becomes `lift_and_sync_tags` and is named for the mutation: it
now rewrites notes.body, and all three callers write the body immediately
before calling, so it overwrites what they wrote on purpose. The graduation
case is handled the same way as on the server — flip the row before the
delete pass, or the same row is dropped for no longer being in the body and
the tag is silently lost.
One thing the server needed and this does not: display_title. The core
derives it on READ rather than storing it, so there is no persisted copy to
go stale.
The rename was done with a lookbehind rather than a plain substitution, after
the same operation an hour ago turned the function it had just written into
`_lift_and_lift_and_reconcile_tags`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 1 made new saves lift; this does the ones already on disk, so a note
stops showing its tag twice without having to be opened.
Same rule, and a FROZEN copy of it — `split_body_tags` is deliberately not
imported, on 0027's principle that a migration has to keep producing what it
produced the day it ran. If the app's rule is ever loosened, this file must
not loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and recomputed only for a
note whose body actually moved: a note named after its `#todo` line needs a
new name.
The label rows graduate in the same transaction, and that is not cosmetic. A
`via_tag` row claims "backed by text still in the body", and reconcile
detaches any row it cannot find a `#tag` for — so leaving them true would
lose every lifted tag on the note's next save. Flipping them to false is also
what makes the chip's × appear, which is now the only way to remove a tag
whose text is gone.
`updated_at` is left alone so a client holding an unpushed edit still wins
under LWW. The `sync_revision` trigger does fire, which is wanted here: unlike
0027 the clients do NOT yet apply this rule locally, so the server's copy is
the only correct one until step 3.
The downgrade is empty and says why. It cannot restore the deleted lines —
nothing distinguishes one this migration removed from one that was never
there — and flipping the rows back would be actively harmful, since the text
that flag claims backs them is gone and the next save would then detach the
label for real.
Tested on the ten cases that matter, three of which are prose that must come
back byte-identical. The test pins the frozen copy against fixed expectations
rather than against the app's rule — they are allowed to diverge later, which
is the whole point of freezing one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sed s/_reconcile_tags/_lift_and_reconcile_tags/` ran over tags.py after the
new function was already written with the new name, so the definition became
`_lift_and_lift_and_reconcile_tags` while all 15 call sites were correct.
Twelve test modules failed to import.
The check that should have caught it is the reason it got through: the
verification grep piped output through `sed 's/:.*_lift/: _lift/'`, which
trims to the LAST `_lift` and therefore prints a doubled name identically to
a correct one. A filter that can only make wrong output look right is worse
than no filter.
Same sed also clobbered the docstring's historical reference — it read "it
used to be `_lift_and_reconcile_tags`", naming the function after itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.
THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.
That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.
Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.
WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.
Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:
standalone lifted, attached as an ORDINARY label. Nothing derives it any
more because nothing is left to derive it from.
inline left in place, still derived, still detached when its text goes.
Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.
A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.
The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".
Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
MagicNumber's ignore list is -1/0/1/2, so the `3 ->` and `4 ->` branch
labels were findings. A lookup table has no literals to flag, and it is the
form colors.ts already uses — the two now read as the same function rather
than as two people's idea of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
`noteIsStrong` had a single-line body expression wrapped onto the next line;
ktlint's function-signature rule wants it on the signature line when it fits.
`firstLabelColor` wrapped a call chain after `note.labels.firstOrNull()`, and
chain-method-continuation wants a newline before EVERY link once one is wrapped.
It reads better as two statements than as a chain, so it is two statements.
Third ktlint round trip on this milestone. I pre-flighted the rules I already
knew and these were not among them — and when I then wrote greps for the two new
rules, they flagged sixteen files that have been passing for months, because my
heuristics do not match what the rules actually check. There is no local ktlint
(rule 10), so CI is the first and only reader; more elaborate greps are not the
fix, and pretending they are would just add false confidence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
`resolvedNoteColor` and `noteTintFor` are the first non-composable functions
here to take more than one parameter, and ktlint_official's function-signature
rule requires each parameter on its own line once there are two or more. Four
findings on one and four on the other, all the same rule.
Nothing had type-checked: ktlint is step 6 and the unit tests are step 8, so the
fixture pinning the derived-tint mirror never ran.
I checked line width, trailing whitespace and KDoc adjacency before pushing —
the three that have bitten before — and not this one. The list of rules learned
by failing CI is not the list of rules.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
`dev` went red on 1e2b42a and nobody was watching — the CI wait was killed with the
session, so the push was never confirmed. Checked on the way back in.
Both findings are ReturnCount: four exits against a limit of two. The same rule
caught continueChecklist earlier the same day, which is the annoying part — I had
the lesson and wrote two more guard-clause ladders anyway.
checkInBackground becomes a `when`, which it wanted to be regardless: it is four
mutually exclusive situations and one action, and the ladder made that read like a
sequence of unrelated escapes.
onWifi folds its three null checks into one nullable chain. Same behaviour, and the
`caps != null &&` reads as what it is — an uncertain answer being treated as no.
Both corrections to what I built, and the second changes the first.
NAG ONLY WHEN READY. The banner is now gated on the bytes being on disk. I had it
appearing as soon as a build was FOUND, with Install downloading on demand — which
turns one tap into an unplanned download, and is exactly the surprise the wifi gate
was meant to avoid. Off wifi the app now stays quiet and picks it up later.
ONLY ON WIFI, and both halves of that. `isActiveNetworkMetered` alone would download
over an unmetered cellular plan, which is not what "on wifi" means. TRANSPORT_WIFI
alone would download over a tethered hotspot, which is mobile data wearing a
different hat and the precise bill this avoids. It now requires both.
Found while making the first change: gating the nag on `ready` broke the nag. The
background path returns early once a build is fetched, so `nagDismissed` would never
be cleared again and a single "Later" would have silenced the update permanently —
the exact "lost" this whole path exists to prevent. Coming forward with a fetched
build now clears the dismissal instead of returning.
Also: a build found off wifi retries its FETCH on the next foreground rather than
waiting out the six-hour check interval. Found on the train, downloaded at home.
The banner loses its two-state text with the change, and BoardUpdate loses `ready` —
it is implied now. It stays visible while installing, deliberately: that is the one
moment it has something to report, and hiding it would look like the tap did nothing.
Anchoring the new function on `fun downloadTarget` put it between that function
and its own KDoc — so downloadTarget lost its doc and onUnmeteredNetwork gained a
second one describing something else entirely. ktlint caught both halves.
Anchor on a declaration and you land inside its documentation. Swept the rest of
the tree for the same shape; nothing else.
`check()` had exactly one caller: a button on the sync screen. So a new build was
found only by someone who went looking for one — and having to remember to go
looking is the same as not being told. The operator has been doing that by hand
every time.
Three parts.
FIND. The app checks when it comes forward, which is the moment the person is
present. Rate-limited to six hours in the view model, so flicking between two apps
is not a re-check, and skipped entirely on an unlinked device — updates come from a
linked server and there is nothing to ask. Same ForegroundTransitions shape as
AutomaticSync, for the same reason.
FETCH. Finding one downloads it, so the nag is a one-tap install rather than the
start of a wait. NOT over mobile data: fifty-odd megabytes is a bill nobody agreed
to, so this is gated on an unmetered connection (new ACCESS_NETWORK_STATE
permission — normal, no prompt). On a metered link the update is still found and
still nags; Install downloads it then, which is a choice rather than a surprise.
NAG. A banner on the board, under the error banners — an update is worth saying and
never worth saying before a note failed to save. "Later" clears it for this sitting
only: the next time the app comes forward the check finds the same build and says so
again. That is the difference between a reminder and a notice you can lose.
downloadAndInstall now skips the download when the background fetch already did it,
so the sync screen's button and the banner's are the same action with the same
name — whether the bytes are already there is this class's problem, not the
person's.
Second pass on the same report. Taking the field's own padding off got rows from
57dp to 48dp and the operator said it was still too big — correctly, because 48dp
was never the field's, it is Material's minimum touch target and every interactive
component gets it.
On a checklist that minimum IS the row height. It is the right floor for a control
somebody has to find on a screen; it is the wrong one for a box that sits in a
predictable column with an identical box directly above and below it, where a near
miss ticks the neighbouring item — visible, and undone by tapping again.
36dp, provided to the row rather than hardcoded into the controls, so the checkbox
and the delete × move together and nothing else in the app is affected.
2026-08-26 08:31:21 -04:00
370 changed files with 31210 additions and 7324 deletions
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT
@@ -127,7 +187,7 @@ jobs:
# from the built .so. Run as its own step so a Rust failure is legible as a
# Rust failure instead of arriving inside a Gradle stack trace.
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
#3324). A merge cannot be undone by repeating it and is reachable here by
a typo, so it says so before it happens — same reasoning as #2116. -->
<stringname="tags_rename_merges_title">Merge with %1$s?</string>
<stringname="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
<!-- The survivor is named in the button, not just the title: this is the one
operation here that repeating does not undo. -->
<stringname="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
<stringname="tags_merge_none">There is no other tag to merge into.</string>
<stringname="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
<stringname="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
<stringname="reminder_inexact_title">Reminders may arrive late</string>
<stringname="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
@@ -120,10 +240,13 @@
<stringname="update_current">You\'re on the newest build this server has.</string>
<stringname="update_check">Check for an update</string>
<stringname="update_install">Update</string>
<stringname="update_banner_ready">Build %1$s is downloaded and ready.</string>
<stringname="update_debug_build">This is a debug build, so it can\'t update itself. Install new builds by hand.</string>
<stringname="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
<stringname="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
<stringname="sync_offline_title">Working offline on this device</string>
<stringname="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
<stringname="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
description="uniffi bindings exposing thoughtsync-core to the native Android client"
description="uniffi bindings exposing inkwell-core to the native Android client"
authors=["bvandeusen"]
edition="2021"
@@ -10,10 +10,10 @@ edition = "2021"
# bindgen binary below — and this crate's own tests — can use the crate normally;
# a cdylib-only crate is unusable from Rust.
crate-type=["cdylib","lib"]
name="thoughtsync_ffi"
name="inkwell_ffi"
[dependencies]
thoughtsync-core={path="../../core"}
inkwell-core={path="../../core"}
serde_json={workspace=true}
log={workspace=true}
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.