Skip to content

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

Merged
Devski merged 3 commits into
mainfrom
claude/66-polish
Sep 12, 2026
Merged

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
Devski merged 3 commits into
mainfrom
claude/66-polish

Conversation

@Devski

@Devski Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner

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 an
account 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 visitor
onward and one who has not finished setting up straight back, so after verification that
link looped to the page it was on.

href on Logo/LogoMark is optional now, and no href means no anchor — a span, which a
keyboard 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.

  • Tab commits what Enter would, and still moves on. On a desktop it only rescues text
    that tabbing away was about to discard.
  • enterKeyHint="done", so the phone's key says what it does — the field is not inside
    a <form>, so it had nothing to infer that from.
  • The hint under the field names both keys, in both languages.
  • Blur still discards, on purpose.

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.ts has 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), components/ui/use-reorder.ts the hand on it.

  • Pointer events, not HTML5 drag-and-drop: dragstart never 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.
  • The same grip answers the arrow keys and keeps focus on the item it moved — without
    that, one press moves the card and drops the keyboard out of the list entirely.
  • The drag reads its state from refs, not from the render it started in: a pointerup can
    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 from created_at so nothing shuffled the
day it arrived, with created_at still 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 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
its 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 check green. In a browser: on /register the wordmark is a span with the screen's
own "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 to
profile-sections.spec.ts): grips only while editing; the arrows moving a work with the
save 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

Devski and others added 2 commits September 12, 2026 06:02
…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>
@Devski Devski changed the title The logo above the auth cards leads nowhere, and a phone can add a place (#66) Small things to polish: a logo that stops leading away, two lists that keep their order, and a place a phone can add (#66) Sep 12, 2026
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>
@Devski

Devski commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

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: userId comes only from the session, the id set is compared against the owner's own works under the lock, and the UPDATE is scoped by user_id as well as by id — two independent answers to "are these yours", not one with a comment. A foreign id and a non-existent one give the same stale_order, so there is no enumeration oracle either.

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 stale_order exists for), that snapshot brought the deleted work back on screen with live edit and delete buttons, and the derived-state resync had already fired, so nothing corrected it short of a page load. In the two-tab case every retry failed identically, for ever. Nothing is reverted from a snapshot now: a failure says what happened and refreshes, and the server's own 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 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 Zapisz flushes a waiting one rather than claiming "Zapisano" over a save that never left the page.

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 cursor-grab, and the keyboard path still worked. No reordering at all while a form stands in the list now, grips included.

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 UPDATE … FROM (VALUES …) now, so the per-user lock and the pooled connection are held for one round trip rather than a dozen. The owner stays in the WHERE.

One finding belongs outside this branch: src/db/client.ts builds its pool with pg's defaults — connectionTimeoutMillis: 0 (waits for ever) and no statement_timeout/lock_timeout anywhere, so contention queues instead of failing. That is every route, not this one.

@Devski
Devski merged commit d89b844 into main Sep 12, 2026
7 checks passed
@Devski
Devski deleted the claude/66-polish branch September 12, 2026 07:04
Devski added a commit that referenced this pull request Sep 12, 2026
* 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>
Devski added a commit that referenced this pull request Sep 12, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Small things to polish: a logo that stops leading away, two lists that keep their order, and a place a phone can add

1 participant