Make the phone work: touch reordering, reachable menus, and nothing lost offline

Four things that were built for a mouse on a desk and quietly did nothing
useful on a phone. The last one is the serious one.

**A note made offline lost everything written into it.** Its create
reached the server, the server named it something else, and every edit
still queued behind it went on addressing the name only that device ever
knew — 404, five retries, thrown away. The note arrived empty and
untitled, with nothing to say that anything had been lost. The database
was repointed at the new id all along; the queue was not. It is now, by
replacing the id across the whole queued payload rather than in a list of
named fields, because a list of fields is a list to forget to add to. The
same held for a notebook made offline and everything filed into it.

**The order of notes could not be changed on a phone**, because the list
went through the HTML5 drag API, which mobile browsers never fire from
touch. Pointer events instead, which mouse, pen and finger all produce. A
mouse still drags a row from anywhere; a finger drags from a grip,
because pressing the row has to go on scrolling and holding it has to go
on opening the menu. The row under the finger is found with
elementFromPoint — a finger raises no pointerover on anything it did not
press.

**"Move to..." opened off the side of the screen**, and could not be
opened at all without a hover. It is a tap now, unfolding in place where
there is no room beside the menu and flipping sides where there is. The
menu itself is placed by measuring rather than by assuming it is 220px
wide, its rows are tall enough to hit with a thumb, and Escape closes it.

**Installing said nothing when it could not be done.** The entry appeared
only when the browser had offered a prompt, so where installing was
impossible there was no button and no reason. It is always there now and
names the cause — most usefully that a browser will only install a site
served over HTTPS, which is also why the service worker would not have
registered, so one cause was showing up as two mysteries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 01:16:41 +02:00
1 parent 6eecf57247
commit 608da16ac2
12 files changed
+743 -147

No files matched your search

+66 -3
View File
@@ -314,6 +314,45 @@ bug they cover actually happened:
- `hasPendingWrites` must cover the debounce window, not just requests in flight
- v1 note bodies must migrate without losing task state or smuggling in markup
### Reordering, and menus, with a finger
Both were built for a mouse and quietly did nothing on a phone.
**Dragging** went through the HTML5 drag-and-drop API, which mobile browsers
never fire from touch — so the arrangement could not be changed at all, with
nothing on screen to say why. [`useReorderDrag`](frontend/src/hooks/useReorderDrag.ts)
uses pointer events instead, which mouse, pen and finger all produce. The two
devices get different starts, because they want different things:
- **A mouse** begins a drag by pressing anywhere on a row and moving a few pixels.
- **A finger** begins one only from the grip. Pressing the row has to go on
scrolling the list, and holding it has to go on opening the menu; a drag that
started from anywhere would take both.
The row under the finger is found with `elementFromPoint`, not with enter/leave
handlers — a finger generates no `pointerover` on anything it did not press.
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
on hover. On a phone the parent already sits against the right edge, so the list
of notebooks under *Move to…* appeared past the edge of the display — and with no
hover, there was no way to open it in the first place. It is now placed by
measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the
other side on a wide one when the right would not fit.
### Installing it
The install entry used to appear only when the browser had offered a prompt
(`beforeinstallprompt`). Where installing was impossible there was nothing on
screen at all — no button, no reason, and no way to find out short of knowing how
PWAs work.
It is always there now, and when there is no prompt it says which of the fixable
causes applies. The common one is worth naming: **a browser will only install a
site served over HTTPS**, and on plain HTTP the service worker never registers
either — so one cause takes the install *and* offline with it, which otherwise
looks like two unrelated mysteries.
### Notebook covers
A cover is stored as an **index** into `COVERS` in
@@ -350,7 +389,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| Suite | Checks | What it holds down |
| --- | --- | --- |
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
| `editor` | 31 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `editor` | 36 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
@@ -358,8 +397,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 39 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
| `organise` | 50 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
Each suite creates a throwaway account through the admin API and deletes it
@@ -1128,6 +1167,30 @@ out as a ZIP rather than as its first page alone.
---
## Made offline, and what it is called
Something created with no connection carries an id this device invented —
`local_x9k2…`. When its create finally reaches the server, the server names it
something else, and the local database is repointed at the new name.
**The queue has to be repointed too**, and for a long time it was not. The create
arrived; every edit still queued behind it went on addressing the name only this
device ever knew; the server answered 404; the entry was retried five times and
thrown away. A note written and drawn on offline reached the server *empty and
untitled*, with nothing to say that anything had been lost. The same held for a
notebook made offline and everything filed into it.
`repointQueue(oldId, newId)` in [`offlineStore.ts`](frontend/src/offlineStore.ts)
now rewrites the waiting entries alongside the database. It replaces the id as a
plain string across the whole queued payload rather than in a list of named
fields — ids are long random strings, so a false match is not a practical worry,
and a list of fields is a list to forget to add to.
The `offline` suite makes a note with the network off, titles it, writes in it,
draws on it, reconnects, and then asks the *server* for all three.
---
## Conflict Resolution
Two devices can edit the same note without knowing about each other — a phone