32 Commits
Author SHA1 Message Date
bvandeusen b6673c6420 The guard fix, and the APK and image #4 never published (#5)
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python tests (push) Successful in 12s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m11s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m25s
2026-08-29 14:19:50 -04:00
Bryan Van Deusen 6e524ec616 guard: an empty channel killed the lane instead of passing it
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
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 6s
CI & Build / Python tests (push) Successful in 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m14s
Desktop (Tauri) / Update manifest (push) Successful in 5s
Android / Kotlin + Rust (APK) (push) Successful in 7m46s
The first merge to `main` took the Android lane down (run 4857): the decide
job exited 1 in 0.16 seconds with no output at all, and the image build
skipped behind it because a failing lane must not publish.

`stable` had never published an APK, which the guard treats as a pass — there
is nothing to go backwards from, and `[ -z "$published" ]` says so in a branch
of its own. That branch was unreachable. `published="$(published_for ...)"`
under `set -e` dies on the substitution before it, and everything the pipeline
would have printed goes into the capture rather than the log.

What decided which lookups had the bug is the last command in the pipeline.
`sed` on empty input exits 0; `grep` exits 1. Three of the four end in `sed`.
Android's version_code ends in `grep -oE '[0-9]+$'`, so it was the only one —
and only on a channel with nothing on it, which is why a week of dev pushes
never saw it.

The tests now reach the half of the guard that talks to a feed, with `curl`
shadowed on PATH so they stay hermetic: an empty channel passes and builds, a
lower published version passes, a higher one fails the lane, and an equal
Android code is refused because Android will not install it.
2026-08-29 13:45:26 -04:00
bvandeusen 6c5a45e195 Checklists in the body, colour from tags, and commit-derived CalVer (#4)
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
CI & Build / TypeScript typecheck (push) Successful in 6s
Android / Build, or is the channel already serving this? (push) Failing after 3s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
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 4s
2026-08-29 13:39:45 -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
bvandeusen 1b3e29e4f6 0.2.0 — a notebook in your pocket, ready to be hosted (#3)
Android / Kotlin + Rust (APK) (push) Successful in 7m45s
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 16s
CI & Build / Build & push image (push) Successful in 15s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s
2026-08-23 16:38:00 -04:00
bvandeusen 867405fae2 M12 — the Android client, end to end (#2)
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) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m8s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m13s
CI & Build / Python tests (push) Successful in 11s
86 commits from dev. Native Kotlin/Compose Android client over the shared Rust
core, the server-served distribution path, signing, and self-update.

Known and recorded rather than fixed: #2810 (release APK carries a debug-profile
.so), allowBackup still true now that the store holds a device token, and
cleartext HTTP enabled app-wide for self-hosted LAN servers.
2026-08-21 08:53:57 -04:00
74 changed files with 3860 additions and 1748 deletions
+75 -25
View File
@@ -19,15 +19,9 @@ name: Android
on:
push:
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
# See desktop.yml for why, and 85ead4d for what the duplication cost.
branches: [dev, main]
paths:
- "android/**"
# The Rust the .so is built from. A core change reaches the phone exactly
# as it reaches the desktop, so this lane has to rebuild on it.
- "core/**"
- "Cargo.toml"
- "Cargo.lock"
- ".forgejo/workflows/android.yml"
workflow_dispatch:
concurrency:
@@ -42,8 +36,46 @@ env:
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
# Does the APK need rebuilding, or is the channel already serving this source?
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
#
# The guard runs here so it covers the skip path too (§6.3).
#
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
# happens — and that is correct, because ci.yml's `gate` stands down only when the
# push touched Android's files, which is the same condition that makes this build.
# The two decisions agree because they read the same fact; they are still two
# readers of it, which is why the gate's grep carries a comment pointing here.
decide:
name: Build, or is the channel already serving this?
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh android "$channel"
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
build:
name: Kotlin + Rust (APK)
needs: [decide]
if: needs.decide.outputs.build == 'true'
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
@@ -63,6 +95,10 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
# Derives a version, so it needs the whole history — see the note in
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
fetch-depth: 0
- name: Cache Gradle and Cargo
uses: actions/cache@v4
@@ -85,13 +121,17 @@ jobs:
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
version="$(sh ../desktop/packaging/build-version.sh)"
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
# same code?", so it comes from the COMMIT and a dev build and the main
# build of one commit read identically. The CODE answers "may this be
# installed over that?" and must be monotonic BY CONSTRUCTION, because
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
# leaves a channel you cannot get out of — so it comes from BUILD time,
# which cannot go backwards. Commit time can.
version="$(sh ../packaging/version.sh display android)"
code="$(sh ../packaging/version.sh key android)"
echo "name=$version" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run
# number is the same monotonic counter the desktop's version scheme
# already uses — no state carried between runs, and immune to the
# shallow checkout that makes a commit count useless here.
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
echo "code=$code" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
@@ -105,7 +145,7 @@ jobs:
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
echo "Signed release build — $version (versionCode $code)"
else
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT
@@ -199,19 +239,29 @@ jobs:
JSON
cat dist/thoughtsync-android.json
# The rolling dev channel, same fixed-tag release the desktop bundles use.
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
# target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot
# install over what they already have.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
# The rolling channel for this branch, the same fixed-tag releases the desktop
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
# publishing an unsigned APK would offer people something they cannot install
# over what they already have.
#
# `stable` from main is new in M314 step 3, and it is what lets the server image
# bake in a client that matches its own channel: a :latest image fetches the APK
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
# all and every image — stable included — baked in the dev one.
- name: Publish to the channel for this branch
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: bash desktop/packaging/publish-release.sh
run: |
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing the APK to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
+70 -46
View File
@@ -1,12 +1,21 @@
# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# main is the production line, so a merge to main rebuilds and moves :latest to its
# tip (family rule 46) — no version release required. The :<sha> image is the
# immutable rollback unit for every build.
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
# nothing pins one — verified by looking for a consumer, not for whether one is
# imaginable — and the git release tag is a different object in a different system
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
#
# A `v*` tag builds nothing at all. The merge to main already published everything,
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
# which rule 145 forbids even when they match.
#
# main is the production line, so a merge moves :latest to its tip (family rule 46)
# — no version release required. :<sha> is the immutable rollback unit, and it is
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
# accumulating forever, for a channel whose entire contract is that it moves.
#
# Required secret (repo -> Settings -> Secrets -> Actions):
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
@@ -16,27 +25,29 @@ name: CI & Build
on:
push:
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
# the image ALWAYS builds. Two reasons:
#
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
# production commit is addressable. A path filter quietly broke that promise
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
# is a floating tag and this can face the internet), and building every push
# picks those updates up. That is why note 3127 §4's base tension does not
# bite here — the one artifact it would apply to never skips.
#
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
# for the clients, which is why THEY skip and this does not.
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- ".forgejo/workflows/ci.yml"
# Dispatched by the Android lane once it has published a client, so the image
# that bakes it in is built AFTER the APK exists rather than racing it. See the
# `gate` job below for the other half.
workflow_dispatch:
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
# own group implicitly and are never cancelled.
# Cancel older runs on the same branch when a newer push lands.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
contents: read
@@ -64,7 +75,7 @@ jobs:
# than a config so at least it is inspectable in the log.
gate:
name: Build now, or wait for Android?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -89,17 +100,6 @@ jobs:
exit 0
fi
# A tag. The Android lane does not run on tags, so nothing would ever
# call back — standing down here would mean a release tag that never
# produces an image at all.
case "${{ github.ref }}" in
refs/tags/*)
echo "Tag build — the Android lane does not run on tags. Building."
echo "build=true" >> $GITHUB_OUTPUT
exit 0
;;
esac
# No parent (first commit, or a force-push that orphaned it) — nothing to
# compare, so build rather than stall.
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
@@ -125,7 +125,12 @@ jobs:
echo "Changed in this push:"
echo "$changed" | sed 's/^/ /'
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
# MUST match android's file set in packaging/version.sh. `packaging/` was
# missing here after step 4 added it there — so a packaging-only push had
# the Android lane rebuild and dispatch while this gate ALSO let the image
# build, producing two images for one commit and, on main, a second push of
# the same :<sha> with different bytes. Rule 145's exact prohibition.
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
echo ""
echo "This push also changes the Android client. Standing down: the"
echo "Android lane will publish a new APK and dispatch this workflow,"
@@ -140,7 +145,7 @@ jobs:
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -157,7 +162,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -170,7 +175,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -200,7 +205,7 @@ jobs:
# discovery step below filters `docker ps` by it. Service hostnames are not routable
# on this runner (rule 79), so the step resolves the container's bridge IP.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
@@ -279,27 +284,35 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
with:
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
# and produces a too-low value silently, with the lane green (§6.1).
fetch-depth: 0
- name: Generate image tags and version
id: tags
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
# NOT bash `[[ ]]`.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev"
# The image's version is DERIVED from its own shipped files — including the
# Android client it bakes in, which is why an APK-only change re-versions
# it. One value and no ordering key: nothing compares a server image, so
# §2 says do not invent one just because the other artifacts have one.
#
# This was a short sha on main and the literal "dev" elsewhere, which could
# not answer "how old is this instance?" — the question that actually gets
# asked of a self-hosted app running in several places.
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
TAGS="${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag;
# the :<sha> above is the rollback unit. Version label = short sha.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
*)
echo "::error::This lane builds images for dev and main only."
exit 1
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -330,7 +343,18 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
mkdir -p client
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. This read `download/dev`
# unconditionally until M314 step 3, on every branch — so every stable
# server shipped a dev-channel APK to anyone who downloaded the client
# from it. Not a versioning gap; a plain defect, fixed here because this
# is the step that gave `stable` an APK to point at.
case "${{ github.ref_name }}" in
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
ok=1
for f in thoughtsync.apk thoughtsync-android.json; do
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
+148 -83
View File
@@ -16,33 +16,20 @@ name: Desktop (Tauri)
on:
push:
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
# from it within a day (85ead4d). The `decide` job below reads the real set and
# skips in seconds when nothing moved — one definition, one reader (§3).
#
# The cost is that this workflow starts on every push rather than on a matching
# one. That is a ~15s container for a decision, against a lane that cannot
# silently fail to run.
branches: [dev, main]
tags: ["v*"]
paths:
- "desktop/**"
# The shared client core (store + sync engine) the desktop wraps. Its own
# crate since the Android client binds the same code, so a change there is a
# change to this app even though nothing under desktop/ moved.
- "core/**"
# The Android uniffi shim. It builds no desktop artifact, but it is a
# workspace member, so this lane's `cargo clippy --all-targets` is what
# compiles and lints it — and until the Android lane exists (M12 step 5),
# it is the ONLY thing that does.
- "android/**"
# The workspace manifest and lockfile, which now live at the repo root.
- "Cargo.toml"
- "Cargo.lock"
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
# the desktop binary, so any part of it changing means the shipped app is out
# of date. Config and lockfile included — a dependency bump changes the bundle
# as surely as a component does.
- "frontend/**"
- ".forgejo/workflows/desktop.yml"
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
# write (not read) so the tag build can publish a Release with the bundles
@@ -51,9 +38,47 @@ permissions:
contents: write
jobs:
# Does anything need building at all?
#
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
# the `paths:` filters that used to state the same fact a second time. They drifted
# from it within a day: `packaging/` was added to the sets and not to the filters,
# so the commit fixing a derivation bug never ran on the two lanes it fixed
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
#
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
# Skipping because "the channel already serves this version" is indistinguishable
# from "we derived a stale value that happens to match" unless something checks.
decide:
name: Build, or is the channel already serving this?
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
outputs:
build: ${{ steps.d.outputs.build }}
steps:
- uses: actions/checkout@v6
with:
# Derives a version — depth-1 is silently wrong (§6.1).
fetch-depth: 0
- name: Decide
id: d
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
case "$GITHUB_REF_NAME" in
main) channel=stable ;;
*) channel=dev ;;
esac
sh packaging/guard-forward.sh desktop "$channel"
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
build:
name: Tauri desktop (Linux)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
@@ -64,6 +89,14 @@ jobs:
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# tauri's generate_context! embeds the built frontend at compile time, so the
# frontend must exist before any cargo compile (clippy/test/build), not just
@@ -123,8 +156,12 @@ jobs:
else
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
fi
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
cargo tauri build \
--config '{"build":{"beforeBuildCommand":""}}' \
--config "{\"version\":\"$version\"}" \
@@ -205,37 +242,40 @@ jobs:
# failure, not as a green run with an empty artifact.
if-no-files-found: error
# Tag builds only: publish a real, versioned Fabled-Git Release with the
# AppImage + .deb attached — the stable fetch target the install script and
# the in-app updater consume (Actions artifacts above are ephemeral/test).
# Cutting the tag is the operator's action (rule 2); this only publishes a
# Release for a tag that already exists. Dormant on dev/main pushes.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
@@ -253,12 +293,21 @@ jobs:
# built, not that it runs. A real-machine check stays mandatory before trusting it.
windows:
name: Windows installer (cross-compiled)
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [decide]
if: needs.decide.outputs.build == 'true'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
# Same reason as the Linux job: generate_context! embeds the built frontend
# at compile time, so it must exist before cargo runs.
@@ -292,8 +341,12 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
version="$(sh ../packaging/build-version.sh)"
echo "Building version $version"
# The ORDERING KEY, not the display version: this string is what Tauri's
# updater parses as semver, and what it stamps into bundle FILENAMES that
# `write-manifest.sh` then selects on. The human-readable version is a
# separate value and arrives with the UI that shows it (#3181).
version="$(sh ../../packaging/version.sh key desktop)"
echo "Building desktop ordering key $version"
updater='{}'
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
updater='{"bundle":{"createUpdaterArtifacts":true}}'
@@ -317,35 +370,40 @@ jobs:
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
if-no-files-found: error
# Publishes to the SAME release as the Linux job. Safe to run twice: the
# script reuses an existing release (409) and nullglob means each job uploads
# only the bundles present in its own workspace.
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ github.token }}
run: bash desktop/packaging/publish-release.sh
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
# the updater has a permanent URL to read — Forgejo has no
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
# are releases whose tag never moves, so the updater has a permanent URL to
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
# cannot be named in a URL.
#
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
# M314 step 3 this job built on main and published nothing, so the stable
# channel moved only when somebody cut a tag — that section's diagnostic
# failing outright: main publishing was not sufficient for a user to receive
# the build.
#
# Gated on the signing key INSIDE the script rather than with an `if:`, because
# the secrets context isn't reliably available to step conditions. Publishing
# bundles the app would then refuse to verify is worse than publishing nothing:
# it looks like a working feed.
- name: Publish to the dev channel
if: github.ref == 'refs/heads/dev'
- name: Publish to the channel for this branch
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
env:
GITHUB_TOKEN: ${{ github.token }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
RELEASE_TAG: dev
RELEASE_PRERELEASE: "true"
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
exit 0
fi
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
# `prerelease` is true for dev so it does not read as a supported build,
# and false for stable, which is the real thing.
case "$GITHUB_REF_NAME" in
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
esac
export RELEASE_TAG RELEASE_PRERELEASE
echo "Publishing to the $RELEASE_TAG channel."
bash desktop/packaging/publish-release.sh
# The updater manifest, written AFTER both bundle jobs — they run in separate
@@ -359,12 +417,20 @@ jobs:
manifest:
name: Update manifest
needs: [build, windows]
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
steps:
- uses: actions/checkout@v6
with:
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
# the lane green — note 3127 §6.1, and the direction you cannot recover
# from. `packaging/version.sh` fails loudly on an empty result rather than
# emitting something plausible, which is what turns this into a red lane
# if it is ever dropped.
fetch-depth: 0
- name: Write and publish latest.json
env:
@@ -376,23 +442,22 @@ jobs:
echo "manifest to write. Add the secret to enable in-app updates."
exit 0
fi
# The SAME helper the bundles were built with — a second derivation here
# could drift, and a manifest whose version doesn't match the binary it
# points at is an updater that never settles.
version="$(sh desktop/packaging/build-version.sh)"
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
# Rolling channel: drop the previous build's bundles once the manifest
# points at this one. Nothing can reach them, and they're ~100 MB a push.
# The SAME helper AND the same request the bundles were built with — a
# second derivation here could drift, and a manifest whose version doesn't
# match the binary it points at is an updater that never settles. It must
# be `key`: this value is matched against bundle filenames.
version="$(sh packaging/version.sh key desktop)"
# Both channels are rolling: the manifest lands on the same release that
# holds the bundles, and the previous build's bundles are dropped once it
# points at this one. Nothing can reach them, and they are ~100 MB a push.
#
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
# triggers release.yml, which writes a changelog and builds nothing.
case "${GITHUB_REF_NAME}" in
main) export RELEASE_TAG=stable
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
*) export RELEASE_TAG=dev
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
esac
export PRUNE_OLD_ASSETS=true
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
else
export RELEASE_TAG="${GITHUB_REF_NAME}"
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
# Twice: once onto the versioned release itself, and once onto the
# permanent `stable` pointer the app actually reads. Same manifest both
# times — its URLs point at the versioned assets either way.
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
fi
+68
View File
@@ -0,0 +1,68 @@
name: Release
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
#
# The merge to `main` already published everything a user can receive: the server
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
# channel, and the updater manifest that advertises them. A tag rebuilding that same
# source would produce identical artifacts under identical names, and would re-push
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
#
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
# what was in it. Note 3127 §5 — there are 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
# what changed since the one ← this
# I was running last month?
#
# Cutting the tag is the operator's act (rule 2). This only responds to one.
#
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
# tags at all. The image is addressed by channel or by commit; the release by date.
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
# system.
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
notes:
name: Write the changelog
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
with:
# The whole history AND every tag: the notes are the commit range between
# this tag and the previous `v*` one, and neither end exists in a shallow
# clone. A depth-limited checkout here does not fail — it produces a
# shorter changelog, which is the kind of wrong nobody notices.
fetch-depth: 0
- name: Publish the release notes
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
echo "$notes"
echo "---"
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
# python3 is on PATH in the three images that call it. `json.dumps` then
# strip the surrounding quotes — the script supplies those.
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
export RELEASE_BODY_JSON
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
# release that is only ever POSTed keeps whatever body its first run
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
# exist in this workspace, so its asset globs match nothing and it
# uploads none — which is the intended behaviour, not a side effect.
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
+4 -2
View File
@@ -99,8 +99,10 @@ Then open `http://<host>:5000` and register — **the first account becomes the
unset, a signing key is generated and persisted in the database (sessions survive restarts).
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
`:<git-sha>` (immutable, for pinning / rollback).
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
no version-shaped tags: nothing pins one, and the build reports its own version
at `/api/config` and `/health`.
- **Putting it on the public internet:** there are four things to do first — close
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
port, and back up the attachment volume as well as the database. See
@@ -0,0 +1,168 @@
"""lift standalone #tags out of note bodies
Revision ID: 0028
Revises: 0027
Create Date: 2026-08-26
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
and with the chip moved to the top of the card the text is redundant. This removes it,
but only from notes where the tag was standing on its own.
## This migration rewrites note bodies
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
applies from here on:
* A line containing nothing but tags and whitespace is REMOVED.
* Every other line is left exactly as written.
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
looser reading — also stripping a trailing tag off a prose line — was 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".
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
the worst possible place to make it.
Two guards, both of which cost a note nothing:
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
snippet somebody pasted, and deleting it would eat a line of their example.
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
which is worse than the duplication this fixes.
## The label rows have to graduate in the same transaction
A `via_tag` row means "this label is backed by text still in the body". Once the text
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
on its very next save. The flip to `via_tag = false` is what makes the label the record
instead — and what makes the chip's × appear in both editors, which is now the only way
to remove a tag whose text no longer exists.
## The transform is inlined, like 0027's
`split_body_tags` is deliberately NOT imported. 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 is only recomputed for a note whose
body actually moved — a note named after a `#todo` line needs a new name, and reading it
from the app would couple this migration to a rule that has already changed once (M13).
## `updated_at` is left alone, and that is load-bearing
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
its edit instead of the migration silently winning.
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
pullable once and clients converge on the server's text. That 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 they do.
## The downgrade is not a true inverse, and says so
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
was never there, and putting one back would be guessing at where in the note it went.
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
label on the note, and the chip still shows it. What a downgrade cannot restore is the
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
is gone, so the next save would detach the label and lose the tag for real. So the
downgrade leaves both alone. The real rollback is a database restore.
"""
import re
import sqlalchemy as sa
from alembic import op
revision = "0028"
down_revision = "0027"
branch_labels = None
depends_on = None
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
_DISPLAY_TITLE_CAP = 200
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
only those is therefore not a tag-only line and is left alone."""
return any(c.isalpha() for c in name)
def _split(body: str) -> tuple[list[str], str]:
"""(standalone tag names, body with their lines removed)."""
standalone: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
kept.append(line)
else:
standalone.extend(m.group(1) for m in matches)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
return [], body # nothing but tags: keep the note readable
# A tag still written in prose somewhere keeps its text, so it stays derived.
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
return [n for n in standalone if n.lower() not in still_in_prose], lifted
def _display_title(body: str) -> str:
for line in body.splitlines():
stripped = line.strip()
match = _TASK_RE.match(stripped)
text = (match.group("text") or "") if match else stripped
text = text.strip()
if text:
return text[:_DISPLAY_TITLE_CAP]
return ""
def upgrade():
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
flip = sa.text(
"UPDATE note_labels nl SET via_tag = false "
"FROM labels l "
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
"AND lower(l.name) IN :names"
).bindparams(sa.bindparam("names", expanding=True))
for note_id, body in rows:
if not body:
continue
standalone, lifted = _split(body)
if lifted != body:
bind.execute(
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
{"body": lifted, "title": _display_title(lifted), "id": note_id},
)
# Even when the body did not move, a tag can be standalone only in the sense
# that its line was already removed by an earlier pass — so the flip is driven
# by the tag list, not by whether the text changed.
if standalone:
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
def downgrade():
"""Deliberately empty — see the module docstring.
Restoring the deleted lines would be guessing, and flipping the rows back to
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
them is gone, so the next save would detach the label and lose the tag for real.
"""
+93
View File
@@ -0,0 +1,93 @@
"""drop notes.color — a card is one neutral surface, colour lives on the tag
Revision ID: 0029
Revises: 0028
Create Date: 2026-08-28
M315 step 3. A note's colour was set by a picker and read by three card renderers.
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
the only coloured thing on a board is a tag. This drops the column that nothing has
been reading since, and the picker goes with it.
`labels.color` is untouched. That is the colour that survived, and the one the whole
milestone was about keeping.
## What is lost, and why that is the change rather than a cost of it
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
it would be preserved in is the one being dropped — and nothing renders it, so a
preserved value would be a column kept warm for a feature that was deliberately
removed. A note that had a colour now takes its identity from its tags, which is what
the operator asked for: "strip color from the cards ... and keep the color for tags
just on the tag."
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
defined on the model that lost one would be an invitation to put the column back.
## The saved-filter sweep is not optional
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
there forever, and `clean_params` only guards what is written FROM here on. A view that
silently filters on a field the app no longer has is worse than one that visibly lost a
criterion, so the stored rows are swept too.
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
try-cast: one malformed blob would abort the whole migration, and these rows are
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
holds and lets every other row be fixed.
## Search is not affected
`notes.search_vector` is a stored generated column over `display_title` and `body`
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
here to tear down and recreate.
## Downgrade
Restores the column, empty, at its old default. The values are not recoverable — see
above. It is the schema that comes back, not the data.
"""
import json
from alembic import op
import sqlalchemy as sa
revision = "0029"
down_revision = "0028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("notes", "color")
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
).fetchall()
for sf_id, params in rows:
try:
parsed = json.loads(params)
except (ValueError, TypeError):
# A blob that does not parse cannot be edited safely. Leaving it is
# correct: it was already unreadable by the app, and this migration is not
# the place to decide what it should have said.
continue
if not isinstance(parsed, dict) or "color" not in parsed:
continue
parsed.pop("color")
bind.execute(
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
{"p": json.dumps(parsed), "id": sf_id},
)
def downgrade() -> None:
# Comes back at the default every note would have had anyway. Which notes once
# carried a chosen colour is not recorded anywhere after the upgrade.
op.add_column(
"notes",
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
)
@@ -221,6 +221,11 @@ private fun App(
onSearch = board::search,
onCompose = board::compose,
onToggleItem = board::toggleItem,
// The SAME seam the editor uses. `onEditorAction` is already the
// exhaustive dispatcher for every action a note has, and it takes
// the note to act on rather than reading the open one — so the board
// can hand it a card without a second dispatcher existing to drift.
onNoteAction = board::onEditorAction,
// Null unless there is genuinely something to say — the board is
// handed a decision, not a state to interpret.
update =
@@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -99,24 +100,45 @@ fun BlockBody(
readOnly = readOnly,
requester = requester,
onChange = { replace(index, it) },
onBlur = {
// Compared by IDENTITY, not equality: `promotingTasks` hands
// back the same list when there was nothing to promote, and a
// blur that changed nothing must not touch the state at all.
val promoted = blocks.promotingTasks(index)
if (promoted !== blocks) onChange(promoted)
},
)
}
}
}
}
/** A run of prose: one ordinary multi-line field, exactly as the editor always had. */
/**
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
*
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
* [promotingTasks] for why blur is the only safe moment to do that.
*
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
* the promotion is a no-op that the caller's identity check drops on the floor.
*/
@Composable
private fun ProseBlock(
block: EditorBlock,
readOnly: Boolean,
requester: FocusRequester,
onChange: (EditorBlock) -> Unit,
onBlur: () -> Unit,
) {
BlockField(
value = block.value,
onValueChange = { onChange(block.copy(value = it)) },
modifier = Modifier.focusRequester(requester),
modifier =
Modifier
.focusRequester(requester)
.onFocusChanged { if (!it.isFocused) onBlur() },
enabled = !readOnly,
hint = R.string.editor_body_hint,
)
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -37,6 +40,11 @@ import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
@@ -44,7 +52,11 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -65,11 +77,51 @@ fun BoardScreen(
onSearch: (String) -> Unit,
onCompose: () -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
update: BoardUpdate?,
onDismissError: () -> Unit,
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val snackbars = remember { SnackbarHostState() }
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
// the moment it scrolls out of view, which would take its dialog down with it —
// and the board can scroll under an open dialog.
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
// Resolved in composition, not inside the coroutine: `stringResource` is a
// composable read and cannot be called from a suspend block.
val trashedMessage = stringResource(R.string.board_trashed)
val undoLabel = stringResource(R.string.board_undo)
// Trash gets an UNDO rather than a confirmation, and the two are not
// interchangeable. A long press is a gesture you can make by accident — resting a
// thumb while reading is enough — so the mistake worth designing for is the one
// nobody meant to make, and a dialog only helps someone who is paying attention
// in the moment they were not. Trash is already recoverable; the snackbar just
// says so where it happened, instead of leaving you to find the Trash view and
// work out which note went missing.
//
// Delete forever keeps its dialog. That one does not undo.
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
onNoteAction(note, action)
if (action == EditorAction.Trash) {
scope.launch {
val outcome =
snackbars.showSnackbar(
message = trashedMessage,
actionLabel = undoLabel,
duration = SnackbarDuration.Short,
)
// `note` is the pre-trash copy and deliberately so: Restore only needs
// its id, and the id is the one thing trashing does not change.
if (outcome == SnackbarResult.ActionPerformed) {
onNoteAction(note, EditorAction.Restore)
}
}
}
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -90,6 +142,22 @@ fun BoardScreen(
},
) {
Scaffold(
// The IME, added to what the Scaffold already insets for. `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 this value, so without it both sit behind the
// keyboard whenever the search field has focus. That is not theoretical: the
// undo on a trashed search hit is exactly the control you cannot reach.
//
// `union` rather than `add` — the two are the same edge, not two stacked ones.
// Adding them would inset by the navigation bar a second time underneath a
// keyboard that already covers it.
//
// One owner for the edge, as with the search bar's missing statusBarsPadding:
// set here, the content Column gets it through `padding` and must not repeat it.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
snackbarHost = { SnackbarHost(snackbars) },
floatingActionButton = {
// The + is the ONLY way in, by design: one obvious target rather
// than a capture bar and a button competing for the same job.
@@ -158,6 +226,8 @@ fun BoardScreen(
notes = state.notes,
onOpenNote = onOpenNote,
onToggleItem = onToggleItem,
onNoteAction = onCardAction,
onConfirmDelete = { confirmingDelete = it },
)
}
// `PullToRefreshBox` would be less code, but it takes no
@@ -171,6 +241,16 @@ fun BoardScreen(
}
}
}
confirmingDelete?.let { note ->
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = null
onNoteAction(note, EditorAction.DeleteForever)
},
onDismiss = { confirmingDelete = null },
)
}
}
}
@@ -361,6 +441,8 @@ private fun NoteBoard(
notes: List<Note>,
onOpenNote: (Note) -> Unit,
onToggleItem: (Note, Int, Boolean) -> Unit,
onNoteAction: (Note, EditorAction) -> Unit,
onConfirmDelete: (Note) -> Unit,
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
@@ -378,6 +460,8 @@ private fun NoteBoard(
note = note,
onOpen = { onOpenNote(note) },
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
onAction = { onNoteAction(note, it) },
onConfirmDelete = { onConfirmDelete(note) },
)
}
}
@@ -354,8 +354,6 @@ class BoardViewModel(
is EditorAction.SaveText ->
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
// Pinning re-sorts the board rather than emptying it, and on a phone
// you often pin while still reading — so unlike the three below, it
// deliberately leaves the editor open.
@@ -511,9 +509,6 @@ class BoardViewModel(
}
}
/** The palette key a note starts on, matching the web and the desktop. */
private const val DEFAULT_COLOR = "default"
// ── pure builders ───────────────────────────────────────────────────────────
//
// Neither of these reads or writes view-model state; they only shape a core input
@@ -529,7 +524,7 @@ private fun draft(content: String): NoteDraft =
// The core names the note from the body's first line, so a captured thought is
// findable without anyone being asked to name it. A checklist is added afterwards,
// in the editor — it is something a note HAS, not a different thing to capture.
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
NoteDraft(body = content, items = null)
/**
* The id a note has before it has been saved.
@@ -545,7 +540,6 @@ private fun blankDraft(): Note =
id = DRAFT_ID,
displayTitle = "",
body = "",
color = DEFAULT_COLOR,
position = 0,
pinned = false,
archived = false,
@@ -1,21 +1,19 @@
package com.fabledsword.thoughtsync.ui
import kotlin.math.abs
// The colour a note has when nothing chose one for it.
// The colour a LABEL wears when nobody picked one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no help
// telling one from the next. Every note now carries some tint; this is where an
// untagged one gets it.
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a tint
// rolled at render time would differ between the phone and the browser and change on
// every reload. Hashing the note's id is deterministic, identical on every surface,
// costs no column and no migration, and a note keeps its colour for life — which is
// what "random" actually meant here.
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
// over the same key order, and the two must agree exactly or a note is one colour on
// over the same key order, and the two must agree exactly or a tag is one colour on
// the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
@@ -27,12 +25,12 @@ import kotlin.math.abs
// mirror gets — see the fixture comment in colors.ts.
/**
* The tints a derived colour can land on: `NOTE_TINTS`' keys minus `default`, which
* is the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence.
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
*
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering
* this list silently recolours every untagged note on one surface only.
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
* list silently recolours every tag on one surface only.
*/
val DERIVED_TINT_KEYS: List<String> =
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
@@ -65,7 +63,8 @@ fun tintHash(id: String): Int {
return hash
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
fun derivedTint(id: String): String {
// Through Long to read the hash as unsigned. A signed remainder would be negative
// for half of all ids and index out of the list.
@@ -74,12 +73,11 @@ fun derivedTint(id: String): String {
}
/**
* The colour key for a LABEL — its chip, and (step 3) every note carrying it.
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
* currently `default`: the server mints one as `Label(owner_id=…, name=name)` with no
* colour, so tag-driven note colour against that would leave the board exactly as
* grey as it was.
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
* so without deriving, a board of tags would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
* a hashed colour written at each of the four places a label can be born — and named
@@ -102,182 +100,3 @@ fun resolvedLabelColor(
name.isEmpty() -> "default"
else -> derivedTint(name.lowercase())
}
/**
* The colour key to actually paint a note with.
*
* 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 note the operator coloured by hand
* changing under them would read as data loss.
*
* `known` is passed in rather than read from `NOTE_TINTS` so this file stays free of
* Compose and therefore testable; `noteTintFor` supplies the real set.
*/
fun resolvedNoteColor(
id: String,
color: String,
labelColor: String,
known: Set<String>,
): String =
when {
color.isNotEmpty() && color != "default" && color in known -> color
// The note's FIRST tag. Passed in already resolved, so this does not need to
// know that a colourless tag derives one from its name.
labelColor.isNotEmpty() -> labelColor
// A draft carries DRAFT_ID (""), so there is no identity to derive from yet.
// Staying white until the note exists costs one colour change at save time;
// hashing the empty string instead would give EVERY draft the same tint and
// then change it anyway, which is two surprises where one will do.
id.isEmpty() -> "default"
else -> derivedTint(id)
}
/**
* Whether a note's colour was CHOSEN rather than derived — which is what decides
* between the two weights the card is drawn at.
*
* A tag (or, until step 5, the picker) means somebody said what this note is. A
* derived tint only means the board should not be a wall of white. Drawing both at
* the same weight is what prompted the operator's "the tints look the same as the
* chosen colors" — the tint was never meant to shout as loudly as a decision.
*/
fun noteColorIsChosen(
color: String,
labelColor: String,
known: Set<String>,
): Boolean = labelColor.isNotEmpty() || (color.isNotEmpty() && color != "default" && color in known)
// ---------------------------------------------------------------------------
// The fill for an UNTAGGED note, which is a different job from the palette above.
//
// The palette has nine keys and they MEAN something: a tag's colour. An untagged
// note's fill means nothing at all — it exists so a board is not a monolithic wall.
// Tying the second job to the first was the mistake. Nine keys is far too few for a
// board of any size, and once the nine were subdued enough not to shout they became
// indistinguishable from each other: measured, the nine dark fills were separated by
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look
// like three is exactly the wall the tint was added to break up.
//
// So this hashes to a colour directly rather than to a key. 324 distinct fills in
// dark, 193 in light, against nine.
//
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old 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 read as one card repeated. Varying lightness too is what makes the
// difference; the hue alone never could at this darkness.
//
// It is only SAFE to vary lightness because the card now has a grey edge of its own
// (see NoteCard.CARD_EDGE_DARK). While the fill was the only boundary the card had,
// it could not afford to drift toward the board. The edge bought that freedom.
/** Lightness steps a derived fill can land on. Six rather than three because the
* levels are what carry the variety, and rather than twelve because past a point
* they stop being distinguishable and only cost contrast headroom. */
private const val TINT_LEVELS = 6
/**
* Saturation is FIXED, and that is what keeps this subtle no matter which hue it
* lands on. Variety comes from hue and lightness; loudness would come from
* saturation, so saturation is the one dial the hash never touches.
*/
private const val DARK_SATURATION = 0.25
private const val LIGHT_SATURATION = 0.60
/**
* Dark starts at 0.090 — a hair under `neutral-900`, the plain card surface — and
* climbs. Nothing is ever darker than an untinted card, so no note recedes into the
* board; they only ever rise off it. Top of the range measures 1.54 against the
* board where the old single level managed 1.14.
*/
private val DARK_LIGHTNESS = doubleArrayOf(0.090, 0.104, 0.118, 0.132, 0.146, 0.160)
/**
* Light runs the other way, from white down toward the `neutral-50` board and just
* past it. A card slightly darker than the board still reads as a card because the
* edge says so — the same freedom the edge bought in dark, spent in the other
* direction.
*/
private val LIGHT_LIGHTNESS = doubleArrayOf(1.000, 0.990, 0.980, 0.970, 0.960, 0.950)
private const val HUE_DEGREES = 360L
private const val LEVEL_BIT_SHIFT = 16
private const val HUE_SECTOR_DEGREES = 60.0
private const val TWO = 2.0
private const val CHANNEL_MAX = 255.0
private const val ROUND_HALF = 0.5
private const val CHANNEL_CEILING = 255
private const val ALPHA_OPAQUE = 0xFF
private const val ALPHA_BIT_SHIFT = 24
private const val RED_BIT_SHIFT = 16
private const val GREEN_BIT_SHIFT = 8
/**
* The opaque ARGB fill an untagged note wears, stable for the life of the note.
*
* Returns an Int rather than a Compose `Color` on purpose: this file stays free of
* `androidx.compose` so `DerivedTintTest` can run on the host JVM, and that test is
* the only mechanical guard the mirror with colors.ts has.
*
* Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a
* function of its hue — two notes of nearly the same hue should still be able to
* differ in weight, which is half of where the variety comes from.
*/
fun derivedFillArgb(
id: String,
dark: Boolean,
): Int {
val hash = tintHash(id).toLong() and UNSIGNED_MASK
val level = ((hash shr LEVEL_BIT_SHIFT) % TINT_LEVELS).toInt()
return hslToArgb(
hue = (hash % HUE_DEGREES).toDouble(),
saturation = if (dark) DARK_SATURATION else LIGHT_SATURATION,
lightness = if (dark) DARK_LIGHTNESS[level] else LIGHT_LIGHTNESS[level],
)
}
/**
* Textbook HSL to RGB, written out rather than pulled from a library because the
* TypeScript side has to compute the same bytes and there is no library both can
* share.
*
* DOUBLE, NOT FLOAT, and that is not a style choice. JavaScript has one number type
* and it is IEEE-754 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 see that as a bug — they would see two
* colours that are "sort of the same" and never work out why. Doubles on both sides
* make it the same arithmetic rather than nearly the same.
*
* Rounding is `floor(v + 0.5)` on both sides, NOT the language's `round`: Kotlin
* rounds half away from zero and JavaScript rounds half up, which agree for the
* non-negative values here, but stating the rule leaves nothing to have to check.
*/
private fun hslToArgb(
hue: Double,
saturation: Double,
lightness: Double,
): Int {
val chroma = (1.0 - abs(TWO * lightness - 1.0)) * saturation
val sector = hue / HUE_SECTOR_DEGREES
val second = chroma * (1.0 - abs(sector % TWO - 1.0))
val match = lightness - chroma / TWO
// The six hue sectors, as a table rather than a `when` — which is also the form
// colors.ts uses, so the two read as the same function rather than as two people's
// idea of it. `sector` is in [0, 6) because the hue it came from is in [0, 360).
val ramps =
listOf(
Triple(chroma, second, 0.0),
Triple(second, chroma, 0.0),
Triple(0.0, chroma, second),
Triple(0.0, second, chroma),
Triple(second, 0.0, chroma),
Triple(chroma, 0.0, second),
)
val (red, green, blue) = ramps[sector.toInt()]
return (ALPHA_OPAQUE shl ALPHA_BIT_SHIFT) or
(channelByte(red + match) shl RED_BIT_SHIFT) or
(channelByte(green + match) shl GREEN_BIT_SHIFT) or
channelByte(blue + match)
}
private fun channelByte(value: Double): Int = (value * CHANNEL_MAX + ROUND_HALF).toInt().coerceIn(0, CHANNEL_CEILING)
@@ -25,10 +25,6 @@ sealed interface EditorAction {
val body: String,
) : EditorAction
data class SetColor(
val color: String,
) : EditorAction
data class SetPinned(
val pinned: Boolean,
) : EditorAction
@@ -142,6 +142,40 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* [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.
*
* **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,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
* leans on that to leave the state alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
val block = getOrNull(index)
if (block == null || block.isTask) return this
val split = splitBlocks(block.value.text, nextId())
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty list, so `first()` is safe.
val changed = split.size > 1 || split.first().isTask
return if (changed) take(index) + split + drop(index + 1) else this
}
/**
* Put the caret at the end of the last block, for an editor that has just opened.
*
@@ -1,7 +1,6 @@
package com.fabledsword.thoughtsync.ui
import android.text.format.DateUtils
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -23,7 +21,6 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
@@ -78,7 +75,6 @@ import com.fabledsword.thoughtsync.core.Note
fun EditorTopBar(
note: Note,
readOnly: Boolean,
tint: NoteTint,
onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit,
@@ -101,18 +97,6 @@ fun EditorTopBar(
},
actions = {
if (!readOnly) {
// A dot in the note's CURRENT colour rather than a palette icon: it
// shows what the colour is as well as what the button does.
IconButton(onClick = { onPicker(Picker.COLOR) }) {
Box(
modifier =
Modifier
.size(SWATCH_DOT)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.border(dark), CircleShape),
)
}
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
Icon(
Icons.Filled.Notifications,
@@ -141,16 +125,18 @@ fun EditorTopBar(
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
// Material derives a bar's content colour from its container via
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
// returns Unspecified for anything else. A note tint is never a role, so the
// icons drew with no colour filter: black vectors on a near-black bar, a
// toolbar that rendered the whole time and was invisible in dark mode.
// returns Unspecified for anything else. The card surface is a plain constant
// and not a role, so the icons drew with no colour filter: black vectors on a
// near-black bar, a toolbar that rendered the whole time and was invisible in
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
// value, so this stays exactly as it is.
//
// onSurface for the actions too, not the default onSurfaceVariant: these sit
// on a tinted bar rather than a scheme surface, and the muted variant does
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
// to read against the card rather than the board, and the muted variant does
// not have the contrast to spare.
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = tint.background(dark),
containerColor = noteCardSurface(dark),
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
@@ -196,11 +182,9 @@ fun EditorTopBar(
fun EditorFooter(
updatedAt: String?,
saving: Boolean,
tint: NoteTint,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier =
modifier
@@ -223,12 +207,20 @@ fun EditorFooter(
)
FilledTonalIconButton(
onClick = onClose,
// The note's own colour rather than the scheme's secondaryContainer,
// which would be the one element on a tinted card ignoring the tint.
// The BRAND, matching the board's compose FAB — the app's one existing
// statement of "this is the affirmative action here", now reused rather
// than a second one invented.
//
// This wore the note's own tint until M315, on the argument that it would
// otherwise be the one element on a tinted card ignoring the tint. There is
// no tint to ignore any more, and the alternative — Material's default
// secondaryContainer — is a baseline M3 colour this theme never sets, so
// taking the default would put an off-brand lilac in the corner of the
// editor.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = tint.chipBackground(dark),
contentColor = tint.chipForeground(dark),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
@@ -295,24 +287,6 @@ private fun OverflowMenu(
}
}
@Composable
private fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
// Close BEFORE acting. An overflow menu left hanging over the sheet
// that just opened underneath it is the classic version of this bug,
// and doing it here means no call site can forget.
onClose()
onClick()
},
)
}
/**
* The note's labels, each removable.
*
@@ -336,9 +310,12 @@ fun EditorLabelRow(
modifier = Modifier.padding(vertical = 2.dp),
) {
Text(
text = label.name,
// `#` on every chip, matching the card. This row still shows the
// tags the BODY owns as well — it is the control surface, and the
// "from tag" hint beside one is what says why it has no cross.
text = "#${label.name}",
style = MaterialTheme.typography.labelLarge,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
modifier =
Modifier
.clip(CircleShape)
@@ -417,6 +394,5 @@ fun EditorReminderRow(
}
}
private val SWATCH_DOT = 22.dp
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,11 +1,7 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -42,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
@@ -57,80 +47,17 @@ import java.time.LocalTime
import java.time.ZoneId
import java.time.temporal.TemporalAdjusters
// The three things you pick rather than type: a colour, a set of labels, a time.
// The two things you pick rather than type: a set of labels, and a time.
//
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
// behind it.
//
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
// the note still visible above it — which matters when the choice you are making
// is about the thing you are looking at.
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColorSheet(
selected: String,
onPick: (String) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.color_picker_title)
// Chunked into fixed rows rather than a flow layout: ten swatches
// always lay out as two rows of five on every phone width, and a flow
// would reshuffle them between devices for no gain.
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
row.forEach { (key, tint) ->
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(SWATCH_SIZE)
.clip(CircleShape)
.background(tint.background(dark))
.border(
// The selected swatch gets a heavier ring
// as well as a tick: on the pale tints the
// tick alone is nearly invisible.
if (key == selected) 2.dp else 1.dp,
if (key == selected) {
MaterialTheme.colorScheme.primary
} else {
tint.border(dark)
},
CircleShape,
).clickable(onClickLabel = tint.label) { onPick(key) },
) {
if (key == selected) {
Icon(
Icons.Filled.Check,
contentDescription = tint.label,
modifier = Modifier.size(18.dp),
)
}
}
}
// Pad a short final row so its swatches line up with the row
// above instead of spreading across the full width.
repeat(SWATCHES_PER_ROW - row.size) {
Box(modifier = Modifier.size(SWATCH_SIZE))
}
}
}
}
}
}
/**
* Every label, ticked where it is on the note.
*
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
"yearly" to R.string.recurrence_yearly,
)
private const val SWATCHES_PER_ROW = 5
private const val EVENING_HOUR = 18
private const val MORNING_HOUR = 8
private val SWATCH_SIZE = 44.dp
private val LABEL_LIST_MAX_HEIGHT = 320.dp
@@ -3,8 +3,10 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -12,17 +14,27 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -30,6 +42,7 @@ import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.BodyItem
import com.fabledsword.thoughtsync.core.Note
import com.fabledsword.thoughtsync.core.NoteLabel
import com.fabledsword.thoughtsync.core.bodyTags
import com.fabledsword.thoughtsync.core.checklistItems
@Composable
@@ -37,9 +50,38 @@ fun NoteCard(
note: Note,
onOpen: () -> Unit,
onToggleItem: (Int, Boolean) -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
var menuOpen by remember { mutableStateOf(false) }
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
// chain-method-continuation wants the next `.` glued to the closing paren of a
// multiline element — `).background(…)` — which is worse to read than a modifier
// with a name. Every other multiline element in this codebase happens to be last
// in its chain, so this is the first place the rule bites.
val opening =
Modifier.combinedClickable(
onClickLabel = stringResource(R.string.board_open_note),
onLongClickLabel = stringResource(R.string.board_note_actions),
onLongClick = {
// Fired HERE rather than when the menu appears. A long press is
// confirmed by the system before the popup has laid out, and the whole
// point of the buzz is to say "that registered" at the moment your
// finger has been still long enough — a menu that arrives with no tick
// under it reads as a phone that missed the gesture and then changed
// its mind.
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
menuOpen = true
},
onClick = onOpen,
)
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
// space, so the card's size is still the Column's.
Box {
Column(
modifier =
Modifier
@@ -48,19 +90,42 @@ fun NoteCard(
// separate a white card from a #fafafa board, and the web's own
// `shadow-sm` is the value it is matching.
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
// Clipped BEFORE clickable, so the ripple is bounded by the card's
// rounded corners instead of a rectangle overhanging them.
// Clipped BEFORE the click modifier, so the ripple is bounded by the
// card's rounded corners instead of a rectangle overhanging them
// and BEFORE padding, so the padded edge is still a tap target.
.clip(RoundedCornerShape(CARD_RADIUS))
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
.background(noteCardColor(note, dark))
// ONE grey edge on every card, regardless of its colour — the tint is
// deliberately not consulted here. See CARD_EDGE_DARK.
.then(opening)
// ONE surface and ONE edge on every card, both neutral, neither
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
.background(noteCardSurface(dark))
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
.padding(12.dp),
) {
// Body then checklist, in order — a note can carry both (M13 step 2), and
// nothing above them: the first line of the body IS the note's name, at the
// same weight as the rest of it (M13 steps 3 and 4).
// TAGS FIRST. They used to sit under everything else, which on a tall note put
// the one thing that says what a note IS below the fold of a glance. A board is
// scanned, not read, and the answer to "which of these is about the thing I am
// looking for" should be the first thing the eye lands on rather than the last.
//
// Above the body rather than beside it, because 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 have tags at all.
//
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
// "backed by body text" since M311, so a chip for one printed the same tag
// twice — once where it was typed, once up here — and the card was carrying
// furniture for information it was already showing. A tag left in prose is
// tinted in place instead; see [tintTags]. What reaches this row is what the
// body cannot say: a tag lifted off its own line, and a label added by hand.
val chips = note.labels.filterNot { it.viaTag }
if (chips.isNotEmpty()) {
LabelChips(labels = chips)
Spacer(Modifier.height(8.dp))
}
// Body then checklist, in order — a note can carry both (M13 step 2). The
// first line of the body IS the note's name, at the same weight as the rest of
// it (M13 steps 3 and 4).
if (note.body.isNotBlank()) {
NoteBody(note = note, onToggleItem = onToggleItem)
}
@@ -76,16 +141,76 @@ fun NoteCard(
)
}
if (note.labels.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LabelChips(labels = note.labels)
}
note.remindAt?.let { at ->
Spacer(Modifier.height(8.dp))
ReminderChip(instant = at, recurrence = note.recurrence)
}
}
NoteMenu(
note = note,
expanded = menuOpen,
onDismiss = { menuOpen = false },
onAction = onAction,
onConfirmDelete = onConfirmDelete,
)
}
}
/**
* What you can do to a note without opening it.
*
* The board used to have none of this, and 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."* Trash was three interactions deep (open, ⋮,
* Move to trash), and on a phone that is far enough from the gesture people reach
* for that it may as well not exist.
*
* **The same items as the editor's overflow, in the same words, from the same string
* resources.** A note has one vocabulary of things that can be done to it, and two
* surfaces that named them differently would be describing two different apps. It
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
* already the exhaustive dispatcher for every one of them, so the board reuses the
* seam rather than growing a parallel one that could drift.
*
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
* gates its own read-only mode on, and it is the only reading that survives the views
* that mix piles: Reminders cuts across archived and active alike, and a search hits
* whatever matches. A menu that offered "Move to trash" on a note already in the
* trash would be offering to do something twice.
*
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
* was already scheduled for removal; M315 removed it. There is no colour to set on a
* note any more — a card is one neutral surface and the only coloured thing on a board
* is a tag — so the row this menu never grew is a row that could not exist.
*
* Labels are absent too, for a duller reason: the picker they open is editor state,
* and hoisting it to the board is a bigger change than the friction actually reported.
*/
@Composable
private fun NoteMenu(
note: Note,
expanded: Boolean,
onDismiss: () -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
onDismiss,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
}
}
/**
@@ -120,13 +245,13 @@ private fun NoteBody(
val found = itemAtLine[n]
when {
found != null ->
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
// Kept as a gap rather than dropped: it is the paragraph break
// somebody typed, and the card reads as a wall without it.
line.isBlank() -> Spacer(Modifier.height(4.dp))
else ->
Text(
text = line,
text = tintTags(line, note),
style = MaterialTheme.typography.bodyMedium,
maxLines = MAX_WRAPPED_LINES,
overflow = TextOverflow.Ellipsis,
@@ -154,6 +279,7 @@ private fun NoteBody(
*/
@Composable
private fun ChecklistRow(
note: Note,
item: BodyItem,
onToggle: () -> Unit,
) {
@@ -167,7 +293,7 @@ private fun ChecklistRow(
.padding(end = 6.dp),
)
Text(
text = item.text,
text = tintTags(item.text, note),
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
color =
@@ -182,6 +308,58 @@ private fun ChecklistRow(
}
}
/**
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
*
* This is what replaced the chip for a tag still living in the prose. The card used to
* print such a tag twice — once where it was typed and once in the row above — and the
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
* that happens to start with the same word". Colouring it in place says the same thing
* with no furniture, and says it more honestly: the token you can see IS the text you
* would delete to remove the tag.
*
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
* lines are checklist items. The grammar already exists three times (Rust, Python,
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
* would fail silently, as the wrong characters tinted rather than an error anywhere.
* The core's offsets are UTF-16 code units for this call site specifically, which is
* the only unit `addStyle` can take.
*
* Called per rendered STRING rather than once per body so a checklist item's text can
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
* that shows up only on the one note that had a tag in a list.
*/
@Composable
private fun tintTags(
text: String,
note: Note,
): AnnotatedString {
val dark = isSystemInDarkTheme()
return remember(text, note.labels, dark) {
val spans = bodyTags(text)
if (spans.isEmpty()) {
AnnotatedString(text)
} else {
// A tag the note does not carry as a label yet — just typed, not yet
// derived — still gets a colour: `labelTint` falls back to deriving one
// from the name, which is what the chip would have shown anyway.
val picked = note.labels.associate { it.name.lowercase() to it.color }
buildAnnotatedString {
append(text)
spans.forEach { tag ->
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
addStyle(
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
tag.start.toInt(),
tag.end.toInt(),
)
}
}
}
}
}
@Composable
private fun LabelChips(labels: List<NoteLabel>) {
val dark = isSystemInDarkTheme()
@@ -191,9 +369,13 @@ private fun LabelChips(labels: List<NoteLabel>) {
labels.take(MAX_LABEL_CHIPS).forEach { label ->
val tint = labelTintFor(label.name, label.color)
Text(
text = label.name,
// The `#` is carried on every chip, because everything that reaches
// this row is a tag — a tag lifted off its own line, or one attached
// through the picker — and the hash is how you would type either. It
// also keeps a lifted chip reading as the `#todo` somebody wrote.
text = "#${label.name}",
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
color = tint.tagInk(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
@@ -244,29 +426,84 @@ private const val MAX_LABEL_CHIPS = 3
private val CARD_RADIUS = 12.dp
private val CARD_ELEVATION = 1.dp
// THE CARD'S EDGE — one grey, every card, both weights, all ten colour keys. It is a
// constant here rather than a column in NoteTint precisely so the palette CANNOT vary
// it; uniformity is the feature.
// ---------------------------------------------------------------------------
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
//
// The version of this that came from the palette was a `{hue}-900` border, and it
// failed twice over: the line measured 1.56-2.09 against its own fill while the fill
// managed only 1.03-1.05 against the board, so it was the loudest thing on the card
// and it carried the same information the fill did. A field of cards read as a grid of
// outlines however different the colours inside were. A neutral line carries no
// information at all, which is exactly what lets it be structure instead of content.
// Both are constants HERE rather than columns in NoteTint precisely so the palette
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
// too, so a note opened is the same object as the note on the board.
/**
* THE CARD SURFACE — one neutral per theme (M315).
*
* This used to be a function of the note: a palette fill for a tagged one, a colour
* generated from the id for the rest. Both are gone. The operator's verdict after four
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
* to make the color work and somehow it never seems to land" — and the diagnosis under
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
* keys is too few to identify anything on a board of any size, and a generated fill
* identifies nothing by construction, so a coloured board taught the eye to read hue
* as significant and then handed it noise. Colour lives on the TAG now, where the
* thing it names is right beside it.
*
* The values are `neutral-900` on dark and white on light — exactly what the palette's
* `default` always was, and exactly what the web card and both editors already use, so
* this is a collapse onto a surface every surface already had rather than a new colour
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
* (`bg-white dark:bg-neutral-900`).
*
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
* and Material's `surfaceContainer` roles are unset here so they would resolve to
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
* a role that nearly does.
*
* Measured, against the operator's "not the same color as their background but close
* to it" — the card fill is deliberately the WEAKEST number on the card:
*
* card vs board light #FFFFFF on #FAFAFA 1.04
* dark #171717 on #0A0A0A 1.10
* edge vs card light #B8B8B8 on #FFFFFF 1.98
* dark #404040 on #171717 1.73
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
* dark #FAFAFA on #171717 17.17
* muted vs card light #404040 on #FFFFFF 10.37
* dark #E5E5E5 on #171717 14.23
*
* A card is not separated from the board by its fill and never was — the edge and the
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
* that separated on its own would be a panel, and a board of panels is the wall this
* whole line of work started from.
*/
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
private val CARD_SURFACE_DARK = Color(0xFF171717)
// THE CARD'S EDGE — one grey, every card, both themes. Since M315 it is the only thing
// that differs from the board by more than a hair, which makes it structure rather than
// decoration: it is what a card IS.
//
// The two values are MATCHED rather than chosen by eye: each measures ~1.6-1.7 against
// the card it edges (light 1.57-1.98 across all twenty fills, dark 1.58-1.73), so the
// edge reads with the same authority in either theme. Dark is `neutral-700`, which is
// 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`, neither of which
// lands in range: 300 fades to 1.18 on a gray-tagged card, 400 jumps to 2.52 and reads
// as a wireframe.
// It was already neutral before the fill was. The version that came from the palette
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
// loudest thing on the card — and it carried the same information the fill did, so a
// field of cards read as a grid of outlines however different the colours inside were.
// A neutral line carries no information at all, which is exactly what lets it be
// structure instead of content. The fill is that same argument one size up.
//
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
// and the complaint that started M315 was about fill, never about edge weight. If an
// operator pass disagrees it is one constant, in two files.
//
// NOT a translucent black/white edge, which is the tidier way to write this and was
// measured and rejected: a border composites over the card's own fill, so `White` at
// 20% comes out #56396D on a purple card and #A3C9C1 on a teal one. Hue-coded edges are
// the thing being removed.
// measured and rejected: a border composites over what is under it, so `White` at 20%
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
// and two surfaces stating the same hex is how they stay the same card.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -12,13 +12,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -27,9 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
import kotlinx.coroutines.delay
@@ -44,8 +39,9 @@ import kotlinx.coroutines.delay
* negotiating with the IME for the bottom half of the display, and the swipe-down it
* buys is a gesture back already does. The shape is what was worth keeping.
*
* The note's own colour paints the WHOLE card rather than a panel inside it, so
* opening a note reads as the same object growing to fill the display.
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
* a note reads as the same object growing to fill the display — the more literally
* true since M315, where the board and the editor became the same one neutral.
*
* No save button, deliberately. Writes are continuous, so a button offering to do
* what already happened would be a lie with a tap attached; [EditorFooter] in the
@@ -63,7 +59,6 @@ fun NoteEditorScreen(
onAction: (EditorAction) -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTintFor(note)
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
// needs a key — but a draft's id changes the moment it is first saved, and
@@ -155,24 +150,23 @@ fun NoteEditorScreen(
Surface(
modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
color = noteCardColor(note, dark),
color = noteCardSurface(dark),
// Both content colours are spelled out for the reason the toolbar had to
// be: 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, so the default publishes
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
// Unspecified as LocalContentColor and everything inside that does not
// set its own colour draws black — which is how the last toolbar became
// invisible in dark mode.
contentColor = MaterialTheme.colorScheme.onSurface,
) {
Scaffold(
containerColor = noteCardColor(note, dark),
containerColor = noteCardSurface(dark),
contentColor = MaterialTheme.colorScheme.onSurface,
topBar = {
EditorTopBar(
note = note,
readOnly = readOnly,
tint = tint,
onClose = leave,
onStartChecklist = {
val (next, id) = blocks.plusTask()
@@ -192,7 +186,6 @@ fun NoteEditorScreen(
EditorFooter(
updatedAt = note.updatedAt,
saving = saving,
tint = tint,
onClose = leave,
)
},
@@ -264,31 +257,18 @@ fun NoteEditorScreen(
)
if (confirmingDelete) {
// The only irreversible action in the app earns the only confirmation in
// it. Everything else — archive, trash, even unlinking a server — undoes.
AlertDialog(
onDismissRequest = { confirmingDelete = false },
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = {
ConfirmDeleteDialog(
onConfirm = {
confirmingDelete = false
onAction(EditorAction.DeleteForever)
}) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = false }) {
Text(stringResource(R.string.editor_cancel))
}
},
onDismiss = { confirmingDelete = false },
)
}
}
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
enum class Picker { NONE, COLOR, LABELS, REMINDER }
enum class Picker { NONE, LABELS, REMINDER }
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
@Composable
@@ -302,15 +282,6 @@ private fun EditorOverlays(
val dismiss = { onPicker(Picker.NONE) }
when (picker) {
Picker.NONE -> Unit
Picker.COLOR ->
ColorSheet(
selected = note.color,
onPick = {
onAction(EditorAction.SetColor(it))
dismiss()
},
onDismiss = dismiss,
)
Picker.LABELS ->
LabelSheet(
note = note,
@@ -3,23 +3,25 @@ package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import com.fabledsword.thoughtsync.core.Note
/**
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
*
* A note's colour is stored by the core as a key ("red", "teal", …) and every
* surface resolves it to its own tints. The web app resolves through Tailwind
* classes; this table is those same Tailwind colours as literals, so a note that
* is amber on the desktop is the same amber on the phone rather than a near-miss.
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
* A colour is stored by the core as a key ("red", "teal", …) and every surface
* resolves it to its own tints. The web app resolves through Tailwind classes; this
* table is those same Tailwind colours as literals, so a tag that is amber on the
* desktop is the same amber on the phone rather than a near-miss. Generated from
* tailwindcss 3.4's palette rather than transcribed by eye.
*
* Dark tints keep the web's ALPHA (`dark:bg-red-950/70`) instead of a precomputed
* blend — Compose composites a translucent colour over what's beneath exactly as CSS
* does, so the card sits on the background the same way in both.
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
* background the same way in both.
*
* This table is the palette of MEANINGFUL colours: a tag's. The fill an untagged note
* wears is generated rather than looked up, and lives in DerivedTint.kt.
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
* edge, where the palette cannot reach either of them. What is left here is the chip,
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
* are.
*
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
* acid against the neutral surfaces.
@@ -31,88 +33,101 @@ data class NoteTint(
val darkBackground: Color,
val darkBorder: Color,
val lightChipBackground: Color,
val lightChipForeground: Color,
val darkChipBackground: Color,
/**
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
*
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
* otherwise). They stay a per-hue column because they are transcribed from the
* web's literals rather than derived from anything here.
*/
val lightChipForeground: Color,
val darkChipForeground: Color,
/**
* False only for `default`, which is the ABSENCE of a colour rather than one of
* them. A tint can be drawn at the chosen weight (see [chosenBackground]);
* `default` cannot, because there is no such thing as an emphatic lack of colour —
* and re-alphaing its opaque neutral fill would make a draft card translucent.
* THE INK A TAG IS DRAWN IN — inline in the prose AND as a chip's text — see
* [tagInk].
*
* These were two columns until M315, and the split was real while it lasted: a chip
* brought its own `-100` fill and could afford `-700`, while inline text sat on
* whatever the card was, which included a gray-tagged card at `neutral-200` where
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
* body text needs. One step deeper cleared every fill at once.
*
* The twenty card fills that split was solving for are gone, so both jobs take this
* one value. The direction is deliberate: since M311 a tag whose text is in the body
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
* common case and collapsing onto ITS column leaves what is seen most exactly as it
* was. The chip is strictly better for the move — on its own fill it goes from
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
* held the same value for all ten hues.
*/
val tintable: Boolean = true,
val lightTagInk: Color,
val darkTagInk: Color,
) {
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
/**
* The fill for a note whose colour was CHOSEN — by a tag, or (until step 5) by
* the picker. A note with no tag does not come through here at all; see
* [noteCardColor].
*
* That split IS the design. The palette's nine keys mean something: which tag.
* An untagged note's fill means nothing, and tying the two together is what left
* the board monolithic — nine keys is far too few for a board of any size, and
* subdued enough not to shout they became indistinguishable from one another
* (measured: the nine dark fills sat within a 1.03 contrast of each other).
* Meaning gets a palette; texture gets a generator.
*
* THE CARD'S EDGE IS NOT A TINT and never comes from here. It used to be a 1px
* `{hue}-900` border measuring 1.562.09 against its own fill while the fill
* managed 1.031.05 against the board — the loudest thing on every card saying
* exactly what the fill already said, so a field of them read as a grid of
* outlines. The edge is now one grey for all ten keys, held as a constant in
* `NoteCard.kt` where the palette cannot reach it. [border] is untouched and
* still serves panels, banners, the update card and the pickers.
*
* Light is one Tailwind step deeper (`-100`, which is exactly
* [lightChipBackground] — already in this table, so no new hex is transcribed);
* dark is the `-950` at [STRONG_DARK_ALPHA].
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
* places that borrow a hue to say what they are. Not a note's: since M315 a card
* has one neutral surface and does not come through this table at all.
*/
fun chosenBackground(dark: Boolean): Color =
when {
!tintable -> background(dark)
dark -> darkBackground.copy(alpha = STRONG_DARK_ALPHA)
else -> lightChipBackground
}
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
/**
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
*
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
* one value; making the tag ink one step deeper (M315) is where they parted, and
* moving the reminder with it would have silently broken that mirror instead.
*/
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
/**
* A hairline edge for a chip, in its own text colour at low alpha.
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
*
* Needed because a chip's fill can no longer be trusted to differ from what is
* behind it. A tagged note takes its FIRST tag's colour, and that card is drawn
* at exactly [lightChipBackground] — the same value the chip uses — so in light
* mode the pill was measured at a contrast ratio of 1.00 against the card it had
* itself coloured. Perfectly invisible: the tag name read as loose text.
*
* An edge rather than shifting the fill, because the fill can collide with any
* card colour and chasing that needs the chip to know what it is sitting on. A
* border in the chip's own foreground always reads, against any background, and
* needs no plumbing.
* A tag whose text is in the body is no longer repeated as a chip (the card was
* printing every tag twice — once where it was typed, once at the top). It is
* tinted in place instead, which is both less furniture and a more honest card:
* the thing you see IS the thing you would delete to remove the tag.
*/
fun chipBorder(dark: Boolean): Color = chipForeground(dark).copy(alpha = CHIP_EDGE_ALPHA)
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
/**
* A hairline edge for a chip, in its own ink at low alpha.
*
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
* 1.02 is literally invisible. Without this the tag name would read as loose text.
* The fill only tints a shape the edge is drawing.
*
* An edge rather than a heavier fill, because a fill loud enough to hold its own
* shape would be the loudest thing on a board whose whole point is now that the tag
* is the one coloured thing on it.
*/
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
}
// How strongly a chip's edge is drawn, as a fraction of its own text colour.
// How strongly a chip's edge is drawn, as a fraction of its own ink.
//
// 0.60 measured, not guessed: against the worst case — a chip on a card of its own
// colour — this is a 2.32:1 boundary, where 0.30 gave only 1.49:1 and was effectively
// no edge at all. It does NOT reach the 3:1 of WCAG 1.4.11, which needs 0.80 and
// draws a hard outline rather than a hairline. 1.4.11 governs boundaries that carry
// REQUIRED information, and a chip's information is its text — which passes AA at
// 8:1 or better on every card in the palette. The edge restores the pill's shape;
// it does not carry the meaning. Raise to 0.80 if that judgment is ever overruled.
private const val CHIP_EDGE_ALPHA = 0.60f
// The chosen weight in dark, as a fraction. Mirrors `dark:bg-{hue}-950/70` in
// colors.ts. There is no counterpart any more: an untagged note's fill is generated
// rather than drawn from this table at a second weight. See DerivedTint.kt.
private const val STRONG_DARK_ALPHA = 0.70f
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
// picked against the worst case of the time: a chip on a card of its OWN colour, back
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
// chip's information is its text.
//
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
// a hairline does the job.
//
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
private const val CHIP_EDGE_ALPHA = 0.65f
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
val NOTE_TINTS: Map<String, NoteTint> =
@@ -128,7 +143,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF525252),
darkChipBackground = Color(0x1AFFFFFF),
darkChipForeground = Color(0xFFD4D4D4),
tintable = false,
lightTagInk = Color(0xFF404040),
darkTagInk = Color(0xFFD4D4D4),
),
"red" to
NoteTint(
@@ -141,6 +157,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFB91C1C),
darkChipBackground = Color(0x80450A0A),
darkChipForeground = Color(0xFFFCA5A5),
lightTagInk = Color(0xFF991B1B),
darkTagInk = Color(0xFFFCA5A5),
),
"orange" to
NoteTint(
@@ -153,6 +171,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFC2410C),
darkChipBackground = Color(0x80431407),
darkChipForeground = Color(0xFFFDBA74),
lightTagInk = Color(0xFF9A3412),
darkTagInk = Color(0xFFFDBA74),
),
"yellow" to
NoteTint(
@@ -165,6 +185,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF92400E),
darkChipBackground = Color(0x80451A03),
darkChipForeground = Color(0xFFFCD34D),
lightTagInk = Color(0xFF92400E),
darkTagInk = Color(0xFFFCD34D),
),
"green" to
NoteTint(
@@ -177,6 +199,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF15803D),
darkChipBackground = Color(0x80052E16),
darkChipForeground = Color(0xFF86EFAC),
lightTagInk = Color(0xFF166534),
darkTagInk = Color(0xFF86EFAC),
),
"teal" to
NoteTint(
@@ -189,6 +213,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF0F766E),
darkChipBackground = Color(0x80042F2E),
darkChipForeground = Color(0xFF5EEAD4),
lightTagInk = Color(0xFF115E59),
darkTagInk = Color(0xFF5EEAD4),
),
"blue" to
NoteTint(
@@ -201,6 +227,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF1D4ED8),
darkChipBackground = Color(0x80172554),
darkChipForeground = Color(0xFF93C5FD),
lightTagInk = Color(0xFF1E40AF),
darkTagInk = Color(0xFF93C5FD),
),
"purple" to
NoteTint(
@@ -213,6 +241,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF7E22CE),
darkChipBackground = Color(0x803B0764),
darkChipForeground = Color(0xFFD8B4FE),
lightTagInk = Color(0xFF6B21A8),
darkTagInk = Color(0xFFD8B4FE),
),
"pink" to
NoteTint(
@@ -225,6 +255,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFFBE185D),
darkChipBackground = Color(0x80500724),
darkChipForeground = Color(0xFFF9A8D4),
lightTagInk = Color(0xFF9D174D),
darkTagInk = Color(0xFFF9A8D4),
),
"gray" to
NoteTint(
@@ -237,6 +269,8 @@ val NOTE_TINTS: Map<String, NoteTint> =
lightChipForeground = Color(0xFF404040),
darkChipBackground = Color(0xFF404040),
darkChipForeground = Color(0xFFE5E5E5),
lightTagInk = Color(0xFF262626),
darkTagInk = Color(0xFFE5E5E5),
),
)
@@ -251,64 +285,6 @@ val NOTE_TINTS: Map<String, NoteTint> =
@ReadOnlyComposable
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
/**
* The tint for a NOTE, which is not the same thing as looking up its stored key: a
* note that has no colour of its own gets one derived from its id, so that a board
* of untagged notes reads as individual items rather than a wall of white.
*
* See `DerivedTint.kt` — the rule lives there, free of Compose, so it can be tested.
*/
@Composable
@ReadOnlyComposable
fun noteTintFor(note: Note): NoteTint =
noteTint(resolvedNoteColor(note.id, note.color, firstLabelColor(note), NOTE_TINTS.keys))
/**
* Whether this note's colour was chosen rather than generated — see [noteCardColor].
*
* Not `@Composable`: the card needs it alongside `isSystemInDarkTheme()`, and keeping
* it an ordinary function means it can be read anywhere the note is.
*/
private fun noteIsStrong(note: Note): Boolean = noteColorIsChosen(note.color, firstLabelColor(note), NOTE_TINTS.keys)
/**
* The single answer to "what colour is this card", from either of the two sources.
*
* A tagged note takes its tag's colour out of [NOTE_TINTS]; an untagged one gets a
* fill generated from its id, which is not a palette key at all. Callers ask this
* rather than choosing between them, so the two paths cannot drift apart between the
* board and the editor.
*
* A draft carries DRAFT_ID ("") and stays on the plain surface — there is no identity
* to derive from yet, and hashing the empty string would give every draft the same
* fill and then change it at save time anyway.
*/
@Composable
@ReadOnlyComposable
fun noteCardColor(
note: Note,
dark: Boolean,
): Color =
when {
noteIsStrong(note) -> noteTintFor(note).chosenBackground(dark)
note.id.isEmpty() -> NOTE_TINTS.getValue("default").background(dark)
else -> Color(derivedFillArgb(note.id, dark))
}
/**
* The colour of the note's FIRST label, already resolved, or "" when it has none.
*
* First rather than any other: it is the one the person controls by typing, where
* alphabetical or most-used would move a note's colour when an unrelated tag was
* added somewhere else. Manual labels count the same as `#tags` — someone looking at
* a chip cannot tell which kind they made, and two identically-tagged notes in
* different colours for an invisible reason is worse than the rule being loose.
*/
private fun firstLabelColor(note: Note): String {
val first = note.labels.firstOrNull() ?: return ""
return resolvedLabelColor(first.name, first.color, NOTE_TINTS.keys)
}
/**
* The tint for a LABEL, derived from its name when nobody has picked one.
*
@@ -321,4 +297,17 @@ private fun firstLabelColor(note: Note): String {
fun labelTintFor(
name: String,
color: String,
): NoteTint = noteTint(resolvedLabelColor(name, color, NOTE_TINTS.keys))
): NoteTint = labelTint(name, color)
/**
* [labelTintFor] with no composable context, for a caller building its value inside
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
* colours are computed there, once per body rather than once per recomposition.
*
* One implementation, two entry points: the composable one delegates here rather than
* repeating the lookup, so the chip and the inline token cannot resolve differently.
*/
fun labelTint(
name: String,
color: String,
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
@@ -1,5 +1,6 @@
package com.fabledsword.thoughtsync.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -79,6 +82,66 @@ fun Notice(
}
}
/**
* One row of a dropdown menu, closing the menu before it acts.
*
* Lives here rather than beside either menu because there are two now — the
* editor's overflow and the board's long-press menu — and they offer the same
* actions in the same words. A second copy of this would be a second place for the
* closing order to be got wrong.
*
* Closing FIRST is the whole point: an action that raises a sheet or a dialog would
* otherwise do it underneath a menu still hanging over the screen. Doing it in here
* means no call site can forget.
*/
@Composable
fun MenuItem(
@StringRes labelRes: Int,
onClose: () -> Unit,
onClick: () -> Unit,
) {
DropdownMenuItem(
text = { Text(stringResource(labelRes)) },
onClick = {
onClose()
onClick()
},
)
}
/**
* The one confirmation in the app.
*
* Delete-forever is the only irreversible thing a note can be asked to do —
* archive, trash, even unlinking a server all undo — so it is the only one that
* interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow
* and the board's long-press menu are two ways to the same act, and two dialogs
* would be two chances to word the consequences differently.
*
* Nothing about a note is passed in. The caller already knows which note it is
* asking about and holds it while this is on screen; taking one here would only let
* the dialog and the action that follows it disagree.
*/
@Composable
fun ConfirmDeleteDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.editor_delete_forever_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
},
)
}
/** The three tones a panel or notice can take, mapped onto the note palette. */
enum class Tone { NEUTRAL, WARN, ERROR }
+8 -1
View File
@@ -14,6 +14,14 @@
<!-- Board -->
<string name="board_empty_note">Empty note</string>
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
note has one vocabulary of things you can do to it, and a board that said
"Delete" where the editor says "Move to trash" would be describing two
different apps. Only the wrapper and the undo need words of their own. -->
<string name="board_note_actions">Note actions</string>
<string name="board_trashed">Moved to trash</string>
<string name="board_undo">Undo</string>
<!-- Empty states. Each destination says something true of ITSELF; a single
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
<string name="board_empty_title">Nothing here yet</string>
@@ -59,7 +67,6 @@
<string name="editor_delete_forever_confirm">Delete</string>
<!-- Pickers -->
<string name="color_picker_title">Color</string>
<string name="label_picker_title">Labels</string>
<string name="label_new_hint">Type a label and press enter</string>
<string name="label_from_tag">from #tag</string>
@@ -5,14 +5,20 @@ import org.junit.Assert.assertNotEquals
import org.junit.Test
/**
* Pins the derived-tint rule against `frontend/src/notes/colors.ts`.
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
*
* These are not tests of Kotlin — they are the ONE mechanical guard the mirrored pair
* has. The web side is TypeScript with no test runner (its CI lane is `vue-tsc
* --noEmit` and nothing else), so if these values drift, nothing on that surface will
* say so and a note will simply be a different colour on the phone than in the
* browser. The same four ids and hashes are written into colors.ts as a comment;
* changing either side means changing both and re-checking here.
* say so and a tag will simply be a different colour on the phone than in the browser.
* The same names and hashes are written into colors.ts as a comment; changing either
* side means changing both and re-checking here.
*
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
* the resolution order that chose between a picked colour, a tag's and a generated one
* — went with the code it guarded when the card became one neutral. The four UUID
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
* colour from an id any more, only from a tag's name.
*/
class DerivedTintTest {
@Test
@@ -27,7 +33,7 @@ class DerivedTintTest {
}
@Test
fun `tints match the fixture shared with the web`() {
fun `colours match the fixture shared with the web`() {
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
@@ -36,12 +42,11 @@ class DerivedTintTest {
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
* index out of the list for those. The bug this catches is a crash, not a wrong
* colour, so it is worth more than one id's worth of coverage. */
* colour, so it is worth more than one name's worth of coverage. */
@Test
fun `every tint is a real palette key, over many ids`() {
fun `every derived colour is a real palette key, over many names`() {
for (n in 0 until 2000) {
val tint = derivedTint("note-$n")
assertEquals(true, tint in DERIVED_TINT_KEYS)
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
}
}
@@ -51,8 +56,8 @@ class DerivedTintTest {
assertEquals(9, DERIVED_TINT_KEYS.size)
}
/** The order IS the mapping — reordering silently recolours every untagged note
* on one surface only. Written out longhand so a reorder fails here loudly. */
/** The order IS the mapping — reordering silently recolours every tag on one
* surface only. Written out longhand so a reorder fails here loudly. */
@Test
fun `key order matches colors ts`() {
assertEquals(
@@ -61,76 +66,8 @@ class DerivedTintTest {
)
}
@Test
fun `an explicitly picked colour still wins`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("teal", resolvedNoteColor("any-id", "teal", "", known))
}
@Test
fun `an unknown colour key falls back to the derived tint`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "00000000-0000-0000-0000-000000000000"
assertEquals("purple", resolvedNoteColor(id, "chartreuse", "", known))
}
/** `default` is not a choice, it is the absence of one — so a note stored as
* `default` gets a derived tint rather than staying white. That is the whole
* point of the change. */
@Test
fun `a default colour is treated as no colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val id = "11111111-1111-1111-1111-111111111111"
assertEquals("blue", resolvedNoteColor(id, "default", "", known))
assertNotEquals("default", resolvedNoteColor(id, "default", "", known))
}
/** A draft has no id yet. It must not be hashed — see the comment in DerivedTint. */
@Test
fun `a draft stays default until it has an id`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedNoteColor("", "", "", known))
assertEquals("default", resolvedNoteColor("", "default", "", known))
}
/** The point of the whole milestone: notes sharing a tag share a colour, however
* different their ids. Four `#todo` notes in four colours is what started this. */
@Test
fun `notes sharing a tag share a colour whatever their ids`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
val a = resolvedNoteColor("11111111-1111-1111-1111-111111111111", "default", todo, known)
val b = resolvedNoteColor("6ba7b810-9dad-11d1-80b4-00c04fd430c8", "default", todo, known)
assertEquals(todo, a)
assertEquals(a, b)
// ...and it is NOT what either id would have derived on its own.
assertNotEquals(derivedTint("11111111-1111-1111-1111-111111111111"), a)
}
/** Until step 5 removes the picker, a hand-picked colour outranks the tag. */
@Test
fun `an explicit note colour still beats its tag`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertNotEquals("teal", todo)
assertEquals("teal", resolvedNoteColor("any-id", "teal", todo, known))
}
/** Strength is not a second decision — it IS whether the colour was chosen. */
@Test
fun `only a chosen colour is drawn strongly`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
val todo = resolvedLabelColor("todo", "default", known)
assertEquals(true, noteColorIsChosen("default", todo, known))
assertEquals(true, noteColorIsChosen("teal", "", known))
assertEquals(false, noteColorIsChosen("default", "", known))
assertEquals(false, noteColorIsChosen("", "", known))
// An unrecognised key is not a choice — it is data we could not read.
assertEquals(false, noteColorIsChosen("chartreuse", "", known))
}
/** A tag with no colour of its own derives one from its NAME, which is what makes
* every `#todo` note the same colour rather than nine different ones. */
* every `#todo` chip the same colour rather than nine different ones. */
@Test
fun `a label with no colour derives one from its name`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
@@ -159,13 +96,29 @@ class DerivedTintTest {
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
}
/** An unreadable key is not a choice — it is data from a server newer than this
* client, and the tag should still be drawn as something. */
@Test
fun `an unknown colour key falls back to the derived colour`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
}
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
@Test
fun `a nameless label stays default`() {
val known = DERIVED_TINT_KEYS.toSet() + "default"
assertEquals("default", resolvedLabelColor("", "", known))
assertEquals("default", resolvedLabelColor("", "default", known))
}
/**
* The spread is real, and collisions are real too.
*
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
* both green. Colour is a hint that two notes are related, never a claim that they
* carry the same tag; the chip's TEXT is what says which tag it is.
* both green. Colour is a hint that two chips are distinct, never a claim that two
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
*/
@Test
fun `different tag names spread across the palette`() {
@@ -175,123 +128,10 @@ class DerivedTintTest {
assertEquals(true, colours.toSet().size >= 5)
}
/** The reason the feature exists: two adjacent notes should not look identical. */
/** The reason the feature exists: two tags on one board should not look identical. */
@Test
fun `the tint spreads across the palette`() {
fun `the derived colour spreads across the palette`() {
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
}
// ---------------------------------------------------------------------
// derivedFillArgb — the generated fill an UNTAGGED note wears.
//
// A different job from the palette above, and pinned separately. The palette's
// nine keys mean "which tag"; this means nothing at all and exists so a board is
// not a monolithic wall. It was nine keys once, and measured, those nine dark
// fills sat within a 1.03 contrast of one another — nine tints that looked like
// three. These are the tests that stop that happening again.
/** The web has no test runner and cannot even be EXECUTED on the dev machine
* (no node), so this fixture is the only place the two implementations are ever
* actually compared. The same four ids and hexes are a comment in colors.ts. */
@Test
fun `generated fills match the fixture shared with the web`() {
assertEquals("#192a29", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = true)))
assertEquals("#152114", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = true)))
assertEquals("#111d14", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = true)))
assertEquals("#2a191b", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = true)))
assertEquals("#f3fcfb", hex(derivedFillArgb("00000000-0000-0000-0000-000000000000", dark = false)))
assertEquals("#fbfefb", hex(derivedFillArgb("11111111-1111-1111-1111-111111111111", dark = false)))
assertEquals("#ffffff", hex(derivedFillArgb("6ba7b810-9dad-11d1-80b4-00c04fd430c8", dark = false)))
assertEquals("#fcf3f4", hex(derivedFillArgb("f47ac10b-58cc-4372-a567-0e02b2c3d479", dark = false)))
}
/** The whole reason this replaced the nine-key ramp. Two orders of magnitude more
* fills than the palette could offer, so a board of any size stops repeating. */
@Test
fun `the generated fill spreads far wider than the palette ever could`() {
val dark = (0 until 500).map { derivedFillArgb("note-$it", dark = true) }.toSet()
val light = (0 until 500).map { derivedFillArgb("note-$it", dark = false) }.toSet()
assertEquals(true, dark.size > 200)
assertEquals(true, light.size > 100)
// ...and far more than the nine it replaced, which is the actual claim.
assertEquals(true, dark.size > DERIVED_TINT_KEYS.size * 20)
}
/**
* Lightness is the axis that carries the variety, so it is the one worth pinning.
*
* The bug this catches is the one that shipped: a ramp that varies hue while
* holding lightness fixed reads as one card repeated, because the eye separates
* by lightness first. If someone collapses these levels again, the fills will
* still all be "different colours" and the board will still be a wall.
*/
@Test
fun `generated fills vary in lightness, not only in hue`() {
val levels = (0 until 500).map { luminance(derivedFillArgb("note-$it", dark = true)) }.toSet()
assertEquals(true, levels.size >= 6)
assertEquals(true, levels.max() / levels.min() > 2.0)
}
/** Nothing may be darker than the plain card surface (`neutral-900`, #171717) in
* dark, or the note recedes into the near-black board instead of sitting on it. */
@Test
fun `no generated fill sinks below the card surface`() {
val surface = luminance(0xFF171717.toInt())
for (n in 0 until 500) {
val l = luminance(derivedFillArgb("note-$n", dark = true))
assertEquals(true, l >= surface * 0.98)
}
}
/** Body text is drawn on these. AA wants 4.5:1 and the meta row 3:1; the margin
* here is enormous, and the test is what keeps it that way if the levels move. */
@Test
fun `every generated fill keeps body text well clear of AA`() {
for (n in 0 until 500) {
assertEquals(true, contrast(0xFFD4D4D4.toInt(), derivedFillArgb("note-$n", dark = true)) >= 4.5)
assertEquals(true, contrast(0xFF404040.toInt(), derivedFillArgb("note-$n", dark = false)) >= 4.5)
}
}
/** Same id, same fill, forever — a note that changed colour on reload would read
* as corruption. The point of hashing rather than rolling a die. */
@Test
fun `the generated fill is stable for an id`() {
val id = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
assertEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = true))
assertNotEquals(derivedFillArgb(id, dark = true), derivedFillArgb(id, dark = false))
}
/** Every fill is fully opaque. A translucent one would composite over whatever is
* behind it, and the editor sheet is not the board — the same note would be two
* colours depending on where you were looking at it. */
@Test
fun `generated fills are opaque`() {
for (n in 0 until 100) {
assertEquals(0xFF, (derivedFillArgb("note-$n", dark = true) ushr 24) and 0xFF)
}
}
private fun hex(argb: Int): String = "#%06x".format(argb and 0xFFFFFF)
private fun channel(argb: Int, shift: Int): Double {
val c = ((argb ushr shift) and 0xFF) / 255.0
return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
/** WCAG relative luminance, so the assertions above are about what an eye sees
* rather than about the bytes. */
private fun luminance(argb: Int): Double =
0.2126 * channel(argb, 16) + 0.7152 * channel(argb, 8) + 0.0722 * channel(argb, 0)
private fun contrast(
a: Int,
b: Int,
): Double {
val la = luminance(a)
val lb = luminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
}
+17 -5
View File
@@ -43,8 +43,8 @@ use thoughtsync_core::sync::blobs::BlobStore;
use thoughtsync_core::sync::{client, compat, engine, push, state};
use models::{
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
patch_from, BodyItem, BodyTag, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit,
NoteQuery, ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
};
uniffi::setup_scaffolding!();
@@ -530,6 +530,21 @@ pub fn checklist_items(body: String) -> Vec<BodyItem> {
.collect()
}
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
/// a card can colour the tag where it was typed instead of printing it twice.
///
/// The same argument as `checklist_items` above, and the same answer: the grammar for
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
/// fourth time in Compose would be a fourth place for a tag to change shape when it
/// syncs — and this one would fail silently, as the wrong characters tinted.
#[uniffi::export]
pub fn body_tags(body: String) -> Vec<BodyTag> {
local::derive::extract_tag_spans(&body)
.into_iter()
.map(BodyTag::from)
.collect()
}
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
/// block names, so these stay Rust-side.
impl ThoughtSync {
@@ -602,7 +617,6 @@ mod tests {
fn draft(body: &str) -> NoteDraft {
NoteDraft {
body: body.to_string(),
color: "default".to_string(),
items: None,
}
}
@@ -656,7 +670,6 @@ mod tests {
let created = app
.create_note(NoteDraft {
body: String::new(),
color: "default".to_string(),
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
})
.expect("create should succeed");
@@ -692,7 +705,6 @@ mod tests {
let note = app
.create_note(NoteDraft {
body: "Packing".to_string(),
color: "default".to_string(),
items: Some(vec!["socks".to_string()]),
})
.expect("create");
+32 -12
View File
@@ -33,7 +33,6 @@ pub struct Note {
/// Always present. Derived by the core, never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -76,6 +75,36 @@ impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
}
}
/// One `#tag` and where it sits in a note's body.
///
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
#[derive(Debug, Clone, uniffi::Record)]
pub struct BodyTag {
pub line: u32,
pub start: u32,
pub end: u32,
pub name: String,
}
impl From<thoughtsync_core::local::derive::DerivedTag> for BodyTag {
fn from(t: thoughtsync_core::local::derive::DerivedTag) -> Self {
let thoughtsync_core::local::derive::DerivedTag {
line,
start,
end,
name,
} = t;
BodyTag {
line,
start,
end,
name,
}
}
}
/// An Android build the linked server is offering, already judged to be newer.
///
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
@@ -157,7 +186,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -176,7 +204,6 @@ impl From<core_models::Note> for Note {
id,
display_title,
body,
color,
position,
pinned,
archived,
@@ -314,7 +341,6 @@ pub struct NoteQuery {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteFacets {
pub q: Option<String>,
pub color: Option<String>,
pub label: Option<Vec<String>>,
pub has_reminder: Option<bool>,
pub has_attachment: Option<bool>,
@@ -343,7 +369,6 @@ impl From<NoteFacets> for core_models::Facets {
fn from(value: NoteFacets) -> Self {
let NoteFacets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -352,7 +377,6 @@ impl From<NoteFacets> for core_models::Facets {
} = value;
core_models::Facets {
q,
color,
label,
has_reminder,
has_attachment,
@@ -366,8 +390,6 @@ impl From<NoteFacets> for core_models::Facets {
#[derive(Debug, Clone, uniffi::Record)]
pub struct NoteDraft {
pub body: String,
/// "default" unless the user picked a colour.
pub color: String,
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
/// is not an alternative to `body` — it is an addition to it.
pub items: Option<Vec<String>>,
@@ -375,8 +397,8 @@ pub struct NoteDraft {
impl From<NoteDraft> for core_models::NoteCreateInput {
fn from(value: NoteDraft) -> Self {
let NoteDraft { body, color, items } = value;
core_models::NoteCreateInput { body, color, items }
let NoteDraft { body, items } = value;
core_models::NoteCreateInput { body, items }
}
}
@@ -391,7 +413,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NoteEdit {
Body { value: String },
Color { value: String },
Pinned { value: bool },
Archived { value: bool },
RemindAt { value: String },
@@ -411,7 +432,6 @@ impl NoteEdit {
use serde_json::Value;
match self {
NoteEdit::Body { value } => ("body", Value::String(value)),
NoteEdit::Color { value } => ("color", Value::String(value)),
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
+301 -6
View File
@@ -19,10 +19,13 @@
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
//! capture-and-recall surface, and a linking system is organization.
/// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> {
let chars: Vec<char> = body.chars().collect();
let mut out: Vec<String> = Vec::new();
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
///
/// Char indices rather than byte offsets so the spans can be used to cut the tags
/// back out of the line without ever landing mid-codepoint — see
/// [`lift_standalone_tags`], which is the only reason the spans exist.
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
@@ -33,8 +36,7 @@ pub fn extract_tags(body: &str) -> Vec<String> {
while j < chars.len() && is_tag_char(chars[j]) {
j += 1;
}
let tag: String = chars[i + 1..j].iter().collect();
push_unique(&mut out, &tag);
out.push((i, j, chars[i + 1..j].iter().collect()));
i = j;
continue;
}
@@ -44,6 +46,183 @@ pub fn extract_tags(body: &str) -> Vec<String> {
out
}
/// Extract every `#tag` name (without the leading `#`) from `body`.
///
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
/// so the same tags come out. It means there is ONE scanner rather than two — this and
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
pub fn extract_tags(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for line in body.split('\n') {
let chars: Vec<char> = line.chars().collect();
for (_, _, name) in line_tags(&chars) {
push_unique(&mut out, &name);
}
}
out
}
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
///
/// The card no longer prints a chip for a tag whose text is still in the note — it
/// colours the token where it was typed instead. To do that a renderer needs the
/// SPAN, not just the name, and asking it to find the name again would be a second
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DerivedTag {
/// Which body line it sits on, like [`DerivedItem::line`].
pub line: u32,
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
///
/// UTF-16 rather than chars or bytes because the two languages that consume this
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
/// index is right up until somebody puts an emoji before a tag, and then it lands
/// mid-token with no error anywhere.
pub start: u32,
pub end: u32,
pub name: String,
}
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
/// keeping the spans instead of throwing them away.
///
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
/// renderer that left it plain would be the only surface disagreeing.
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
let mut out = Vec::new();
for (n, line) in body.split('\n').enumerate() {
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
if spans.is_empty() {
continue;
}
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
let mut total: u32 = 0;
units.push(0);
for c in &chars {
total += c.len_utf16() as u32;
units.push(total);
}
for (start, end, name) in spans {
out.push(DerivedTag {
line: n as u32,
start: units[start],
end: units[end],
name,
});
}
}
out
}
/// Whether a line opens or closes a fenced code block.
fn is_fence(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
/// Runs of three or more newlines become two, and the ends are trimmed.
///
/// Removing a line must not leave a hole where it was.
fn collapse_blank_runs(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut run = 0;
for c in text.chars() {
if c == '\n' {
run += 1;
if run <= 2 {
out.push(c);
}
} else {
run = 0;
out.push(c);
}
}
out.trim_matches('\n').to_string()
}
/// Split a body's tags by whether the text around them can be taken away.
///
/// Returns `(standalone, inline, lifted_body)`.
///
/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything
/// else is left exactly as written.
///
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
/// one: a note lifted differently here than there would change under the operator the
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
///
/// The conservative reading of "standalone" is deliberate. A trailing tag is
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
/// `remember to call #mom` is the sentence's object, and lifting the second leaves
/// "remember to call". A tag sharing a line with words keeps its words.
///
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
/// them from, so the row becomes the record and the chip's × becomes the way to remove
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
/// here on — backed by text still in the body.
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
let mut standalone: Vec<String> = Vec::new();
let mut inline: Vec<String> = Vec::new();
let mut kept: Vec<&str> = Vec::new();
let mut in_fence = false;
for line in body.split('\n') {
if is_fence(line) {
in_fence = !in_fence;
kept.push(line);
continue;
}
let chars: Vec<char> = line.chars().collect();
let spans = line_tags(&chars);
// Cut the tags out and see whether anything is left. That is what
// "standalone" means, and it is the whole rule.
let mut remainder = String::new();
let mut pos = 0;
for (start, end, _) in &spans {
remainder.extend(chars[pos..*start].iter());
pos = *end;
}
remainder.extend(chars[pos..].iter());
// A fence's contents are CODE: a `#tag` there is a shell comment in somebody's
// snippet, and deleting the line would eat part of their example.
if in_fence || spans.is_empty() || !remainder.trim().is_empty() {
for (_, _, name) in &spans {
push_unique(&mut inline, name);
}
kept.push(line);
} else {
for (_, _, name) in &spans {
push_unique(&mut standalone, name);
}
}
}
let lifted = collapse_blank_runs(&kept.join("\n"));
if !body.trim().is_empty() && lifted.trim().is_empty() {
// The note was NOTHING but tags. Lifting would leave a blank card, which is a
// worse outcome than a duplicated chip — so leave it alone.
let mut all = standalone;
for name in &inline {
push_unique(&mut all, name);
}
return (Vec::new(), all, body.to_string());
}
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
// so deleting that copy should still detach the label.
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
let standalone = standalone
.into_iter()
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
.collect();
(standalone, inline, lifted)
}
fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
@@ -315,6 +494,122 @@ mod tests {
assert!(extract_tags("").is_empty());
}
// ── tag spans, for the renderer that draws them in place ─────────────────
#[test]
fn tag_spans_carry_the_hash_and_the_line() {
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
assert_eq!(spans.len(), 3);
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
assert_eq!(spans[0].name, "grocery");
// Not deduped: two mentions are two pieces of text to colour.
assert_eq!(spans[1].line, 1);
assert_eq!(spans[2].name, "mom");
assert_eq!((spans[2].start, spans[2].end), (20, 24));
}
#[test]
fn tag_spans_are_utf16_offsets_not_char_indices() {
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
// the second way, so a char index would highlight one character too early.
let spans = extract_tag_spans("🎁 #gift");
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].start, spans[0].end), (3, 8));
}
#[test]
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
let body = "#1 nope a#b no but #Yes ##no";
let names: Vec<String> = extract_tag_spans(body)
.into_iter()
.map(|t| t.name)
.collect();
assert_eq!(names, extract_tags(body));
}
// ── lifting standalone tags ──────────────────────────────────────────────
//
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
// A note lifted differently here than there would change under the operator the
// moment it synced, so these are the cases that file agrees to.
#[test]
fn lifts_a_line_that_is_nothing_but_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage");
assert_eq!(standalone, vec!["todo"]);
assert!(inline.is_empty());
assert_eq!(body, "reorganize the homepage");
let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo");
assert_eq!(standalone, vec!["todo"]);
assert_eq!(body, "needs a tauri app");
let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text");
assert_eq!(standalone, vec!["todo", "work"]);
assert_eq!(body, "real text");
}
/// The cases that must come back byte-identical. Getting any of these wrong
/// destroys somebody's words, which is why the rule is the conservative one:
/// a trailing tag is ambiguous and the text does not say which kind it is.
#[test]
fn leaves_a_tag_that_shares_its_line_with_words() {
for prose in [
"remember to call #mom tomorrow",
"buy milk #grocery",
"#2024\nreal",
] {
let (standalone, _, body) = lift_standalone_tags(prose);
assert!(standalone.is_empty(), "{prose}");
assert_eq!(body, prose, "{prose}");
}
}
#[test]
fn removing_a_line_leaves_no_hole() {
let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar");
assert_eq!(body, "foo\n\nbar");
}
/// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes
/// a label — it always has — but the line is never touched.
#[test]
fn never_touches_a_fenced_line() {
let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone";
let (standalone, inline, body) = lift_standalone_tags(fenced);
assert!(standalone.is_empty());
assert_eq!(inline, vec!["deploy"]);
assert_eq!(body, fenced);
}
/// Lifting would leave a blank card, which is worse than the duplication this
/// removes. So the note keeps its text and its tags stay derived.
#[test]
fn will_not_blank_a_note_that_is_only_tags() {
let (standalone, inline, body) = lift_standalone_tags("#todo");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "#todo");
}
/// Appearing on its own line does NOT lift a tag also written in a sentence — the
/// sentence still backs it, so deleting the sentence should still detach it.
#[test]
fn a_tag_still_in_prose_stays_derived() {
let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list");
assert!(standalone.is_empty());
assert_eq!(inline, vec!["todo"]);
assert_eq!(body, "remember the #todo list");
}
#[test]
fn lifting_an_empty_body_is_a_no_op() {
let (standalone, inline, body) = lift_standalone_tags("");
assert!(standalone.is_empty());
assert!(inline.is_empty());
assert_eq!(body, "");
}
// ── checklist items ─────────────────────────────────────────────────────
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
-9
View File
@@ -13,7 +13,6 @@ pub struct Note {
/// never stored.
pub display_title: String,
pub body: String,
pub color: String,
pub position: i64,
pub pinned: bool,
pub archived: bool,
@@ -121,16 +120,10 @@ pub struct User {
pub is_admin: bool,
}
fn default_color() -> String {
"default".to_string()
}
#[derive(Deserialize)]
pub struct NoteCreateInput {
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub items: Option<Vec<String>>,
}
@@ -154,8 +147,6 @@ pub struct Facets {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub label: Option<Vec<String>>,
#[serde(default)]
pub has_reminder: Option<bool>,
+98 -3
View File
@@ -15,7 +15,7 @@ CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT,
body TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
color TEXT NOT NULL DEFAULT 'default', -- dropped in v9; kept so DROP COLUMN has something to drop
kind TEXT NOT NULL DEFAULT 'text', -- dropped in v6; kept so DROP COLUMN has something to drop
position INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
@@ -250,6 +250,30 @@ fn migrate_v8(conn: &Connection) -> rusqlite::Result<()> {
}
/// Bring the database up to the latest schema. Idempotent.
// v9 (M315): `notes.color` is gone. A card is one neutral surface now and colour lives
// only on a tag, so the column was written by a picker nothing read and read by nothing
// at all. `labels.color` is untouched — that is the colour that survived.
//
// The saved-filter sweep is the second half and not optional. `params` is opaque JSON
// and a stored view could carry `"color": "teal"`; with the facet gone that key would
// sit there forever, and a view that silently filters on a field the app no longer has
// is worse than one that visibly lost a criterion. Guarded on `json_valid` because a
// corrupt blob must keep whatever it holds, not become NULL.
//
// The second guard is a LIKE and not `json_extract(...) 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
// abort the migration for every other row too. `LIKE` is total over any text.
const SCHEMA_V9: &str = r#"
ALTER TABLE notes DROP COLUMN color;
UPDATE saved_filters
SET params = json_remove(params, '$.color')
WHERE json_valid(params)
AND params LIKE '%"color"%';
"#;
pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
@@ -285,6 +309,10 @@ pub fn migrate(conn: &Connection) -> rusqlite::Result<()> {
migrate_v8(conn)?;
conn.execute_batch("PRAGMA user_version = 8;")?;
}
if version < 9 {
conn.execute_batch(SCHEMA_V9)?;
conn.execute_batch("PRAGMA user_version = 9;")?;
}
Ok(())
}
@@ -393,12 +421,79 @@ mod tests {
}
#[test]
fn a_fresh_database_reaches_v8() {
fn a_fresh_database_reaches_the_latest_version() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let version: i64 = conn
.query_row("PRAGMA user_version", [], |r| r.get(0))
.expect("version");
assert_eq!(version, 8);
assert_eq!(version, 9);
}
/// The column is gone, not merely unread. Asserted by asking SQLite rather than by
/// reading a row: a SELECT that omits `color` would pass either way.
#[test]
fn v9_drops_the_note_colour_column() {
let conn = Connection::open_in_memory().expect("open");
migrate(&conn).expect("migrate");
let mut stmt = conn.prepare("PRAGMA table_info(notes)").expect("pragma");
let columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(!columns.iter().any(|c| c == "color"));
// The one that survived. Getting this wrong would take every tag's colour with
// it, which is the whole thing M315 was keeping.
let mut stmt = conn.prepare("PRAGMA table_info(labels)").expect("pragma");
let label_columns: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(1))
.expect("query")
.collect::<rusqlite::Result<Vec<String>>>()
.expect("collect");
assert!(label_columns.iter().any(|c| c == "color"));
}
/// A stored view that filtered on colour loses that criterion and keeps the rest.
/// The alternative — leaving the key — is a lens that silently narrows on a field
/// the app no longer has and never says why it returned nothing.
#[test]
fn v9_sweeps_colour_out_of_saved_filters() {
let conn = Connection::open_in_memory().expect("open");
conn.execute_batch("PRAGMA foreign_keys = ON;").expect("fk");
for batch in [
SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7,
] {
conn.execute_batch(batch).expect("schema");
}
conn.execute_batch("PRAGMA user_version = 8;").expect("v8");
for (id, params) in [
("a", r#"{"color":"teal","q":"milk"}"#),
("b", r#"{"q":"eggs"}"#),
// Not JSON at all. It must come out UNCHANGED rather than NULL — a blob
// this migration cannot read is not a blob it gets to destroy.
("c", "not json"),
] {
conn.execute(
"INSERT INTO saved_filters (id, name, params, created_at)
VALUES (?1, ?1, ?2, '2026-08-28T00:00:00.000Z')",
params![id, params],
)
.expect("seed");
}
migrate(&conn).expect("migrate");
let read = |id: &str| -> String {
conn.query_row(
"SELECT params FROM saved_filters WHERE id = ?1",
[id],
|r| r.get(0),
)
.expect("read")
};
assert_eq!(read("a"), r#"{"q":"milk"}"#);
assert_eq!(read("b"), r#"{"q":"eggs"}"#);
assert_eq!(read("c"), "not json");
}
}
+82 -37
View File
@@ -141,7 +141,7 @@ fn load_previews(conn: &Connection, note_id: &str) -> rusqlite::Result<Vec<LinkP
fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
let mut note = conn.query_row(
"SELECT id, body, color, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
"SELECT id, body, position, pinned, archived, trashed, remind_at, recurrence, created_at, updated_at, trashed_at
FROM notes WHERE id = ?1",
[id],
|r| {
@@ -150,14 +150,13 @@ fn load_note(conn: &Connection, id: &str) -> rusqlite::Result<Note> {
id: r.get(0)?,
display_title: String::new(), // filled below — it may need a query
body,
color: r.get(2)?,
position: r.get(3)?,
pinned: r.get(4)?,
archived: r.get(5)?,
trashed: r.get(6)?,
deleted_at: r.get(11)?,
remind_at: r.get(7)?,
recurrence: r.get(8)?,
position: r.get(2)?,
pinned: r.get(3)?,
archived: r.get(4)?,
trashed: r.get(5)?,
deleted_at: r.get(10)?,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
labels: Vec::new(),
items: Vec::new(),
attachments: Vec::new(),
@@ -205,34 +204,89 @@ fn find_or_create_label(conn: &Connection, name: &str) -> rusqlite::Result<Strin
Ok(id)
}
/// Re-sync the note's `via_tag` labels to exactly the `#tags` in its body.
fn sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let tags = derive::extract_tags(body);
let mut desired: Vec<String> = Vec::with_capacity(tags.len());
for t in &tags {
desired.push(find_or_create_label(conn, t)?);
/// Attach the note's tag labels, LIFT its standalone tags out of the body, and write
/// the shortened body back.
///
/// NAMED FOR THE MUTATION. It used to be `sync_tags` and only touched label rows; it
/// now rewrites `notes.body`, and every caller writes the body just before calling —
/// so this overwrites what they wrote, on purpose.
///
/// `display_title` needs no attention here, unlike on the server: the core derives it
/// on READ (see `display_title` above, called from `load_note`) rather than storing
/// it, so there is no persisted copy to go stale.
///
/// The two kinds of tag are handled differently, and that difference IS what `via_tag`
/// means from here on — backed by text still in the body:
///
/// standalone lifted out, attached as an ORDINARY label. Nothing derives it any
/// more, and the way to remove it becomes the chip's ×.
/// inline left in place, attached via_tag = 1, still detached when its text
/// goes. Unchanged from before.
///
/// Mirrors `_lift_and_reconcile_tags` in the server's `notes/tags.py`.
fn lift_and_sync_tags(conn: &Connection, note_id: &str, body: &str) -> rusqlite::Result<()> {
let (standalone, inline, lifted) = derive::lift_standalone_tags(body);
let mut standalone_ids: Vec<String> = Vec::with_capacity(standalone.len());
for name in &standalone {
standalone_ids.push(find_or_create_label(conn, name)?);
}
let mut inline_ids: Vec<String> = Vec::with_capacity(inline.len());
for name in &inline {
inline_ids.push(find_or_create_label(conn, name)?);
}
let current: Vec<String> = {
let current: Vec<(String, bool)> = {
let mut stmt =
conn.prepare("SELECT label_id FROM note_labels WHERE note_id = ?1 AND via_tag = 1")?;
let rows = stmt.query_map([note_id], |r| r.get::<_, String>(0))?;
rows.collect::<rusqlite::Result<Vec<String>>>()?
conn.prepare("SELECT label_id, via_tag FROM note_labels WHERE note_id = ?1")?;
let rows = stmt.query_map([note_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, bool>(1)?))
})?;
rows.collect::<rusqlite::Result<Vec<(String, bool)>>>()?
};
for lid in &current {
if !desired.contains(lid) {
for (lid, via_tag) in &current {
if !*via_tag {
continue; // manual already: a #tag of the same name changes nothing
}
if standalone_ids.contains(lid) {
// It GRADUATED. The text backing it is about to go, so the row has to
// become the record instead — and BEFORE the delete below, or the same row
// is dropped for no longer being in the body. That is the bug a naive lift
// has, and it silently loses the tag.
conn.execute(
"UPDATE note_labels SET via_tag = 0 WHERE note_id = ?1 AND label_id = ?2",
params![note_id, lid],
)?;
} else if !inline_ids.contains(lid) {
conn.execute(
"DELETE FROM note_labels WHERE note_id = ?1 AND label_id = ?2 AND via_tag = 1",
params![note_id, lid],
)?;
}
}
for lid in &desired {
// OR IGNORE leaves a label already attached in ANY form alone, which is what keeps
// a manually-added label of the same name manual.
for lid in &standalone_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 0)",
params![note_id, lid],
)?;
}
for lid in &inline_ids {
conn.execute(
"INSERT OR IGNORE INTO note_labels (note_id, label_id, via_tag) VALUES (?1, ?2, 1)",
params![note_id, lid],
)?;
}
if lifted != body {
conn.execute(
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![lifted, note_id],
)?;
}
Ok(())
}
@@ -272,10 +326,6 @@ pub fn list_notes(conn: &Connection, q: &ListQuery) -> rusqlite::Result<Vec<Note
binds.push(pat.clone());
binds.push(pat);
}
if let Some(c) = f.color.as_deref().filter(|s| !s.is_empty()) {
sql.push_str(" AND color = ?");
binds.push(c.to_string());
}
if f.has_reminder == Some(true) {
sql.push_str(" AND remind_at IS NOT NULL");
}
@@ -373,12 +423,12 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
}
}
conn.execute(
"INSERT INTO notes (id, body, color, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?5, 1)",
params![id, body, input.color, position, ts],
"INSERT INTO notes (id, body, position, created_at, updated_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?4, 1)",
params![id, body, position, ts],
)?;
// The FOLDED body, not the input one: an item can carry a #tag too.
sync_tags(conn, &id, &body)?;
lift_and_sync_tags(conn, &id, &body)?;
load_note(conn, &id)
}
@@ -451,12 +501,7 @@ pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Re
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, body)?;
}
"color" => {
if let Some(s) = v.as_str() {
conn.execute("UPDATE notes SET color = ?1 WHERE id = ?2", params![s, id])?;
}
lift_and_sync_tags(conn, id, body)?;
}
"pinned" => {
if let Some(b) = v.as_bool() {
@@ -727,7 +772,7 @@ pub fn restore_revision(conn: &Connection, id: &str, rev_id: &str) -> rusqlite::
"UPDATE notes SET body = ?1 WHERE id = ?2",
params![body, id],
)?;
sync_tags(conn, id, &body)?;
lift_and_sync_tags(conn, id, &body)?;
touch(conn, id)?;
load_note(conn, id)
}
+15 -1
View File
@@ -19,10 +19,24 @@
use serde::{Deserialize, Serialize};
/// The sync wire protocol this client speaks.
pub const CLIENT_PROTOCOL_VERSION: u32 = 3;
///
/// v4 (M315): `color` left the note. NOT a floor raise on either side — see the note
/// on [`MIN_SERVER_PROTOCOL_VERSION`].
pub const CLIENT_PROTOCOL_VERSION: u32 = 4;
/// The oldest server protocol this client can drive — the symmetric half of the
/// server's `min_client_protocol_version`.
///
/// STAYS AT 3 ACROSS v4, and the v2 precedent is the reason to say why rather than
/// leave it looking like an oversight. v2 dropped `kind` and `title` and DID move both
/// floors, on the rule that "dropping a field a client sends and expects back is
/// breaking". `color` fails that test on the second half: a v3 client reading a v4
/// server gets `"default"` from serde's default and draws the colour it derives
/// locally, which is a board that looks exactly like the one it drew yesterday. A v3
/// client PUSHING `color` to a v4 server has the key ignored — the server reads its
/// payload key by key and never validates the shape. Neither direction errors, and
/// neither loses anything a person can see; `title` was the note's NAME, and this is a
/// field that no longer renders anywhere.
pub const MIN_SERVER_PROTOCOL_VERSION: u32 = 3;
/// Capabilities without which syncing is meaningless, so their absence BLOCKS the
+2 -5
View File
@@ -240,13 +240,12 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
// `created_at` is deliberately absent from the UPDATE clause: a note's birth time
// never changes, and the server's copy is the same value anyway.
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, remind_at, recurrence, created_at, updated_at,
sync_revision, trashed_at, dirty)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0)
ON CONFLICT(id) DO UPDATE SET
body = excluded.body,
color = excluded.color,
position = excluded.position,
pinned = excluded.pinned,
archived = excluded.archived,
@@ -260,7 +259,6 @@ fn upsert_note(conn: &Connection, note: &wire::Note) -> rusqlite::Result<()> {
params![
note.id,
note.body,
note.color,
note.position,
note.pinned,
note.archived,
@@ -463,7 +461,6 @@ mod tests {
wire::Note {
id: id.to_string(),
body: "Body".into(),
color: "default".into(),
position: 0,
pinned: false,
archived: false,
+15 -14
View File
@@ -64,6 +64,8 @@ pub struct Change {
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
/// A LABEL's colour. A note has none since M315, so a note change leaves this
/// `None` and the key never reaches the wire.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -220,7 +222,6 @@ fn collect_notes(conn: &Connection, out: &mut Vec<Change>, limit: usize) -> rusq
/// field-to-column mapping stays readable at the call site.
struct NoteRow {
body: String,
color: String,
position: i64,
pinned: bool,
archived: bool,
@@ -233,22 +234,21 @@ struct NoteRow {
fn note_row(conn: &Connection, id: &str) -> rusqlite::Result<NoteRow> {
conn.query_row(
"SELECT body, color, position, pinned, archived, trashed,
"SELECT body, position, pinned, archived, trashed,
remind_at, recurrence, created_at, updated_at
FROM notes WHERE id = ?1",
params![id],
|r| {
Ok(NoteRow {
body: r.get(0)?,
color: r.get(1)?,
position: r.get(2)?,
pinned: r.get::<_, i64>(3)? != 0,
archived: r.get::<_, i64>(4)? != 0,
trashed: r.get::<_, i64>(5)? != 0,
remind_at: r.get(6)?,
recurrence: r.get(7)?,
created_at: r.get(8)?,
updated_at: r.get(9)?,
position: r.get(1)?,
pinned: r.get::<_, i64>(2)? != 0,
archived: r.get::<_, i64>(3)? != 0,
trashed: r.get::<_, i64>(4)? != 0,
remind_at: r.get(5)?,
recurrence: r.get(6)?,
created_at: r.get(7)?,
updated_at: r.get(8)?,
})
},
)
@@ -275,7 +275,8 @@ fn note_change(conn: &Connection, id: &str) -> rusqlite::Result<Change> {
// server's last-write-wins comparison runs against.
edited_at: row.updated_at,
body: Some(row.body),
color: Some(row.color),
// A note has no colour to send. See the field on `Change`.
color: None,
pinned: Some(row.pinned),
archived: Some(row.archived),
trashed: Some(row.trashed),
@@ -497,9 +498,9 @@ mod tests {
fn seed_note(conn: &Connection, id: &str, dirty: i64) {
conn.execute(
"INSERT INTO notes (id, body, color, position, pinned, archived,
"INSERT INTO notes (id, body, position, pinned, archived,
trashed, created_at, updated_at, sync_revision, dirty)
VALUES (?1, 'B', 'default', 0, 0, 0, 0,
VALUES (?1, 'B', 0, 0, 0, 0,
'2026-07-26T00:00:00.000Z', '2026-07-26T00:00:00.000Z', 3, ?2)",
params![id, dirty],
)
-2
View File
@@ -25,8 +25,6 @@ pub struct Note {
pub id: String,
#[serde(default)]
pub body: String,
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub position: i64,
#[serde(default)]
+1 -1
View File
@@ -18,7 +18,7 @@ pacman system:
curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
```
That installs the newest tagged release. To follow the rolling development
That installs the newest build from `main`. To follow the rolling development
channel instead, pass the flag through the pipe:
```sh
+3 -1
View File
@@ -64,7 +64,9 @@ DEPENDS=(webkit2gtk-4.1 gtk3)
# this?" question unanswerable.
# `|| true` so a miss falls through to the explicit error below rather than
# aborting on pipefail with no explanation.
PKGVER="$(sh "$SCRIPT_DIR/../build-version.sh" || true)"
# The ORDERING KEY: pacman compares this, and it must match the filename the
# bundle build produced (write-manifest.sh selects on it).
PKGVER="$(sh "$SCRIPT_DIR/../../../packaging/version.sh" key desktop || true)"
[ -n "$PKGVER" ] || { echo "ERROR: could not determine the build version" >&2; exit 1; }
# Reproducible-ish: prefer the commit date over "now" so rebuilding the same
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env sh
#
# Echo the version this build should carry. One definition, used in three places
# (both bundle jobs and the manifest writer) — if they ever disagreed, the app would
# compare its own version against a manifest describing a different build, and the
# updater would either offer nothing or loop forever offering the same thing.
#
# WHY DEV BUILDS NEED THEIR OWN VERSION AT ALL:
# an updater decides by comparing semver. Every dev build carries the version in
# Cargo.toml, so without this they'd all be `0.1.0` — an installed build would see a
# manifest advertising the version it already has, conclude it was current, and never
# update. The rolling channel needs a number that actually rises.
#
# The CI run number is that number: monotonic, already unique per build, and it needs
# no state carried between runs. `0.1.0` + run 2932 becomes `0.1.2932`.
#
# Plain semver on purpose, NOT a `-dev.N` prerelease tag: prerelease versions sort
# BELOW the release they qualify (`0.1.0-dev.5` < `0.1.0`), so a tagged build would
# never update to a newer dev one, and Windows installer metadata wants a numeric
# X.Y.Z anyway. 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.
set -eu
CARGO_TOML="$(dirname "$0")/../src-tauri/Cargo.toml"
base="$(grep -m1 '^version' "$CARGO_TOML" | sed -E 's/.*"([^"]+)".*/\1/')"
# Dev builds only. Anything else (a v* tag, main) ships the version as written.
if [ "${GITHUB_REF_NAME:-}" = "dev" ] && [ -n "${GITHUB_RUN_NUMBER:-}" ]; then
printf '%s.%s\n' "${base%.*}" "$GITHUB_RUN_NUMBER"
else
printf '%s\n' "$base"
fi
+24 -20
View File
@@ -5,8 +5,12 @@
# curl -fsSL https://git.fabledsword.com/bvandeusen/thoughtsync/raw/branch/dev/desktop/packaging/install.sh | sh
#
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the newest tagged v* release.
# stable (default) — the rolling build from every merge to `main`.
# dev — the rolling build from every green push to `dev`.
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut.
# Pick one with `--channel dev` or `TS_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
@@ -41,7 +45,7 @@ ThoughtSync desktop installer.
install.sh [--channel stable|dev]
--channel stable newest tagged release (default)
--channel stable newest build from main (default)
--channel dev rolling build from the latest green push to `dev`
-h, --help this text
@@ -82,20 +86,23 @@ esac
# --- resolve the release for this channel -----------------------------------
say "Finding the latest ThoughtSync build on the $channel channel…"
if [ "$channel" = "dev" ]; then
# A release whose tag never moves and whose assets are pruned to the current
# build — so the tag alone always names the newest dev build.
json="$(curl -fsSL "$API/releases/tags/dev" 2>/dev/null)" ||
die "the dev channel has nothing published yet."
else
# Ask the stable channel's own manifest which version is current, then install
# THAT release. This is the same file the in-app updater reads, so the installer
# and the updater can never disagree about what `stable` means.
#
# Not `/releases/latest`: that returns the newest non-prerelease release by date,
# and the `stable` pointer release (manifest only, no bundles — see
# write-manifest.sh) is itself a non-prerelease created moments after the
# versioned one. It would win, and it carries nothing installable.
# ONE lookup for both channels now. Each is a release whose tag never moves and whose
# assets are pruned to the current build, so the tag alone names the newest build on
# that channel — which is exactly what an installer wants and what the in-app updater
# already reads.
json="$(curl -fsSL "$API/releases/tags/$channel" 2>/dev/null)" ||
die "the $channel channel has nothing published yet."
# TRANSITIONAL — delete with the rest of the old scheme (M314 step 7).
#
# `stable` existed before this as a manifest-ONLY pointer: `latest.json` naming a
# version whose bundles lived on a separate `v<version>` release. Between this commit
# and the first merge to `main` it still looks like that, and `stable` is the DEFAULT
# channel — so without this fallback `curl … | sh` is broken for everyone in that
# window. It costs nothing once main has published: the grep finds the bundles and
# this branch never runs again.
if [ "$channel" = "stable" ] && ! printf '%s' "$json" | grep -q "releases/download/stable/[^\"]*\.\(AppImage\|deb\|pkg\.tar\)"; then
say "stable has no bundles of its own yet — falling back to the version its manifest names."
manifest="$(curl -fsSL "$INSTANCE/$REPO/releases/download/stable/latest.json" 2>/dev/null || true)"
stable_version="$(printf '%s' "$manifest" |
grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 |
@@ -104,11 +111,8 @@ else
json="$(curl -fsSL "$API/releases/tags/v$stable_version" 2>/dev/null)" ||
die "the stable channel names $stable_version, but there is no v$stable_version release to install."
else
# No stable pointer yet — the channel predates the updater. Fall back to the
# newest non-prerelease release, which is what stable meant before there was
# a manifest to ask.
json="$(curl -fsSL "$API/releases/latest" 2>/dev/null)" ||
die "no stable release published yet — try --channel dev, or ask the maintainer to tag one."
die "no stable build published yet — try --channel dev, or merge to main."
fi
fi
+24
View File
@@ -118,6 +118,11 @@ first_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE
# install.sh defaults to stable, so the rolling dev release must opt in explicitly
# — otherwise someone following the instructions here lands on a tagged build and
# wonders why the version they were sent isn't what they got.
#
# Both CHANNELS are rolling pointer releases (M314 step 3): `dev` republishes on
# every green push to dev, `stable` on every merge to main. Each says so, because a
# release that prunes its own assets behaves differently from a versioned one and a
# reader deserves to know which they are looking at.
if [ "$TAG" = "dev" ]; then
INSTALL_TAIL='sh -s -- --channel dev'
# Backticks BARE, not `\``. The heredoc below is unquoted, so there the backslash
@@ -125,6 +130,10 @@ if [ "$TAG" = "dev" ]; then
# Here single quotes already do that job, so a backslash would survive into the
# body as `\``, which is not a legal JSON escape: Forgejo answers 422.
CHANNEL_NOTE='\n\nThis is the rolling **dev** channel: republished on every green push to `dev`, and pruned to the current build.'
elif [ "$TAG" = "stable" ]; then
# install.sh defaults to stable, so no flag.
INSTALL_TAIL='sh'
CHANNEL_NOTE='\n\nThis is the rolling **stable** channel: republished on every merge to `main`, and pruned to the current build. No tag is required for a build to arrive here.'
else
INSTALL_TAIL='sh'
CHANNEL_NOTE=''
@@ -136,6 +145,21 @@ BODY=$(cat <<JSON
"body":"ThoughtSync $TAG.\n\n**Desktop**\n\n- **Debian / Ubuntu** — native \`.deb\`\n- **Arch / CachyOS** — native \`.pkg.tar.*\`\n- **everything else** — \`.AppImage\` (de-bundled graphics: renders on any GPU/Wayland setup)\n\nInstall / update — picks the right one for your system:\n\`\`\`\ncurl -fsSL $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/branch/dev/desktop/packaging/install.sh | $INSTALL_TAIL\n\`\`\`\n\n**Android** — \`thoughtsync.apk\`. Copy it and \`thoughtsync-android.json\` into your server's \`/var/thoughtsync/client/\` and the server will offer it to your devices; see docs/android-distribution.md.$CHANNEL_NOTE"}
JSON
)
# An explicit body, replacing the install instructions above.
#
# Used by the RELEASE lane, whose job is a changelog rather than artifacts (M314
# step 7). It goes through this script rather than making its own API calls so that
# the create-or-PATCH-on-409 path is shared: a fixed-tag release that only ever
# POSTs keeps whatever text its FIRST build wrote, which is #2182 exactly, and
# re-implementing that correctly in a second place is how it comes back.
#
# CONTRACT: already JSON-escaped, without surrounding quotes. The caller knows
# whether it has a JSON encoder; this script cannot assume python3 is on PATH in
# every image that sources it.
if [ -n "${RELEASE_BODY_JSON:-}" ]; then
BODY="{\"tag_name\":\"$TAG\",\"name\":\"ThoughtSync $TAG\",\"draft\":false,\"prerelease\":$RELEASE_PRERELEASE,\"body\":\"$RELEASE_BODY_JSON\"}"
fi
# 409 = a release for this tag already exists (re-run) — fall through to lookup.
release="$(ALLOW_CODES=409 api POST "$API/releases" -H "Content-Type: application/json" -d "$BODY")"
RELEASE_ID="$(printf '%s' "$release" | first_id || true)"
+15 -28
View File
@@ -25,14 +25,15 @@ set -euo pipefail
: "${RELEASE_TAG:?RELEASE_TAG is required (the release holding the bundles)}"
: "${APP_VERSION:?APP_VERSION is required (the version the bundles carry)}"
# Where the manifest is PUBLISHED, which need not be where the bundles live.
# The manifest is published to the release that HOLDS the bundles. There is no
# second place any more.
#
# That split is what makes the stable channel work at all. A versioned release
# (`v0.2.0`) holds the real assets, but the app can only read a URL that never
# changes — so the same manifest is also attached to a `stable` release whose tag is
# permanent and whose only content is this file. It points back at the versioned
# assets, so nothing is duplicated.
MANIFEST_TAG="${MANIFEST_TAG:-$RELEASE_TAG}"
# There used to be: `MANIFEST_TAG` let the manifest live on a `stable` pointer
# release while the bundles sat on a versioned `v*` one, because the app can only
# read a URL that never changes and a versioned tag is not that. M314 step 3 made
# `stable` a rolling release that holds its own bundles, exactly like `dev`, so the
# split had nothing left to bridge — and a parameter that can only ever be passed
# its own default is a branch nobody exercises and a comment that goes stale.
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
AUTH=(-H "Authorization: token $GITHUB_TOKEN")
@@ -115,25 +116,11 @@ pub_date="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "==> Manifest:"
cat "$work/latest.json"
# --- resolve the release the manifest is published TO ------------------------
if [ "$MANIFEST_TAG" = "$RELEASE_TAG" ]; then
target_id="$release_id"
target_assets="$assets"
else
echo "==> Resolving the $MANIFEST_TAG channel release"
target="$(curl -sS "${AUTH[@]}" "$API/releases/tags/$MANIFEST_TAG")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+' || true)"
if [ -z "${target_id:-}" ]; then
# First publish to this channel. A pointer release: no bundles of its own, just
# a permanent tag for the manifest to live under.
echo " creating it (pointer release, manifest only)"
body="{\"tag_name\":\"$MANIFEST_TAG\",\"name\":\"ThoughtSync ($MANIFEST_TAG channel)\",\"draft\":false,\"prerelease\":false,\"body\":\"Update channel pointer. The installable builds live on the versioned releases; this holds only the updater manifest.\"}"
target="$(curl -sS -X POST "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$API/releases")"
target_id="$(printf '%s' "$target" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')"
fi
[ -n "${target_id:-}" ] || { echo "ERROR: could not resolve the $MANIFEST_TAG release" >&2; exit 1; }
target_assets="$(curl -sS "${AUTH[@]}" "$API/releases/$target_id/assets")"
fi
# The manifest goes on the same release the bundles were just read from — which is
# also the one `publish-release.sh` created or refreshed moments earlier, so it is
# guaranteed to exist by the time this runs.
target_id="$release_id"
target_assets="$assets"
# Replace rather than duplicate: Forgejo rejects a second asset with the same name,
# and this file is rewritten on every publish by design.
@@ -145,11 +132,11 @@ if [ -n "${old_id:-}" ]; then
curl -fsS -X DELETE "${AUTH[@]}" "$API/releases/$target_id/assets/$old_id" >/dev/null
fi
echo "==> Uploading latest.json to $MANIFEST_TAG"
echo "==> Uploading latest.json to $RELEASE_TAG"
curl -fsS -X POST "${AUTH[@]}" "$API/releases/$target_id/assets?name=latest.json" \
-F "attachment=@$work/latest.json" >/dev/null
echo "==> Done. $MANIFEST_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
echo "==> Done. $RELEASE_TAG now advertises $APP_VERSION for ${#entries[@]} platform(s)."
# --- prune superseded builds from a rolling channel ---------------------------
#
+12
View File
@@ -1,5 +1,17 @@
[package]
name = "thoughtsync-desktop"
# NOT THE SHIPPED VERSION, and bumping it has no effect on anything a user sees.
#
# Cargo requires a version here, and Tauri reads one from `tauri.conf.json` — both
# are overridden per build by `cargo tauri build --config '{"version": ...}'` with
# the value `packaging/version.sh key desktop` derives. See #3144.
#
# It used to matter: the old scheme took its base from this line and appended the CI
# run number on dev, so `0.2.<run>` on dev sat against a bare `0.2.0` on main and
# every dev build outranked every stable one. The remedy was "remember to bump the
# minor before tagging" — documented in a comment, enforced nowhere, and #2183 is
# what that looked like in the field. A scheme needing a human to remember something
# before each release has not removed the decision, only hidden it.
version = "0.2.0"
description = "ThoughtSync desktop — local-first Keep-style thought capture"
authors = ["bvandeusen"]
+11 -5
View File
@@ -1,10 +1,16 @@
//! In-app updates (M10.9).
//!
//! Two channels, because two audiences: `stable` follows tagged `v*` releases,
//! `dev` follows every green push. Each reads a `latest.json` published as an asset
//! on a release whose TAG NEVER CHANGES — verified necessary, because Forgejo has no
//! `/releases/latest/download/<asset>` route (it 404s), so "newest" cannot be named
//! in a URL. A fixed tag can.
//! Two channels, because two audiences: `stable` follows every merge to `main`,
//! `dev` follows every green push to `dev`. Each reads a `latest.json` published as
//! an asset on a release whose TAG NEVER CHANGES — verified necessary, because
//! Forgejo has no `/releases/latest/download/<asset>` route (it 404s), so "newest"
//! cannot be named in a URL. A fixed tag can.
//!
//! `stable` followed tagged `v*` releases until M314 step 3, and its manifest pointed
//! at bundles living on a different release. It holds its own bundles now, exactly as
//! `dev` always has — so a build reaches stable users with no tag cut anywhere, which
//! is the whole point of the change. NOTHING HERE MOVED: this code only ever read
//! `<channel>/latest.json`, and that is still where the manifest lands.
//!
//! The feed lives on Fabled-Git rather than on a ThoughtSync server, deliberately:
//! this app is usable having never linked a server, and an install that can't reach
+12 -6
View File
@@ -15,13 +15,19 @@ cannot talk to.
**Normally: nowhere. It is already in the image.**
CI fetches the newest published Android build into every server image it builds,
so `:dev`, `:latest` and `:<version>` all ship a client. `docker compose pull &&
docker compose up -d` delivers a new server and a new client together, and there
is nothing to copy.
CI fetches the published Android build into every server image it builds, so
`:dev` and `:latest` both ship a client. `docker compose pull && docker compose
up -d` delivers a new server and a new client together, and there is nothing to
copy.
A versioned image therefore carries the *newest* client rather than one pinned to
that version. That is deliberate: the two negotiate a sync protocol version
**The channel is a property of the image you run.** A `:dev` image bakes in the
dev-channel APK, `:latest` the stable one — so pointing a phone at a stable
server gets it a stable client, with no second place holding that decision. (Until
M314 step 3 the fetch was hard-wired to the dev release on every branch, so a
stable server served a dev client.)
An image therefore carries the *newest* client on its channel rather than one
pinned to a version. That is deliberate: the two negotiate a sync protocol version
before they link, so a mismatch is caught by the handshake rather than by
pinning.
+20 -5
View File
@@ -52,6 +52,15 @@ syncs everything else.
### The policy
- **Any wire change** → bump `SYNC_PROTOCOL_VERSION`.
- v2 (M13): `kind` and `title` left the wire; **floor raised**, because a v1
client kept pushing both and read back notes carrying neither — and `title` was
the note's NAME, so an old client showed nameless notes.
- v3: attachments/tombstones/revisions.
- v4 (M315): `color` left the note; **floor NOT raised**. Both directions degrade
in silence and neither loses anything visible — an old client reading a v4 note
falls back to the colour it derives locally, and one pushing `color` has the key
ignored. The test is not "did a field leave" but "does either side end up
showing something wrong".
- **Additive change** (a new field, a new capability) → add a `sync_features`
name. Do **not** raise a minimum. Old clients keep working.
- **Breaking change only** → raise `MIN_CLIENT_PROTOCOL_VERSION` (or the client's
@@ -190,18 +199,24 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
```json
{ "entity": "note", "id": "<uuid>", "op": "upsert", "edited_at": "<iso8601>",
"title": "...", "body": "...", "color": "blue", "kind": "text",
"body": "...",
"pinned": false, "archived": false, "trashed": false, "remind_at": null,
"position": 0, "items": [ {"text": "...", "checked": false} ],
"recurrence": null, "position": 0,
"label_ids": ["<uuid>", ...], "created_at": "<iso8601, on create>" }
```
- **Client-generated ids.** Notes/labels are UUIDs; the client mints the id when
it creates the row offline and sends it here. Create-if-absent, else update.
- **Whole-note semantics.** A note upsert carries the client's *full* current
state (not a partial patch) — the server overwrites all scalar fields, replaces
items, and sets manual label memberships from `label_ids` (tag-sourced labels
are re-derived from the body). `#tags` are recomputed server-side.
state (not a partial patch) — the server overwrites all scalar fields and sets
manual label memberships from `label_ids` (tag-sourced labels are re-derived
from the body). `#tags` are recomputed server-side. A checklist is `- [ ] ` lines
inside `body` (M304), so there is no separate `items` array.
- **Fields a change may still carry, and the server reads past.** `title` and
`kind` (removed in v2), `items` (M304) and `color` (v4, M315). The server reads
its payload key by key and never validates the shape, which is exactly what lets
an older client keep pushing a field this one has stopped storing — see the
version policy above for why none of those needed a floor raise on their own.
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
`trashed: true`.
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
+1 -3
View File
@@ -9,7 +9,6 @@
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
@@ -33,13 +32,12 @@ export interface NoteListQuery {
export interface NoteCreateInput {
body: string;
color: NoteColor;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
-1
View File
@@ -31,7 +31,6 @@ function notesQuery(q: NoteListQuery): string {
if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
-24
View File
@@ -1,24 +0,0 @@
<script setup lang="ts">
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
defineProps<{ modelValue: NoteColor }>();
defineEmits<{ (e: "update:modelValue", value: NoteColor): void }>();
</script>
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
:aria-pressed="modelValue === key"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110
focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-1
focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900"
:class="[NOTE_SWATCH_CLASSES[key], modelValue === key ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="$emit('update:modelValue', key)"
/>
</div>
</template>
-18
View File
@@ -7,7 +7,6 @@ import { useUiStore } from "../stores/ui";
import type { NoteFacets } from "../stores/notes";
import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import { NOTE_COLOR_KEYS, NOTE_COLOR_LABELS, NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
import Icon from "./Icon.vue";
// A dead-simple facet bar over the board: color + labels + has-reminder
@@ -32,9 +31,6 @@ function patch(p: Partial<NoteFacets>) {
function clearAll() {
void router.replace({ path: "/", query: {} });
}
function setColor(c: NoteColor) {
patch({ color: facets.value.color === c ? undefined : c });
}
function toggleLabel(id: string) {
const cur = facets.value.label ?? [];
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
@@ -111,20 +107,6 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
v-if="open"
class="mt-2 flex flex-col gap-3 rounded-xl border border-neutral-200 p-3 dark:border-neutral-800"
>
<div class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Color</span>
<button
v-for="c in NOTE_COLOR_KEYS"
:key="c"
type="button"
:title="NOTE_COLOR_LABELS[c]"
:aria-label="NOTE_COLOR_LABELS[c]"
class="h-6 w-6 rounded-full border border-black/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-white/10"
:class="[NOTE_SWATCH_CLASSES[c], facets.color === c ? 'ring-2 ring-brand ring-offset-1' : '']"
@click="setColor(c)"
/>
</div>
<div v-if="labels.items.length" class="flex flex-wrap items-center gap-1.5">
<span class="w-16 shrink-0 text-xs text-neutral-400">Labels</span>
<button
+10 -2
View File
@@ -1,10 +1,16 @@
<script setup lang="ts">
import type { InlineToken } from "../notes/markdown";
import { tagTextClasses } from "../notes/colors";
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
// Emphasis, code, and `#tags`. `[[wiki-links]]` were the one token type that needed a
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
// of that.
defineProps<{ tokens: InlineToken[] }>();
//
// `tagColors` maps a lowercased tag name to the colour stored on that label. Threaded
// down from the card rather than looked up here, because this component renders text
// and has no idea which note the text belongs to — and a tag the operator recoloured
// must read the same here as it does on a chip.
defineProps<{ tokens: InlineToken[]; tagColors?: Record<string, string> }>();
</script>
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
@@ -17,6 +23,8 @@ defineProps<{ tokens: InlineToken[] }>();
v-else-if="t.type === 'code'"
class="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10"
>{{ t.value }}</code
><span v-else-if="t.type === 'tag'" class="font-medium" :class="tagTextClasses(t.value, tagColors)"
>#{{ t.value }}</span
><template v-else>{{ t.value }}</template></template
></template
>
+21 -9
View File
@@ -3,7 +3,13 @@ import { computed } from "vue";
import { parseMarkdown } from "../notes/markdown";
import MarkdownInline from "./MarkdownInline.vue";
const props = defineProps<{ text: string; toggleable?: boolean }>();
// `tagColors` is passed straight through to MarkdownInline — see there for why the
// card owns the lookup rather than the renderer.
const props = defineProps<{
text: string;
toggleable?: boolean;
tagColors?: Record<string, string>;
}>();
// Ticking a box rewrites a line of the note's body, which is a thing only the owner
// of that note can do — so this renders the checkbox and hands the intent up rather
// than reaching for the store itself. The card wires it; a read-only render does not
@@ -15,14 +21,20 @@ const blocks = computed(() => parseMarkdown(props.text));
<template>
<div class="space-y-1.5 break-words">
<template v-for="(b, i) in blocks" :key="i">
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
<h3 v-if="b.type === 'h1'" class="text-base font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h3>
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h4>
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold">
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</h5>
<blockquote
v-else-if="b.type === 'quote'"
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
>
<MarkdownInline :tokens="b.inline ?? []" />
<MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" />
</blockquote>
<div v-else-if="b.type === 'task'" class="flex flex-col gap-1">
<div v-for="(it, j) in b.items ?? []" :key="j" class="flex items-start gap-2">
@@ -42,22 +54,22 @@ const blocks = computed(() => parseMarkdown(props.text));
class="min-w-0 flex-1"
:class="b.tasks?.[j]?.checked ? 'text-neutral-400 line-through' : ''"
>
<MarkdownInline :tokens="it" />
<MarkdownInline :tokens="it" :tag-colors="tagColors" />
</span>
</div>
</div>
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ul>
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :tag-colors="tagColors" /></li>
</ol>
<pre
v-else-if="b.type === 'pre'"
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
>{{ b.value ?? "" }}</pre
>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :tag-colors="tagColors" /></p>
</template>
</div>
</template>
+68 -98
View File
@@ -1,16 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import {
LABEL_CHIP_CLASSES,
NOTE_COLOR_KEYS,
NOTE_COLOR_LABELS,
NOTE_SWATCH_CLASSES,
noteCardClasses,
noteTintVars,
resolveLabelColor,
type NoteColor,
} from "../notes/colors";
import { NOTE_CARD_SURFACE, labelChipClasses } from "../notes/colors";
import type { Note } from "../stores/notes";
import Icon from "./Icon.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -191,33 +182,23 @@ async function snoozeReminder(minutes: number): Promise<void> {
emit("reminder-changed");
}
// Takes the label, not its colour: a tag nobody has coloured derives one from its
// name, so chips carry the tag's identity rather than all being the same grey.
function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
}
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
// text still in the note" since M311, so a chip for one printed the same tag twice —
// once where it was typed, once in this row — and the loud copy was the duplicate. A
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
// here is what the body cannot say: a tag lifted off its own line, and a label added
// through the picker.
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
// Per-card color popover (recolor without opening the editor).
const colorOpen = ref(false);
function swatch(color: string): string {
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
}
function pickColor(color: NoteColor) {
colorOpen.value = false;
void notes.setColor(props.note.id, color);
}
function onDocMousedown(e: MouseEvent) {
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
}
// Only listen for outside clicks while the popover is actually open.
watch(colorOpen, (open) => {
if (open) document.addEventListener("mousedown", onDocMousedown);
else document.removeEventListener("mousedown", onDocMousedown);
// The colour the operator stored for each of this note's tags, keyed by lowercased
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
const tagColors = computed<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
return map;
});
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
</script>
<template>
@@ -225,40 +206,70 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
:class="[
noteCardClasses(note),
NOTE_CARD_SURFACE,
dragging ? 'opacity-40' : '',
dragOver
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
: '',
active ? 'ring-2 ring-brand' : '',
]"
:style="noteTintVars(note)"
:data-note-id="note.id"
>
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it.
Every card gets the same grey hairline whatever colour it is; the fill is the
only thing that varies. The version of this that was a `{hue}-900` border
failed because the line was both the loudest element on the card (1.56-2.09
against its own fill, where the fill managed 1.03-1.05 against the board) AND
carried the same information the fill did, so a board of them read as a grid
of outlines. A neutral line carries no information at all, which is exactly
what lets it be structure.
<!-- THE EDGE IS THE CARD'S BOUNDARY, and since M315 it is the ONLY thing that
varies from the board: the fill is one neutral (NOTE_CARD_SURFACE) and no
longer says anything about the note. That makes this line load-bearing rather
than decorative — it is what a card IS.
The two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same
authority in either theme. #404040 is `neutral-700`, which is what the default
card's border always was — promoted from one entry in the palette to the rule
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400
jumps to 2.52 and reads as a wireframe.
It was already neutral before the fill was. The version that came from the
palette was a `{hue}-900` border and failed twice over: the line was the
loudest element on the card (1.56-2.09 against its own fill, where the fill
managed 1.03-1.05 against the board) AND carried the same information the fill
did, so a board of them read as a grid of outlines. A neutral line carries no
information at all, which is exactly what lets it be structure. The fill is
the same argument one size up, made two milestones later.
MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #b8b8b8 on
white is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these
values already shipped at across twenty fills (light 1.57-1.98, dark
1.58-1.73), but at the top of them rather than the ~1.6-1.7 the pair was
originally matched on. Softening the light edge to re-match would weaken the
only boundary a white card on a #fafafa board has, and the complaint that
started M315 was about fill, never about edge weight. If an operator pass
disagrees it is one constant, in two files.
NOT a translucent black/white edge, which is the tidier-looking way to do this
and was measured and rejected: 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.
and was measured and rejected: a border composites over what is under it, so
`border-white/20` came out #56396d on a purple card and #a3c9c1 on a teal one.
With one fill that argument no longer bites — but an opaque grey is what the
Android side must also write, and two surfaces stating the same hex is how
they stay the same card.
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
shadow is only depth. -->
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
the one thing that says what a note IS below the fold of a glance. A board is
scanned, not read, and the answer to "which of these is about the thing I am
looking for" should be the first thing the eye lands on rather than the last.
Above the image and the body rather than beside them, because 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. -->
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
<!-- Every chip carries the `#`, not just the ones derived from body text. That
branch used to distinguish a `#tag` from a picker label; it cannot any more,
because a tag whose text is still in the body no longer reaches this row at
all. What is left is all the same thing to the eye and to the vocabulary —
and the hash is what keeps a lifted chip reading as the `#todo` somebody
typed. Android's row says the same, which it did not before. -->
<span
v-for="lb in chipLabels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChipClasses(lb)"
>#{{ lb.name }}</span
>
</div>
<img
v-if="firstImage"
:src="firstImage.url"
@@ -296,7 +307,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" toggleable @toggle="toggleTask" />
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
</div>
<p
v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -317,16 +328,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
two paragraphs instead of always after them. Rendering both would have shown
every list twice. -->
<div v-if="note.labels.length" class="mt-2 flex flex-wrap gap-1">
<span
v-for="lb in note.labels"
:key="lb.id"
class="rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
>{{ lb.via_tag ? "#" + lb.name : lb.name }}</span
>
</div>
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
@@ -440,19 +441,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
</button>
</template>
<template v-else>
<button
type="button"
class="icon-btn"
title="Change color"
aria-label="Change color"
:aria-expanded="colorOpen"
@click.stop="colorOpen = !colorOpen"
>
<span
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
:class="swatch(note.color)"
></span>
</button>
<button
type="button"
class="icon-btn"
@@ -483,24 +471,6 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
<Icon name="trash" />
</button>
</template>
<!-- Inside the action set rather than beside it, so it follows the set to
whichever corner or footer the device put it in. -->
<div
v-if="colorOpen"
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
<button
v-for="key in NOTE_COLOR_KEYS"
:key="key"
type="button"
:title="NOTE_COLOR_LABELS[key]"
:aria-label="NOTE_COLOR_LABELS[key]"
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
@click.stop="pickColor(key)"
/>
</div>
</div>
</div>
</div>
+38 -30
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import ColorPicker from "./ColorPicker.vue";
import Icon from "./Icon.vue";
import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
@@ -9,12 +8,13 @@ import { fromLocalInput, toLocalInput } from "../notes/datetime";
import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { LABEL_CHIP_CLASSES, resolveLabelColor, type NoteColor } from "../notes/colors";
import { labelChipClasses } from "../notes/colors";
import {
afterEnter,
type EditorBlock,
joinBlocks,
plusTask,
promoteTasks,
splitBlocks,
withoutIndex,
} from "../notes/blocks";
@@ -40,7 +40,6 @@ const body = computed(() => joinBlocks(blocks.value));
function setBody(text: string): void {
blocks.value = splitBlocks(text);
}
const color = ref<NoteColor>(props.note?.color ?? "default");
const labelList = ref<NoteLabel[]>(props.note ? [...props.note.labels] : []);
// Whether this editor is showing the checklist. A note HAS a checklist (M13 step 2)
// rather than BEING one, so this is a view flag, not a property of the note: it turns
@@ -79,10 +78,7 @@ const fileInput = ref<HTMLInputElement | null>(null);
const uploadError = ref("");
// Baseline for edit-mode change detection (save only when text actually changed).
const baseline = ref<{ body: string; color: NoteColor }>({
body: props.note?.body ?? "",
color: (props.note?.color ?? "default") as NoteColor,
});
const baseline = ref<{ body: string }>({ body: props.note?.body ?? "" });
const isCreate = computed(() => noteId.value === null);
const hasContent = computed(() => body.value.trim() !== "");
@@ -95,7 +91,6 @@ const draftNote = computed<Note>(() => ({
id: "",
display_title: "",
body: body.value,
color: color.value,
position: 0,
pinned: false,
archived: false,
@@ -123,17 +118,16 @@ watch(
(n) => {
noteId.value = n?.id ?? null;
setBody(n?.body ?? "");
color.value = (n?.color ?? "default") as NoteColor;
labelList.value = n ? [...n.labels] : [];
baseline.value = { body: n?.body ?? "", color: (n?.color ?? "default") as NoteColor };
baseline.value = { body: n?.body ?? "" };
},
);
// ---- persistence ----
async function createFromFields(): Promise<void> {
const created = await notes.create({ body: body.value, color: color.value });
const created = await notes.create({ body: body.value });
noteId.value = created.id;
baseline.value = { body: created.body, color: created.color as NoteColor };
baseline.value = { body: created.body };
}
// Ensure a persisted note exists (for rich actions mid-compose). Returns its id, or
@@ -160,12 +154,12 @@ async function flush(): Promise<void> {
}
const b = baseline.value;
const nextBody = body.value;
const changed = nextBody !== b.body || color.value !== b.color;
const changed = nextBody !== b.body;
if (!changed) return;
saving.value = true;
try {
await notes.saveEdit(noteId.value as string, { body: nextBody, color: color.value });
baseline.value = { body: nextBody, color: color.value };
await notes.saveEdit(noteId.value as string, { body: nextBody });
baseline.value = { body: nextBody };
} finally {
saving.value = false;
}
@@ -174,9 +168,8 @@ async function flush(): Promise<void> {
function resetCompose(): void {
noteId.value = null;
setBody("");
color.value = "default";
labelList.value = [];
baseline.value = { body: "", color: "default" };
baseline.value = { body: "" };
uploadError.value = "";
}
@@ -285,6 +278,23 @@ function onProseInput(index: number, e: Event): void {
grow(el);
}
/**
* Leaving a prose block is when a `- [ ] ` typed by hand becomes a real item.
*
* See notes/blocks.ts for why blur is the only safe moment. The identity check is the
* contract `promoteTasks` offers: an untouched array back means nothing to promote, and
* reassigning the ref anyway would re-key every field below this one for no reason.
*
* `growAll` after the DOM settles, because the textarea being left is now shorter by
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
void nextTick().then(growAll);
}
/** Compose: Shift+Enter saves the note and starts a fresh one (rapid capture). */
function onProseKeydown(e: KeyboardEvent): void {
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
@@ -358,13 +368,6 @@ async function onLabelsChange(next: NoteLabel[]) {
async function removeLabel(id: string) {
await onLabelsChange(labelList.value.filter((lb) => lb.id !== id));
}
// Takes the label, not its colour — a tag nobody has coloured derives one from its
// name. Must match NoteCard's chip exactly: the same tag either side of opening a
// note changing colour would be worse than both being grey.
function labelChip(label: { name: string; color: string }): string {
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
}
// ---- add a checklist ----
//
// Appends an empty item and puts the caret in it. Unlike every other toolbar button
@@ -457,8 +460,7 @@ async function restoreRevisionAt(revId: string) {
if (!id) return;
const updated = await notes.restoreRevision(id, revId);
setBody(updated.body);
color.value = updated.color;
baseline.value = { body: updated.body, color: updated.color };
baseline.value = { body: updated.body };
void loadRevisions(); // the pre-restore state became a new revision
}
function revLabel(iso: string | null): string {
@@ -600,6 +602,7 @@ function revPreview(rev: NoteRevision): string {
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@blur="onProseBlur(i)"
/>
</template>
</div>
@@ -611,9 +614,12 @@ function revPreview(rev: NoteRevision): string {
v-for="lb in labelList"
:key="lb.id"
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="labelChip(lb)"
:class="labelChipClasses(lb)"
>
{{ lb.via_tag ? "#" + lb.name : lb.name }}
<!-- `#` on every chip, matching the card. This row still lists the tags
the BODY owns too — it is the control surface, and `via_tag` is what
decides whether there is a cross to remove one with. -->
#{{ lb.name }}
<button
v-if="!lb.via_tag"
type="button"
@@ -698,8 +704,10 @@ function revPreview(rev: NoteRevision): string {
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<ColorPicker v-model="color" />
<!-- `justify-end`, not `justify-between`: the colour picker sat on the left of
this row until M315 and the row was balanced around it. With one child left,
`between` would push the actions to the far left of a full-width bar. -->
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
+34
View File
@@ -108,3 +108,37 @@ export function plusTask(blocks: EditorBlock[]): { blocks: EditorBlock[]; focus:
const id = nextId(blocks);
return { blocks: [...blocks, { id, text: "", checked: false }], focus: id };
}
/**
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
*
* `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.
*
* 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,
* so a re-split costs no caret and cannot catch a half-typed line.
*
* Returns THE SAME ARRAY, not an equal copy, when there was nothing to promote — the
* caller leans on that to leave the ref alone, and a blur that changed nothing must not
* re-key every field below it.
*
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
* would have on reopen. That is the only case where this changes the body rather than
* only the way it is drawn.
*/
export function promoteTasks(blocks: EditorBlock[], index: number): EditorBlock[] {
const block = blocks[index];
if (!block || block.checked !== null) return blocks;
const split = splitBlocks(block.text, nextId(blocks));
// A single prose block back means there was nothing to promote. `splitBlocks` never
// returns an empty array, so `split[0]` is safe.
const changed = split.length > 1 || split[0].checked !== null;
return changed ? [...blocks.slice(0, index), ...split, ...blocks.slice(index + 1)] : blocks;
}
+168 -251
View File
@@ -18,8 +18,8 @@ export const NOTE_COLOR_KEYS = [
export type NoteColor = (typeof NOTE_COLOR_KEYS)[number];
/** Membership test for a colour key arriving from the server, which may be newer
* than this client. Was a lookup in the subdued card table until that table was
* deleted — an untagged note's fill is generated now, not chosen from a palette. */
* than this client. Once a lookup in a card-fill table; since M315 there is no such
* table, and this guards a LABEL's stored colour on its way into the palette. */
const KNOWN_COLORS = new Set<string>(NOTE_COLOR_KEYS);
export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
@@ -35,43 +35,108 @@ export const NOTE_SWATCH_CLASSES: Record<NoteColor, string> = {
gray: "bg-neutral-400 dark:bg-neutral-500",
};
// Label chip tints (bg + readable text + a hairline edge), keyed by the same color
// vocabulary.
// A chip's SHELL: its fill and its hairline edge, keyed by the colour vocabulary. The
// INK is not here — see TAG_TEXT_CLASSES, which one table now serves both a chip's text
// and a `#tag` left in the prose. Compose the two with `labelChipClasses`.
//
// The RING is not decoration. A tagged note takes its first tag's colour and is drawn
// at that hue's `-100` — exactly what the chip uses as its fill — so in light mode the
// chip measured a contrast ratio of 1.00 against the card it had itself coloured.
// Perfectly invisible; the tag name read as loose text. An edge holds the pill's shape
// against ANY background, where shifting the fill only moves which card it collides
// THE RING IS NOT DECORATION. A chip's fill measures 1.02-1.26 against the card in
// light and 1.02-1.73 in dark — that is to say, very nearly nothing. The pill's shape
// is the edge; the fill only tints it. (Dark red is the extreme at 1.02, which is
// invisible: without the ring that chip would be loose text.) An edge holds the shape
// against any background, where shifting the fill only moves which card it collides
// with.
export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
default: "bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300 ring-1 ring-inset ring-black/10 dark:ring-white/15",
red: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300 ring-1 ring-inset ring-red-700/60 dark:ring-red-300/60",
orange: "bg-orange-100 text-orange-700 dark:bg-orange-950/50 dark:text-orange-300 ring-1 ring-inset ring-orange-700/60 dark:ring-orange-300/60",
yellow: "bg-amber-100 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300 ring-1 ring-inset ring-amber-700/60 dark:ring-amber-300/60",
green: "bg-green-100 text-green-700 dark:bg-green-950/50 dark:text-green-300 ring-1 ring-inset ring-green-700/60 dark:ring-green-300/60",
teal: "bg-teal-100 text-teal-700 dark:bg-teal-950/50 dark:text-teal-300 ring-1 ring-inset ring-teal-700/60 dark:ring-teal-300/60",
blue: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300 ring-1 ring-inset ring-blue-700/60 dark:ring-blue-300/60",
purple: "bg-purple-100 text-purple-700 dark:bg-purple-950/50 dark:text-purple-300 ring-1 ring-inset ring-purple-700/60 dark:ring-purple-300/60",
pink: "bg-pink-100 text-pink-700 dark:bg-pink-950/50 dark:text-pink-300 ring-1 ring-inset ring-pink-700/60 dark:ring-pink-300/60",
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 ring-1 ring-inset ring-neutral-700/60 dark:ring-neutral-200/60",
//
// AT 65%, NOT 60%, AND SOLVED FOR RATHER THAN GUESSED. 0.60 was chosen against a worst
// case that no longer exists — a chip sitting on a card of its own colour, back when a
// note took its first tag's fill. Against the one card surface (M315) the ring is the
// ink at alpha over a known fill, so the alpha that clears the 3:1 of WCAG 1.4.11 for
// all ten hues can simply be solved: 0.60 gives 2.75-3.82 in light and misses for six
// of them, 0.65 gives 3.03-4.36 and misses for none. Dark is 4.52-5.76. 0.80 was the
// number the old comment named as the fallback and it is more than is needed — it
// draws a hard outline where a hairline does the job.
//
// `default`'s ring was `black/10 dark:white/15` here and the ink at alpha on the phone —
// 1.36 against its own fill in light against Compose's 3.21, so the two surfaces were
// drawing visibly different pills for the same chip. It is the ink at 65% on both now,
// like every other key: one rule for ten hues, not nine and an exception.
//
// Mirrored in NoteTint.kt as `chipBackground` and `chipBorder` / CHIP_EDGE_ALPHA.
export const LABEL_CHIP_SHELL: Record<NoteColor, string> = {
default: "bg-black/5 dark:bg-white/10 ring-1 ring-inset ring-neutral-700/65 dark:ring-neutral-300/65",
red: "bg-red-100 dark:bg-red-950/50 ring-1 ring-inset ring-red-800/65 dark:ring-red-300/65",
orange: "bg-orange-100 dark:bg-orange-950/50 ring-1 ring-inset ring-orange-800/65 dark:ring-orange-300/65",
yellow: "bg-amber-100 dark:bg-amber-950/50 ring-1 ring-inset ring-amber-800/65 dark:ring-amber-300/65",
green: "bg-green-100 dark:bg-green-950/50 ring-1 ring-inset ring-green-800/65 dark:ring-green-300/65",
teal: "bg-teal-100 dark:bg-teal-950/50 ring-1 ring-inset ring-teal-800/65 dark:ring-teal-300/65",
blue: "bg-blue-100 dark:bg-blue-950/50 ring-1 ring-inset ring-blue-800/65 dark:ring-blue-300/65",
purple: "bg-purple-100 dark:bg-purple-950/50 ring-1 ring-inset ring-purple-800/65 dark:ring-purple-300/65",
pink: "bg-pink-100 dark:bg-pink-950/50 ring-1 ring-inset ring-pink-800/65 dark:ring-pink-300/65",
gray: "bg-neutral-200 dark:bg-neutral-700 ring-1 ring-inset ring-neutral-800/65 dark:ring-neutral-200/65",
};
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
// Mid-tone hues read on both the light and dark graph background.
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
default: "#9ca3af",
red: "#ef4444",
orange: "#f97316",
yellow: "#f59e0b",
green: "#22c55e",
teal: "#14b8a6",
blue: "#3b82f6",
purple: "#a855f7",
pink: "#ec4899",
gray: "#6b7280",
// THE INK A TAG IS DRAWN IN — one table, for a `#tag` left in the prose AND for a
// chip's text. It was two, and the split was real while it lasted: a chip carried its
// own `-100` fill and could afford `-700`, while inline text sat on whatever the card
// was, which included a gray-tagged card at `neutral-200` where `-700` measured 3.98
// (green), 4.11 (orange) and 4.34 (teal) — all under the 4.5 body text needs. One step
// deeper cleared every fill at once, so inline got `-800` and the chip kept `-700`.
//
// M315 removed the twenty card fills the split was solving for, and this is the payoff:
// against ONE card surface both jobs can take the same value. `-800`/`-300` is the one
// they take, and the direction is deliberate — the inline token is the common case
// (since M311 a tag whose text is in the body is drawn where it was typed and NOT
// repeated as a chip), so collapsing onto the inline column leaves what is seen most
// exactly as it was, and moves only the chip. The chip is strictly better for it:
//
// 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)
//
// Dark needed no decision at all: the two tables were already the same value for all
// ten hues, which is on its own most of the argument that one table was always enough.
//
// Mirrored in NoteTint.kt as `lightTagInk` / `darkTagInk`.
export const TAG_TEXT_CLASSES: Record<NoteColor, string> = {
default: "text-neutral-700 dark:text-neutral-300",
red: "text-red-800 dark:text-red-300",
orange: "text-orange-800 dark:text-orange-300",
yellow: "text-amber-800 dark:text-amber-300",
green: "text-green-800 dark:text-green-300",
teal: "text-teal-800 dark:text-teal-300",
blue: "text-blue-800 dark:text-blue-300",
purple: "text-purple-800 dark:text-purple-300",
pink: "text-pink-800 dark:text-pink-300",
gray: "text-neutral-800 dark:text-neutral-200",
};
/**
* The classes for one `#tag` in a note's own words.
*
* `picked` maps a lowercased tag name to the colour stored on that label, so a tag the
* operator has recoloured reads the same inline as it does on a chip. A tag the note
* does not carry as a label yet — just typed, not yet derived — is not in the map, and
* `resolveLabelColor` derives one from the name exactly as the chip would have.
*/
export function tagTextClasses(name: string, picked?: Record<string, string>): string {
return TAG_TEXT_CLASSES[resolveLabelColor({ name, color: picked?.[name.toLowerCase()] })];
}
/**
* The whole class list for a label CHIP: the shell and the ink, composed.
*
* One function rather than the composition written out at each call site, because the
* board's chip and the editor's chip have to be the same pill — the same tag changing
* colour on opening a note would be worse than both being grey. That was a comment
* asking two files to stay in step; it is one call now.
*
* Takes the LABEL, not its colour: a tag nobody has coloured derives one from its name,
* so chips carry the tag's identity rather than all being the same grey.
*/
export function labelChipClasses(label: { name: string; color?: string | null }): string {
const color = resolveLabelColor(label);
return `${LABEL_CHIP_SHELL[color]} ${TAG_TEXT_CLASSES[color]}`;
}
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
default: "Default",
red: "Red",
@@ -86,31 +151,27 @@ export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
};
// ---------------------------------------------------------------------------
// Derived tints — the colour a note has when nothing chose one for it.
// Derived colour — the hue a LABEL wears when nobody picked one for it.
//
// A board of `default` notes is a wall of white rectangles and the eye gets no
// help telling one from the next. Every note now carries some tint; this is where
// an untagged one gets it.
// Every `#tag` is born colourless, so without this a board of tags is a board of
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
// surface, costs no column and no migration, and a tag keeps its colour for life.
//
// "RANDOM" MEANS DERIVED. The operator asked for "random subdued colors", but a
// tint rolled at render time would differ between the phone and the browser and
// change on every reload. Hashing the note's id is deterministic, identical on
// every surface, costs no column and no migration, and a note keeps its colour
// for life — which is what "random" actually meant here.
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
// one neutral and only its tags carry hue. The hash survived that removal because the
// job it still does — give a name a stable colour — was never the job that failed.
// What failed was asking a colour that means "which tag" to also mean nothing at all
// on an untagged note, at which point the board had two vocabularies and neither read.
//
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/NoteTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a note is one
// colour on the phone and another in the browser. Same discipline as the
// checklist grammar's three implementations, and the same reason: a value that
// disagrees across surfaces is a bug you cannot unsee and cannot explain.
// THIS IS HALF A MIRRORED PAIR. `android/.../ui/DerivedTint.kt` computes the same
// hash over the same key order, and the two must agree exactly or a tag is one colour
// on the phone and another in the browser. Same discipline as the checklist grammar's
// three implementations, and the same reason: a value that disagrees across surfaces
// is a bug you cannot unsee and cannot explain.
//
// The Kotlin side has a unit test pinning the fixture below. THIS SIDE HAS NO
// MECHANICAL GUARD — the frontend has no test runner, only `vue-tsc --noEmit`.
// If you change anything here, check it against the fixture by hand.
/** The tints a derived colour can land on: the palette minus `default`, which is
* the white this exists to eliminate. `gray` stays — `bg-neutral-100` reads as a
* deliberate card against the board's `bg-neutral-50`, not as an absence. */
export const DERIVED_TINT_KEYS: readonly NoteColor[] = NOTE_COLOR_KEYS.filter(
(key) => key !== "default",
);
@@ -138,118 +199,26 @@ export function tintHash(id: string): number {
return hash >>> 0;
}
/** The tint a note with no colour of its own wears. Stable for the life of the note. */
/** The colour a name maps to, stable for as long as the name is. Called with a
* label's lowercased name; `id` is the parameter's history, not its meaning. */
export function derivedTint(id: string): NoteColor {
return DERIVED_TINT_KEYS[tintHash(id) % DERIVED_TINT_KEYS.length];
}
// ---------------------------------------------------------------------------
// The fill for an UNTAGGED note, which is a different job from the palette above.
//
// The palette has nine keys and they MEAN something: a tag's colour. An untagged
// note's fill means nothing at all — it exists so a board is not a monolithic wall.
// Tying the second job to the first was the mistake. Nine keys is far too few for a
// board of any size, and once the nine were subdued enough not to shout they became
// indistinguishable from each other: measured, the nine dark fills were separated by
// at most a 1.03 contrast ratio, which is to say not at all. Nine tints that look
// like three is exactly the wall the tint was added to break up.
//
// So this hashes to a colour directly rather than to a key. 324 distinct fills in
// dark, 193 in light, against nine.
//
// TWO AXES, AND THE SECOND ONE IS THE FIX. The old 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 read as one card repeated. Varying lightness too is what makes the
// difference; hue alone never could at this darkness.
//
// It is only SAFE to vary lightness because the card now has a grey edge of its own
// (NoteCard.vue). While the fill was the only boundary the card had, it could not
// afford to drift toward the board. The edge bought that freedom.
//
// MIRRORED in `android/.../ui/DerivedTint.kt`, which has the unit test. Same hash,
// same levels, same rounding — see the fixture below.
/** Lightness steps a generated fill can land on. Six rather than three because the
* levels are what carry the variety, and rather than twelve because past a point
* they stop being distinguishable and only cost contrast headroom. */
const TINT_LEVELS = 6;
// Saturation is FIXED, and that is what keeps this subtle whichever hue it lands on.
// Variety comes from hue and lightness; loudness would come from saturation, so
// saturation is the one dial the hash never touches.
const DARK_SATURATION = 0.25;
const LIGHT_SATURATION = 0.6;
// Dark starts a hair under `neutral-900`, the plain card surface, and climbs — so
// nothing is ever darker than an untinted card and no note recedes into the board.
// The top of the range measures 1.54 against the board where the old single level
// managed 1.14. Light runs the other way, from white down past the `neutral-50`
// board; a card slightly darker than the board still reads as one because the edge
// says so.
const DARK_LIGHTNESS = [0.09, 0.104, 0.118, 0.132, 0.146, 0.16];
const LIGHT_LIGHTNESS = [1.0, 0.99, 0.98, 0.97, 0.96, 0.95];
/**
* The opaque fill an untagged note wears, as `#rrggbb`. Stable for the note's life.
*
* Hue and level are read from DIFFERENT parts of the hash so a note's shade is not a
* function of its hue — two notes of nearly the same hue should still be able to
* differ in weight, which is half of where the variety comes from.
*/
export function derivedFill(id: string, dark: boolean): string {
const hash = tintHash(id);
const level = (hash >>> 16) % TINT_LEVELS;
return hslHex(
hash % 360,
dark ? DARK_SATURATION : LIGHT_SATURATION,
dark ? DARK_LIGHTNESS[level] : LIGHT_LIGHTNESS[level],
);
}
/**
* Textbook HSL to RGB, written out rather than pulled from a library because the
* Kotlin side has to compute the same bytes and there is no library both can share.
* Rounding is `floor(v + 0.5)` on both sides rather than the language's `round`:
* Kotlin rounds half away from zero and JS rounds half up, which agree here, but
* stating the rule leaves nothing for a future reader to have to check.
*/
function hslHex(hue: number, saturation: number, lightness: number): string {
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const sector = hue / 60;
const second = chroma * (1 - Math.abs((sector % 2) - 1));
const match = lightness - chroma / 2;
const ramps: [number, number, number][] = [
[chroma, second, 0],
[second, chroma, 0],
[0, chroma, second],
[0, second, chroma],
[second, 0, chroma],
[chroma, 0, second],
];
const [red, green, blue] = ramps[Math.floor(sector)];
const byte = (v: number) =>
Math.min(255, Math.max(0, Math.floor((v + match) * 255 + 0.5)))
.toString(16)
.padStart(2, "0");
return `#${byte(red)}${byte(green)}${byte(blue)}`;
}
/**
* The colour to paint a LABEL — its chip, and (step 3) every note carrying it.
* The colour to paint a LABEL — its chip, and its `#tag` where it sits in the prose.
*
* Derived from the tag's NAME, not stored, when nobody has picked one. Every `#tag`
* ever typed is currently `default`: `notes/tags.py` mints one as
* `Label(owner_id=…, name=name)` with no colour, so it takes the column default.
* Tag-driven note colour against that would leave the board exactly as grey as it
* was.
* ever typed is `default`: `notes/tags.py` mints one as `Label(owner_id=…, name=name)`
* with no colour, so it takes the column default. Without deriving, a board of tags
* would be a board of identical grey chips.
*
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing the original plan in #2965.
* That plan wanted a hashed colour written at each of the four places a label can be
* born — and named the risk itself: `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 the notes already use. The cost is
* tags that already exist, and reuses a hash that is already written twice. The cost is
* that renaming a tag recolours it, which is defensible: the name IS the tag.
*
* An explicitly-picked colour is still stored and still wins, so tag colours stay
@@ -266,119 +235,67 @@ export function resolveLabelColor(label: { name: string; color?: string | null }
return derivedTint(label.name.toLowerCase());
}
// Fixture — the same ids and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash
// or the key order, these four must still hold on BOTH surfaces:
// Fixture — the same names and expected keys the Kotlin test asserts. Kept here as
// prose because there is nowhere on this side to assert it. If you change the hash or
// the key order, these must still hold on BOTH surfaces.
//
// The raw hash, over four UUIDs — ids no longer pick a colour, but they are what the
// hash itself is pinned by and the Kotlin test still asserts them:
//
// 00000000-0000-0000-0000-000000000000 0xbe478ed1 purple
// 11111111-1111-1111-1111-111111111111 0x3d75cc01 blue
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 0xf108e530 orange
// f47ac10b-58cc-4372-a567-0e02b2c3d479 0x5b651540 orange
//
// And for labels, which hash the lowercased NAME rather than an id:
// And the live path — a label, hashing its lowercased NAME:
//
// todo -> pink grocery -> blue work -> green home -> gray
// ideas -> green reading -> gray urgent -> red
//
// And for derivedFill, which uses the same hash on two axes (dark / light):
//
// 00000000-0000-0000-0000-000000000000 hue 177 lev 3 #192a29 #f3fcfb
// 11111111-1111-1111-1111-111111111111 hue 113 lev 1 #152114 #fbfefb
// 6ba7b810-9dad-11d1-80b4-00c04fd430c8 hue 136 lev 0 #111d14 #ffffff
// f47ac10b-58cc-4372-a567-0e02b2c3d479 hue 352 lev 3 #2a191b #fcf3f4
//
// Note `work`/`ideas` and `home`/`reading` collide. Nine keys makes that unavoidable
// and it is not a bug: colour hints that two notes are related, it never claims they
// carry the same tag. The chip's text is what says which tag it is.
// and it is not a bug: colour hints that two tags are distinct, it never claims two
// chips of one colour are the same tag. The chip's text is what says which tag it is.
// The FULL-strength ramp: what a note wears when its colour was CHOSEN — by a tag, or
// (until step 5) by the picker. One Tailwind step deeper in light mode, and a much
// heavier fill in dark.
// ---------------------------------------------------------------------------
// THE CARD SURFACE — one neutral, no hue, no note involved (M315).
//
// UNCHANGED by the border pass, deliberately. The operator's complaint was the edge
// line and the loudness of the DERIVED tint; the chosen colours were "pretty well"
// where they landed, and a ramp somebody has already signed off on is not something
// to redo while fixing something else.
// This used to be a function of the note: a palette class for a tagged one, a fill
// generated from the id for the rest. Both are gone. The operator's verdict after
// four passes at it — "my coloring attempt has failed and nothing looks right… we've
// tried a lot to make the color work and somehow it never seems to land" — and the
// diagnosis underneath it is that a card's fill was being asked to carry meaning it
// could not carry. Nine keys is too few to identify anything on a board of any size,
// and a generated fill identifies nothing by construction, so a coloured board taught
// the eye to read hue as significant and then handed it noise.
//
// THERE IS NO SECOND RAMP ANY MORE. This one is reached only by a note that HAS a
// colour; a note without one gets a generated fill instead (`derivedFill`), because
// nine palette keys could never carry both jobs. The palette says WHICH TAG. The
// generator says nothing at all, and only has to keep the board from repeating.
// COLOUR NOW LIVES ONLY ON THE TAG — the chip and the inline `#tag`, both of which sit
// on this one known background from here on. That is a smaller job done properly
// instead of a larger one done four times.
//
// So these values do not need to be subtle and never did — a tagged note is making a
// statement, and the quiet end of the board is now handled somewhere else entirely.
export const NOTE_CARD_CLASSES_STRONG: Record<NoteColor, string> = {
default: "bg-white dark:bg-neutral-900",
red: "bg-red-100 dark:bg-red-950/70",
orange: "bg-orange-100 dark:bg-orange-950/70",
yellow: "bg-amber-100 dark:bg-amber-950/70",
green: "bg-green-100 dark:bg-green-950/70",
teal: "bg-teal-100 dark:bg-teal-950/70",
blue: "bg-blue-100 dark:bg-blue-950/70",
purple: "bg-purple-100 dark:bg-purple-950/70",
pink: "bg-pink-100 dark:bg-pink-950/70",
gray: "bg-neutral-200 dark:bg-neutral-800/70",
};
/**
* The palette key a note was GIVEN, or null when nothing gave it one.
*
* Null is the interesting answer: it means the fill has to be generated, because the
* note carries no statement about what it is. Everything downstream branches here.
*
* Resolution order, and why: an explicit pick beats a tag because it is the more
* specific statement and the picker still exists. The FIRST label wins among tags —
* it is the one the person controls by typing, where alphabetical or most-used would
* move a note's colour when an unrelated tag was added somewhere else.
*
* Manual labels count the same as `#tags`. Someone looking at a chip cannot tell which
* kind they made, and two identically-tagged notes in different colours for an
* invisible reason is worse than the rule being slightly loose.
*/
export function chosenNoteColor(note: {
color?: string | null;
labels?: { name: string; color: string }[];
}): NoteColor | null {
const picked = note.color as NoteColor | undefined | null;
if (picked && picked !== "default" && KNOWN_COLORS.has(picked)) return picked;
const first = note.labels?.[0];
if (first) return resolveLabelColor(first);
return null;
}
/**
* The class list for a note card.
*
* A tagged note gets a palette class. An untagged one gets `note-tint`, whose fill
* arrives through the custom properties in `noteTintVars` — see the rule in
* style.css, which exists because an inline style cannot answer a media query and the
* light and dark fills are two different generated colours.
*/
export function noteCardClasses(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): string {
const chosen = chosenNoteColor(note);
return chosen ? NOTE_CARD_CLASSES_STRONG[chosen] : "note-tint";
}
/**
* The generated fill for an untagged note, as the two custom properties `note-tint`
* reads — or undefined when the note has a colour of its own, or is a draft.
*
* A draft carries no id, so there is nothing to derive from; `note-tint`'s fallbacks
* catch that and paint the plain card surface. Hashing the empty string instead would
* give every draft the same fill and then change it at save time anyway.
*/
export function noteTintVars(note: {
id: string;
color?: string | null;
labels?: { name: string; color: string }[];
}): Record<string, string> | undefined {
if (chosenNoteColor(note) || !note.id) return undefined;
return {
"--tint-light": derivedFill(note.id, false),
"--tint-dark": derivedFill(note.id, true),
};
}
// The codebase had already made this argument about the card's EDGE, one level down:
// the hue-coded border came out as "a neutral line carries no information at all,
// which is exactly what lets it be structure instead of content". The fill is the same
// argument at the next size up.
//
// THE VALUES. `bg-white dark:bg-neutral-900` — which is exactly what `default` always
// was, and exactly what the note EDITOR panel has always been (NoteEditor.vue), so
// this is a collapse onto a surface both other surfaces already used rather than a
// new colour anybody has to like.
//
// Measured against the operator's constraint, "not the same color as their background
// but close to it":
//
// card vs board light #ffffff on #fafafa 1.04
// dark #171717 on #0a0a0a 1.10
// edge vs card light #b8b8b8 on #ffffff 1.98
// dark #404040 on #171717 1.73
// body vs card light #171717 on #ffffff 17.93 (needs 4.5)
// dark #fafafa on #171717 17.17
// muted vs card light #404040 on #ffffff 10.37
// dark #e5e5e5 on #171717 14.23
//
// The fill is deliberately the WEAKEST of those numbers. A card is not separated from
// the board by its fill and never was — the edge and the shadow do that, which is why
// 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
// be a panel, and a board of panels is the wall this whole line of work started from.
export const NOTE_CARD_SURFACE = "bg-white dark:bg-neutral-900";
-4
View File
@@ -15,8 +15,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
const f: NoteFacets = {};
const text = one(q.q);
if (text) f.q = text;
const color = one(q.color);
if (color) f.color = color;
if (labels.length) f.label = labels;
if (one(q.has_reminder) === "true") f.has_reminder = true;
if (one(q.has_attachment) === "true") f.has_attachment = true;
@@ -30,7 +28,6 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
const q: LocationQueryRaw = {};
if (f.q) q.q = f.q;
if (f.color) q.color = f.color;
if (f.label?.length) q.label = f.label;
if (f.has_reminder) q.has_reminder = "true";
if (f.has_attachment) q.has_attachment = "true";
@@ -43,7 +40,6 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
export function facetCount(f: NoteFacets): number {
let n = 0;
if (f.q) n++;
if (f.color) n++;
n += f.label?.length ?? 0;
if (f.has_reminder) n++;
if (f.has_attachment) n++;
+20 -2
View File
@@ -7,7 +7,9 @@
// a heading.
export interface InlineToken {
type: "text" | "bold" | "italic" | "code";
/** `tag` carries the NAME, without the leading `#` — it is both what gets looked up
* for a colour and what is rendered, so the renderer puts the `#` back. */
type: "text" | "bold" | "italic" | "code" | "tag";
value: string;
}
@@ -37,7 +39,22 @@ export interface TaskMeta {
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
// renders as the literal characters someone typed, which is what it always was.
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
// `#tag` is LAST in the alternation and that is load-bearing twice over. JS tries
// alternatives left to right, so a `#tag` inside backticks is claimed by `code` first
// and stays literal — matching the core, where a fenced block's contents are code.
// And a tag is the one token here that is not delimiter-based, so it must not get a
// chance to start inside `**bold #x**`.
//
// The grammar MIRRORS `line_tags` in core/src/local/derive.rs, which is the definition:
// a `#` at a word boundary (the preceding character is neither a tag character nor
// another `#`, so `a#b` and `##x` are not tags), a letter immediately after it, then
// alphanumerics, `_` and `-`. Rust's `is_alphanumeric` is `Alphabetic | N`, hence the
// property escapes rather than `\w` — and hence the `u` flag.
//
// A heading cannot collide with this: `parseMarkdown` requires a space after the `#`s,
// which `#tag` by definition does not have.
const INLINE_RE =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)|((?<![\p{Alphabetic}\p{N}_#-])#\p{Alphabetic}[\p{Alphabetic}\p{N}_-]*)/gu;
export function parseInline(text: string): InlineToken[] {
const tokens: InlineToken[] = [];
@@ -49,6 +66,7 @@ export function parseInline(text: string): InlineToken[] {
const raw = m[0];
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
else if (m[5]) tokens.push({ type: "tag", value: raw.slice(1) });
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
last = m.index + raw.length;
}
+10 -16
View File
@@ -2,14 +2,12 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { repo } from "../adapters";
import { useUiStore } from "./ui";
import type { NoteColor } from "../notes/colors";
export type NoteView = "active" | "archived" | "trash";
// Combinable facet filters for the board (mirrors the GET /api/notes query + a saved
// view's stored params). All optional; empty = the plain, unfiltered board.
export interface NoteFacets {
q?: string;
color?: string;
label?: string[];
has_reminder?: boolean;
has_attachment?: boolean;
@@ -21,8 +19,13 @@ export interface NoteLabel {
id: string;
name: string;
color: string;
// True when this label is attached because of a #tag in the note body (kept in
// sync with the text); false = added manually via the picker.
// True when the label is backed by text STILL IN THE BODY — a `#tag` written
// mid-sentence, kept in sync with those words. False covers both a label added
// through the picker and a tag lifted off a line of its own (M311), which is why
// it is also what decides whether a chip can be removed with a cross.
//
// The card reads it the other way round: a true here means the body is already
// showing this tag, so the chip would be the second copy and is not drawn.
via_tag: boolean;
}
@@ -65,7 +68,6 @@ export interface Note {
// (server-derived). Every note has one, so every note has something to be called.
display_title: string;
body: string;
color: NoteColor;
position: number;
pinned: boolean;
archived: boolean;
@@ -131,11 +133,7 @@ export const useNotesStore = defineStore("notes", () => {
}
}
async function create(input: {
body: string;
color: NoteColor;
items?: string[];
}): Promise<Note> {
async function create(input: { body: string; items?: string[] }): Promise<Note> {
const note = await repo.notes.create(input);
reconcile(note);
return note;
@@ -143,9 +141,7 @@ export const useNotesStore = defineStore("notes", () => {
async function mutate(
id: string,
changes: Partial<
Pick<Note, "body" | "color" | "pinned" | "archived" | "remind_at" | "recurrence">
>,
changes: Partial<Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">>,
): Promise<void> {
reconcile(await repo.notes.update(id, changes));
}
@@ -156,10 +152,9 @@ export const useNotesStore = defineStore("notes", () => {
if (archived)
useUiStore().showToast("Note archived", { label: "Undo", run: () => void setArchived(id, false) });
};
const setColor = (id: string, color: NoteColor) => mutate(id, { color });
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence });
const saveEdit = (id: string, changes: { body: string; color: NoteColor }) => mutate(id, changes);
const saveEdit = (id: string, changes: { body: string }) => mutate(id, changes);
async function completeReminder(id: string): Promise<void> {
reconcile(await repo.notes.completeReminder(id));
@@ -274,7 +269,6 @@ export const useNotesStore = defineStore("notes", () => {
create,
setPinned,
setArchived,
setColor,
setReminder,
setRecurrence,
completeReminder,
-39
View File
@@ -47,26 +47,6 @@ body {
}
}
/* An untagged note's fill is GENERATED from its id, not chosen from the palette —
* see `derivedFill` in notes/colors.ts for why nine keys was never going to be
* enough. That means it cannot be a Tailwind class, and it cannot be a plain inline
* style either: light and dark are two different computed colours and an inline
* style has no way to answer a media query. So the card publishes both as custom
* properties and this rule picks between them.
*
* The fallbacks are the draft case. A note with no id yet has nothing to hash, so it
* publishes no properties and lands on the plain card surface — one colour change at
* save time, rather than every draft sharing a fill and then changing anyway. */
.note-tint {
background-color: var(--tint-light, #ffffff);
}
@media (prefers-color-scheme: dark) {
.note-tint {
background-color: var(--tint-dark, #171717);
}
}
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told
* us something about vestibular comfort or attention, and the answer is to arrive
* instantly rather than to animate faster.
@@ -163,25 +143,6 @@ body {
}
}
/* The per-card colour popover, anchored to whichever end of the card the action set
* currently occupies: it opens DOWNWARD from a floating top-corner pill, and UPWARD
* from a footer row, so in both cases it grows into the card rather than off it. */
.note-swatches {
position: absolute;
right: 0;
bottom: 100%;
margin-bottom: 0.375rem;
z-index: 20;
}
@media (hover: hover) {
.note-swatches {
top: 100%;
bottom: auto;
margin-top: 0.375rem;
margin-bottom: 0;
}
}
/* Board motion (M7). Defined once here rather than three times in BoardView's
* markup, because "how the board moves" is one idea even though the pinned, other
* and non-board grids are three TransitionGroups.
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env sh
#
# Refuse to publish a version lower than the one already on the channel.
#
# guard-forward.sh <desktop|android> <dev|stable>
# guard-forward.sh compare <a> <b> exit 0 iff a sorts below b
# guard-forward.sh published <artifact> <channel> print what the channel serves
#
# Note 3127 §6.3. Everything else in this milestone derives a number and trusts it;
# this is the one thing that checks the answer against reality before a user gets it.
#
# WHAT IT CATCHES that nothing else does:
#
# * A SQUASH OR REBASE MERGE (§6.2). Both rewrite the committer date, so `main`
# could stamp a value unrelated to the dev commit it merged. Rule 153 mandates
# plain merge commits — but that rule governs people, and a forge UI's squash
# button does not read it.
# * A REBUILD OF AN OLDER COMMIT. Commit time can go backwards; this is the entire
# mitigation for the desktop key's clock choice (step 4), and the thing to
# revisit first if this repo ever starts rebuilding old commits routinely.
# * CLOCK SKEW between runners, for a build-time key.
#
# What it does NOT catch, because something better does: a shallow clone. That is
# tested directly in `version.sh` via `--is-shallow-repository`, which needs no
# network and covers artifacts that have no published value to compare against.
#
# TOO-LOW IS THE UNRECOVERABLE DIRECTION. A version below what is published means
# every installed client reports "up to date" forever and there is no build you can
# ship to fix it — you have to get back ABOVE the bad number. That is #2183 and
# #2993's shared symptom, and it is why this fails the lane rather than warning.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
# True when $1 sorts strictly below $2, comparing NUMERICALLY per dot-segment.
#
# Not a string compare, which is the classic way to get this wrong: `1.0.10` sorts
# below `1.0.9` as text. A missing segment reads as 0, so `1.0` == `1.0.0`.
version_lt() {
_a="$1"; _b="$2"
while [ -n "$_a" ] || [ -n "$_b" ]; do
if [ "${_a%%.*}" = "$_a" ]; then _ah="$_a"; _at=""; else _ah="${_a%%.*}"; _at="${_a#*.}"; fi
if [ "${_b%%.*}" = "$_b" ]; then _bh="$_b"; _bt=""; else _bh="${_b%%.*}"; _bt="${_b#*.}"; fi
[ -n "$_ah" ] || _ah=0
[ -n "$_bh" ] || _bh=0
if [ "$_ah" -lt "$_bh" ]; then return 0; fi
if [ "$_ah" -gt "$_bh" ]; then return 1; fi
_a="$_at"; _b="$_bt"
done
return 1 # equal
}
# Auth if we have it, anonymous if not — the releases are public, but a token costs
# nothing and keeps this working if that ever changes.
#
# MISSING CURL IS FATAL, not empty. Every fetch here ends in `|| true` so a network
# blip reads as "nothing published yet" and passes — which is right for a genuinely
# empty channel and catastrophic for a runner image without curl, where it would
# silently turn the guard into a no-op that reports success on every build.
if ! command -v curl >/dev/null 2>&1; then
echo "guard-forward.sh: curl is not on PATH — refusing to run, because every" >&2
echo " lookup here would read as 'nothing published' and this" >&2
echo " guard would pass without checking anything." >&2
exit 1
fi
fetch() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" "$1" 2>/dev/null || true
else
curl -fsSL "$1" 2>/dev/null || true
fi
}
# What the channel is serving, per artifact. ONE definition of where to look, shared
# with `should-build.sh` — the skip decision and the guard must agree about what is
# published, and two readers of one fact is how this repo keeps producing #2181-2183.
#
# EVERY LOOKUP HERE MUST SUCCEED EVEN WHEN IT FINDS NOTHING. That is what the `|| true`
# on each pipeline is for, and it is load-bearing rather than defensive noise.
#
# An empty channel is a REAL state this guard is written to pass — `[ -z "$published" ]`
# further down says so in as many words. But the value is captured as
# `published="$(published_for ...)"`, and under `set -e` a command substitution that
# exits non-zero kills the script before that branch is ever reached. Silently, too:
# everything the pipeline would have said went into the capture rather than the log.
#
# WHICH COMMAND THE PIPELINE HAPPENS TO END ON decides whether that fires, which is the
# part worth remembering. `sed` on empty input exits 0; `grep` exits 1. Three of these
# four lookups end in `sed` and were fine. The one that ends in `grep -oE '[0-9]+$'` —
# Android's version_code — was not, and it failed the whole Android lane on the first
# merge to `main` (run 4857): exit 1, no output, 0.16 seconds, on the one channel that
# had no APK published yet. Its three neighbours hid it until then.
published_for() {
case "$1" in
desktop)
# What the UPDATER reads. The manifest is the thing that decides whether a
# client is offered a build, so it is the authority on what is published.
{ fetch "$SERVER/$REPO/releases/download/$2/latest.json" \
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
android)
{ fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_code"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 \
| grep -oE '[0-9]+$'; } || true
;;
esac
}
# The NAME the channel serves, which is the commit-derived value. Separate from
# `published_for` because the guard compares ordering KEYS and the skip decision
# compares identity — for Android those are different fields, and conflating them
# would make every build look like a change (the code is build-time; it always moves).
published_name() {
case "$1" in
desktop) published_for desktop "$2" ;;
android)
{ fetch "$SERVER/$REPO/releases/download/$2/thoughtsync-android.json" \
| grep -oE '"version_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -1 \
| sed -E 's/.*"([^"]+)"$/\1/'; } || true
;;
esac
}
# An explicit comparison mode, so the ordering logic is testable without a network
# and inspectable without a push. Read-only and bypasses nothing — it is the same
# function the guard itself uses, which is the point: a test of a reimplementation
# would prove nothing about the code that runs.
if [ "$artifact" = "compare" ]; then
a="${2:?usage: guard-forward.sh compare <a> <b>}"
b="${3:?usage: guard-forward.sh compare <a> <b>}"
if version_lt "$a" "$b"; then exit 0; else exit 1; fi
fi
if [ "$artifact" = "published" ]; then
a2="${2:?usage: guard-forward.sh published <artifact> <channel>}"
c2="${3:?usage: guard-forward.sh published <artifact> <channel>}"
published_name "$a2" "$c2"
exit 0
fi
channel="${2:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "guard-forward.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "guard-forward.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
case "$artifact" in
desktop)
derived="$(sh "$ROOT/packaging/version.sh" key desktop)"
# What the UPDATER reads, not what the release happens to hold — the manifest is
# the thing that decides whether a client is offered this build.
published="$(published_for desktop "$channel")"
# COMMIT time, so EQUALITY IS THE ORDINARY CASE: an unchanged source derives
# exactly what it derived last time, and `<=` would fail every no-change build.
# §6.3 says *strictly* less for exactly this reason.
strict=""
;;
android)
derived="$(sh "$ROOT/packaging/version.sh" key android)"
published="$(published_for android "$channel")"
# BUILD time, so equality is NOT ordinary — it means two builds landed in the
# same minute, and Android refuses to install an APK whose versionCode does not
# RISE. So this one requires strictly greater.
#
# If it ever fires, the cheap fix is seconds rather than minutes in version.sh
# (~210M today against Android's 2.1e9 ceiling, so ~60 years of headroom).
# Not done pre-emptively: the concurrency group cancels older runs on a branch,
# so two builds finishing in one minute needs concurrent runs on different
# branches, and the failure is a refused install rather than a stranded channel.
strict="yes"
;;
esac
if [ -z "$published" ]; then
# A channel with nothing on it yet — `stable` before its first merge, or a fresh
# repo. PASS: there is nothing to go backwards from. Failing here would block the
# very first publish to a channel, which is the one case where "lower than what is
# published" is meaningless.
echo "guard: $channel has no published $artifact version yet — nothing to compare."
echo "guard: publishing $derived."
exit 0
fi
echo "guard: $artifact on $channel — derived $derived, published $published"
if version_lt "$derived" "$published"; then
echo "" >&2
echo "GUARD FAILED: $derived is BELOW the published $published on $channel." >&2
echo "" >&2
echo " Publishing it would leave every installed client reporting 'up to date'" >&2
echo " forever, and no later build fixes that until one climbs back above the" >&2
echo " bad number. Do not force past this." >&2
echo "" >&2
echo " Usual causes (note 3127 §6.2, §6.3):" >&2
echo " - a squash or rebase merge rewrote the committer date" >&2
echo " - this build is a rebuild of an older commit" >&2
echo " - clock skew between runners (build-time keys)" >&2
exit 1
fi
if [ -n "$strict" ] && [ "$derived" = "$published" ]; then
echo "" >&2
echo "GUARD FAILED: $derived EQUALS the published $published on $channel." >&2
echo "" >&2
echo " Android requires versionCode to RISE; an equal one cannot be installed" >&2
echo " over what is already out there. Two builds landed in the same minute." >&2
exit 1
fi
echo "guard: ok — $derived may be published."
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env sh
#
# The markdown body for a release: what went live since the previous one.
#
# release-notes.sh <tag>
#
# A release BUILDS NOTHING now (M314 step 7). The merge to `main` already published
# `:latest`, `:<sha>` and both channel feeds, so a tag rebuilding that same source
# would produce identical artifacts and re-push `:<sha>` with different bytes —
# which rule 145 forbids even when the bytes match.
#
# So what is a release FOR? Note 3127 §5 answers it: the changelog. There are 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
# what is in it that was not ← this
# in the one I ran last month
#
# DERIVED FROM GIT, not hand-maintained. A CHANGELOG.md drifts into being
# aspirational — it records what someone meant to ship. `git log` records what
# shipped, and cannot say otherwise.
set -eu
cd "$(git rev-parse --show-toplevel)"
tag="${1:?usage: release-notes.sh <tag>}"
# The previous release tag, by DATE rather than by name.
#
# `v*` only: this repo also carries `dev` and `stable` tags, which are the fixed-tag
# pointer releases the updater reads. They move constantly and are not releases in
# this sense; sorting them in would make "the previous release" mean whichever
# channel published most recently.
#
# Excludes the tag being described, so re-running on an existing tag still produces
# the range that tag covers rather than an empty one.
prev="$(git tag -l 'v*' --sort=-creatordate | grep -vxF "$tag" | head -1 || true)"
if [ -n "$prev" ]; then
range="$prev..$tag"
header="Changes since \`$prev\`."
else
# The first release. Everything is new, and listing the entire history would be
# noise — say so instead.
range="$tag"
header="First release."
fi
printf 'ThoughtSync %s\n\n%s\n\n' "$tag" "$header"
# `--no-merges`: a merge commit's subject is "Merge branch ..." and says nothing
# about what shipped. The commits it brought in are listed individually, which is
# what somebody reading this wants.
#
# `%s` alone, not `%s (%h)`: the sha is in the forge's own view of the release and
# a reader chasing a specific change clicks through rather than copying a hash out
# of prose.
# CAPPED, because an unbounded list is not a changelog — it is a wall.
#
# The first dated release spans everything since `v0.1.0` — 181 commits at the
# time of writing: nobody reads that, and burying twelve interesting changes in it is worse
# than not writing one. Later releases will be short and the cap will never bite.
#
# The most RECENT are kept, not the oldest, and the count of what was dropped is
# stated — a truncated list that does not say it is truncated is a lie.
CAP=60
total="$(git log --no-merges --format='%s' "$range" | wc -l | tr -d ' ')"
git log --no-merges --reverse --format='- %s' "$range" | tail -"$CAP"
if [ "$total" -gt "$CAP" ]; then
printf '\n_...and %s earlier commits in this range, omitted for length._\n' \
"$((total - CAP))"
fi
printf '\n'
printf '%s\n' "_No artifacts here. Builds reach users from \`main\`: the desktop and Android"
printf '%s\n' "channels and the server image all publish on merge, with no tag required. This"
printf '%s\n' "release is a bookmark — it names a moment and says what was in it._"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env sh
#
# Does this artifact need building, or is the channel already serving this exact
# source? Prints `true` or `false`.
#
# should-build.sh <desktop|android> <dev|stable>
#
# Note 3127 §4, skip-if-exists — adapted, because §4 assumes a registry keyed by
# VERSION and rule 145 removed exactly that. There is no `:<version>` tag to ask
# about. What there IS, for both clients, is a channel that publishes the version it
# is serving, and that answers the same question: if the channel already serves what
# this source derives, the artifact would be byte-identical and there is nothing to
# build.
#
# WHAT THIS REPLACES, and why that matters more here than the cost saving: the
# `paths:` filters in the workflows were a SECOND, independent statement of each
# artifact's file set, hand-kept beside the one in `version.sh`. They disagreed
# within a day of the sets being written — `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). §3 warns about exactly this duplication; one definition
# with one reader is the fix, and the cost saving is a bonus.
#
# THE SERVER IS NOT LISTED HERE, DELIBERATELY. Its image build is ~15 seconds against
# 6 and 9 minutes for the clients, so there is little to save — and always building
# it is strictly better for a server that can face the internet, because it picks up
# `python:3.12-slim` base updates on every push. That is also why the base-image
# tension in §4 does not bite this project: the artifact most exposed to it never
# skips. The clients' bases are CI runner images, pinned deliberately.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
artifact="${1:?usage: should-build.sh <desktop|android> <dev|stable>}"
channel="${2:?usage: should-build.sh <desktop|android> <dev|stable>}"
case "$artifact" in desktop|android) : ;; *)
echo "should-build.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
case "$channel" in dev|stable) : ;; *)
echo "should-build.sh: unknown channel '$channel'" >&2; exit 2 ;;
esac
# The value that answers "is this the same code?" — which is not the same as the one
# the guard compares.
#
# desktop the ordering key IS the identity; one value, one clock.
# android the NAME. Its versionCode is build-time and moves every run, so
# comparing that would report a change on every push and never skip.
case "$artifact" in
desktop) derived="$(sh "$ROOT/packaging/version.sh" key desktop)" ;;
android) derived="$(sh "$ROOT/packaging/version.sh" display android)" ;;
esac
published="$(sh "$ROOT/packaging/guard-forward.sh" published "$artifact" "$channel")"
if [ -z "$published" ]; then
echo "should-build: $channel serves no $artifact yet — building." >&2
echo true
exit 0
fi
if [ "$derived" = "$published" ]; then
# UNCHANGED. The channel is already serving this exact source, so a build would
# produce the same artifact under the same name and republish it for nothing.
#
# Skipping is safe here in a way it would not be if anything pinned: there is no
# immutable tag to re-push with different bytes (rule 145 removed version tags),
# so the immutability argument in §4.2 does not apply and this stands on cost
# alone — which is the smaller, honest claim.
echo "should-build: $channel already serves $artifact $derived — skipping." >&2
echo false
exit 0
fi
echo "should-build: $artifact moved $published -> $derived — building." >&2
echo true
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env sh
#
# What version an artifact carries, derived from its OWN shipped files.
#
# Replaces desktop/packaging/build-version.sh, which was one generator feeding the
# desktop bundles AND the Android APK off `GITHUB_RUN_NUMBER`. A Kotlin-only commit
# re-versioned the desktop; a Rust-only commit re-versioned the phone. It read as
# tidy — one definition, no drift — which is exactly why it survived review. One
# definition of HOW to derive is right; one VALUE for unrelated artifacts is not.
# (Note 3127 §3, which cites this repo as its example of the failure.)
#
# Lives at the repo root, not under desktop/, because it now serves three artifacts
# and a shared thing filed under one consumer is how it ends up owned by that one.
#
# version.sh display <artifact> the human-readable version — 2026.08.28.1815
# version.sh key <artifact> the ordering key a comparator reads
# version.sh paths <artifact> the shipped file set (for tests and debugging)
#
# TWO VALUES, NOT ONE, and which you want depends on the question:
#
# "is this the same code?" -> display. A dev build and the main build of one
# commit read identically, because they ARE the
# same bytes (note 3127 §2, reason 4).
# "may this replace that?" -> key. What an updater or an install gate
# compares, and never shown to a person.
#
# The desktop needs both because Tauri's updater parses `latest.json`'s version with
# the semver crate, and `2026.08.28.1815` is not valid semver — four segments where
# the spec allows three, and `08` is a leading zero, which it forbids outright. A
# non-semver string does not sort low: the feed fails to DESERIALIZE and every client
# reports "no update available" forever. So the platform's field takes an opaque key
# and the display version lives beside it. See #3142's spike.
#
# WHY NOT A `-dev.N` PRERELEASE for the dev channel — carried over from the script
# this replaces, because it is a real finding and the reasoning is not obvious:
# a prerelease sorts BELOW the release it qualifies (`0.1.0-dev.5` < `0.1.0`), so a
# dev build could never be offered as an update to a tagged one, and Windows
# installer metadata wants a numeric X.Y.Z anyway. The channel goes in a sibling
# field, never in the version — note 3127 §7, and rule 149.
set -eu
# ANCHOR AT THE REPO ROOT BEFORE ANYTHING ELSE.
#
# `git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY, not to
# the repo root. 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 without this the same request answers differently per caller.
#
# It is not a tidy failure. Measured on run 4796, one push produced THREE versions:
# the desktop build (cwd `desktop/src-tauri`) said 1.0.3494522, while the pacman
# packager and the manifest job both said 1.0.3502131. The build's pathspec had
# matched `desktop/src-tauri/Cargo.toml` — a real file — so git returned the newest
# commit touching THAT, six days stale. Non-empty, so the guard below could not fire;
# the manifest then found no bundle matching its own answer and the lane went red for
# a reason two steps removed from the cause.
#
# The Android job failed loudly in the same run only because its pathspec happened to
# match nothing from `android/`. Same bug, louder symptom, pure luck.
cd "$(git rev-parse --show-toplevel)"
# A SHALLOW CLONE IS FATAL, and asked directly rather than inferred.
#
# Landmine §6.1: depth-1 sees one commit, so `git log -- <paths>` answers about
# whatever happens to be in that commit and the result is a too-LOW version — the
# unrecoverable direction, arrived at silently with every lane green.
#
# The empty-result guard below catches only the case where NOTHING matches. It missed
# the worse one: on run 4796 a partial match returned a real, six-days-stale answer.
# `--is-shallow-repository` tests the actual hazard instead of a symptom of it, costs
# no network, and covers every artifact including the ones with no published value to
# compare against.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "version.sh: this is a SHALLOW clone — any version derived here would be" >&2
echo " too low, silently. Add 'fetch-depth: 0' to the checkout." >&2
echo " (note 3127 §6.1)" >&2
exit 1
fi
# 2020-01-01T00:00:00Z. The counter epoch, and it must NEVER move: shifting it
# renumbers every artifact downwards, which is the one direction you cannot recover
# from (note 3127 §6.4).
EPOCH=1577836800
# --- the shipped file sets ---------------------------------------------------
#
# ONE definition, read by every consumer. The `paths:` filters in the three
# workflows are a second, independent statement of the same fact today; they come
# out in step 6 when skip-if-exists replaces them. Until then, a change here that is
# not mirrored there means a lane that does not fire — check both.
#
# Read off what actually PACKAGES each artifact, not off intuition. Miss a file and
# a stale build keeps its version; include one that does not ship and you re-version
# for nothing.
#
# THE BUILD DEFINITION IS IN THE SET, and it is the part that is easy to leave out.
# A workflow file is not "shipped" — but change a Gradle flag or a `cargo tauri
# build` argument and the bytes change while the source does not. Once step 6 skips
# a build whose version already exists, that combination serves the OLD artifact on
# a green run: exactly the "miss a file and a stale build keeps its version" failure,
# arriving through the build recipe rather than the source. Same reason `packaging/`
# is in every set: this script decides identity, so a change to it is a change to
# what each artifact claims to be.
paths_for() {
case "$1" in
# tauri's generate_context! embeds the BUILT frontend in the binary, so a
# frontend commit is a desktop change even though nothing under desktop/ moved.
desktop) echo "desktop core frontend Cargo.toml Cargo.lock .forgejo/workflows/desktop.yml packaging" ;;
# The .so is cross-compiled from core/ through uniffi.
android) echo "android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
# BUNDLED ARTIFACT: the image bakes in the Android client (ci.yml fetches the APK
# from the channel release and copies it into the package). So the image's set
# must contain the APK's set — an APK-only change genuinely changes what this
# image ships. Note 3127 §3 names this trap; FC's web image embeds the extension
# the same way.
#
# The base images are NOT listed and do not need to be: `Dockerfile` is in the
# set, so pinning `FROM` by digest (step 6) puts the base inside the set for
# free. Resolving a digest at derive time would work too and is WRONG — it is an
# external lookup, which §7's corollary forbids because it makes the value depend
# on when it was computed.
server) echo "src frontend alembic alembic.ini Dockerfile pyproject.toml .forgejo/workflows/ci.yml android core Cargo.toml Cargo.lock .forgejo/workflows/android.yml packaging" ;;
*) echo "version.sh: unknown artifact '$1'" >&2; exit 2 ;;
esac
}
# Sets TS to the newest commit timestamp touching this artifact's files, or exits.
#
# EMPTY IS FATAL, deliberately. A shallow clone sees one commit and derives a
# too-low value with every lane green — the failure landmine §6.1 exists for, and
# the unrecoverable direction. Every job that calls this needs `fetch-depth: 0`;
# this is what turns forgetting it into a red lane instead of a stranded channel.
#
# SETS A GLOBAL RATHER THAN ECHOING, and that is not a style preference. Written as
# `$(commit_ts desktop)` the function runs in a SUBSHELL, so its `exit` ends only
# that subshell and the caller continues with an empty string. Measured before this
# was fixed: `key desktop` on a repo with no matching history printed the error to
# stderr and then emitted `1.0.-26297280` and exited ZERO. A guard that reports a
# problem and does not stop is worse than none — it looks like it is working.
resolve_ts() {
# Unquoted on purpose: the path list is several words.
# shellcheck disable=SC2046
TS="$(git log --format=%ct -1 HEAD -- $(paths_for "$1"))"
if [ -z "$TS" ]; then
echo "version.sh: no commit touches $1's file set — is this a shallow clone?" >&2
echo " (needs fetch-depth: 0; see note 3127 §6.1)" >&2
exit 1
fi
}
minutes_since_epoch() { echo $(( ($1 - EPOCH) / 60 )); }
what="${1:?usage: version.sh <display|key|paths> <desktop|android|server>}"
artifact="${2:?usage: version.sh <display|key|paths> <desktop|android|server>}"
# VALIDATED HERE, in the parent shell, and not left to `paths_for`'s default arm.
#
# Third instance of one trap in this script, so it is worth stating plainly: `exit`
# inside a function called as `$(...)` ends the SUBSHELL, not the script. `paths_for`
# is reached through `$(paths_for "$1")`, so its `exit 2` printed the error and
# returned an EMPTY pathspec — and an empty pathspec matches everything, so
# `version.sh display nope` answered `2026.08.28.0900` and exited 0. A confident
# version for an artifact that does not exist.
#
# The other two were the shallow-clone guard on the `key` path (emitted
# `1.0.-26297280`, exit 0) and the same guard on `display` (which failed only because
# `date` then choked on an empty string — luck, not design). Each was found by a
# different mechanism; none by reading the code. If you add a guard to this file,
# make sure it runs where the script does.
case "$artifact" in
desktop|android|server) : ;;
*)
echo "version.sh: unknown artifact '$artifact' (want desktop, android or server)" >&2
exit 2
;;
esac
case "$what" in
paths)
paths_for "$artifact"
;;
display)
# One shape for every human-readable version in this repo, and for the release
# tag: YYYY.MM.DD.HHMM, zero-padded, UTC (note 3127 §1). Padded so it sorts as
# text as well as numerically, and so two lanes cannot emit forms one character
# apart.
resolve_ts "$artifact"
date -u -d "@$TS" +%Y.%m.%d.%H%M
;;
key)
case "$artifact" in
desktop)
# COMMIT time. The desktop is a one-value system to Tauri — its comparator
# reads the version name — so this key is also what lands in bundle
# filenames and .deb metadata. Commit time buys the property in §2 reason
# (4): the last dev build before a PR and the main build from it are the
# same bytes and derive the same key, so the artifact is reused rather than
# rebuilt and re-signed under a new name.
#
# Commit time CAN go backwards (rebuild an older commit). The backwards
# guard in step 5 is the whole mitigation, and the desktop's failure there
# is soft: an update is not offered. Contrast Android below.
#
# `1.0.` and not `0.0.`: the minor must clear the installed `0.2.<run>` line
# or every dev user is stranded on "up to date" permanently. Checked against
# the live feed (0.2.466), not against what we thought we had published.
resolve_ts desktop
echo "1.0.$(minutes_since_epoch "$TS")"
;;
android)
# BUILD time, and the asymmetry with the desktop is deliberate. Android
# HARD-FAILS an install on a downgrade (INSTALL_FAILED_VERSION_DOWNGRADE)
# and leaves a channel you cannot get out of, so its key must be monotonic
# BY CONSTRUCTION rather than by a guard that runs in CI. Build time cannot
# go backwards; commit time can.
#
# An Int, which is what Android compares. ~3.5M today against a 2.1e9
# ceiling — roughly four thousand years of headroom.
minutes_since_epoch "$(date -u +%s)"
;;
server)
# NO ORDERING KEY. Nothing compares the server image: no updater, no install
# gate, and `:latest` is moved by the registry rather than chosen by a
# client. §2 is explicit that an artifact with nothing to compare needs only
# a name — do not add one because the other two have one.
echo "version.sh: the server has no ordering key; use 'display'" >&2
exit 2
;;
*) echo "version.sh: unknown artifact '$artifact'" >&2; exit 2 ;;
esac
;;
*)
echo "version.sh: unknown request '$what' (want display, key or paths)" >&2
exit 2
;;
esac
+7
View File
@@ -1,3 +1,10 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
# The FALLBACK version, used only when APP_VERSION is absent from the environment —
# i.e. running from a checkout rather than from an image. A built image always has
# it, derived from the server's own shipped file set (packaging/version.sh), so this
# string never reaches a deployed instance and bumping it changes nothing a user
# sees. Kept because a package needs a version and "unknown" is not a valid one for
# packaging metadata; the honest "I cannot say" for a running server is APP_VERSION
# being missing, which app.py already handles.
__version__ = "0.2.0"
+28 -8
View File
@@ -1,16 +1,36 @@
from __future__ import annotations
from .models.note import NOTE_COLORS
# Notes and labels share one colour palette (their sets were identical). NOTE_COLORS
# is the canonical vocabulary (defined on the model); this module is the single home
# for the "clamp to the palette" normalizer so notes.py, labels.py and sync.py stop
# each carrying their own copy.
# The colour palette, and the one place that clamps to it.
#
# It lived on `models/note.py` until M315, when a note stopped having a colour. A
# palette defined on the model that lost one would be a standing invitation to put the
# column back; here it reads as what it now is — a LABEL's vocabulary, shared with the
# saved-filter and import paths that still name a colour.
#
# Keys, not tints. The actual colours live in each client (frontend/src/notes/colors.ts
# and NoteTint.kt), so they can be retuned without a schema migration — which M315 spent
# two steps doing.
NOTE_COLORS = {
"default",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"purple",
"pink",
"gray",
}
__all__ = ["NOTE_COLORS", "normalize_color"]
def normalize_color(color: object) -> str:
"""Return `color` if it's a known palette key, else the default. One definition
for both notes and labels."""
"""Return `color` if it's a known palette key, else the default.
`default` is no longer something anybody can CHOOSE — nothing offers a colour
picker since M315 — but it is still where unrecognised input has to land, so this
fallback is unreachable by choice rather than dead.
"""
return color if color in NOTE_COLORS else "default"
+1 -1
View File
@@ -2,7 +2,7 @@
labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between
the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels).
Single home so both stay in lockstep. via_tag=True rows track the body #tags and are
governed by _reconcile_tags — this function never touches them."""
governed by _lift_and_reconcile_tags — this function never touches them."""
from __future__ import annotations
from sqlalchemy import select
-18
View File
@@ -10,22 +10,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from . import Base
from ..common import iso
# The Keep-style palette. Stored as a key string, so the actual tints live in the
# frontend and can change without a schema migration.
NOTE_COLORS = {
"default",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"purple",
"pink",
"gray",
}
class Note(Base):
__tablename__ = "notes"
__table_args__ = (
@@ -45,7 +29,6 @@ class Note(Base):
# the full-text vector can weight it above the rest of the body.
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
# Manual drag order (higher = earlier); 0 until the user reorders.
position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
@@ -74,7 +57,6 @@ class Note(Base):
"id": str(self.id),
"display_title": self.display_title,
"body": self.body,
"color": self.color,
"position": self.position,
"pinned": self.pinned,
"archived": self.archived,
+3 -2
View File
@@ -12,8 +12,9 @@ from . import Base
class SavedFilter(Base):
"""A named, saved facet combination (a 'view'/lens) the user can re-apply in one
click — e.g. "Yellow + #ideas". `params` is a JSON-encoded facet dict matching the
GET /api/notes query (q/color/labels/has_reminder/has_attachment/date range)."""
click — e.g. "#ideas with a reminder". `params` is a JSON-encoded facet dict
matching the GET /api/notes query (q/labels/has_reminder/has_attachment/date
range). Colour was a facet until M315; 0029 swept the key out of stored rows."""
__tablename__ = "saved_filters"
+7 -16
View File
@@ -23,7 +23,7 @@ from sqlalchemy import func, literal_column, select
from ..acl import visible_to_user
from ..auth import login_required
from ..colors import NOTE_COLORS, normalize_color
from ..colors import normalize_color
from ..common import coerce_bool, iso, parse_dt
from ..config import Config
from ..db import session_scope
@@ -72,7 +72,7 @@ from .import_export import (
_usec_to_dt,
)
from .tags import (
_reconcile_tags,
_lift_and_reconcile_tags,
parse_tags,
)
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
@@ -87,7 +87,7 @@ __all__ = [
"normalize_color",
"normalize_recurrence",
"next_occurrence",
"_reconcile_tags",
"_lift_and_reconcile_tags",
"_serialize_notes",
"_safe_filename",
"_attachment_ext",
@@ -108,7 +108,6 @@ async def list_notes():
# Combinable facet filters (all optional, AND-ed together) — the rich-search /
# saved-filter lens. Multiple ?label= narrow to notes carrying ALL of them.
label_params = request.args.getlist("label")
color = request.args.get("color")
has_reminder = coerce_bool(request.args.get("has_reminder"))
has_attachment = coerce_bool(request.args.get("has_attachment"))
query_text = (request.args.get("q") or "").strip()
@@ -128,10 +127,6 @@ async def list_notes():
if lid is None:
return json_error("invalid label", 400)
stmt = stmt.where(Note.id.in_(select(NoteLabel.note_id).where(NoteLabel.label_id == lid)))
if color is not None:
if color not in NOTE_COLORS:
return json_error("invalid color", 400)
stmt = stmt.where(Note.color == color)
if has_reminder:
stmt = stmt.where(Note.remind_at.is_not(None))
if has_attachment:
@@ -259,7 +254,6 @@ async def export_notes():
"id": str(n.id),
"display_title": n.display_title,
"body": n.body,
"color": n.color,
"pinned": n.pinned,
"archived": n.archived,
"remind_at": n.remind_at.isoformat() if n.remind_at else None,
@@ -412,13 +406,12 @@ async def create_note():
owner_id=g.user_id,
display_title=derive_display_title(body),
body=body,
color=normalize_color(data.get("color")),
position=int(max_pos) + 1,
)
db.add(note)
await db.flush() # assign note.id before writing links
# The FOLDED body: an item can carry a #tag too.
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
# After the commit, never before it: the note is saved and the response is
@@ -453,8 +446,6 @@ async def update_note(note_id: str):
old_body = note.body
if "body" in data and isinstance(data["body"], str):
note.body = data["body"]
if "color" in data:
note.color = normalize_color(data["color"])
if "pinned" in data:
note.pinned = bool(data["pinned"])
if "archived" in data:
@@ -473,7 +464,7 @@ async def update_note(note_id: str):
note.recurrence = normalize_recurrence(data["recurrence"])
if "body" in data:
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
# Version history: snapshot the PRE-edit body, once per editing session
# rather than once per write — see revisions.should_snapshot. Writing often
# is what lets a client autosave instead of hoarding text until it closes.
@@ -532,7 +523,7 @@ async def restore_revision(note_id: str, rev_id: str):
db.add(NoteRevision(note_id=note.id, body=note.body))
note.body = rev.body
note.display_title = derive_display_title(note.body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
return jsonify(await _serialize_note(db, note))
@@ -586,7 +577,7 @@ async def _rewrite_body(db, note: Note, body: str):
db.add(NoteRevision(note_id=note.id, body=old_body))
note.body = body
note.display_title = derive_display_title(body)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await db.commit()
await db.refresh(note)
if note.body != old_body:
+4 -27
View File
@@ -14,7 +14,6 @@ from datetime import datetime, timezone
from sqlalchemy import select
from ..colors import normalize_color
from ..common import parse_dt
from ..config import Config
from ..models.label import NoteLabel
@@ -28,7 +27,7 @@ from .helpers import (
is_empty_note,
)
from .checklist import append_item
from .tags import _find_or_create_label, _reconcile_tags
from .tags import _find_or_create_label, _lift_and_reconcile_tags
from .recurrence import normalize_recurrence
@@ -39,7 +38,6 @@ def _note_markdown(note: Note, labels: list) -> str:
fm.append(f"display_name: {note.display_title}")
if labels:
fm.append("labels: [" + ", ".join(lb["name"] for lb in labels) + "]")
fm.append(f"color: {note.color}")
if note.pinned:
fm.append("pinned: true")
if note.archived:
@@ -62,24 +60,6 @@ def _note_markdown(note: Note, labels: list) -> str:
# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip ---
# Google Keep (Takeout) color enum → our palette. Keep has a few hues we don't
# (BROWN/DARKBLUE/CERULEAN); map each to the nearest. Unknowns fall back to default.
_KEEP_COLOR_MAP = {
"DEFAULT": "default",
"RED": "red",
"ORANGE": "orange",
"YELLOW": "yellow",
"GREEN": "green",
"TEAL": "teal",
"CERULEAN": "teal",
"BLUE": "blue",
"DARKBLUE": "blue",
"PURPLE": "purple",
"PINK": "pink",
"BROWN": "orange",
"GRAY": "gray",
}
# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its
# filename when the source didn't record one (Keep usually does; be defensive).
_EXT_MIME = {ext: mime for mime, ext in ALLOWED_IMAGE_MIMES.items()}
@@ -100,7 +80,6 @@ def _native_spec(n: dict) -> dict:
return {
"title": n.get("title"),
"body": n.get("body") or "",
"color": n.get("color"),
"pinned": bool(n.get("pinned")),
"archived": bool(n.get("archived")),
"trashed": False, # export only includes live notes
@@ -156,7 +135,6 @@ def _keep_spec(kn: dict, keep_dir: str) -> dict:
return {
"title": kn.get("title"),
"body": body,
"color": _KEEP_COLOR_MAP.get(str(kn.get("color") or "DEFAULT").upper(), "default"),
"pinned": bool(kn.get("isPinned")),
"archived": bool(kn.get("isArchived")),
"trashed": bool(kn.get("isTrashed")),
@@ -293,7 +271,7 @@ async def _create_imported_note(
return False
# Items fold into the body, which is where a checklist lives now (M304). Done
# before the Note is built so display_title and _reconcile_tags both see the
# before the Note is built so display_title and _lift_and_reconcile_tags both see the
# finished text — an imported item can carry a #tag like any other line.
for it in items:
text = (it.get("text") or "").strip()
@@ -304,7 +282,6 @@ async def _create_imported_note(
owner_id=owner_id,
display_title=derive_display_title(body),
body=body,
color=normalize_color(spec.get("color")),
pinned=bool(spec.get("pinned")),
archived=bool(spec.get("archived")),
position=position,
@@ -325,7 +302,7 @@ async def _create_imported_note(
await db.flush() # assign note.id before labels/attachments/links
# Explicit (picker-style) labels are manual — via_tag=False. Inline #tags in the
# body are handled by _reconcile_tags below, same as a normal create.
# body are handled by _lift_and_reconcile_tags below, same as a normal create.
for name in spec.get("labels") or []:
name = (name or "").strip()
if not name:
@@ -341,5 +318,5 @@ async def _create_imported_note(
if isinstance(att, dict):
_import_attachment(db, note, zf, att, budget)
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
return True
+148 -31
View File
@@ -1,6 +1,11 @@
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
module).
"""#tags — parsing note bodies, LIFTING the standalone ones out of the text, and
keeping the still-in-text ones in sync with the note_labels rows. Manual (picker)
labels are NOT touched here (see the labeling module).
A tag used to be shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card (M311) and the text now goes, but only when the tag
was standing on its own — see `split_body_tags` for the rule and why it is the
conservative one.
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
this app is an intermediary surface for capture and recall, and a linking system is
@@ -15,6 +20,7 @@ from sqlalchemy import func, select
from ..models.label import Label, NoteLabel
from ..models.note import Note
from .helpers import derive_display_title
# A #tag: `#` at the start of the body or after whitespace, then a word char and
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
@@ -22,24 +28,93 @@ from ..models.note import Note
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
if not body:
return []
# A fence opens or closes a code block. A `#tag` inside one is CODE — the shell
# comment in a snippet someone pasted — and lifting it would delete a line of their
# example. It still becomes a label, because it always has and that is a separate
# question from whether the text may be touched.
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
def _is_tag(name: str) -> bool:
"""A tag must contain a letter, so #2024 and #_ are ignored (avoids numeric noise)."""
return any(c.isalpha() for c in name)
def _dedupe(names: list[str]) -> list[str]:
"""First-seen order, deduped case-insensitively — tags are case-insensitive."""
out: list[str] = []
seen: set[str] = set()
for match in _TAG_RE.finditer(body):
tag = match.group(1)
if not any(c.isalpha() for c in tag):
continue
norm = tag.lower()
for name in names:
norm = name.lower()
if norm not in seen:
seen.add(norm)
out.append(tag)
out.append(name)
return out
def parse_tags(body: str | None) -> list[str]:
"""Distinct #hashtags from a note body, in order, deduped case-insensitively."""
if not body:
return []
return _dedupe([m.group(1) for m in _TAG_RE.finditer(body) if _is_tag(m.group(1))])
def split_body_tags(body: str | None) -> tuple[list[str], list[str], str]:
"""Split a body's tags by whether the text around them can be taken away.
Returns `(standalone, inline, lifted_body)`.
THE RULE: a line containing nothing but tags and whitespace is removed. Anything
else is left exactly as written.
That is deliberately the conservative reading of "standalone". The looser one —
also stripping a trailing tag off a prose line — was rejected because a trailing
tag is ambiguous and the text does not say which 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. A tag sharing a line with words keeps its words.
`standalone` tags become ORDINARY labels — the text no longer backs them, so
nothing can derive them any more, and the way to remove one becomes the chip's ×
rather than deleting the text. `inline` tags stay derived exactly as before. That
is the whole meaning of `via_tag` after this change: backed by text still present.
"""
if not body:
return [], [], body or ""
standalone: list[str] = []
inline: list[str] = []
kept: list[str] = []
in_fence = False
for line in body.split("\n"):
if _FENCE_RE.match(line):
in_fence = not in_fence
kept.append(line)
continue
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
names = [m.group(1) for m in matches]
# Cutting the tags out and finding nothing left is what "standalone" means.
remainder = line
for m in reversed(matches):
remainder = remainder[: m.start()] + remainder[m.end() :]
if in_fence or not matches or remainder.strip():
inline.extend(names)
kept.append(line)
else:
standalone.extend(names)
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
if body.strip() and not lifted.strip():
# The note was NOTHING but tags. Lifting would leave a blank card, which is a
# worse outcome than a duplicated chip — so leave it alone and let its tags
# stay derived.
return [], _dedupe(standalone + inline), body
# A tag that ALSO appears in prose stays derived: the prose copy still backs it,
# so deleting that copy should still detach the label.
inline_names = _dedupe(inline)
inline_lower = {n.lower() for n in inline_names}
standalone_names = [n for n in _dedupe(standalone) if n.lower() not in inline_lower]
return standalone_names, inline_names, lifted
async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
existing = await db.scalar(
@@ -53,23 +128,65 @@ async def _find_or_create_label(db, owner_id, name: str):
return label.id
async def _reconcile_tags(db, note: Note) -> None:
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
picker labels (via_tag=False) are never touched."""
tag_label_ids: set = set()
for name in parse_tags(note.body):
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
async def _lift_and_reconcile_tags(db, note: Note) -> None:
"""Attach the note's tag labels, LIFT its standalone tags out of the body, and
re-derive display_title if the body moved.
NAMED FOR THE MUTATION. It used to be `_reconcile_tags` and only touched rows; it
now rewrites `note.body`, and a caller that does not expect that will compute a
display_title from text this function is about to delete.
Which is why the lift and the re-derivation both live HERE rather than at the
seven call sites that would each have to remember. Spreading a derived-value
update across every place a body can be written is precisely the failure #2965
named about label minting: "easy to miss, and it is the common one".
The two kinds of tag are handled differently, and that difference IS what
`via_tag` means from here on — backed by text still in the body:
standalone lifted out, attached as an ORDINARY label (via_tag=False). Nothing
derives it any more because nothing is left to derive it from, and
the way to remove it becomes the chip's × — which both editors
already offer for exactly this class of label.
inline left in place, attached via_tag=True, and still detached when its
text goes. Unchanged from before.
"""
standalone, inline, lifted = split_body_tags(note.body)
standalone_ids: set = set()
for name in standalone:
standalone_ids.add(await _find_or_create_label(db, note.owner_id, name))
inline_ids: set = set()
for name in inline:
inline_ids.add(await _find_or_create_label(db, note.owner_id, name))
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached_ids = {r.label_id for r in rows}
# Detach tag-labels no longer backed by a #tag in the body.
attached: set = set()
for r in rows:
if r.via_tag and r.label_id not in tag_label_ids:
await db.delete(r)
attached_ids.discard(r.label_id)
# Attach new tags — skip labels already attached (in any form) to respect the PK
# and leave a manually-added label of the same name as-is.
for lid in tag_label_ids:
if lid not in attached_ids:
if not r.via_tag:
attached.add(r.label_id) # manual already: a #tag of the same name changes nothing
elif r.label_id in standalone_ids:
# It GRADUATED. The text backing it is about to be deleted, so the row has
# to become the record instead. This must run BEFORE the detach below, or
# the same row would be dropped for no longer being in the body — which is
# the bug that makes a naive lift delete every tag it touches.
r.via_tag = False
attached.add(r.label_id)
elif r.label_id in inline_ids:
attached.add(r.label_id)
else:
await db.delete(r) # its #tag was deleted from the text
# Skip anything already attached in ANY form: it respects the PK, and it leaves a
# manually-added label of the same name as the manual row it already is.
for lid in standalone_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))
attached.add(lid)
for lid in inline_ids:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
attached_ids.add(lid)
attached.add(lid)
if lifted != note.body:
note.body = lifted
note.display_title = derive_display_title(note.body)
+3 -1
View File
@@ -16,9 +16,11 @@ bp = Blueprint("saved_filters", __name__, url_prefix="/api/saved-filters")
NAME_CAP = 100
# Facet keys allowed in a saved view (must match the GET /api/notes query surface).
# `color` was here until M315. A note has no colour to filter on, and `clean_params`
# drops the key on the way in — the migration that dropped the column sweeps it out of
# the views already stored.
_ALLOWED_PARAM_KEYS = {
"q",
"color",
"label", # matches the repeatable ?label= query param (stored as an array)
"has_reminder",
"has_attachment",
+13 -5
View File
@@ -27,7 +27,7 @@ from .models.note import Note
from .models.note_revision import NoteRevision
from .revisions import should_snapshot
from .notes import (
_reconcile_tags,
_lift_and_reconcile_tags,
_serialize_notes,
derive_display_title,
normalize_color,
@@ -62,7 +62,16 @@ MAX_PUSH = 1000 # per-batch change cap
#
# One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 3
# v4 (M315): `color` left the note. The FLOOR DELIBERATELY DOES NOT MOVE, and v2 is
# the precedent that makes saying so worthwhile — it dropped `kind` and `title` and did
# raise the floor, on the rule that dropping a field a client sends and expects back is
# breaking. `color` fails the second half of that: a v3 client reading a v4 note gets
# `"default"` from its own serde default and draws the colour it derives locally, which
# is 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 loses anything visible. `title` was the note's
# NAME; this is a field that no longer renders anywhere.
SYNC_PROTOCOL_VERSION = 4
MIN_CLIENT_PROTOCOL_VERSION = 3
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
@@ -198,7 +207,6 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
whole-note, not a partial patch — the client sends its authoritative version)."""
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color"))
note.pinned = bool(ch.get("pinned"))
note.archived = bool(ch.get("archived"))
if ch.get("trashed"):
@@ -222,7 +230,7 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
"""Set the note's MANUAL (picker) label memberships from client label_ids, leaving
tag-sourced (via_tag) rows to _reconcile_tags. Only labels the caller owns count."""
tag-sourced (via_tag) rows to _lift_and_reconcile_tags. Only labels the caller owns count."""
raw = ch.get("label_ids")
if not isinstance(raw, list):
return
@@ -289,7 +297,7 @@ async def _apply_note(db, ch: dict) -> dict:
if not creating and await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _reconcile_tags(db, note)
await _lift_and_reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch)
await db.flush()
await db.refresh(note, ["sync_revision"])
+70
View File
@@ -25,8 +25,10 @@ from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note
from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title
@@ -199,6 +201,74 @@ async def test_ticking_an_item_is_a_body_edit(db, owner):
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is False
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is True
assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from
+111 -9
View File
@@ -4,7 +4,8 @@ import pytest
from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.models.note import NOTE_COLORS, Note
from thoughtsync.colors import NOTE_COLORS
from thoughtsync.models.note import Note
from thoughtsync.notes.checklist import (
append_item,
parse_items,
@@ -31,6 +32,7 @@ from thoughtsync.notes import (
parse_list_items,
parse_tags,
)
from thoughtsync.notes.tags import split_body_tags
@pytest.fixture
@@ -75,16 +77,16 @@ def test_normalize_color():
def test_palette_has_core_colors():
# A LABEL's vocabulary since M315 — a note has no colour to be one of these.
for c in ("default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"):
assert c in NOTE_COLORS
def test_serialize_shape():
n = Note(body="b", color="blue", pinned=True, archived=False)
n = Note(body="b", pinned=True, archived=False)
s = n.serialize()
assert "title" not in s # there is no title field any more (M13 step 3)
assert s["body"] == "b"
assert s["color"] == "blue"
assert s["pinned"] is True
assert s["archived"] is False
assert s["trashed"] is False
@@ -168,6 +170,101 @@ def test_derive_display_title_caps_length():
assert derive_display_title(f"- [ ] {long}") == "x" * 200
def test_split_body_tags_lifts_only_a_line_that_is_nothing_else():
"""The rule, in the cases it exists to get right.
A tag on a line of its own is filing and the line can go. A tag sharing a line
with words is part of what was written, and taking it out would leave "remember to
call" — so the line is left exactly as typed. The trailing-tag case (`buy milk
#grocery`) is deliberately on the conservative side of the line: it reads like
filing, but nothing in the text distinguishes it from `remember to call #mom`, and
guessing wrong mangles a sentence to save a duplicate chip.
"""
assert split_body_tags("#todo\nreorganize the homepage") == (["todo"], [], "reorganize the homepage")
assert split_body_tags("needs a tauri app\n#todo") == (["todo"], [], "needs a tauri app")
assert split_body_tags("#todo #work\nreal text") == (["todo", "work"], [], "real text")
unchanged = "remember to call #mom tomorrow"
assert split_body_tags(unchanged) == ([], ["mom"], unchanged)
trailing = "buy milk #grocery"
assert split_body_tags(trailing) == ([], ["grocery"], trailing)
def test_split_body_tags_leaves_the_note_readable():
# Removing a line must not leave a hole where it was.
assert split_body_tags("foo\n\n#todo\n\nbar") == (["todo"], [], "foo\n\nbar")
# A note that is NOTHING but tags would be blanked. A duplicated chip beats an
# empty card, so it keeps its text and its tags stay derived.
assert split_body_tags("#todo") == ([], ["todo"], "#todo")
assert split_body_tags("#todo #work") == ([], ["todo", "work"], "#todo #work")
# A tag in a code fence is CODE — a shell comment in somebody's snippet. It still
# becomes a label, because it always has, but the line is never touched.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert split_body_tags(fenced) == ([], ["deploy"], fenced)
# Not a tag at all (no letter), so not a tag-only line either.
assert split_body_tags("#2024\nreal") == ([], [], "#2024\nreal")
assert split_body_tags("") == ([], [], "")
assert split_body_tags(None) == ([], [], "")
def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
"""Appearing on its own line does NOT lift a tag that is also written in a
sentence — the sentence still backs it, so deleting that sentence should still
detach the label. Standalone and inline are not both true of one tag."""
standalone, inline, body = split_body_tags("#todo\nremember the #todo list")
assert standalone == []
assert inline == ["todo"]
assert body == "remember the #todo list"
def test_migration_0028_lifts_exactly_what_the_app_lifts_today():
"""The 0028 data migration rewrites note bodies, and that is not undoable.
It carries its OWN frozen copy of the rule rather than importing
`split_body_tags`, on 0027's principle that a migration must keep producing what
it produced the day it ran. This does not assert the two agree — they are allowed
to diverge later, which is the entire point of freezing one. It pins the frozen
copy against fixed expectations, so nobody can "tidy" it into eating prose.
"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0028_lift_standalone_tags.py"
spec = importlib.util.spec_from_file_location("migration_0028", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Lifted: the tag was the whole line.
assert mod._split("#todo\nreorganize the homepage") == (["todo"], "reorganize the homepage")
assert mod._split("needs a tauri app\n#todo") == (["todo"], "needs a tauri app")
assert mod._split("#todo #work\nreal text") == (["todo", "work"], "real text")
assert mod._split("foo\n\n#todo\n\nbar") == (["todo"], "foo\n\nbar")
# Untouched: prose. Getting any of these wrong destroys somebody's words.
for prose in ("remember to call #mom tomorrow", "buy milk #grocery", "#2024\nreal"):
assert mod._split(prose) == ([], prose), prose
# Untouched: a tag inside a fence is a shell comment in somebody's snippet.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert mod._split(fenced) == ([], fenced)
# Untouched: a note that is nothing but tags would be blanked.
assert mod._split("#todo") == ([], "#todo")
# Not flipped: the tag is still written in prose, so its text still backs it and
# it must stay derived — flipping it would be claiming otherwise.
assert mod._split("#todo\nremember the #todo list") == ([], "remember the #todo list")
# The name has to move with the body, or a note is titled after a deleted line.
assert mod._display_title("reorganize the homepage") == "reorganize the homepage"
assert mod._display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert mod._display_title("") == ""
def test_parse_tags():
assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"]
# case-insensitive dedup, first spelling wins
@@ -346,6 +443,8 @@ def test_keep_spec_list_note_keeps_its_text_too():
"textContent": "for the weekend",
"listContent": [{"text": "Milk", "isChecked": False}, {"text": "Eggs", "isChecked": True}],
"labels": [{"name": "shopping"}],
# Keep's own colour, which the importer now reads past: there is nothing on a
# note for it to land on, and a spec carrying a key nobody applies is a lie.
"color": "TEAL",
"isPinned": True,
"isArchived": False,
@@ -355,7 +454,7 @@ def test_keep_spec_list_note_keeps_its_text_too():
}
spec = _keep_spec(kn, "Takeout/Keep")
assert spec["body"] == "for the weekend"
assert spec["color"] == "teal"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["archived"] is False
assert spec["trashed"] is False
@@ -364,16 +463,18 @@ def test_keep_spec_list_note_keeps_its_text_too():
assert spec["created_at"].year == 2020
def test_keep_spec_text_note_folds_annotation_urls_and_maps_color():
def test_keep_spec_text_note_folds_annotation_urls_and_drops_color():
kn = {
"textContent": "Read this later",
"annotations": [{"url": "https://example.com"}],
"color": "BROWN", # no brown in our palette → nearest (orange)
"color": "BROWN",
"attachments": [{"filePath": "img.jpg", "mimetype": "image/jpeg"}],
}
spec = _keep_spec(kn, "Takeout/Keep")
assert "https://example.com" in spec["body"]
assert spec["color"] == "orange"
# BROWN used to map to the nearest hue we had. There is no hue to map TO now, so a
# Keep import brings across everything except the one thing this app stopped having.
assert "color" not in spec
# attachment path is resolved relative to the note JSON's folder
assert spec["attachments"] == [{"file": "Takeout/Keep/img.jpg", "mime": "image/jpeg"}]
@@ -382,6 +483,8 @@ def test_native_spec_roundtrip_fields():
n = {
"title": "T",
"body": "b",
# An export taken before M315 still carries this. Reading past it rather than
# rejecting the file is the whole point — old exports must still import.
"color": "blue",
"pinned": True,
"archived": False,
@@ -395,7 +498,7 @@ def test_native_spec_roundtrip_fields():
# _create_imported_note folds it into the body rather than dropping it.
assert spec["title"] == "T"
assert spec["body"] == "b"
assert spec["color"] == "blue"
assert "color" not in spec
assert spec["pinned"] is True
assert spec["trashed"] is False # exports only carry live notes
assert spec["created_at"].year == 2026
@@ -546,7 +649,6 @@ def test_note_markdown_writes_a_checklist_once():
note = Note(
display_title="shopping",
body="shopping\n\n- [ ] milk\n- [x] eggs",
color="default",
pinned=False,
archived=False,
)
+4 -1
View File
@@ -12,13 +12,16 @@ def app():
def test_clean_params_whitelists_facet_keys():
raw = {
"q": "hi",
# A facet until M315. It is junk now, and has to be dropped like any other —
# a stored view that still filtered on a field the app lost would return
# nothing and never say why.
"color": "yellow",
"label": ["a"],
"has_reminder": True,
"junk": 1,
"__proto__": 2,
}
assert clean_params(raw) == {"q": "hi", "color": "yellow", "label": ["a"], "has_reminder": True}
assert clean_params(raw) == {"q": "hi", "label": ["a"], "has_reminder": True}
assert clean_params("nope") == {}
assert clean_params(None) == {}
+417
View File
@@ -0,0 +1,417 @@
"""The version derivation — `packaging/version.sh`.
These tests build their OWN git repo in a tmpdir rather than reading this one's
history. Two reasons, and the second is the important one:
* They then need no `fetch-depth: 0` on the test lane, and cannot start passing or
failing because somebody pushed.
* They can commit to ONE artifact's file set at a time, which is the only way to
assert the property this whole change exists for: that a Kotlin-only commit
leaves the desktop's version alone. Against real history you can only observe
whatever the last commits happened to touch.
Note 3127 §3 cites this repo as its example of the failure being fixed here — one
generator feeding three artifacts, so a Rust-only commit re-versioned the phone.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "version.sh"
GUARD = Path(__file__).resolve().parent.parent / "packaging" / "guard-forward.sh"
SHOULD_BUILD = Path(__file__).resolve().parent.parent / "packaging" / "should-build.sh"
# 2020-01-01T00:00:00Z, the counter epoch. Duplicated from the script deliberately:
# a test that imported the value could not catch the value being changed, and moving
# this epoch renumbers every artifact downwards (note 3127 §6.4).
EPOCH = 1577836800
def git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-C", str(repo), *args],
check=True, capture_output=True, text=True,
).stdout.strip()
def commit(repo: Path, path: str, when: int) -> None:
"""Write a file and commit it with a FIXED committer date.
`%ct` is the committer date, so both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE have
to be pinned or the test is timing-dependent.
"""
f = repo / path
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(f"{when}\n")
git(repo, "add", "-A")
subprocess.run(
["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "-m", f"touch {path}"],
check=True, capture_output=True, text=True,
# EXTEND the environment rather than replacing it: a minimal env is enough
# for git here but not necessarily inside the CI container, and a test that
# fails only there is worse than no test.
env={**os.environ,
"GIT_AUTHOR_DATE": f"@{when} +0000", "GIT_COMMITTER_DATE": f"@{when} +0000"},
)
def version(repo: Path, what: str, artifact: str, *, subdir: str = "") -> str:
return subprocess.run(
["sh", str(SCRIPT), what, artifact],
cwd=repo / subdir if subdir else repo,
check=True, capture_output=True, text=True,
).stdout.strip()
@pytest.fixture
def repo(tmp_path: Path) -> Path:
"""A repo with one commit per artifact area, at three known instants."""
git(tmp_path, "init", "-q", "-b", "dev")
# 2026-08-28 in UTC, an hour apart so each is distinguishable.
commit(tmp_path, "core/lib.rs", 1787900400) # 2026-08-28 07:00 — shared
commit(tmp_path, "android/app/build.gradle.kts", 1787904000) # 08:00 — android only
commit(tmp_path, "desktop/src-tauri/main.rs", 1787907600) # 09:00 — desktop only
return tmp_path
# --- shape -------------------------------------------------------------------
def test_display_is_zero_padded_calver(repo: Path) -> None:
"""`YYYY.MM.DD.HHMM`, padded. Padding is what makes it sort as text as well as
numerically, and what keeps two lanes from emitting forms one character apart."""
for artifact in ("desktop", "android", "server"):
assert re.fullmatch(r"\d{4}\.\d{2}\.\d{2}\.\d{4}", version(repo, "display", artifact))
def test_display_is_the_commit_instant_in_utc(repo: Path) -> None:
# The desktop's newest commit is 09:00 UTC on 2026-08-28.
assert version(repo, "display", "desktop") == "2026.08.28.0900"
# Android's is an hour earlier, and it does not see the desktop commit at all.
assert version(repo, "display", "android") == "2026.08.28.0800"
# --- the property the whole change exists for --------------------------------
def test_a_desktop_commit_does_not_move_android(repo: Path) -> None:
before = version(repo, "display", "android")
commit(repo, "desktop/src-tauri/other.rs", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
assert version(repo, "display", "android") == before
def test_an_android_commit_does_not_move_the_desktop(repo: Path) -> None:
before = version(repo, "display", "desktop")
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
assert version(repo, "display", "android") == "2026.08.28.1000"
assert version(repo, "display", "desktop") == before
def test_a_shared_core_commit_moves_both(repo: Path) -> None:
"""`core/` is genuinely in both sets — the .so and the desktop binary are built
from it — so this is correct rather than a leak between them."""
commit(repo, "core/src/sync.rs", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
assert version(repo, "display", "android") == "2026.08.28.1000"
def test_the_server_set_contains_the_android_set(repo: Path) -> None:
"""The image BAKES IN the APK, so an APK-only change changes what the image
ships. Note 3127 §3's bundled-artifact trap; FC's web image embeds the extension
the same way, and Roundtable needed a bespoke workflow for want of modelling it."""
commit(repo, "android/app/src/Main.kt", 1787911200) # 10:00
assert version(repo, "display", "server") == "2026.08.28.1000"
assert "android" in version(repo, "paths", "server")
def test_the_build_recipe_is_in_the_set(repo: Path) -> None:
"""A workflow file is not shipped, but change a build flag and the bytes change
while the source does not. Once step 6 skips a build whose version already
exists, that combination serves the OLD artifact on a green run."""
commit(repo, ".forgejo/workflows/desktop.yml", 1787911200) # 10:00
assert version(repo, "display", "desktop") == "2026.08.28.1000"
# --- where it is called from -------------------------------------------------
@pytest.mark.parametrize("subdir", ["", "desktop/src-tauri", "android", "core"])
def test_the_answer_does_not_depend_on_the_caller_s_directory(repo: Path, subdir: str) -> None:
"""`git log -- <paths>` resolves pathspecs relative to the CURRENT DIRECTORY.
Every caller runs from somewhere different — the desktop build from
`desktop/src-tauri`, the Android build from `android`, the manifest from the root
— so without an anchor the same request answers differently per caller.
This is not hypothetical and it is not a loud failure. Run 4796 produced THREE
versions from one push: the desktop build said 1.0.3494522 while the manifest and
the pacman packager said 1.0.3502131, because the build's pathspec matched
`desktop/src-tauri/Cargo.toml` — a real file, six days stale. Non-empty, so the
shallow-clone guard could not fire. The Android job failed loudly in the same run
only because ITS pathspec happened to match nothing; same bug, luckier symptom."""
(repo / "desktop/src-tauri").mkdir(parents=True, exist_ok=True)
(repo / "core").mkdir(parents=True, exist_ok=True)
assert version(repo, "display", "desktop", subdir=subdir) == "2026.08.28.0900"
assert version(repo, "key", "desktop", subdir=subdir) == version(repo, "key", "desktop")
def test_every_artifact_agrees_across_directories(repo: Path) -> None:
"""The property the manifest job actually depends on: the value the bundle was
built with and the value the manifest looks for must be the same string, and they
are computed by different jobs in different directories."""
for artifact in ("desktop", "android", "server"):
root = version(repo, "display", artifact)
assert version(repo, "display", artifact, subdir="desktop/src-tauri") == root
assert version(repo, "display", artifact, subdir="android") == root
# --- the ordering keys -------------------------------------------------------
def test_the_desktop_key_is_valid_semver(repo: Path) -> None:
"""THE test that keeps the update channel alive. Tauri parses `latest.json`'s
version with the semver crate AT DESERIALIZATION — a string it cannot parse does
not sort low, it makes the whole feed fail to load and every client report "no
update available" forever. Exactly three numeric segments, no leading zeros."""
key = version(repo, "key", "desktop")
assert re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", key), key
def test_the_desktop_key_and_display_describe_one_build(repo: Path) -> None:
"""They are allowed to look unrelated. They are not allowed to disagree about
WHICH build — so both come from one timestamp."""
key = version(repo, "key", "desktop")
minutes = int(key.split(".")[2])
assert EPOCH + minutes * 60 == 1787907600 # the desktop's newest commit, 09:00
def test_the_desktop_key_clears_what_is_already_installed(repo: Path) -> None:
"""`0.0.<minutes>` reads best as "not a version" and would have stranded every
dev user: minor 0 < 2 puts it below the installed `0.2.466` line, and "up to
date" forever is the direction you cannot recover from."""
key = tuple(int(p) for p in version(repo, "key", "desktop").split("."))
assert key > (0, 2, 466)
assert key > (0, 2, 999999) # and above any run number that line could reach
def test_the_android_key_is_an_int_android_will_accept(repo: Path) -> None:
"""Build time, not commit time: Android HARD-FAILS a downgrade with
INSTALL_FAILED_VERSION_DOWNGRADE and leaves a channel you cannot get out of, so
the key must be monotonic by construction rather than by a CI guard."""
code = int(version(repo, "key", "android"))
assert code > 1_000_000 # far above the run numbers it replaces (~470)
assert code < 2_100_000_000 # Android's Int ceiling
def test_the_server_has_no_ordering_key(repo: Path) -> None:
"""Nothing compares a server image — no updater, no install gate. §2: do not add
an ordering key because the other artifacts have one."""
r = subprocess.run(["sh", str(SCRIPT), "key", "server"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0
assert "no ordering key" in r.stderr
# --- failing loudly ----------------------------------------------------------
@pytest.mark.parametrize("what", ["display", "key", "paths"])
def test_an_unknown_artifact_is_rejected(repo: Path, what: str) -> None:
"""It was not. `paths_for`'s `exit 2` ran inside `$(paths_for ...)`, so it printed
the error, returned an EMPTY pathspec — which matches everything — and answered
`2026.08.28.0900` with exit 0. A confident version for an artifact that does not
exist, which is the worst kind of wrong for a value nothing else can contradict.
Asserting on stdout as well as the exit code, because the exit code alone passed
for `display` in the first version of this file while stdout carried a lie."""
r = subprocess.run(["sh", str(SCRIPT), what, "nope"],
cwd=repo, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert r.stdout.strip() == "", f"{what} emitted a value for a bogus artifact: {r.stdout!r}"
@pytest.mark.parametrize("what", ["display", "key"])
def test_no_matching_history_fails_rather_than_guessing(tmp_path: Path, what: str) -> None:
"""The shallow-clone failure (note 3127 §6.1), which is the one that matters:
depth-1 sees one commit, `git log -- <paths>` finds nothing for most artifacts,
and a script that shrugged would emit a too-LOW version with the lane green.
Too-low is unrecoverable — every installed client is stranded.
BOTH REQUESTS, and the parametrize is the point rather than thoroughness. The
first version of this guard `exit 1`-ed inside a function called as `$(...)`,
which ends the SUBSHELL and not the script. `display` still failed — but only
because `date` then choked on an empty string. `key` printed the error, emitted
`1.0.-26297280`, and exited ZERO. One path was covered and the other was broken
in exactly the way the guard existed to prevent."""
git(tmp_path, "init", "-q", "-b", "dev")
commit(tmp_path, "README.md", 1787900400) # in no artifact's set
r = subprocess.run(["sh", str(SCRIPT), what, "desktop"],
cwd=tmp_path, capture_output=True, text=True)
assert r.returncode != 0, f"{what} exited 0 with stdout={r.stdout!r}"
assert "shallow" in r.stderr
assert r.stdout.strip() == "", f"{what} emitted a value anyway: {r.stdout!r}"
# --- the backwards guard's comparison ----------------------------------------
#
# Exercised through the guard's own `compare` mode rather than a reimplementation
# here: a test of a copy proves nothing about the code that runs. No network — the
# comparison is pure, and the fetch/compare halves are separable for exactly this.
def compare(a: str, b: str) -> bool:
"""True when the guard considers `a` to sort strictly below `b`."""
return subprocess.run(["sh", str(GUARD), "compare", a, b],
capture_output=True, text=True).returncode == 0
@pytest.mark.parametrize("a,b,expect_lt", [
# THE trap: as text, "1.0.9" > "1.0.10". The comparison must be numeric
# per dot-segment, which is what note 3127 §1 spells out and what a naive
# `[ "$a" \< "$b" ]` would get exactly backwards.
("1.0.9", "1.0.10", True),
("1.0.10", "1.0.9", False),
# Equal is NOT less. Under commit time an unchanged source derives what it
# derived last time, so this is the ordinary no-change build.
("1.0.5", "1.0.5", False),
# A missing segment reads as zero.
("1.0", "1.0.0", False),
("1.0.0", "1.0", False),
("1.0", "1.0.1", True),
# The real transition this milestone performs, on both channels: dev was
# publishing 0.2.<run>, stable was on the bare 0.2.0 from Cargo.toml.
("0.2.466", "1.0.3502151", True),
("0.2.0", "1.0.3502151", True),
("1.0.3502151", "0.2.466", False),
# Android codes are bare integers.
("3502151", "3502152", True),
("3502152", "3502151", False),
# Zero-padded display versions compare correctly despite the leading zeros
# (which is why they are stripped on parse rather than compared as text).
("2026.08.29.0110", "2026.08.29.0111", True),
("2026.08.29.0111", "2026.08.29.0110", False),
("2026.08.09.0111", "2026.08.10.0111", True),
("2026.12.31.2359", "2027.01.01.0000", True),
])
def test_the_guard_orders_versions_numerically(a: str, b: str, expect_lt: bool) -> None:
assert compare(a, b) is expect_lt
@pytest.mark.parametrize("args", [["compare"], ["compare", "1.0.0"], ["nope", "dev"],
["desktop", "nope"]])
def test_the_guard_rejects_bad_invocations(args: list[str]) -> None:
"""Including the two-place validation the version script needed twice — a guard
that answers confidently for input it does not understand is worse than none."""
r = subprocess.run(["sh", str(GUARD), *args], capture_output=True, text=True)
assert r.returncode != 0
# --- the backwards guard against a channel ------------------------------------
#
# The half of the guard that talks to a release feed, which the `compare` tests above
# deliberately do not reach. Hermetic anyway: `curl` is shadowed on PATH by a stub, so
# these assert what the guard does with an answer rather than what the forge returns.
#
# WHY THIS SECTION EXISTS AT ALL. The empty-channel case — a channel that has never
# published this artifact — is one the guard is written to PASS, and it says so in a
# branch of its own. It did not: `published="$(published_for ...)"` under `set -e`
# died on the substitution before that branch could run, because Android's lookup
# ended in a `grep` that exits 1 on no match while its three siblings ended in a `sed`
# that exits 0. Silent, because everything the pipeline printed went into the capture.
# It took the Android lane down on the first merge to `main` (run 4857) and nothing
# here would have caught it.
@pytest.fixture
def curl_stub(tmp_path: Path):
"""A `curl` on PATH that serves whatever the test says, or 404s.
Returns a callable: `serve(None)` for a channel with nothing published (the stub
exits 22, as real curl does under `-f` on an HTTP error), `serve(body)` to hand
back a feed.
"""
bindir = tmp_path / "stubbin"
bindir.mkdir()
body = bindir / "body"
stub = bindir / "curl"
stub.write_text(
"#!/bin/sh\n"
f'[ -f "{body}" ] || exit 22\n'
f'cat "{body}"\n'
)
stub.chmod(0o755)
def serve(content: str | None) -> dict[str, str]:
if content is None:
body.unlink(missing_ok=True)
else:
body.write_text(content)
return {**os.environ, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}
return serve
def guard(repo: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["sh", str(GUARD), *args],
cwd=repo, env=env, capture_output=True, text=True)
@pytest.mark.parametrize("artifact", ["desktop", "android"])
def test_an_empty_channel_passes_the_guard(repo: Path, curl_stub, artifact: str) -> None:
"""Nothing published yet is not a backwards move — it is the first publish.
Failing here would block the very first build to reach a channel, which is exactly
what happened to Android on `stable`.
"""
r = guard(repo, curl_stub(None), artifact, "stable")
assert r.returncode == 0, f"exit={r.returncode} stdout={r.stdout!r} stderr={r.stderr!r}"
assert "nothing to compare" in r.stdout
@pytest.mark.parametrize("artifact", ["desktop", "android"])
def test_an_empty_channel_reports_no_published_version(
repo: Path, curl_stub, artifact: str
) -> None:
"""`published` answers empty rather than failing — `should-build.sh` captures it
the same way the guard does, so a non-zero exit strands that caller too."""
r = guard(repo, curl_stub(None), "published", artifact, "stable")
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
assert r.stdout.strip() == ""
def test_an_empty_channel_builds(repo: Path, curl_stub) -> None:
r = subprocess.run(["sh", str(SHOULD_BUILD), "android", "stable"],
cwd=repo, env=curl_stub(None), capture_output=True, text=True)
assert r.returncode == 0, f"exit={r.returncode} stderr={r.stderr!r}"
assert r.stdout.strip() == "true"
def test_a_lower_published_version_passes(repo: Path, curl_stub) -> None:
"""The transition this milestone performs: `stable` served the bare 0.2.0 from
Cargo.toml, and the new key has to clear it."""
env = curl_stub('{"version": "0.2.0", "platforms": {}}')
r = guard(repo, env, "desktop", "stable")
assert r.returncode == 0, f"stderr={r.stderr!r}"
assert "may be published" in r.stdout
def test_a_higher_published_version_fails_the_lane(repo: Path, curl_stub) -> None:
"""The unrecoverable direction. A version below what is published leaves every
installed client reporting 'up to date' forever, so this fails rather than warns."""
env = curl_stub('{"version": "9.9.9", "platforms": {}}')
r = guard(repo, env, "desktop", "stable")
assert r.returncode != 0
assert "GUARD FAILED" in r.stderr
def test_an_equal_android_code_fails_because_android_will_not_install_it(
repo: Path, curl_stub
) -> None:
"""Android hard-fails a non-rising versionCode, so equality is refused there —
unlike the desktop, where an unchanged source deriving its own value is ordinary."""
code = version(repo, "key", "android")
env = curl_stub(f'{{"version_code": {code}, "version_name": "x"}}')
r = guard(repo, env, "android", "stable")
assert r.returncode != 0
assert "EQUALS" in r.stderr