Small things to polish: a logo that stops leading away, two lists that keep their order, and a place a phone can add (#66) - #170
Conversation
…ace (#66) Two of the four things on #66, the two that need no schema. The logo sat above every auth card as a link to `/`. A visitor part-way through making an account was being offered a way out of the view, which is a conversion question before it is a styling one (Dawid, 12.09.2026). It was also broken as navigation: `/` sends a signed-in visitor onward and one who has not finished setting up straight back, so after verification the link looped to the page it was on. `href` on Logo/LogoMark is now optional and no href means no anchor — a span, which a keyboard and a screen reader both pass by. All seven auth screens drop it; the homepage, the public profile, settings and the owner's view keep theirs. Nobody is stranded: every one of those screens carries its own links. Adding a place on a phone was impossible. Only Enter committed, and a phone's keyboard offers "next" — which is Tab, so the key moved focus and took the typed text with it. Tab now adds what Enter would and still moves on; on a desktop it only rescues text that tabbing away was about to discard anyway. The field gets enterKeyHint="done" so the phone's key says what it does (it is not inside a form, so there was nothing to infer it from), and the hint under the field names both keys in both languages. Blur still discards, on purpose. Added while there: the place list says out loud what happened to it. A chip appearing was the whole of the feedback, and the field empties itself the moment a place is taken — to a screen reader, indistinguishable from the text being thrown away. Verified in a browser: on /register the wordmark is a span with the screen's own "Log in" link intact, and on the homepage the logo is still a link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The places on a profile and the works below it both had one order: the one they were added in. Now the owner drags either where they want it, and a visitor reads it that way. One piece of machinery for both, because they look nothing alike — a wrapped row of chips and a grid of cards. lib/reorder.ts holds the arithmetic (move an item; which box a pointer is over, falling back to the nearest middle so a finger in the gap between two cards still means something) and components/ui/use-reorder.ts the hand on it. Pointer events rather than HTML5 drag-and-drop: dragstart never fires on a touch screen, and half of this is used on a phone. The same grip answers the arrow keys and keeps focus on the item it moved, so the feature exists for a keyboard too. Places cost nothing to order: the order IS the array, saved by the call that already saves them. Works needed a column — position, backfilled from created_at so nothing shuffled the day it arrived, with created_at still breaking the tie. The whole order goes to /api/works/order, which refuses one about any list other than the owner's current works: a second tab adding or deleting a work makes the save fail rather than quietly move something nobody touched. A refused save puts the order on screen back. The schema canaries did their job and were extended consciously: works grew a column, and the works index was re-created with the owner's order in front of the adding order it used to carry alone. Verified against a real browser and the test database: grips only while editing, arrows moving a work with the save asserted and surviving a reload, a drag by the grip landing where it was dropped, a visitor seeing the owner's order, and the same for places. The drag test caught two real faults in itself — a reload racing the save, and a card dragged at coordinates below the fold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two lanes went over it; the endpoint came back clean, the hand on the list
did not.
- **A refused save could resurrect a deleted work.** The revert put back a
snapshot taken before the save — and if the list had changed underneath
(another tab deleted a work, which is exactly what `stale_order` is for),
that snapshot brought the deleted one back on screen with live controls,
permanently: the derived-state resync had already fired, so nothing would
correct it short of a page load. Nothing is reverted from a snapshot now.
A failure says so and refreshes, and the server's answer is the way back.
- **Saves were not sequenced.** An arrow key repeats about twenty-five times
a second and each press was its own request: several in flight, committed
in whatever order they reached the lock, the tail of them refused by the
rate limit — and each refusal reverting to its own stale snapshot. The
order is now sent once the moving stops, and "Zapisz" flushes a waiting
one rather than claiming "Zapisano" over it.
- **An open work form disabled every pointer drag, silently.** Its row is not
one of the measured boxes, so the measuring loop bailed for every grip
while the grips stayed there looking draggable. No reordering while a form
stands in the list, grips and all.
- **A second finger took over a drag**, and the first one's release then
committed the second's half-finished move.
- The order error outlived editing and was never cleared.
- Capture lost without a cancel (the grip unmounting mid-drag) left a card
looking held until the next drag.
Accessibility, which no e2e test would have caught: a grip is a button, and
its keys — Enter, Space — move nothing, so both lists now name the arrows in
a line every grip points at, and every grip says where its item sits ("2 of
5"). What remains and is written down rather than fixed: in a screen reader's
browse mode the arrows are consumed before they reach the button, so the
conventional pick-up-and-drop model is the fuller answer.
Also from the security lane: the ten UPDATEs are one statement now, so the
per-user lock and the pooled connection are held for a round trip rather than
a dozen. The owner stays in the WHERE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Two review lanes went over this — one on the change as a whole, one on the new endpoint. The endpoint came back clean. No critical, high or medium findings: The hand on the list did not come back clean. Six things, now fixed: A refused save could resurrect a deleted work — permanently. The revert put back a snapshot taken before the save. If the list had changed underneath (another tab deleted a work, which is exactly what Saves were not sequenced. An arrow key repeats about twenty-five times a second, and each press was its own request: several in flight at once, committed in whatever order they reached the lock, the tail refused by the rate limit — and each refusal reverting to its own stale snapshot, so the screen and the database could disagree silently. The order is now sent once the moving stops (300 ms), and An open work form disabled every pointer drag, silently. The in-place form's row is not one of the measured boxes, so the measuring loop bailed out for every grip — while the grips stayed on screen with A second finger took over a drag in flight, and the first finger's release then committed the second one's half-finished move. One drag at a time. Plus: the order error outlived editing and was never cleared, and capture lost without a cancel (the grip unmounting mid-drag) left a card looking held. Accessibility, which no e2e test would have caught. A grip is a button, and a button's keys are Enter and Space — neither moves anything here. Both lists now point every grip at one line naming the arrows, and every grip says where its item sits ("2 of 5"). Written down rather than fixed: in a screen reader's browse mode the arrows are consumed before they reach the button, so the conventional pick-up-and-drop model (Space picks up, arrows move, Space drops, Escape cancels) is the fuller answer — a separate decision, since it trades away the "no mode to forget to leave" property. From the security lane: the ten UPDATEs are one One finding belongs outside this branch: |
* Task index: #66 done, two new tasks, and #113 raised (#66, #113, #172, #173) #66 landed with #170: the logo above the auth cards, the place a phone could not add, and both lists put in order by hand. Two things it produced rather than closed: - #173, Dawid's own words on seeing it work — the order is right, the movement is not, and for now it stays. - #172, from the security review — the pool waits for ever and no statement is ever cut off, which is every route rather than that one. And #113 is raised, with the reason written down: a preview running the pull request's image against dev's database means any pull request carrying a migration looks broken on its own preview, exactly when the preview exists to be looked at. Twice now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * The reorder tests read the page before React has drawn it (#66) `main` went red on the test I wrote, not on the product: `works-order` compared the list of names with a plain expect straight after a key press. That is a snapshot, not a retrying assertion, so it asked the page what it looked like before React had finished putting it there — green on a quiet machine, red on CI. Both specs use `toHaveText` now, which waits. Second one in the same test, and this one my own doing: the order is sent once the moving stops (300 ms), so the reload that follows the second arrow press was racing the request and would have cancelled it in flight. It is waited for, like the first. Nothing in the product changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* A preview runs on its own copy of dev (#113) A preview ran the pull request's image against DEV's database, and previews never migrate. So every pull request that added a column its pages read previewed as a server error: #112 on 09.09.2026, #170 again five days later, each time exactly when the preview existed to be looked at. The workaround both times was to apply the migration to dev by hand first — safe only while migrations stay expand-only and somebody is there to reason about it. Now preview-up.sh copies dev into platform_pr_<n>, runs the pull request's own migrator against the copy, and points the container at it; preview-down.sh drops it again. A preview is therefore dev's data as of its start plus this pull request's schema, and the accounts and uploads made inside one die with it — said out loud in the line CI puts in the job summary. The copy is a dump and a restore, not `create database ... template`: PostgreSQL refuses a template clone while anything is connected to the source, and dev's own container always is. Dump to a file rather than piping into psql, because a pipeline reports only its last command's status — a failed dump would have passed for a successful restore of nothing, and the preview would have looked like a working one with an empty database. Not "the preview migrates the shared database", which was the other candidate: a pull request revised after its preview ran would leave a migration in dev's journal whose hash no longer matched the file, and the next deployment of main would fail on it. CI gains what #113 asked for: deploy-config now pins that a preview creates its database, migrates it, is pointed at it, and drops it on the way out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * What the reviews found in the preview's own database (#113) Two of them would have been expensive. The nightly copy of the databases (#168) selects every `platform_%` database that is not a test one — which now includes the preview copies. A night with two previews open would copy dev's data three times over, and the night after they merged would produce a third of that, which the shrink guard reads as a copy gone bad: it refuses the upload, does not record a success, and so refuses every night after it too. The backup would have stopped, silently, for a reason that has nothing to do with dev. The copies are excluded now, and CI pins the exclusion. And a preview could delete dev's objects. The copy carries dev's file rows, which name dev's keys; the bucket is shared and only new WRITES are scoped by S3_PREFIX. So deleting a work in a preview would have deleted dev's object and left dev's own row pointing at nothing — permanent, silent, and invisible to the environment that lost it. lib/storage.ts now refuses a delete outside the environment's own prefix and says which key it refused; production's prefix is blank, so the bare bucket stays production's own. The rest: - The old container is removed BEFORE its database is taken from under it. Every step after that can fail, and what must never happen is a container still serving against a database being replaced. - A copy with no container is dropped at the start of every run. Only closing a pull request removed one, an older branch's preview-down.sh knows nothing about copies at all, and a cancelled CI run takes its ssh with it — so the sweep, not the teardown, is what keeps copies of every dev account's rows from accumulating on a disk that has filled once already (#119). - The copy is restored with `sessions` and `verifications` emptied. A session is valid for thirty days and both environments sign with the same key, so a copied session is a credential that signing out on dev can no longer revoke. You sign in to a preview. - The dump is written under umask 077 and both psql runs are VERBOSITY=terse: the file is every account's address and password hash in plain SQL, and psql prints the offending ROW as context when a restore fails — into a CI log this public repository keeps for ninety days. - The migrator gets only DATABASE_URL, exported rather than put in argv, and a memory cap. It read one variable and was handed the mail key, the bucket credentials and AUTH_SECRET. - The CI checks no longer pass on comment text: they match the psql flag, not the SQL, and count both containers rather than either. - Comments that this change made false are corrected — the collector's guard in particular, whose stated reason was that previews share dev's database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #66 — all four things on it.
The logo above the card leads nowhere
It was a link to
/on all seven auth screens. A visitor part-way through making anaccount was being offered a way out of the view — a conversion question before a styling
one (Dawid, 12.09.2026). It was also broken as navigation:
/sends a signed-in visitoronward and one who has not finished setting up straight back, so after verification that
link looped to the page it was on.
hrefonLogo/LogoMarkis optional now, and no href means no anchor — a span, which akeyboard and a screen reader both pass by, rather than a link plus a second prop that could
contradict it. One rule for the whole auth shell: each of those screens carries its own
links, so nobody is stranded. The homepage hero, the public profile, settings and the
owner's view keep theirs.
A place a phone could not add
Only Enter committed, and a phone's keyboard offers "next" — which is Tab, so the key moved
focus and took the typed text with it.
that tabbing away was about to discard.
enterKeyHint="done", so the phone's key says what it does — the field is not insidea
<form>, so it had nothing to infer that from.Two lists the owner can put in order
Places and works both had one order: the one they were added in.
One piece of machinery for both, because they look nothing alike — a wrapped row of chips,
a grid of cards.
lib/reorder.tshas the arithmetic (move an item; which box a pointer isover, falling back to the nearest middle so a finger in the gap between two cards still
means something),
components/ui/use-reorder.tsthe hand on it.dragstartnever fires on a touch screen,and half of this product is used on a phone. One path serves a mouse, a finger and a
stylus.
that, one press moves the card and drops the keyboard out of the list entirely.
arrive before the render a pointermove scheduled has committed, and a handler closed over
the older render sees the drag still sitting where it began.
Places cost nothing to order — the order is the array, saved by the call that already
saves them.
Works needed a column.
position, backfilled fromcreated_atso nothing shuffled theday it arrived, with
created_atstill breaking the tie; a new work takes the next number.The whole order goes to
POST /api/works/order, which refuses one about any list other thanthe owner's current works — a second tab adding or deleting a work makes the save fail
rather than quietly move something nobody touched. A refused save puts the order on screen
back.
The schema canaries did their job and were extended consciously:
worksgrew a column, andits index was re-created with the owner's order in front of the adding order it used to
carry alone.
Added while in there
The place list says out loud what happened to it, and so does the works list (
role="status",visually hidden). A chip appearing was the whole of the feedback, and the field empties
itself the moment a place is taken — which to a screen reader is indistinguishable from the
text having been thrown away.
Verified
pnpm checkgreen. In a browser: on/registerthe wordmark is a span with the screen'sown "Log in" link intact; on the homepage the logo is still a link.
Against the test database (
e2e/db/works-order.spec.ts, new, and a case added toprofile-sections.spec.ts): grips only while editing; the arrows moving a work with thesave asserted and the order surviving a reload; a drag by the grip landing where it was
dropped; a visitor seeing the owner's order; and the same for places.
The drag test earned its keep by failing twice — once on a reload racing the save (the
test now waits for the response, which is the honest assertion anyway), once on dragging a
card at coordinates below the fold.
🤖 Generated with Claude Code