Commit Graph
100 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 d838b27518 ffi: Kotlin could list and create a tag but never rename, recolour, delete or merge one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m0s
`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
2026-08-31 15:52:00 -04:00
bvandeusenandClaude Opus 5 a69159e562 fmt: rustfmt breaks the tuple-index chain, and the desktop lane means it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 23s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m27s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m21s
`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
2026-08-31 14:57:07 -04:00
bvandeusenandClaude Opus 5 c40916699b sync: the client header said "desktop" from every phone, and named the wrong version
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m37s
`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
2026-08-31 08:40:01 -04:00
bvandeusenandClaude Opus 5 ef418a8c92 buttons: one definition of the shape, worn by a <button> and by an <a>
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The download links added in 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
2026-08-31 08:08:01 -04:00
bvandeusenandClaude Opus 5 fd1e4ae487 downloads: five clients, and the page leads with the one that fits you
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Successful in 36s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The Account page offered the APK and nothing else, because the APK was all
the server held. Step 3 baked in four more, so the single card had to become
a section — and five artifacts is exactly where a downloads page turns into
a table of filenames and stops being a product.

So it LEADS with what fits the machine asking, from the user agent, and keeps
the rest quiet but visible. A wrong guess costs nothing: nothing is behind a
disclosure and every other client is one click away.

Linux gets all three at once, because the UA says "Linux" and nothing about
dpkg or pacman — there is no better answer available. They are named for the
distro rather than the package format, since a person knows which system they
run and not necessarily which packaging it uses. The AppImage carries one
clause of its own: it is 95 MB against 3, and it is also the only bundle that
updates itself in place. Both facts belong to the same decision.

macOS and iOS lead with nothing and say so. There is no build for either, and
"There's no macOS build yet" is the difference between deliberate and broken.

The version renders `unknown` rather than blank, and the download stays
offered — not knowing which build it is, is not a reason to withhold it.

Two things this did NOT do, both deliberate:

The task asked for a Tauri case — do not offer the desktop app to someone
already running it. That case cannot be reached: `/account` redirects to the
board in the desktop app (requiresServer, router/index.ts), because device
tokens are a server-side concept. A branch for it would be dead code.

`.btn-link` mirrors BaseButton's declarations rather than replacing them.
BaseButton is a <button> and cannot carry an href, and unifying the two would
have put every button in the app into an operator pass that CI cannot check —
for a cosmetic gain. The comment names the pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
2026-08-31 07:58:17 -04:00
bvandeusenandClaude Opus 5 c2fdc05e5c release: a tag builds nothing and carries a changelog instead
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m17s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
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>
2026-08-29 00:43:21 -04:00
bvandeusenandClaude Opus 5 fa43c2f4e9 ci: a docs-only merge to main produced no image, so no :<sha> for that commit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 17s
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>
2026-08-29 00:32:27 -04:00
bvandeusenandClaude Opus 5 a0c789b3ba docs: the image tag list said something step 6 stopped being true
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
`:<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>
2026-08-29 00:27:43 -04:00
bvandeusenandClaude Opus 5 22a9a279b1 ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
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>
2026-08-29 00:19:16 -04:00
bvandeusenandClaude Opus 5 0ab7d94294 versioning: refuse to publish a version below what the channel already serves
CI & Build / Python tests (push) Successful in 17s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m21s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m19s
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>
2026-08-28 21:33:00 -04:00
bvandeusenandClaude Opus 5 6e891357ff ci: the deriver is in the file sets but was not in the path filters
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
`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>
2026-08-28 21:11:26 -04:00
bvandeusenandClaude Opus 5 85ead4d66b versioning: anchor at the repo root — a pathspec is relative to the caller's cwd
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
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>
2026-08-28 21:03:04 -04:00
bvandeusenandClaude Opus 5 c5044339a1 versioning: each artifact derives from its own files, with the clock picked per value
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Canceled after 13s
CI & Build / integration (push) Canceled after 13s
CI & Build / Build & push image (push) Canceled after 0s
Android / Kotlin + Rust (APK) (push) Failing after 14s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Failing after 4s
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>
2026-08-28 20:51:39 -04:00
bvandeusenandClaude Opus 5 c268ae4f23 ci: main publishes, so a tag stops being required — and :latest stops shipping a dev client
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
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>
2026-08-28 18:22:31 -04:00
bvandeusenandClaude Opus 5 b7e0e5dbba ffi: two items: lines left at the indent of the field above them
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m33s
`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>
2026-08-28 14:23:10 -04:00
bvandeusenandClaude Opus 5 e14d9d340a core: the v9 test pinned v8, and a blank line ktlint counted
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m33s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 7m35s
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>
2026-08-28 14:15:33 -04:00
bvandeusenandClaude Opus 5 fa89da1fab notes: color leaves the model, the wire and all three surfaces
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m28s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m1s
Step 3 of M315, and the destructive half. Steps 1 and 2 stopped every read of
this field: a card is one neutral surface per theme, and the only coloured
thing on a board is a tag. What was left was a column written by a picker and
read by nothing.

Rule 22 — the old path comes out completely. No flag, no fallback, no
"override if set".

Server: the column, the `?color=` facet, the create/update/serialise paths,
the sync assignment, the front-matter line, and Keep's colour map. Alembic
0029 drops it and sweeps `"color"` out of stored saved-filter params — a view
that silently filtered on a field the app no longer has would return nothing
and never say why. That sweep is Python, not `params::jsonb - 'color'`,
because Postgres has no try-cast and one malformed blob would abort a
migration that is running over somebody's saved views.

`NOTE_COLORS` moves from `models/note.py` to `colors.py`. A palette defined on
the model that lost one is an invitation to put the column back; labels still
name a colour, so the vocabulary belongs where the normalizer already is.

Core: the field, the facet, the `NoteCreateInput`, and every read and write in
store/push/pull. Local schema v9 drops the column and does the same
saved-filter sweep, guarded on `json_valid` so a corrupt blob loses a key
rather than becoming NULL. The uniffi layer drops `NoteEdit::Color` and
`NoteDraft.color` with it.

Web: `ColorPicker.vue`, the per-card swatch popover and its stylesheet rule,
the FilterBar colour row, the facet in the query round-trip, and the colour
half of the editor's baseline-and-save. Android: the `ColorSheet`, the
`Picker.COLOR` case, the toolbar's swatch dot, `EditorAction.SetColor`.

## The protocol: v4, and the floor deliberately stays at 3

Checked against `compat.rs` and the push handler rather than trusting the
`#[serde(default)]` annotation, because the v2 precedent points the other way:
v2 dropped `kind` and `title` and DID raise both floors, on the rule that
dropping a field a client sends and expects back is breaking.

`color` fails the second half of that test. A v3 client reading a v4 note gets
`"default"` from its own serde default and draws the colour it derives
locally — the board it drew yesterday. A v3 client pushing `color` has the key
ignored, since `_assign_note_fields` reads its payload key by key and never
validates the shape. Neither direction errors and neither shows anything
wrong. `title` was the note's NAME; this is a field that no longer renders.

So `SYNC_PROTOCOL_VERSION` and `CLIENT_PROTOCOL_VERSION` go to 4, and both
floors stay at 3. `docs/sync.md` carries the reasoning and the per-version
history, and its push example is brought back in line — it still listed
`title`, `kind` and `items`, all gone before this.

Import stays tolerant: a pre-M315 export or a Keep takeout carrying `color:`
imports fine, the key simply read past. Old exports must still import.

#3041

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:07:03 -04:00
bvandeusenandClaude Opus 5 13a88179b8 tags: one ink, chip and inline, and the chip edge solved for 3:1
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m31s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m1s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m45s
Step 1 left one card surface per theme, so the tag ink is no longer
choosing a value that has to clear twenty backgrounds. Re-measured against
the one it actually lands on, the two tables collapse into one.

`-800` in light, `-300` in dark, for a `#tag` in the prose AND for a chip's
text. Dark needed no decision at all — the two tables already held the same
value for all ten hues, which is most of the argument on its own. Light
collapses onto the INLINE column deliberately: since M311 a tag whose text
is in the body is drawn where it was typed and not repeated as a chip, so
inline is the common case and this leaves what is seen most exactly as it
was. The chip is strictly better for the move:

                    inline, on the card    as a chip, on its own fill
  light `-800`      7.09 - 15.13           6.37 - 12.01  (was 4.52 - 8.23)
  dark  `-300`      9.45 - 14.23           8.23 - 11.88  (unchanged)

The chip edge goes 0.60 -> 0.65, and this is the first time that number
could be solved rather than judged. 0.60 was picked against a chip sitting
on a card of its own colour, a case that no longer exists; against a known
fill the smallest alpha clearing the 3:1 of WCAG 1.4.11 for all ten hues is
arithmetic. 0.60 gives 2.75-3.82 and misses for six of them, 0.65 gives
3.03-4.36 and misses for none. Dark runs 4.52-5.76.

That edge is doing more work than it looks: a chip's fill measures 1.02-1.26
against the card in light and 1.02-1.73 in dark, and dark red at 1.02 is
invisible. The ring is the pill; the fill only tints it.

`LABEL_CHIP_CLASSES` becomes `LABEL_CHIP_SHELL` — fill and edge, no ink —
and `labelChipClasses(label)` composes shell and ink in one place. The board
and the editor each had their own copy of that composition, with a comment
on one of them asking the other to stay in step. Now it is one call.

Fixed on the way past: the web drew `default`'s chip ring at `black/10`
(1.36 against its own fill) where Compose derived it from the ink (3.21) —
the same chip, visibly different pills. Both are the ink at 65% now.

`chipForeground` stays, narrowed to what it always actually was: the
REMINDER pill's ink, transcribed from NoteCard.vue's literal red-700 /
neutral-600. It is not a tag and must not move with one.

Also gone: `NOTE_NODE_FILL`, a per-hue table of solid hexes for graph nodes
with no consumer anywhere in the repo.

Step 2 of M315. #3149

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:13:43 -04:00
bvandeusenandClaude Opus 5 b91091caca cards: one neutral surface, and the generated fill deleted with it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 13s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
A card's fill stops being a function of the note. One neutral per theme on
all three surfaces — white in light, neutral-900 in dark, which is what the
palette's `default` always was and what both editors already used, so this
is a collapse onto a surface everything already had rather than a colour
anybody has to like.

Measured, against "not the same color as their background but close to it":
card vs board 1.04 light / 1.10 dark, edge vs card 1.98 / 1.73, body text
17.93 / 17.17, muted 10.37 / 14.23. The fill is deliberately the weakest
number on the card — the edge and the shadow separate it from the board, so
a fill that separated on its own would make it a panel.

Deleted, since the card was their only consumer: `derivedFill` / `hslHex`
and the level tables in colors.ts, `derivedFillArgb` / `hslToArgb` in
DerivedTint.kt, `NOTE_CARD_CLASSES_STRONG`, `chosenNoteColor`,
`noteCardClasses`, `noteTintVars`, the `.note-tint` rule in style.css,
`chosenBackground` / `tintable` / `noteTintFor` / `noteCardColor` /
`noteIsStrong` / `firstLabelColor` in NoteTint.kt, and
`resolvedNoteColor` / `noteColorIsChosen`.

`tintHash` and `derivedTint` STAY, against the plan: a label with no colour
of its own still derives one from its name, and that path was never the one
that failed. The mirrored pair and its fixture survive intact.

The editor follows the card, and its Done button takes the brand — the
board's compose FAB is the app's existing statement of "affirmative action
here", where Material's default secondaryContainer is a baseline colour this
theme never sets.

The colour picker is left in place, doing nothing, for exactly one step:
removing it here would leave `note.color` written by nothing and read by
nothing, which is a worse intermediate than a control that visibly does
nothing. #3041 takes the field and the picker together.

Step 1 of M315. #3148

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:55:51 -04:00
bvandeusenandClaude Opus 5 f50204a98b editor: detekt counts returns, so the promotion guards collapse into one
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
`promotingTasks` had four returns against ReturnCount's limit of two — three of
them the same `return this`. Collapsed into a null-or-task guard and a
`changed` flag, which says the contract more plainly anyway: the list comes
back untouched unless something was actually promoted.

Mirrored in blocks.ts even though nothing lints it there. The two files are
kept line-by-line alike on purpose, and letting them drift on shape is how the
next person stops trusting that reading one tells you the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:59:15 -04:00
bvandeusenandClaude Opus 5 1a49ae7ea9 editor: a - [ ] typed by hand becomes a real item when you leave the line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m15s
Android / Kotlin + Rust (APK) (push) Failing after 5m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m31s
Desktop (Tauri) / Update manifest (push) Successful in 4s
`splitBlocks` runs once, when the editor opens. After that the blocks ARE the
state and nothing reads the body again — every edit travels the other way,
through `joinBlocks`. So a marker typed by hand stayed literal text on screen
until the note was closed and reopened, even though it was already a real item
in storage and the card was already drawing a checkbox for it. The editor was
the only place that disagreed with itself. (#3024)

On BLUR, and only the block being left. There is no good moment to convert
while someone is typing: re-splitting on a keystroke moves the caret out of the
word being written, and converting the instant `- [ ]` is complete does it
before the item has any text. Blur is the one moment the person has
demonstrably finished with the block.

`promotingTasks` / `promoteTasks` return the SAME list when there was nothing
to promote, and both call sites compare by identity. Without that, every blur
would re-key every field below it — including the blur that fires on first
composition, before a field has ever held focus.

Both surfaces in one commit, deliberately: blocks.ts is a line-by-line mirror
of EditorBlock.kt, and the reason that mirror is worth keeping is that the two
editors behave identically. Fixing one would spend its whole value.

Non-canonical markers (`- [X]`, an odd bullet) come back canonical — the only
case where this changes the body rather than just how it is drawn, and exactly
what reopening the note already did.

No unit test: `splitBlocks` reaches the core over uniffi for the grammar, so it
needs the native library and cannot run in the JVM lane. No existing Android
test touches the core for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:50:43 -04:00
bvandeusenandClaude Opus 5 e7af7a4b77 board: the FAB and the undo snackbar rode behind the keyboard
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 8m57s
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>
2026-08-27 11:55:23 -04:00
bvandeusenandClaude Opus 5 396e91e609 board: ktlint on the long-press menu — a named modifier, three dead imports
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m0s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
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>
2026-08-27 07:49:57 -04:00
bvandeusenandClaude Opus 5 3f0eef145b board: a long press on a card does what the editor's overflow does
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m9s
Android / Kotlin + Rust (APK) (push) Failing after 4m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 5s
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>
2026-08-27 07:40:29 -04:00
bvandeusenandClaude Opus 5 4f351c10ca core: rustfmt wraps the chain in the tag-span grammar test
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m29s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
`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>
2026-08-26 22:23:46 -04:00
bvandeusenandClaude Opus 5 d9e5753dc2 board: a tag in the prose is coloured where it sits, not printed twice
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a
chip — and the duplicate was the loud copy. Now the chip row carries only what
the body cannot say (a tag lifted off its own line, a label from the picker),
and a `#tag` left mid-sentence is tinted in place.

Which characters are a tag is asked of the CORE, the way the card already asks
it which lines are checklist items: `extract_tag_spans` keeps the spans
`extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are
UTF-16 code units, because `AnnotatedString` and JS both index that way and a
char index lands mid-token the first time somebody writes an emoji. The web
keeps its own matcher in markdown.ts, mirroring `line_tags` case for case.

The inline ink is its own table, one Tailwind step deeper than the chip's. A
chip brings its own -100 fill and reads against that alone; inline text sits on
whatever the card is, including a gray-tagged card at neutral-200 — where the
chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5
body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84
dark across every palette and generated fill.

Chips now carry the `#` on every surface. The via_tag branch that used to
decide it is gone from the card, and Android's row said no hash at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:20:17 -04:00
bvandeusenandClaude Opus 5 8c22425e91 M311 step 3 — the core lifts too, so a note never lifts twice
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Android / Kotlin + Rust (APK) (push) Successful in 7m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 4s
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>
2026-08-26 20:01:32 -04:00
bvandeusenandClaude Opus 5 9810a75564 M311 step 2 — the migration that lifts the notes already written
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 19s
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>
2026-08-26 19:54:26 -04:00
bvandeusenandClaude Opus 5 606e345580 Fix the rename that renamed itself
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 28s
`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>
2026-08-26 19:48:50 -04:00
bvandeusenandClaude Opus 5 ad48d30c68 M311 step 1 — lift a tag that is standing on its own
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped
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>
2026-08-26 19:44:43 -04:00
bvandeusenandClaude Opus 5 23fd2da91e The tag goes at the top of the card, where it gets looked at
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m6s
Label chips sat under the body, the checklist, the attachments and the link
previews. On a tall note that puts the one thing saying what a note IS below
the fold of a glance — and a board is scanned, not read. "Which of these is
about the thing I am looking for" should be the first thing the eye lands on.

Above the body rather than beside it: the body's first line is the note's
NAME (M13 steps 3 and 4), and a chip floated next to it would compete with
the thing that identifies the note. A row of its own costs one line, and only
on notes that carry tags.

Both surfaces, same order. Does not depend on tag lifting, which is a much
larger change — see the task.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 3d490bb6f3 HSL lightness is not luminance — the dark floor was too low
The unit test I added with the generated fills failed on its first run, on
exactly the claim it was written to check, so it earned its keep immediately.

The floor was 0.090 — `neutral-900`'s own HSL lightness — reasoning that a
ramp starting at the card surface and climbing could not end up below it.
That confuses HSL lightness with luminance. At one fixed lightness the eye
sees very different brightnesses by hue, because green carries 71% of the
luminance formula and blue only 7%: at L=0.090 a yellow measures 0.0118 and a
blue 0.0061. Every blue-ish untagged note was 1.41x DARKER than the card it
was supposed to match, which on the board reads as a hole rather than as
variety — the opposite of what the whole change is for.

Solved rather than nudged: 0.113 is the lowest floor at which EVERY hue
clears the card surface. The range now measures 1.11-1.71 against the board
against the old 1.06-1.54, so the floor is back where the shipped ramp had it
and the ceiling is higher. Body text 7.8 against the 4.5 it needs, meta 4.6
against 3.0. 338 distinct dark fills.

Two things about the test are worth keeping.

It asserts on LUMINANCE rather than on the lightness that was put in — a test
of the input would have agreed with the bug and passed.

And it now sweeps 40,000 ids rather than 500. The worst case is a HUE, not an
id, and 500 ids reach only 459 of the 2160 hue/level combinations — it caught
this one by luck. 40,000 covers all 2160.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:17:32 -04:00
bvandeusenandClaude Opus 5 86f1e4a08f detekt: sector indices as a table, not a when
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 5m23s
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>
2026-08-26 17:08:20 -04:00
bvandeusenandClaude Opus 5 c255b170d4 ktlint: a stray blank line from the append
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Android / Kotlin + Rust (APK) (push) Failing after 4m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:01:40 -04:00
bvandeusenandClaude Opus 5 1d3cc7bcd4 Nine tints that looked like three — generate the fill instead
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Measured, the nine dark subdued fills were separated from each other by at
most a 1.03 contrast ratio. That is not "subtle", it is identical, and it is
why a board of them reads as one card repeated: "I only see 3 colors ... it
looks like a monolithic wall."

Two causes, and the second one is mine.

  NINE IS TOO FEW. The palette exists to say WHICH TAG. An untagged note's
  fill says nothing at all — it only has to keep the board from repeating.
  Those are different jobs and tying them together capped the second at nine
  values for a board that will hold hundreds.

  ONE AXIS IS TOO FEW. The subdued ramp varied hue while pinning every fill
  to the same lightness — deliberately, so each would read as a card against
  the board. But the eye separates by lightness first, so nine hues at one
  lightness are one card nine times. Hue alone was never going to carry it at
  that darkness.

So an untagged note's fill is now generated from its id rather than looked up:
hue anywhere on the circle, one of six lightness levels, saturation fixed.
324 distinct fills in dark and 193 in light, against nine. Separation between
fills goes from a 1.03 ceiling to 1.42.

Varying lightness is only SAFE because the card has its own grey edge now.
While the fill was the card's only boundary it could not afford to drift
toward the board; the edge bought that freedom, one commit before it was
needed.

Saturation is the one dial the hash never touches — variety comes from hue and
lightness, loudness would come from saturation. Dark starts a hair under
`neutral-900` and climbs, so no note is ever darker than a plain card. Light
runs from white down past the board. Body text measures 8.7 at worst against
the 4.5 it needs; the meta row 5.1 against 3.0.

DOUBLE, NOT FLOAT, on the Kotlin side. JavaScript has one number type and it
is binary64; a Kotlin Float is binary32, so the two would round differently
near a channel boundary and a note would be one byte off between the phone and
the browser. Nobody would ever file that — they would see two colours that are
"sort of the same" and never work out why.

The web half cannot be executed here at all (no node on this machine), so the
Kotlin fixture test is the only place the two implementations are ever
compared. It now pins eight generated values as well as the hash, plus the
properties that actually matter: that lightness varies, that nothing sinks
below the card surface, and that body text stays clear of AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:55:50 -04:00
bvandeusenandClaude Opus 5 ae2053d2ed Give the cards an edge again — one grey, not ten hues
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m20s
The border was never the problem; a border that carried COLOUR was. It said
exactly what the fill already said, at 1.56-2.09 against that fill where the
fill managed 1.03-1.05 against the board — the loudest element on every card
was redundant with the quietest. A line that varies by colour is content and
competes with the fill. A line that never varies is structure and does not.

So the edge comes back, and it comes back as a constant in NoteCard rather
than a column in the palette. Uniformity is the feature, and putting it where
the palette cannot reach it is how that stays true.

  light  #b8b8b8    1.57-1.98 against all twenty card fills
  dark   #404040    1.58-1.73

Matched, not eyeballed: both land at ~1.6-1.7 against the card they edge, so
the edge reads with the same authority in either theme. Dark is `neutral-700`
— what the `default` card's border always was, one entry's value promoted to
the rule for all of them. Light sits between `neutral-300` and `neutral-400`
because neither lands in range: 300 fades to 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.

Rejected on measurement: a translucent black/white edge, which is the tidier
way to write it and self-adjusts per card. A border composites over the
card's own fill, so `border-white/20` comes out #56396d on a purple card and
#a3c9c1 on a teal one. Hue-coded edges are the thing being removed.

The shadow steps back to what it was for — depth, not the boundary. Web
returns to `shadow-sm`; Android's 2dp drops to 1dp, matching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:00:22 -04:00
bvandeusenandClaude Opus 5 47f108c9c8 The border was the thing making every note look the same
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m4s
A note card carried a 1px tint border. Measured against its own fill, that
line was a 1.56-2.09 contrast in dark mode while the fill managed only
1.03-1.05 against the board — so the loudest thing on every card was an
identical line in an identical place, and a field of them read as a grid of
outlined rectangles however different the colours inside were.

Removed from the note card on both surfaces. `border` survives for panels,
banners, the update card and the pickers: those are single elements, not a
field of them.

What replaces it differs by theme, because elevation does.

  Light leans on a shadow. An untagged card is `bg-red-50` on a `neutral-50`
  board — a 1.04 contrast that can only read as a card by sitting above one.
  The web goes `shadow-sm` -> `shadow`; Android had no shadow at all and gets
  2dp.

  Dark cannot use one, black on near-black. So the subdued fills moved onto
  the card surface instead: `{hue}-950` composited at 0.18 over #171717 and
  baked, rather than the same hue at 0.25 over the near-black board. An
  untagged card now sits where the plain white card always sat (1.11-1.14
  against the board, against `bg-neutral-900`'s 1.10) while carrying LESS hue
  than before — chroma 7-17 where the old ramp had 10-23.

Subtler and more visible at once, which is only a contradiction if subtlety
has to come from lightness. Here it comes from chroma, and lightness is left
to say "this is a card". Which also reframes the two weights: in dark they
now sit within a hair of each other (red: 1.11 vs 1.12) and differ threefold
in colour (chroma 10 vs 41).

The chosen ramp is untouched — the operator signed those colours off, and a
ramp somebody likes is not something to redo while fixing something else.
Light was already built this way: `-50` and `-100` are both white plus a
different amount of hue.

Body text still measures 14.3-16.4 against the 4.5 it needs, meta 6.9-7.1
against 3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:21:42 -04:00
bvandeusenandClaude Opus 5 fe18aaa956 The contrast pass, and the invisible chip it found
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
Step 4 of milestone 309. All 40 combinations measured rather than eyeballed —
10 hues x 2 themes x 2 weights, dark ones composited over the board the way
Compose and CSS both do, against the text actually drawn on a card
(neutral-700/300 body, neutral-500/400 meta).

Body text ranges 8.23:1 to 13.01:1 against a 4.5:1 requirement; meta text 4.33
to 7.11 against 3.0. Every combination passes AA with room to spare, so the two
ramps step 3 introduced need no adjustment. That is the boring half.

THE PASS FOUND A REAL REGRESSION. A tagged note takes its first tag's colour and
is drawn at that hue's `-100` — which is exactly what the chip uses as its fill.
Measured contrast between the chip and the card it had itself coloured: 1.00 in
light mode. Perfectly invisible. Dark was 1.04-1.07, invisible in practice. On
every tagged note the tag name had stopped reading as a chip and become loose
text, and nothing about step 3 looked wrong while writing it.

Fixed with an EDGE rather than a different fill. A fill can collide with any card
colour and chasing that would need the chip to know what it is sitting on; a
border in the chip's own foreground reads against any background and needs no
plumbing.

Alpha is 0.60, measured: 2.32:1 at worst, where the 0.30 I first wrote gave 1.49
and was no edge at all. It does not reach WCAG 1.4.11's 3:1, which needs 0.80 and
draws a hard outline instead of a hairline. 1.4.11 governs boundaries carrying
REQUIRED information, and a chip's information is its text — passing AA at 8:1 or
better on every card here. The number and the reasoning are both in the source so
the judgment can be overruled rather than rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:37:51 -04:00
bvandeusenandClaude Opus 5 20e9d535de android: two more ktlint rules, both in the code I just added
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m54s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m57s
`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>
2026-08-26 13:25:28 -04:00
bvandeusenandClaude Opus 5 988e1d3f00 A note's colour is its first tag's colour, at a heavier weight
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Successful in 20s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m41s
Android / Kotlin + Rust (APK) (push) Failing after 3m53s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m7s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Four #todo notes on the operator's board in four different colours, because the
tint was derived per-note-id and ignored tags entirely. Now a tagged note wears
its first tag's colour, so notes that share a tag share a look.

TWO WEIGHTS, NOT ONE RAMP. The operator, seeing step 1: "the tints look the same
as the chosen colors". They did — there was only one ramp. `strong` is not a
second decision, it IS whether the colour was chosen: a tag (or, until step 5,
the picker) means somebody said what this note is, while a derived tint only
means the board should not be a wall of white.

The two weights move in OPPOSITE directions per theme, because that is where
each has headroom. The operator asked whether the tint could go lighter instead
of the tagged end going darker; in dark mode that is the better half of the
answer, so the derived end drops to a quarter opacity — closer to the board,
which gives the light body text MORE contrast rather than less. Light mode has
nowhere to go below `-50` without being white again, so there the gap opens by
deepening the chosen end to `-100`.

No hex was transcribed for any of it. `-100` is already in NoteTint.kt as every
hue's `lightChipBackground`, and the dark weights are the existing `-950` fill
re-alphaed, so the only two numbers that have to agree by hand are the alphas.
Copying ten more Tailwind values from memory is exactly how this mirror would
have drifted.

`default` is marked not tintable — it is the ABSENCE of a colour, there is no
emphatic version of it, and re-alphaing its opaque neutral fill would have made
every draft card translucent.

Borders untouched: the fill is the signal, moving both muddies the edge.
Resolution order is explicit pick, then first tag, then the id hash. First tag
because it is the one you control by typing; manual labels count the same as
#tags because nobody can tell which kind they made by looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:15:47 -04:00
bvandeusenandClaude Opus 5 6fbee27f9c A tag with no colour of its own derives one from its name
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 6s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Android / Kotlin + Rust (APK) (push) Successful in 7m43s
Every #tag ever typed is `default`. `notes/tags.py` mints one as
`Label(owner_id=…, name=name)` with no colour, so it takes the column default —
which means tag-driven note colour, built on top, would have left the board
exactly as grey as it was. Four #todo notes in the operator's screenshot, four
different colours, because the tint is per-note-id and ignores tags entirely.

DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the plan in 2965. That plan
wanted a hashed colour written wherever a label is born, and named the risk in
its own body: `find_or_create_label` is "easy to miss, and it is the common one",
because most tags are born from typing `#grocery`, not from a management screen.
Deriving has no mint points to miss, needs no backfill for the tags that already
exist, and reuses the hash and the fixture the notes already have.

The cost is that renaming a tag recolours it. That is defensible — the name IS
the tag — and an explicitly picked colour is still stored and still wins, so tag
colours stay editable exactly as asked.

Lowercased before hashing: tags dedupe case-insensitively, so #Todo and #todo are
one tag and must not be two colours.

All five places a label's colour is drawn now resolve the same way — the card
chip, the editor chip, the drawer's tag list, and the management modal's dot and
swatch ring. The modal's ring follows the resolved colour rather than the stored
one, so opening the picker highlights what you can already see instead of
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:57:59 -04:00
bvandeusenandClaude Opus 5 cddaf35280 android: ktlint forces a multiline signature at two parameters
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m13s
Desktop (Tauri) / Update manifest (push) Successful in 3s
`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>
2026-08-26 12:38:23 -04:00
bvandeusenandClaude Opus 5 6f173b166b Every note carries a tint, derived from its id
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android / Kotlin + Rust (APK) (push) Failing after 6m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m2s
Desktop (Tauri) / Update manifest (push) Successful in 3s
The board was a wall of white rectangles: `default` is the colour nobody picks,
so it was the colour of every note except the two the operator had coloured by
hand. Reported twice — 2026-08-23 as "a wall of broken up text", and again today
as "all the existing notes are the same dull color".

The ask was "random subdued colors", but random is the one thing it must not be.
A tint rolled at render time would differ between the phone and the browser and
change on every reload. FNV-1a over the note's id is deterministic, identical on
every surface, needs no column and no migration, and a note keeps its colour for
life — which is what "random" meant here.

Two implementations, deliberately mirrored, same discipline as the checklist
grammar. The Kotlin half lives in a Compose-free file so a host-JVM test can pin
the fixture; the TypeScript half carries the same four ids and hashes as a
comment because the frontend has no test runner at all — its whole CI lane is
`vue-tsc --noEmit`. That asymmetry is worth naming rather than papering over.

A draft has no id yet (DRAFT_ID is ""), so it stays white until it is saved.
Hashing the empty string would give every draft one shared tint and then change
it on save anyway — two surprises where one will do.

An explicitly-picked colour still wins. The picker is on its way out (milestone
309 step 5) but it has not gone yet, and a hand-coloured note changing under the
operator would read as data loss.

First of five steps toward colour coming from tags. This one stands alone: no
storage change, nothing removed, and the board stops being white today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:26:01 -04:00
bvandeusen f92a3d0a99 android: detekt's return limit, on two functions I wrote after it caught me once
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m12s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 9s
Android / Kotlin + Rust (APK) (push) Successful in 8m33s
`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.
2026-08-26 10:01:24 -04:00
bvandeusen 1e2b42af25 android: say nothing until the update is downloaded, and only fetch on wifi
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m29s
Android / Kotlin + Rust (APK) (push) Failing after 5m39s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m30s
Desktop (Tauri) / Update manifest (push) Successful in 4s
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.
2026-08-26 09:53:40 -04:00
bvandeusen a48b034a94 android: my insertion stole downloadTarget's doc comment
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m57s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m58s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m41s
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.
2026-08-26 08:43:42 -04:00
bvandeusen ee47a61270 android: find updates without being asked, fetch them, then nag
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m14s
Android / Kotlin + Rust (APK) (push) Failing after 4m29s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 5s
`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.
2026-08-26 08:34:28 -04:00
bvandeusen 68f851110f android: checklist rows were still 48dp of touch target
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
bvandeusen 96a6f6e691 web: the editor draws the checklist too
CI & Build / integration (push) Successful in 19s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 38s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m34s
Desktop (Tauri) / Update manifest (push) Successful in 3s
2992's other half. The browser was the last surface still showing `- [ ] ` as
markup: cards rendered and ticked checkboxes, the editor did not.

Same shape as Android, deliberately. notes/blocks.ts mirrors EditorBlock.kt —
splitBlocks, joinBlocks, afterEnter, withoutIndex, plusTask — because the two
editors should behave alike and the cheapest way to keep them that way is for the
code to read alike. `body` becomes a computed over the blocks, so every save,
baseline check and draft still reads the one markdown string they always did.

markdown.ts now exports parseTaskLine and renderTaskLine, and parseMarkdown uses
the former. The read view and the editor's block split had been matching the same
grammar through two separate copies of one regex; now they agree by construction.

Two places the web can do better than Compose, and does:

  * Backspace at the start of an empty item removes it. A browser sends a real
    keydown for Backspace; an Android soft keyboard sends an IME delete that never
    surfaces as one, which is why that surface only has Enter-on-empty.
  * Prose fields size to their text — rows="1" plus a scrollHeight fit, which beats
    guessing a row count that is wrong the moment a line wraps.

KNOWN, and the same on both surfaces: typing `- [ ] ` by hand into a prose block
leaves it prose until the note is reopened. Blocks are split when the editor loads,
not re-derived per keystroke — re-splitting mid-type would move the caret. The
toolbar button is the intended path. Converting on blur would fix it and is worth
doing to BOTH editors at once rather than letting them drift.
2026-08-26 07:53:13 -04:00
bvandeusen 44b3bcb2b2 Correct a claim about the operator's data, and the first-row delete
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m39s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s
Two things, one of which I got wrong in a place that outlives the session.

**The claim.** Migration 0027's docstring said "The Google Keep import is genuine
content on this instance, not fixtures." That is not true and I had no basis for it.
Note 2916's headline is the opposite — "there is no work that anyone has done that
isn't test data" — and its clause about imports is CONDITIONAL: text arriving from
another app would be real, and any import path has to treat it that way. I read a
rule about how import code must behave as a fact about what is in the database, then
repeated it in a migration that will be read long after anyone remembers this week.

The operator has never run the importer. They did not know it existed.

Nothing about the migration changes. Content-preserving was cheap and is right for
anything that rewrites somebody's text — and it is what the rule will demand the day
an import does happen. Only the reason recorded in the file was wrong, and a wrong
reason in a migration is how a later decision gets made on a false premise.

**The delete.** Removing the FIRST checklist row asked to focus `index - 1`, which is
-1, so nothing took focus and the keyboard stayed up over a list with no cursor in
it. It now focuses whichever row takes the deleted one's place, which also does the
right thing when the deleted row was the only one — `withoutIndex` leaves a fresh
empty block behind, and that block is what gets the caret.

Found by reading the path the operator said they were about to test, rather than by
waiting for them to find it.
2026-08-26 07:44:31 -04:00
bvandeusen a45a44ef11 android: split the block model from the block UI
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m41s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 9m16s
detekt's TooManyFunctions, at exactly the threshold. Worth taking as the signal it
is rather than suppressing: the file held the block MODEL — split a body, join it
back, add an item, drop one — and the COMPOSABLES that draw it, which are two jobs
that happen to share a data class.

EditorBlock.kt keeps the model and is pure: no Compose imports beyond the types it
stores, and testable on its own if it ever earns tests. BlockBody.kt keeps the four
composables.

afterEnter, withoutIndex and nextId become internal, since the UI half calls them
across the file boundary now. That is the one cost of the split and it is small —
same module, same package, and each says why in its doc.
2026-08-26 07:13:27 -04:00
bvandeusen 56264a9220 android: the checklist rows were carrying a form field's padding
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m50s
Android / Kotlin + Rust (APK) (push) Failing after 3m52s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Reported from the device with a screenshot: six items took up most of a phone
screen. The rows measured ~57dp apart, which is Material's TextField content
padding almost exactly — 16dp above the text, 16dp below, around a 24dp line.

That padding is right for a form field, where it is the difference between a
comfortable target and a fiddly one. On a checklist it IS the row height, so every
item was paying for a hit area the checkbox beside it already provides.

The editor's blocks drop to BasicTextField. Nothing about the "no box" treatment is
lost — PlainTextField exists to strip a container and an indicator, and
BasicTextField never had either, so there is nothing here to drift back into
existence. What it does not supply and BlockField now does: the text colour, which
defaults to Color.Unspecified and draws BLACK (the same default that made the
toolbar invisible in dark mode), the cursor brush for the same reason, and the
placeholder, which becomes a plain Text behind the field.

PlainTextField keeps serving the search box, the label picker and the sync-pairing
form — fields where Material's padding is what you want. Its TextFieldValue
overload went with the change: the editor was its only caller, and every remaining
one passes a String.

Rows are now bound by the 48dp checkbox rather than by the field. If that is still
looser than it should be, the next lever is the touch targets themselves, which
trades against how easy the box is to hit — worth looking at on a device before
spending it.
2026-08-26 07:03:15 -04:00
bvandeusen a88f7c2dd0 core: drop the two helpers the block editor made unnecessary
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m30s
`toggle_at` existed to map a tap on `[ ]` inside a plain text field to an item, and
`continuation` to make Return start the next one. The block editor needs neither: a
checkbox is a real Checkbox, so it is tapped rather than located, and Return is the
field's own IME action rather than a shape recognised in a string.

Removed rather than kept for later (rule 22). Both were exported over the FFI with
no Kotlin caller, which is API surface promising something nothing does — and their
tests were weight on code nothing runs.

The section comment above them described the tap-in-a-text-field problem, which is
no longer the problem this pair solves. Rewritten to say what is actually there:
one function to read a body apart, one to put a line back together, and between them
Kotlin renders checkboxes without owning the grammar.
2026-08-24 10:42:26 -04:00
bvandeusen 32ec29fc4a android: the comment pointed at the file's old name
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m48s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
2026-08-24 10:32:55 -04:00
bvandeusen ae17b8a8e7 android: name the file after the type in it
Android / Kotlin + Rust (APK) (push) Canceled after 7s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 7s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 7s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
detekt's MatchingDeclarationName: a file whose only top-level type is EditorBlock
has to be EditorBlock.kt. The plural read better as 'the blocks and the machinery
around them', but the rule is about the type, and the convention here already works
that way — NoteCard.kt holds NoteCard plus its helpers.
2026-08-24 10:32:45 -04:00
bvandeusen eeca4d48c2 android: a trailing blank line where the dead helpers used to be
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m49s
Android / Kotlin + Rust (APK) (push) Failing after 4m20s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m49s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Removing MIN_BODY_LINES took its declaration but left the newline in front of it,
so the file ended with a blank line. My pre-push sweep only looked for consecutive
blanks INSIDE a file and could not see one at the end — checked across the whole
Kotlin tree this time, not just the files I touched.
2026-08-24 10:23:59 -04:00
bvandeusen b2435d97b6 android: the editor draws the checklist instead of the markup for one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m30s
Android / Kotlin + Rust (APK) (push) Failing after 4m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m53s
Desktop (Tauri) / Update manifest (push) Successful in 4s
2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.

The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.

A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.

Two things that look like detail and are not:

  * A block carries its own TextFieldValue, and an ID that survives insertion.
    Compose keys fields by position unless told otherwise, so adding an item would
    otherwise move every caret below it up a row. Content cannot be that key —
    two empty items are identical and neither is the other.
  * Focus is hoisted to the screen rather than kept inside BlockBody, because the
    toolbar's checklist button also asks for one. Two owners of one cursor is one
    too many.

Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
appends rather than splitting at the caret: splitting an item in two is a rarity,
and the caret is at the end for every ordinary use of that key.

The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.

WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
2026-08-24 10:14:53 -04:00
bvandeusen 9a3c4ec377 android: ticking a box on a card threw the editor open on top of it
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m18s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m31s
Reported from the device: tapping a checkbox on the board checks it AND opens the
note. The "and" is the tell — both things happened, so this was never a tap landing
on the wrong target.

`mutate` ends with `editing = updated ?: state.editing`. That is right for an editor
action, where the reloaded note refreshes a screen already on display. But
`editing != null` IS "the editor is up" — it is what MainActivity's `when` selects
on — so calling `mutate` from the BOARD, where editing is null, wrote the mutated
note into it and opened the editor as a side effect of saving.

Fixed at `mutate` rather than at the caller, because the caller was not wrong: any
board-initiated mutation would have done this, and toggleItem is simply the first
one to exist. It now refreshes an open editor and cannot open a closed one.

The comment claimed the narrower behaviour all along — "so an open editor shows its
own change" — which is what the code should have been doing and wasn't.
2026-08-24 09:59:00 -04:00
bvandeusen 315c5f19e6 android: detekt caught a callback that never reached the cards
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m46s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m39s
Not a style finding. `onToggleItem` was added to BoardScreen's signature and read
inside NoteBoard — which is a separate top-level composable, not a nested one, so
the two were never connected. detekt reported it as an unused parameter; the
compiler would have called it an unresolved reference. Neither had run: Kotlin is
compiled at the "Unit tests" step, which is gated behind detekt, so nothing in this
lane had type-checked the Android changes yet.

Threaded properly now, which is what makes ticking a box from the board actually
work rather than merely appear to.

Two more from reading it again with that in mind:

  * `when { item != null -> … onToggleItem(index, …) }` would not have compiled.
    Kotlin does not infer that a non-null item implies a non-null index, so the
    index stayed `Int?` against an `Int` parameter. Both are in the condition now.
  * continueChecklist had six returns against detekt's limit of two. Collapsed to
    one `when`, with the two intermediate values guarded on `typedNewline` —
    `caret - 1` is only a real index once it is known to be the newline just typed.
2026-08-24 08:35:52 -04:00
bvandeusen 1a66d9c3a8 tests: pin the export against writing every checklist twice
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 15s
M304 step 7. The code change landed with the server half — _note_markdown's
`if items:` branch went, and the export payload stopped carrying an items array —
but neither had a test, and the failure mode is quiet: every list appears twice in
an export, then twice again when that export is imported back.

Three cases, and the third is the one worth having. An export taken BEFORE this
milestone has a body with no task lines and a separate items array, so importing
one still has to fold the checklist in. That is the same fold the Keep importer
does, and the reason _insert_note still accepts items at all — asymmetric on
purpose: the export stopped writing them, the import did not stop reading them.
2026-08-24 08:26:50 -04:00
bvandeusen 77b1a87712 android: ktlint on the import order and a leftover blank line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m56s
Android / Kotlin + Rust (APK) (push) Failing after 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Inserting the checklistLines import after Note split Note from NoteLabel, and
removing the checklistOpen state left two blank lines behind it. Both are the
formatter only — the bindings built and the Rust lanes were already green.
2026-08-24 08:26:01 -04:00
bvandeusen 68b2a5dc8d android: a checklist is lines of the note here too
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m22s
Android / Kotlin + Rust (APK) (push) Failing after 4m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m3s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 6, and the surface with the least room to hide: Android has no markdown
renderer at all, so the card was about to show every list twice — once as literal
`- [ ] milk` in the body preview, and again as the glyph rows underneath. Same bug
the web had, one commit later.

The card now renders the body LINE BY LINE and draws a checkbox where one belongs,
which is what puts a list between two paragraphs instead of always after them. The
glyphs became tappable while they were being rewritten: ticking something off from
the board without opening the note is the common gesture, and the web just gained
it. The tap target is the glyph, not the row — tapping the TEXT still opens the
note, the way tapping anywhere else on a card does.

Kotlin gets no parser. Three implementations of the grammar is the price already
paid; a fourth in Compose would be a fourth place for a checklist to change shape
when it syncs. So the core exposes three pure functions instead —
`checklist_lines`, `checklist_continuation`, `checklist_toggle_at` — and Kotlin
does the caret arithmetic around them.

Those are FREE functions, not methods, and that is the interesting constraint. The
editor's body field is LOCAL state on an idle-debounced autosave, so anything that
edits a checklist there has to rewrite the text the field is holding, not a row the
store would hand back a moment later. Going through the store would overwrite
whatever was being typed. The BOARD has no such problem — nothing there is holding
a half-typed body — so the card's toggle goes through the store as usual.

`toggle_at` addresses an item by LINE and COLUMN rather than a text offset, because
the two sides do not count the same way: Compose measures in UTF-16 units and Rust
in bytes, so the same number means different places in a note with an emoji in it. A
line number is identical in every encoding, and so is a column inside the marker,
which is ASCII at the start of its line.

In the editor: the toolbar button inserts `- [ ] ` at the caret — the only toolbar
action needing no saved note, so it works on an empty compose box the moment it
opens — and Enter continues the list, or ends it on an empty item. Continuation is
recognised by SHAPE inside onValueChange (exactly one more character, and it is a
newline) rather than by a key event, so a paste or an autocorrect falls through
untouched.

EditorChecklist.kt and the four item actions are gone (rule 22). Adding, renaming,
ticking or deleting an item is editing text now, and the editor already does that —
through SaveText, with the same autosave and the same revision window as any other
edit.

KNOWN GAP, not an oversight: tapping a checkbox inside the EDITOR does nothing yet.
Material3's TextField does not expose onTextLayout, so mapping a tap to a character
offset means either moving the body to BasicTextField or intercepting pointer events
ahead of the field — both real changes to the surface this operator uses most, and
neither verifiable without a device. `checklist_toggle_at` lands here, tested, so
that task is pure UI. Ticking from the board works today.
2026-08-24 08:16:57 -04:00
bvandeusen 3cab054684 web: task lines render as checkboxes where they sit in the note
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / Build & push image (push) Successful in 41s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m55s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M304 step 5. The server already returns a derived `items` array, so the web kept
working across the last two commits — but it was rendering every list TWICE: once
as literal `- [ ] milk` bullets inside the body, and again as the separate
NoteChecklist block underneath. This is the commit that makes the body the only
place a checklist appears.

markdown.ts gains a `task` block, matched BEFORE the plain bullet — which would
otherwise swallow the marker and leave the brackets showing, the same ordering
reason `code` is matched before emphasis in INLINE_RE. Each item carries its
ordinal across the WHOLE document, because that is what an item's id means
everywhere else now; counting per block would have made the second list's
checkboxes toggle the first list's items.

The card's preview clamp is why that ordinal is safe there: it only ever drops
lines from the end, so a visible item's index is the same whether or not the body
was truncated.

MarkdownText emits a toggle rather than reaching for the store. Ticking a box
rewrites a line of someone's note, and a renderer used in several places should not
be the thing deciding that is allowed — the card passes `toggleable` and wires it,
a read-only render does not and the boxes are inert. Not wrapped in a <label>
either: on a card the text is the note's own words and clicking it opens the note,
so only the box toggles.

In the editor, the toolbar button stops revealing a section and inserts `- [ ] ` at
the caret. That makes it the one toolbar action needing no persisted note to hang
anything off — ensureDraft is gone from it, and it works on an empty compose box
the moment it opens. Enter on a task line continues the list, and on an EMPTY one
clears the marker; without that second half a list would be impossible to get out
of. Indent and bullet are carried over rather than normalised, because continuing
someone's `*` list with a `-` is an edit they did not ask for.

NoteChecklist.vue is deleted (rule 22). The store's item methods stay: they are the
repository seam the REST routes and Tauri commands both implement, not the old path.

CI cannot check any of this beyond types — there are no frontend tests, only
vue-tsc. It wants a real browser pass.
2026-08-24 08:10:20 -04:00
bvandeusen fe1f72ae1b tests: the display-title tests still passed the argument that went away
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 18s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build & push image (push) Successful in 30s
Three of them called derive_display_title(body, first_item). I updated the call
sites in src/ and not these — the integration lane and the linter both passed,
because a stale keyword argument is only a TypeError at the moment it runs.

Rewritten rather than deleted. The property the fallback existed to protect is
still real — a note that is only a checklist has to have a name — it is just
reached differently now: an item IS a body line, so the first one is simply the
first line with its marker stripped. The new cases pin the two edges that rule
introduces: an empty item must not name a note "", and a list of nothing but empty
items still has no name.
2026-08-24 08:06:11 -04:00
bvandeusen 761c3b5e82 server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these
belong in one deploy, and the protocol floor below is what enforces that.

notes/checklist.py is the Python half of a grammar that now exists three times —
here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That
triplication is the deliberate cost: the alternative is a round trip to the server
before a phone can draw a checkbox. Each copy names the other two, and each is
tested against the same table of cases, including the near-misses that must stay
prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence.

Routes: add/update/delete items stop touching rows and rewrite note.body, all
through one _rewrite_body that runs the same sequence the PATCH route runs for a
body change — because it IS a body change. Revisions, #tag reconciliation, the
name, and link unfurls therefore happen in one place rather than three routes each
remembering to.

The reorder route is gone (rule 22). Reordering a checklist is moving a line, and
no client ever called it — the only reference in the tree was a test asserting the
route existed.

The API still returns `items`, DERIVED from the body on the way out. That is not a
second source of truth and it cannot disagree with the body it came from; it keeps
the web client working across the rest of this milestone and saves any consumer
that only wants to draw checkboxes from carrying a parser.

Export drops its separate items block, in both formats. The body already ends with
those exact lines, so writing them again would double every checklist in an export
and then double it again on re-import. Import still ACCEPTS items, because a Keep
takeout has a list and not a blob; it folds them in before the Note is built, so
display_title and _reconcile_tags both see the finished text.

Protocol 3 on both sides now. A v2 client is refused rather than half-served —
which matters more than I first said: _apply_note_items returned early on an absent
`items` key, so an un-bumped v3 client against a v2 server would not have LOST the
rows, it would have kept them and then had the migration fold them a second time.
Duplicated lists rather than missing ones. The floor prevents both.

Migration 0027 folds every existing row into its note's body and drops the table.
It inlines its own copy of the fold on purpose — a migration has to keep producing
what it produced the day it ran — and a test pins that copy against the app's until
they are allowed to diverge. updated_at is deliberately untouched: a client holding
an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work
instead of the migration silently winning.

The downgrade is honest rather than faithful. It recreates an empty note_items and
leaves the bodies alone, because once items are lines nothing distinguishes one this
migration wrote from one somebody typed, and a downgrade that guessed would eat
hand-written lists. Recreating the table is still necessary: 0015's downgrade drops
a trigger ON note_items, and IF EXISTS covers the trigger, not the table.
2026-08-24 08:03:37 -04:00
bvandeusen 32dafca148 core: what rustfmt actually wanted
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Three from the checker's own diff. Two are the rule I had guessed at: when a
call overflows and its last argument is a closure, rustfmt keeps the earlier
arguments on the line and expands the closure into a block, rather than putting
every argument on its own line.

The third is a stray double blank line before the test module.
2026-08-24 00:49:05 -04:00
bvandeusen 668f7faf03 core: the body is the checklist, and checklist_items is gone
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m30s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
M304 steps 2 and the client half of 4, together — they cannot be separated. A
commit where the store writes items into the body while push.rs still reads them
from a table is one that silently pushes the wrong list, and dev publishes to the
dev channel on every green build.

Store:
  * load_items becomes items_of(body) — a parse, not a query. An item's id is its
    ORDINAL, which is all it ever amounted to: push.rs sent text and checked and
    never an id, and both sides replaced the whole list on every sync.
  * add_item / update_item / delete_item route through update_note, so they get
    revision snapshotting, #tag re-derivation and the dirty/updated_at bookkeeping
    without any of it being written a second time.
  * create_note folds its items: input into the body, and syncs tags from the
    FOLDED body — an item can carry a #tag too.
  * display_title no longer takes items, because items ARE body lines now. It
    strips the task marker instead: a list-only note is still named by its first
    item, and calling that note "- [ ] milk" would show someone the storage.

Wire: items leave it. A second copy of data already in the body field of the same
message is how the two come to disagree. CLIENT_PROTOCOL_VERSION and
MIN_SERVER_PROTOCOL_VERSION go to 3, which is what makes this safe to land before
the server: a v3 client refuses a v2 server outright rather than pushing a body
whose list the old _apply_note_items would then delete.

Schema v8 folds every existing row into its note's body before dropping the
table. Written in Rust, not SQL: the fold has to produce exactly what
derive::append_item produces, and group_concat only gained a guaranteed ORDER BY
in SQLite 3.44 — a checklist that quietly reordered itself during a migration
would be a poor way to learn that. updated_at and dirty are deliberately left
alone, because the server's migration folds the same rows the same way and both
sides land on identical bodies; marking every note dirty would push a body the
server already has, from every device at once.

NOT deployable yet. The server still speaks v2 and still has note_items, so a
client built from this will refuse to sync until the server half lands.
2026-08-24 00:41:23 -04:00
bvandeusen d0e3e48943 core: rustfmt splits on fn_call_width, not max_width
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m52s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Three assertions I had collapsed to one line because they fit inside
max_width=100. rustfmt's fn_call_width is 60 and applies to the ARGUMENT list,
so a call can sit well under the line limit and still be split vertically.
Clippy and the tests were already green; this is the formatter only.
2026-08-24 00:28:08 -04:00
bvandeusen 1045db318b core: derive checklist items from the body, the way tags already are
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m17s
First step of M304. Additive on its own — nothing calls this yet — so it can be
read and tested before anything depends on it.

A checklist is currently a TABLE, and a table can only ever render after the
body, because a row has no idea where in the note it belongs. That is why
"inline with the note" is not a styling problem: prose, three checkboxes, then
more prose is not expressible at all today.

derive.rs already owns "structure derived from body text" for #tags and says so
in its module doc. Task lines join it rather than opening a second home for the
same idea. The difference between the two is worth stating and now is: tags
MATERIALISE into label rows because the board queries by label; items
materialise into nothing, because nothing queries them. Their only readers are
the card, the editor, and display_title.

The grammar is fixed here because three languages will implement it —
derive.rs, notes/checklist.py, notes/markdown.ts — and any difference between
two of them is a checklist that changes shape when it syncs. `*` is accepted
since markdown.ts already takes it for a plain bullet, and a rule that allowed
`* item` but not `* [ ] item` would be one nobody could guess. `- [ ]` with
nothing after it parses as an empty item: that is what pressing Enter on a list
leaves behind, and refusing it would make a half-typed list stop being a list.
`- [X]` parses and normalises to lowercase on the first rewrite, so round trips
are stable.

append_item spaces its output exactly as import_export.py:_note_markdown does.
That is not cosmetic — the server migration will fold existing rows into bodies
with the same layout, so an export taken before it and one taken after have to
agree byte for byte.

A stale index is inert rather than fatal: the index comes from a UI that may be
a moment behind the store, and a late tap should do nothing rather than panic.
2026-08-24 00:20:32 -04:00
bvandeusen 65af37d159 android: a checkmark to leave, and asking for a checklist stops writing a blank one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m16s
Two reports from the same device pass.

**The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the
argument that a tick in a NOTES app reads as a checklist item to anyone who has
used one. Overruled by the operator, and the filled treatment is what settles the
objection anyway: a tonal button in the note's own colour is plainly a control,
where a bare glyph beside a checklist would not be. It carries "Done" as its
content description, so the argument survives where it actually mattered — read
aloud.

**Starting a checklist wrote an empty item**, purely so the section would have
something to render. That left a blank row with the always-present add-row beneath
it — two empty fields, and the caret in the lower one. Whether a checklist is
SHOWING is view state, not a row in the store: the toolbar reveals the section and
focuses the add row, and nothing reaches SQLite until an item has words in it.

EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the
first real item the action that can create a body-less note — a note named from its
first item, which the core already does.
2026-08-23 23:50:30 -04:00
bvandeusen 8257e1035c android: put the way out of a note back within reach
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Android / Kotlin + Rust (APK) (push) Canceled after 4m27s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m27s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Moving the toolbar to the top took the back arrow with it, and left the only
exit from a full-screen editor in the top-left corner — the furthest point on
the display from a right-handed thumb, reached over the whole note to get to.
Reported on the first device pass, and correctly.

So the footer carries a Done as well as the timestamp. With the keyboard up it
sits directly above the thumb, which is where a hand already is for every other
part of writing a note.

The top-left arrow stays. Two affordances for one action is usually clutter,
but this is the case that earns it: the arrow is what habit, the system back
gesture and TalkBack all expect of a full-screen surface, and removing it would
strand the reflex to strike a duplicate costing one icon slot.

A word rather than a checkmark, on the same argument the overflow menu makes: a
tick in a notes app is a checklist item to anyone who has used one, and "Done"
cannot be misread, including aloud.

EditorSavedLine is now EditorFooter, since it is no longer only a line.
2026-08-23 23:46:03 -04:00
bvandeusen bca9e16bd0 android: ktlint wants that body expression on one line
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m17s
2026-08-23 22:30:56 -04:00
bvandeusen 9ea2a2f9b6 android: the toolbar moves to the top, and the note says when it saved
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Android / Kotlin + Rust (APK) (push) Failing after 3m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m41s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
The capture sheet's drag handle cost a strip of screen and did nothing a back
gesture does not already do. The toolbar takes that strip instead, which is
where it belonged once one surface served both writing and editing: the
keyboard owns the bottom of the display for most of a note's life, so a bar
down there spends its time riding on the IME.

The bottom is now the answer to "did that land". There is no save button —
writes are continuous, so a button offering to do what already happened would
be a lie with a tap attached — but that left nothing on screen saying the work
was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in
the corner, and someone who watches it once never has to be told that closing
a note keeps it. DateUtils formats the relative part, so plurals and
"yesterday" are not this app's problem to solve twice.

Shape: the screen keeps the sheet's rounded top and its gap below the status
bar, so opening a note still reads as something rising over the board. Full
height rather than a real ModalBottomSheet — a sheet spends a writing session
negotiating with the IME for the bottom half of the display, and the
swipe-down it buys is a gesture back already does.

Both content colours on the card are spelled out. Surface and Scaffold each
default theirs to contentColorFor(their container), which returns Unspecified
for anything that is not a colour-scheme role; a note tint never is. That is
the same default that made the last toolbar invisible in dark mode, latent in
two more places.

Also: the running LinearProgressIndicator is gone, since the corner line now
says the same thing without moving the text; and the SaveText comment in
BoardViewModel still claimed saves happened on close.
2026-08-23 22:26:12 -04:00
bvandeusenandClaude Opus 5 ce6a1093a3 android: writing a note and editing one are the same surface
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m40s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m44s
The + button raised a capture sheet with a single text field. The editor is
a screen with a toolbar. So a note being WRITTEN could not be given a
colour, a reminder or a checklist — those live on the toolbar, and the sheet
had none. To make a checklist you wrote a note, saved it, reopened it, and
found a control you had never seen.

ComposeSheet is deleted. + opens the editor on an unsaved draft.

A draft is a real Note carrying DRAFT_ID (the empty string) rather than a
null. Note has eighteen fields and the editor reads eight of them; threading
nullability through all of that to express "not saved yet" would spread the
concept across a screen that should not have to know about it. A real id is
a uuid, so the sentinel cannot collide.

It becomes a row on its first save, and the first save is now an autosave:
the editor writes a second after typing stops. That is affordable because
2707054 made a body write stop costing a revision — before it, saving this
often would have meant a revision per second.

Autosave is also what makes materialisation work at all. Creating the note
on a toolbar tap instead races: the typed text lives in the field's own
state and only reaches the view model on flush, so the tap would create an
EMPTY note and lose what was written. With a one-second debounce the note
already exists by the time any button is reachable.

Three consequences worth naming:

- editingSession, bumped only when the editor opens on a DIFFERENT note.
  The text field keys on it instead of note.id, because a draft's id changes
  the moment it is first saved and re-keying on that would reset the field
  to whatever the store just returned — discarding everything typed during
  the write.
- The field is rememberSaveable now. A new note has nothing to fall back on,
  and the old sheet used rememberSaveable for exactly this reason; the
  editor inherits the requirement along with the job.
- draftDismissed, so a create still in flight cannot reopen an editor the
  user has already closed.

Starting a checklist may create an empty note — a note named from its first
item is one this app already has. Colour and reminder are attributes OF a
note and need words first.

editor_body_hint becomes "Take a note…". It read "Note", which is a label on
a blank screen where the sheet's was an invitation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:14:02 -04:00
bvandeusenandClaude Opus 5 2707054563 A write should not cost a revision
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 19s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m21s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m42s
Every body change snapshotted into history — core/src/local/store.rs and
notes/__init__.py both — so a write was expensive, and the clients
compensated by writing as rarely as they could. BoardViewModel says it
outright: "Saved on close rather than per keystroke, so a session of typing
costs one write and one revision snapshot."

That is durability paying for version history. An app kill mid-session lost
everything typed, so that the revision list would stay tidy. The safety
property is worth more than the feature it was subsidising, and no
comparable product makes this trade: Keep and Apple Notes write
continuously with no history, Docs and Notion write continuously and
coalesce history behind the scenes, Obsidian debounces and snapshots on an
interval. Save-on-close is the outlier, and this coupling is why we had it.

A body change now earns a snapshot only if it is the first of an editing
session — the body actually differs, and the note carries no revision from
the last ten minutes.

Session granularity falls out of the window rather than being declared. A
snapshot stores the body as it was BEFORE the edit, so the first write of a
sitting captures the note as you found it and every write after it inside
the window adds nothing. One revision per sitting, with no commit flag for
a client to send and no wire surface to carry it.

That is why it is a time rule and not a protocol one. sync.py applies pushed
bodies through the same check, so a client autosaving every second cannot
make the server snapshot every second either — which a client-declared
commit point could not have guaranteed without a protocol bump.

Restoring a revision still snapshots unconditionally: a considered act, not
a keystroke, and it stays undoable.

Unblocks idle-debounced autosave, an honest updated_at, and the "Edited just
now" line the editor is getting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:47:01 -04:00
bvandeusenandClaude Opus 5 24685556b7 android: open an existing note ready to keep writing
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m51s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s
Opening a note put no cursor anywhere, so carrying on cost a tap into the
body and usually a second one to drag the caret past the existing text.
The compose sheet has always focused its field on open; the editor never
did, and continuing a note is the more common act of the two.

Focus the body on open, caret at the end. Not for a trashed note — that
renders read-only and a keyboard over a record you cannot edit is noise.
Keyed on note.id so the reused editor re-requests when pointed at a
different note.

The caret position is why the body state moves from String to
TextFieldValue: a String field always starts its selection at offset zero,
so focusing one lands the cursor before the first character — the wrong
end of a note you meant to continue. PlainTextField gains a TextFieldValue
overload for it, and the two overloads share one colours definition rather
than growing a second copy of the "no box" treatment this file exists to
keep in one place.

I recorded this backwards in Scribe 2947 — as the keyboard opening
unwanted, when the report was the opposite. The source having no
FocusRequester was the tell, and I read it as a mystery instead of as
evidence I had the direction wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:00:57 -04:00
bvandeusenandClaude Opus 5 50e2d308ea android: the editor toolbar was black icons on a near-black bar
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 7m54s
Reported as "I'm unable to see a toolbar in the editor on android, is
there one?" — and it was rendering the whole time.

EditorBottomBar passed containerColor but no contentColor, so Material3
defaulted it to contentColorFor(containerColor). That maps a colour-SCHEME
ROLE to its `on-` pair and returns Color.Unspecified for anything else. A
note tint is never a role: the default note is 0xFF171717 while the dark
scheme's surface is 0xFF0A0A0A. So contentColor resolved to Unspecified,
Surface published it as LocalContentColor, Icon took it as its tint, and an
unspecified tint applies no colour filter — leaving the icons-core vectors
their intrinsic black, on a near-black bar.

Every note colour, both themes, only visible in dark. The top bar escaped
it because topAppBarColors(containerColor = …) overrides the container and
leaves the icon colours at their scheme defaults.

Also inset the bar for the keyboard. enableEdgeToEdge makes the manifest's
adjustResize a no-op and Scaffold does not inset its bottomBar slot, so the
bar would sit under the IME the moment anyone typed — a second way to not
see it. imePadding moves to the bar; the content Column drops its own, since
Scaffold now measures the bar at its lifted height and the inset reaches the
content through innerPadding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:40:28 -04:00
bvandeusenandClaude Opus 5 77c5422951 ci: a failing lane must not publish an image
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.

The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at 6f21db8, which added the integration
lane. The reason went away; the exception didn't.

Gate on test and integration too. A :<sha> image is the rollback unit for
its commit (family rule 46) — one publishable from a failing run is not
something you can roll back to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:52:59 -04:00
bvandeusenandClaude Opus 5 c851b901df The proxy-hops test still read the value from Config
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 15s
09b5f87 moved trusted_proxy_hops out of the environment and into the
settings registry, but tests/test_proxy.py kept asserting against
Config.trusted_proxy_hops() — which no longer exists. The unit lane has
been red since that commit.

Assert through live() instead. That is what proxy.py actually calls, and
it is seeded from the defaults at import time, so the test covers the
case that matters: a boot that has not reached the database yet still
counts one hop rather than zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 16:31:21 -04:00
bvandeusen abe01da5f7 compose: say which of the three deployment shapes you're in
The operator asked why `THOUGHTSYNC_BIND` isn't just defaulted to the safe value.
Fair question, and the answer exposed that my own advice was incomplete: I told
them to set it to 127.0.0.1 without asking where their proxy runs, and for a
proxy inside Docker that is the wrong fix.

There are three shapes, not two:

1. **LAN, no proxy** — the default. Binds every interface so a phone and a desktop
   can reach the server. This is why the default is NOT the locked-down value: a
   server reachable only from the machine it runs on isn't hardened, it's broken,
   and that is the primary documented use of this app.
2. **Proxy in Docker** — delete the `ports:` block entirely. The proxy reaches the
   app over the compose network; publishing a host port is a second,
   unauthenticated way in that bypasses whatever the proxy does about TLS. Safer
   than 127.0.0.1, because there is no host port to reach even from the host.
3. **Proxy on the host** — `THOUGHTSYNC_BIND=127.0.0.1`.

The compose file now spells out all three where the decision is made, and
`docs/public-hosting.md` item 4 asks where your proxy runs before telling you what
to do, plus how to check: `curl http://<lan-ip>:5000/api/health` from another
machine should NOT answer once you're proxied.

No default changed. Changing it would silently break every LAN install on the next
`docker compose pull` — the phone would just stop syncing, with nothing saying why.
2026-08-23 15:30:01 -04:00
bvandeusen 09b5f874b6 Security values move into the Settings UI
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 9s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Failing after 12s
CI & Build / Build & push image (push) Successful in 32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator: *"proxy hops defaults to 1 and should be in the settings UI not in the
envs, we need the security values to be in the UI."* Overrules the call I made
yesterday, and rule 25 is on your side — I argued deployment-topology, but the
operator has to be able to SEE what protects them, and reading a container's
environment is not seeing.

Six new settings in a **Security** group: trusted proxy hops (default 1), the
per-account and per-address sign-in limits with their shared window, and the
sign-up limit with its own. `THOUGHTSYNC_TRUSTED_PROXY_HOPS` is gone; the rate
limits are no longer hardcoded constants.

**The hard part was keeping the throttle cheap.** It consults these BEFORE opening
a database connection — deliberately, because a refused attempt is meant to cost
nothing, and the hop count is needed to know who is even asking. A query per
attempt would undo both. So there is a small cache seeded from the registry
defaults (the app works with no database at all, which is what the DB-free unit
lane relies on), loaded at boot, and refreshed on every settings save — the same
live-update contract `session_ttl_days` already had.

`SlidingWindow` now takes its limit and window as SUPPLIERS rather than values, so
a saved number applies to the next attempt instead of the next deploy.

**Bounds are rejected, not clamped.** A hop count of 99 would trust anything a
caller sent; a sign-in limit of 0 would lock every account out permanently. Both
now fail validation with a message naming the range, and the number input carries
min/max so the browser objects first. Silently storing a different number than the
one typed is how somebody ends up believing a protection is set to something it is
not.

`MAX_BUCKETS` stays a constant on purpose: it protects the limiter from itself
rather than the app from a caller, and there is no operator judgment to apply.

Two integration tests, because the whole point is the round trip: a dangerous
value refused, a legitimate one reaching the cache the throttle reads and
persisting; and every Security row reaching the admin payload with bounds and a
description that explains itself.
2026-08-23 15:24:15 -04:00
bvandeusen a85c53ba2c Trust proxy headers by hop count, and log every credential event
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 32s
Operator, before exposing the instance: *"I'd expect that we should have a proxy
hops setting for how many proxy hops we should trust a shared real-ip at… and is
there any session logging."* Neither existed, and the first one was a real hole.

**The address was forgeable.** `client_address()` read the LEFTMOST
`X-Forwarded-For` entry — nominally "the original client", and precisely the one a
caller controls, because anything they send arrives before what proxies append. So
`curl -H "X-Forwarded-For: 1.2.3.4"`, rotated per request, minted a fresh
rate-limit bucket every time.

Concretely: stuffing ONE account stayed limited (the account key is unforgeable
and that is why it exists), but spraying MANY accounts from one source was not —
each account got its own budget, and the per-address cap meant to bound the total
was defeated by a header. On a LAN that is nothing. It is not nothing on a public
host.

Now it counts in from the RIGHT by `THOUGHTSYNC_TRUSTED_PROXY_HOPS`, default 1.
Each hop appends what it saw, so the rightmost entries are the ones our own
infrastructure wrote and a forged prefix lands to the left of them where it can
never be selected — proven for the honest, forged, padded, CDN and
shorter-than-configured cases. 0 ignores the header entirely; 2 is Cloudflare in
front of a proxy. Too high is the dangerous direction, so a header shorter than
configured falls back to the socket address rather than reaching further left.

`X-Forwarded-Proto` had the same bug and now shares the same rule. Both live in a
new `proxy.py` rather than being written twice — two places holding one decision
is how issue 2183 happened, and this is the same decision.

Env rather than the Settings UI, against rule 25's usual pull: it is deployment
topology rather than preference, and the limiter consults it BEFORE opening a
database connection, which is the entire point of checking a throttle before doing
expensive work. Easy to move if that reads wrong.

**And there was no logging at all** — `auth.py` had no logger, and the only record
of anything was `device_tokens.last_used_at`. Sign-ins, failures, throttle trips,
new accounts and device-token issuance now all log, with the attempted email and
the trusted address. Deliberately including the email: it is the operator's own
server, and "somebody failed a login" without saying against which account is not
actionable.

`basicConfig` at INFO in `create_app`, because hypercorn configures its own loggers
and leaves the root at WARNING — without it every line above would have gone
nowhere, which is a worse failure than not writing them.

This is the app log, not an audit table. Not queryable, not retained past log
rotation. The table is task 2939; this is what makes the next few days observable.
2026-08-23 15:12:14 -04:00
bvandeusen 2141a0ac45 Registration closes itself once the instance has an owner
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 28s
Operator: *"registration should be open only for the first user and they get
granted admin privileges. then registration is closed."*

The old shape had a window in it. The first account was always allowed and became
admin; every account after that was gated by `allow_registration` — which
defaulted to ON. So the door stayed open between "my account exists" and "I
remembered to turn it off in Settings", and on a public host that gap is the
entire exposure: it starts the moment DNS resolves and lasts until someone
remembers.

Now the door shuts as a CONSEQUENCE of the admin account existing, in the same
transaction that creates it. Not "defaults closed" — that would still need the
first person to get in somehow. There is no window to remember, because there is
no window.

Re-opening it is a deliberate act in Settings → Access: turn it on, have the
person register, turn it off. Crude, and it is the only mechanism there is —
**there is no invite system**, not even a stub. That is real work (a token table,
admin create/revoke, a redemption flow, expiry) and is filed as later work rather
than smuggled into a release.

An integration test covers it, because it is the interaction between two writes
in one transaction: first register → 201 and `is_admin: true`; the setting is
then false; a second register → 403; re-open deliberately and a third → 201, not
admin.

**This does not retroactively close an instance that already has users.** The
close fires on first-account creation, so a server whose admin predates this
keeps whatever the setting was — which was on. `docs/public-hosting.md` now says
so explicitly, and step 1 of the checklist is "check" rather than "do" for
exactly that reason.
2026-08-23 14:18:58 -04:00
bvandeusen 1aca294b95 Bump to 0.2.0 — a release at 0.1.0 would have been a downgrade
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 14s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m0s
Found while preparing the release, and it would have quietly defeated the point
of cutting one.

The version does NOT come from the tag. `desktop/packaging/build-version.sh`
reads `desktop/src-tauri/Cargo.toml`, and on `dev` it appends the CI run number
(`0.1.269`) while on a tag or `main` it ships the file's value verbatim — which
was still `0.1.0`.

So tagging today would have published a "release" numbered BELOW every dev build
already out there, and below the 0.1.227 on the operator's phone. The desktop
updater compares semver: an installed build would have read the stable manifest,
seen a version older than its own, and correctly concluded it was already
current. The APK would have installed (versionCode is the run number and keeps
rising) while displaying a version that reads as going backwards.

build-version.sh predicted this in its own comment: "Bumping the minor in
Cargo.toml still wins over any dev build on the old line, which is the ordering
you want: 0.2.0 > 0.1.2932."

Bumped in four places, which is every one that can be read by something:
- `desktop/src-tauri/Cargo.toml` — the actual source; everything else derives
- `tauri.conf.json` — overridden at build time by `--config`, but a checked-in
  value that lies is exactly how issue 2183 happened
- `pyproject.toml` + `__init__.py` — the server's APP_VERSION fallback when no
  BUILD_VERSION is injected

`core` and `android/ffi` stay at 0.1.0 deliberately: internal library crates whose
version reaches no surface, and versioning workspace libs independently of the
app is normal.

Cargo.lock regenerated with `cargo fetch` per ci-requirements — one line, the
version itself. Verified: a tag build now yields 0.2.0 and a dev build 0.2.270,
so stable is an upgrade for every existing install and dev stays ahead of stable.
2026-08-23 14:02:02 -04:00
bvandeusen 7033995975 search is a facet on the board, not a place you go
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m13s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Operator (note 2930): tags exist so you can *"filter during a search"*. The
server has always been able to do that — `GET /api/notes` composes `?q=` with
`?label=` and the rest into one AND-ed query. The frontend never reached it.

The header search box navigated to `/search`, and that view called a DIFFERENT
endpoint — `GET /api/notes/search?q=`, full text only, no facets at all. So the
one screen you landed on when you searched was the one screen where you could not
narrow by tag. Tag filtering lived on the board's FilterBar, which is where you
weren't searching. Two search boxes, two endpoints, and only the hidden one did
what tags are for.

Now the header box writes `?q=` into the board's URL beside whatever labels are
already there, and stays on the lens you're in — searching while looking at Trash
searches Trash. The box READS from the URL rather than holding its own copy, so
it stays in step with the Filters panel's Clear and with a saved view opened from
the sidebar.

Deleted: `SearchView.vue`, its route, `GET /api/notes/search`, `repo.notes.search`
and both adapter implementations, and the `notes_search` Tauri command whose only
caller was the adapter entry. FilterBar loses its own "Search text…" input — it
was the same facet, hidden behind a collapsed panel, duplicating a box that is
always on screen. Filters now does what its name says: narrowing. The header does
searching.

`core::store::search` STAYS. Android calls it through the FFI (`search_notes`) and
has its own search surface — which has the same no-tag-filter gap the web just
lost, and deserves the same fix on its own terms rather than as a rider here.
2026-08-23 10:58:23 -04:00
bvandeusen de72d27bd4 URLs unfurl on their own, and a lone link becomes the note
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m6s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m16s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Operator: *"I'd like for URLs to unfurl. To be the whole note when the note is a
single URL, and to be a compact slot on the bottom of the note when the URL is
inline. We also need to support multiple URLs in a single note."*

Less new machinery than it sounds: `unfurl.py` already fetched and parsed OG
tags, SSRF-hardened, and `note_link_previews` was already `UNIQUE(note_id, url)`
— so several URLs per note has worked at the storage layer all along. What was
missing was that it needed a button, had one size, and drew that size in the
wrong place.

**Automatic, and never in the way.** New `unfurl_queue.py` detects a body's URLs
and fetches them on a background task AFTER the note is committed. Capture speed
is the product: an unfurl is a five-second timeout against a host nobody
controls, and a note has to persist the instant someone stops typing. Scheduled
from create, from a body edit, and from a synced push — so a linked desktop or
Android client gets previews too, on its next pull. An unlinked one has no server
to ask and simply has none, which is the honest consequence of being offline.

Safe to call on every save: it re-reads what's cached and does nothing when
nothing is new. Capped at five URLs per note, silent on every failure (a link
that won't fetch isn't an error the person needs — the note is fine, the link is
still there), and it re-checks before storing, so a slow fetch can't resurrect a
preview for a URL that was deleted while it was in flight.

**Two presentations.** A note whose body is nothing but a URL renders as its
preview and nothing else — printing the raw URL under a card that already says
where it goes is saying the same thing twice, badly. Until the fetch lands, or if
it never does, the URL stands in, so the card is never blank. Anything else gets
a compact strip.

**And the strip moved.** Previews were rendered ABOVE the body, which put a
stranger's headline where the note's own first line should be — worse now that
the first line IS the note's name. They sit at the foot of the card now, under
the note's own words.

The editor's "Preview example.com" button is gone with the manual path; removing
an unwanted preview stays, and stays editor-only.

Nine tests: three on detection (order, dedupe, sentence-punctuation trimming,
non-http rejection) in the unit lane, and three in the integration lane for what
only a real database shows — the upsert landing on the right row, a second pass
fetching nothing, and a preview NOT being stored for a URL that left the body.
2026-08-23 01:05:37 -04:00
bvandeusen c99cbb3e14 cards: clamp the web note preview, as Android always has
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 29s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m16s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
M13 step 4 asked for no bold first line, and step 3 already delivered that —
removing `note.title` took the card's <h3> and the Android editor's bold field
with it. What step 4 also asked for, and hadn't been done, was the other half:
"be willing to spend something small on legibility that isn't weight on the
first line."

The web card rendered the entire body. Android has always clamped to eight lines
(`MAX_PREVIEW_LINES`), so one long note produced a card taller than the screen on
the web and pushed the rest of the board off it — a real asymmetry between two
surfaces that are supposed to be peers.

It matters more without a title. The first line used to be what your eye caught;
with one weight throughout, an unbounded card is just a wall, and the note beside
it is the one you were actually looking for.

Clamped in the STRING, not with CSS `line-clamp` — that needs a `-webkit-box` and
behaves unreliably around the block elements MarkdownText emits (lists, quotes,
fenced code). Doing it before the parse is deterministic, matches Android's
semantics exactly, and skips parsing a body the card was never going to show.
2026-08-23 01:00:39 -04:00
bvandeusen 6f21db85a1 ci: an integration lane, so the migrations are finally run by something
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / integration (push) Successful in 14s
CI & Build / Build & push image (push) Successful in 25s
26 Alembic revisions and none had ever been executed by CI. `alembic upgrade
head` ran for the first time when the operator's container started, and the
schema the migrations build had never been checked against the models that read
it. M13 dropped three columns and rebuilt a STORED GENERATED column with nothing
watching but a server boot.

Copied from FabledScribe's `integration` job, which had already solved the parts
that are easy to get wrong — and which are family rules precisely because they
were: a separator-free job key with no `name:` (act_runner derives the service
container name from the truncated display name, and the discovery step filters
`docker ps` by it), bridge-IP resolution because service hostnames aren't
routable on this runner, and a Python readiness wait because `run:` is busybox
sh with no `/dev/tcp`.

`postgres:16-alpine` to match the production compose. The schema is built by
real migrations, never metadata.create_all — that step IS the migration test.

Six tests, each pinning something that has only ever been checked by hand:

- an ORM insert against the migrated schema, which is the model/migration
  agreement nothing has verified until now;
- `notes.title`, `notes.kind` and `note_revisions.title` are actually gone, and
  `note_links` with them — a silently no-op migration shows up here;
- the rebuilt `search_vector` indexes both the name and the body, which matters
  because 0026 had to DROP and recreate a generated column rather than alter it;
- a note keeps its body AND its items, the shape step 2 made normal;
- `_apply_note_items` leaves items alone when a change doesn't mention them —
  the data-loss path step 2 removed, pinned so its return would be caught;
- a note with no body is still named by its first item, the hole that made
  removing the title unsafe until checklists stopped being their own kind.

Runs for visibility; does not gate the build, matching `test` and Scribe.

No local equivalent: running it means standing up Postgres on the workstation,
which rule 12 reserves for an explicit request. Documented in ci-requirements
alongside the Rust, Kotlin and frontend gates.
2026-08-23 00:24:57 -04:00
bvandeusen 924ddb20db notes: saveEdit still asked for a title
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m0s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The one thing step 3 missed, and the typecheck lane caught it: `saveEdit`'s
parameter type still declared `title`, so the editor's call — correctly no
longer passing one — didn't match.

I gated Rust locally and not the frontend. Both are now in ci-requirements,
including WHY the frontend one has to be `npm run build` rather than
`vue-tsc --noEmit`: the typecheck only reads the script block, so a malformed
template sails past it and fails `vite build` in a different workflow, which is
exactly how the stray `</div>` got two commits away from where it was written.
2026-08-22 21:38:13 -04:00
bvandeusen 95aa10c2c3 Remove the title field — a note is named by its first line
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 7s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 31s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 6m45s
Operator (note 2897): "notes shouldn't have a title field." The concept of a NAME
stays — search results, export filenames and the command palette all need one —
but nothing is typed into it any more. `display_title` is now the first non-empty
line of the body, falling back to the first checklist item.

That fallback is what step 2 bought, and the reason this could not go first: a
checklist had no body to be named from, so the title was its only name. Now every
note has a body, and a note that is only a checklist is named by its first item.

Gone everywhere: the column and note_revisions.title (0026), the field on the
core's Note/NoteCreateInput/NoteRevision and its SQLite columns (user_version 7),
`normalize_title`, the wire field, the FFI record and `NoteEdit::Title` /
`ClearTitle`, the web editor's "Title (optional)" input and the card's <h3>, and
the Android title field in both the compose sheet and the editor.

**The search vector had to be rebuilt, not just left alone.** `notes.search_vector`
is a STORED GENERATED column whose expression names `title` — Postgres refuses to
drop a column another generated column depends on. It is dropped and recreated over
`display_title` at weight A, which keeps the original intent: a note's NAME ranks
above the rest of its body.

**An imported title becomes the note's first body line.** Keep notes carry one, and
so does any ThoughtSync export taken before this. Dropping it would silently lose
text someone wrote; folding it in puts it exactly where a name now lives, so the
note arrives named as it was. Skipped when the body already opens with that line,
so re-importing an export this code produced doesn't stack duplicates.

Two smaller things fell out. The Android editor loses its bold first field — one
weight throughout, because the first line is the note's name but not a different
KIND of text, which is most of step 4 arriving early. And `ClearTitle`'s
justification comment moved to `ClearRemindAt`, which is now the surviving example
of why NoteEdit is a list rather than a struct of options.

Protocol note corrected to say what actually shipped: v2 is "no kind, no title",
one bump for the pair.

Verified with the local Rust gate this time, not by CI: fmt, clippy and 116 tests
all green before pushing. It caught four things — orphaned serde attributes where
fields were removed, a `wire::Preview.title` I deleted by mistake (a link preview
still has one), nine retention fixtures inserting a dropped column, and four
rustfmt diffs.
2026-08-22 19:33:57 -04:00
bvandeusen 6d778f26a7 Fix the ktlint and compat-test failures, and start using the Rust gate
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m44s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m46s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m47s
Two more from the step-2 removals:

**Two unused Kotlin imports** — `FilterChip` (the Note/List switch) and
`Icons.Filled.Create` (the "switch to a note" icon), both orphaned when their
callers went. ktlint treats them as errors.

**`server_info_tolerates_unknown_and_absent_fields`** pinned
`sync_protocol_version: 1` as a literal, so bumping the protocol to v2 made it
fail for a reason that has nothing to do with what it tests. It is about unknown
FIELDS; the versions now come from `CLIENT_PROTOCOL_VERSION`, like every other
test in that file already did.

The bigger fix is the habit. `ci-requirements.md` has documented since
2026-08-18 that the operator authorised running fmt/clippy/test against the CI
image locally, and I had not been doing it. All three now pass here — 116 tests,
clippy clean, fmt clean — and every Rust failure in this milestone so far would
have been caught by them in under a minute instead of by CI, several commits
downstream. Noted in ci-requirements so the next session doesn't relearn it: a
removal is exactly the change that looks too safe to check.
2026-08-22 14:51:38 -04:00
bvandeusen 33e9278975 Fix three breaks the removals left behind
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m4s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 4m27s
**`snapshot_revision` was deleted with `create_titled`** (bc22f8e). I sliced the
function out by scanning to the next `pub fn`, and the private `fn` sitting
between them went too. Nothing in Python or TypeScript compiles Rust, so it sat
undetected until the first lane that does. Restored verbatim.

**An orphaned serde attribute** in push.rs: removing `pub kind: Option<String>`
left its `#[serde(skip_serializing_if)]` behind, which then stacked onto the
next field's. That failed the derive, which is why three follow-on errors all
said `Change: Serialize is not satisfied` — one cause, four messages.

**An unbalanced `</div>`** in NoteEditor.vue, orphaned when the "Links / Linked
from" footer was cut. `vue-tsc --noEmit` type-checks the SCRIPT block and never
parses the template, so the typecheck lane passed it and `vite build` caught it
two workflows later. Worth remembering: a green typecheck says nothing about
template structure.

I also pushed step 2 without waiting for ad21eac to go terminal, which is what
let the Rust break travel a commit further than it should have.

Each fix comes with the check that would have caught it: a scan for stacked
serde attributes and called-but-undefined fns across every .rs, and a tag
balance pass over every .vue. Both are clean.
2026-08-22 13:25:26 -04:00
bvandeusen c46a4a7709 A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.

Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.

The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.

Two things that fell out of the merge rather than being aimed at:

- The Keep importer was DISCARDING `textContent` whenever a note also had
  `listContent`, because the target could only hold one. Both survive now, and
  the test says so.
- Markdown export wrote the body OR the checklist. It writes both.

Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.

The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
2026-08-22 12:53:53 -04:00
bvandeusen 229076c82d sync: stop deleting a note's checklist items because it isn't a "list"
`_apply_note_items` didn't ignore items on a non-list note — it deleted them.
That was survivable only because nothing in the product could produce a note
holding both a body and items.

M13 makes exactly that the normal shape: a checklist is something a note HAS,
not something a note IS. Against that shape this guard is a data-loss path — the
first sync after adding a checklist to a note would wipe it.

Landing it before the UI that can create the state, so there is never a window
where the two disagree. `kind` itself, and the rest of the merge, follow.
2026-08-22 12:45:14 -04:00
bvandeusen ad21eac5bc editor: drop the adapter import that went with backlinks
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 27s
CI & Build / Build & push image (push) Failing after 22s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 30s
Desktop (Tauri) / Update manifest (push) Skipped
`repo` reached the editor for exactly two calls — `repo.notes.backlinks` and
`repo.notes.linkSearch` — and both left with the linking system. vue-tsc runs
with noUnusedLocals, so one stale import failed the whole shared-frontend build
and took both desktop lanes down with it (TS6133).

My local sweep checked for dangling *references*; it never checked the inverse,
that every import still has one. It does now, across all fifteen files that
removal touched — `repo` was the only one.
2026-08-22 12:44:33 -04:00
bvandeusen bc22f8e249 Remove [[wiki-links]], backlinks and the graph
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.

So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.

This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.

Two things deliberately kept, because they were serving recall and only
incidentally serving links:

- `/api/notes/titles` and the titles store. The command palette lists them so
  you can jump to a note by name. `resolve()` — the name→note lookup that only
  linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
  filenames. What that name is FOR changed; that it exists did not.

`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.

Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
2026-08-22 12:00:57 -04:00
bvandeusen 982d24c83b links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
2026-08-22 11:02:39 -04:00
bvandeusen bacedea8a3 tests: seed the throttle counters on the clock the routes actually read
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 19s
The three route tests stamped their pre-loaded hits at t=0..9 through the
injected clock, then called a route that reads `time.monotonic()`. Against a
trailing window those hits are fifteen minutes stale on arrival, so they were
pruned before they could refuse anything, the request carried on to the database
that this suite doesn't have, and the assertion read `500 == 429`.

The window's own tests keep the injected clock — they pass the same one to both
sides, which is what makes them deterministic and instant. Only the tests that
hand off to a route need the real one.
2026-08-21 22:03:19 -04:00
bvandeusen b6152ec18b server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s
On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.

Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.

The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.

A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable oracle for which emails
are registered here.

Every response carries a CSP with script-src 'self', object-src 'none' and
frame-ancestors 'none', plus nosniff, a referrer policy and a permissions
policy. The app has no inline and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.

X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.

docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
2026-08-21 22:01:37 -04:00
bvandeusen 16f86bef93 web: make the board usable on a phone, not just reachable
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 50s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m6s
Desktop (Tauri) / Update manifest (push) Successful in 6s
The controls a card carries were always-visible overlays on a touch device —
correct as far as it went (task 2697: a finger cannot hover, and the pill is
the only way to pin or archive), but they were still absolutely positioned, so
they sat ON the note's own title. A card reading "thought sync tauri app"
rendered as "ught sync tauri app" with the grip parked over the first three
characters, and the four-icon pill covering the right half of the first line.

Placement is now CSS's decision. One element each, two placements: where a
pointer can hover they lift out of flow into the floating top-corner pills they
have always been; where nothing can hover they stay in flow as a footer row,
which cannot overlap anything by construction. Keyed on hover rather than
width, for the same reason `.hover-reveal` already is — a narrow window on a
laptop still hovers, a wide tablet still doesn't. The colour popover moved
inside the action set so it follows it, and opens into the card from either
end.

The header was sharing one phone-width row between a menu button, the logo, the
lens name, a search field and four icons; everything in it was truncated, the
lens down to "N…" and the search box to an empty pill. It wraps now, so search
takes its own line below sm, and account / settings / sign-out move into the
drawer where there is room to name them rather than guess at a glyph. One input,
moved by CSS — duplicating it would have meant two `searchInput` refs and a `/`
shortcut that focuses the wrong one.

Also closes the other half of task 2706, which was waiting on a device to look
at: `viewport-fit=cover` together with the `env(safe-area-inset-*)` padding
that makes it safe (sides on body, top on the sticky header, bottom on the
board and the drawer), and `100dvh` behind an @supports so the app box follows
the visual viewport when the keyboard opens instead of the layout viewport.
Both halves in one change, as that task insisted.

And the composer no longer tells a phone to "Press Enter".
2026-08-21 21:55:46 -04:00