Skip to content

Clean up, polish and modernise the retro board - #36

Merged
ellgreen merged 17 commits into
mainfrom
cleanup-and-polish
Aug 11, 2026
Merged

Clean up, polish and modernise the retro board#36
ellgreen merged 17 commits into
mainfrom
cleanup-and-polish

Conversation

@ellgreen

@ellgreen ellgreen commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Seventeen commits covering four areas: real defects in the backend, a replacement for the dead GIF provider, editable columns, and a visual and motion pass over the whole UI.

The commits are ordered and self-contained, so it reads best commit by commit.

Correctness and security

  • Note authorisation. handleNoteUpdate and handleNoteDelete ignored the user entirely, so a crafted socket frame could edit or delete anyone's note, including notes in a different retro. Retro scoping is now always enforced; ownership is enforced for content-bearing fields and deletes, while structural moves stay open so grouping still works.
  • WebSocket origin was accepting anything alongside cookie auth. Now same-origin, plus the configured UI address in dev.
  • One wedged client could freeze broadcasts for a whole retro: the hub sent synchronously down an unbuffered channel. The channel is buffered and slow clients are dropped rather than blocking everyone.
  • The 512-byte frame limit silently dropped legitimate messages — a title plus tags already exceeded it.
  • Session cookies were being marked Secure on a plain http server, which broke login outside Chrome. gorilla/sessions v1.4 defaults to Secure + SameSite=None, so the options are now set explicitly and derived from the scheme.

Identity is unchanged, after a wrong turn

Earlier on this branch I read the repeated user rows as a bug and made the name the identity — reusing a row on login, with a unique index and a data migration to enforce it. That was wrong and has been fully reverted.

A name here is a label, not an account: two people called Alex are two people, and one person entering their name twice is two sessions. The migration that came with it merged users by name and deleted the duplicates; both it and the follow-up that dropped the index are gone, so migrations/ is identical to main. There is a test asserting two logins with the same name get separate identities, and CLAUDE.md records the rule.

If you ran the removed migration locally, your dev database still has the index. goose up copes with the orphaned version row, but a second Alex will fail on the constraint until you clear it:

drop index if exists unique_user_name;
delete from goose_db_version where version_id = 20260810120000;

GIFs

Google shut the Tenor API down, and GIPHY's free tier is gone. Replaced with a small provider registry (Provider, Resolve, SearchPage) shipping Klipy, which is free, plus a keyless paste-a-link path so the feature works with no configuration at all. POST /api/gifs became a GET with trending on an empty query.

Worth flagging for review: the first cut of the Klipy mapping was wrong — the API nests size before format (file.md.gif, not files.gif.md) — and the unit test encoded the same wrong guess, so it passed while the feature returned nothing. There is now a contract test that hits the live API, skipped unless THOUGHTS_GIF_API_KEY is set:

THOUGHTS_GIF_API_KEY=... go test ./cmd/thoughts/gif/ -run Live

Columns

Columns can be renamed, added and deleted from the board header in any stage. Deletion requires the column to be empty, which is what makes it safe without stage gating. Columns live in a JSON blob rather than a table, and the socket payload binder only handles flat scalars, so the events are one column at a time and the read-modify-write is guarded by a mutex on the broker.

This also fixed a latent bug: the only retro_updated listener lived inside a collapsed Radix panel, which unmounts, so with the board header collapsed a client never saw other people's changes.

UI and motion

A visual pass plus a shared motion vocabulary (ui/src/lib/motion.ts). Notes carry a layoutId so they glide between stages rather than blinking; the board is a snap-scroller on small screens instead of crushing five columns onto a phone; stage progression is a labelled button rather than something you have to guess is clickable; "Brainstorm" reads as "Reflect" in the interface, with stage labels centralised in ui/src/lib/stages.ts.

Tests, CI and housekeeping

There was no test harness at all. Added one for both sides — a SQLite-backed helper for Go tests, Vitest for the frontend — and a CI workflow running build, vet and tests for Go, and lint, tsc, tests and build for the UI.

pnpm lint now reports zero warnings, not just zero errors. The react-refresh warnings came from modules exporting both components and other values, so the variant definitions, the form-field hook and the shared textarea class moved into modules of their own and main.tsx keeps only the render call.

.github/copilot-instructions.md is replaced by a CLAUDE.md covering the same architecture and conventions, plus the rules this branch got wrong: a name is a label rather than an account, migrations are append-only, and the default for a comment is not to write one.

Reviewer notes

  • Two commits fix a subtle interaction: AnimatePresence mode="popLayout" clones each child with a ref of its own, which was silently overwriting dnd-kit's setNodeRef from inside a props spread — for both the draggable notes and the group droppables. Same shape as a react-hook-form field.ref collision fixed earlier in the branch, so worth a look anywhere a ref is passed through a spread.
  • The drag interaction is the one thing not verified end to end. The browser automation reports a zero-width viewport to JavaScript and emits only two pointermove events per drag, which is not enough to start a dnd-kit drag, so the overlay and drop behaviour were reasoned from the mechanism rather than observed. Dropping a note into open column space to ungroup it was still not landing in manual testing, which is why ungrouping has an explicit button on the note.
  • The AI template generation has only been exercised on its loading, error and timeout paths; the success path has never run against a real OpenAI key.
  • Column accent colour is derived from position, not stored, so there is no migration and no change to the create form.

🤖 Generated with Claude Code

ellgreen and others added 17 commits August 10, 2026 23:18
Correctness and security:
- Note updates and deletes went unauthorised: any authenticated user could
  edit or delete anyone's note, including notes belonging to a different
  retro. Content changes and deletes are now author-only while moving notes
  between columns and groups stays open, since that is the point of the
  group stage.
- The websocket accepted any Origin. Sessions are cookie based, so this let
  any page a logged-in user visited drive their retros.
- Client send channels were unbuffered and the hub wrote to them
  synchronously, so a single stalled client froze broadcasts for the whole
  retro. They are now buffered, with stalled clients dropped.
- The 512 byte frame limit silently closed connections: a retro_update with
  a full title and ten tags already exceeded it.
- note_created always obfuscated its content regardless of stage, so notes
  written after brainstorm read as noise until a refresh.
- Optimistic updates all shared one "placeholder" id, so any failure wiped
  every in-flight create and failed edits were never rolled back. Mutations
  now carry a ref that the server echoes on both confirmation and failure.

Identity: logging in inserted a fresh user row every time, so people lost
ownership of their own notes on re-login. Names are the identity in a
name-only auth model, so logins now resolve to a single row. The migration
collapses existing duplicates onto the earliest row, repoints notes and
votes, and adds a unique index.

Consistency: column count limits now agree between API and UI, max_votes: 0
reports as a range error rather than "required", names may contain spaces
and accents, retro tags load in one query instead of one per retro,
Obfuscate can emit the last character of each class, and GIFs survive the
markdown export.

Tests: first coverage in the repo - the status machine, note authorisation,
the websocket payload binder, the obfuscator, the markdown exporter, the
user migration and the notes reducer. CI now runs build, vet, race tests,
lint, typecheck and the UI build on every push and pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google shut the Tenor API down on 2026-06-30, so GIF search had been failing
on every request. Giphy no longer has a free tier either, so the gif package
is now a small registry rather than one hard-wired vendor.

- Klipy is the new default: free for life, and the closest thing to a drop-in
  Tenor replacement. Giphy ships alongside it for anyone holding a key.
- THOUGHTS_GIF_PROVIDER / THOUGHTS_GIF_API_KEY replace THOUGHTS_TENOR_API_KEY,
  which now only logs a warning explaining the shutdown.
- Providers gained trending and pagination, so the picker has something to
  show before anyone types and can keep loading as you scroll.
- Outbound calls now have an 8s timeout; http.DefaultClient had none, so a
  hung provider could pin a request goroutine indefinitely.
- GET /api/gifs replaces POST, since it is a read.

Search is no longer required for the feature to work. The picker has a second
tab where you paste any https image link and see a live preview before
attaching it, so images work with zero configuration. Because those URLs are
now genuinely user-supplied, img_url must be https and under 2048 characters.

The picker itself was a bare form with nine results, no loading state and no
error handling. It now debounces as you type, loads trending on open, reserves
each tile's space so the grid does not reflow as images stream in, scrolls
infinitely, and has real empty, error and loading states.

Also fixes THOUGHTS_DATA in the Dockerfile: the config key is data_path, so
that variable was never read and the database landed in the container's
working directory rather than the mounted volume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A typo in a column title used to mean recreating the whole retro: columns
had no update path at all, since dal.RetroUpdate never touched the field.

Columns are a JSON blob on the retro, not a table, and the websocket payload
binder cannot decode nested structs, so each change is its own flat event:
column_create, column_update and column_delete. They broadcast the existing
retro_updated, so every connected client re-renders for free.

Guards, all covered by tests: a column must exist, only empty columns can be
deleted, a retro keeps at least two columns and gains at most five, and a
note can no longer be written into a column that has just been deleted -
without that check it would land somewhere nothing renders or exports while
still counting towards the retro's total.

Simultaneous edits are a read-modify-write race on one blob, so the broker
serialises them with a mutex, released before broadcasting. A transaction
would not help: sqlx issues a deferred BEGIN, so under WAL a concurrent
writer fails rather than serialising.

The controls sit on the column header, revealed on hover like the note
actions, and are available in every stage - deleting an empty column strands
nothing, because no notes means no groups and no votes. When a column cannot
be deleted the button is disabled with a tooltip saying why.

Two fixes fell out of this:
- The retro_updated listener lived in Settings, which renders inside the
  board's collapsible header. Radix unmounts collapsed content, so with the
  header collapsed no settings or column change from anyone else arrived.
  It now lives on the route.
- The column grid picked its width from an array indexed by child count,
  which was undefined outside 2..6 and collapsed the board into a single
  column. Discuss appends a Tasks column, so a six-column retro already hit
  this. The index is now clamped and Discuss declares its real count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The UI worked but had no shared visual or motion language: instant DOM
swaps, five near-identical grey "chart" tokens doing nothing, and a board
that laid out five fixed columns at every screen size.

Motion, via motion/react behind LazyMotion and a global
MotionConfig reducedMotion="user", so the OS setting is honoured everywhere
without per-component guards. All timings come from one small vocabulary in
lib/motion.ts.

The showpiece is a shared layoutId on each note: advancing a stage no longer
blinks the board away and back, the same cards glide from their brainstorm
positions into their groups and then into vote-ranked order. Around that,
notes spring in and out, groups reflow, the dragged card lifts and tilts,
drop targets outline in their column's colour, and the vote count pops when
it changes.

Colour: the dead --chart-1..5 tokens become a five-hue column accent palette,
tuned separately for light and dark. Accents are derived from column position,
so no migration and no change to the creation form. Once notes are grouped
and reordered by vote, colour is the only remaining signal of where a thought
came from. Surface tokens let cards layer instead of sitting on flat white.

Component work:
- A stage rail replaces two anonymous chevrons, showing where the retro is,
  with the active pill sliding between steps and direction-aware confirm copy.
- Note cards move to elevation and a ring, with author chips after brainstorm,
  and GIFs in a fixed aspect box with a placeholder - they used to pop in at
  their natural height and shove the column down the page.
- The home page leads with the retros. Creating one is behind a "New retro"
  dialog rather than a permanently open form taking half the page, the list is
  a card grid with a stage-coloured spine, and the hero stats count up.
- Presence shows avatars that animate in and out, with a pulse on the live dot.
- Real empty states and loading skeletons: columns used to render blank both
  while fetching and when genuinely empty, which look identical.

Two fixes:
- The board is a horizontal snap-scroller below `lg`, so a five-column retro
  is usable on a phone instead of being crushed to unreadable slivers. The
  header stacks too - the title and the rail cannot share 375px.
- The note dialog said "press enter to submit" and had no button, relying on
  implicit form submission. It has a Save button now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It pointed at ./build/thoughts, which only exists after a bundled build, so
it would fail for anyone who ran it without building first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Logging in returned 200 and then /api/retros and /api/stats came back 401.

The cause: the route guard treated localStorage as proof of authentication.
localStorage only caches the name so the nav can render immediately - the
session cookie is the credential, and it can expire or stop resolving to a
user without the browser saying so. Land on the app with a stale cache and
_auth/beforeLoad waved you through, the loaders fired, and every one of them
401'd.

The auth provider now asks the server before anything decides what to
render, and nothing mounts the router until that answer arrives. A stale
cache with no cookie is now a single /api/auth/self 401 and the login
screen, rather than four failing requests and a bounce.

Two things made it stick rather than recover:

- The axios interceptor threw a router redirect on 401. That is only caught
  inside a loader; from the provider's own effect it was a swallowed
  rejection that had already cleared the stored user on the way past, so a
  slow /api/auth/self could wipe a login that had just succeeded. The
  interceptor now reports expiry to the auth layer and the router decides
  where to send people, so a session dying mid-use is one 401 and a clean
  trip to the login screen.
- Nav redirected to /login whenever it had no user, racing the route guard
  it duplicated. Removed; the guard owns this.

Server: the auth middleware wrote 500 on a lookup failure and then carried
on into the handler with a nil user, where UserFromRequest panicked. Covered
by a test, along with the sessions that legitimately fail to resolve - no
cookie, a cookie signed with a rotated key, and one pointing at a user that
no longer exists.

Login also tells you when it fails now, instead of the rejection vanishing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The actual reason logins would not stick. gorilla/sessions v1.4.0 builds
NewCookieStore with Secure: true and SameSite=None, so the session cookie
was only ever storable over https. Chrome treats http://localhost as a
trustworthy origin and keeps it anyway, which is why this went unnoticed;
Safari does not, and neither does anything else served over plain http. The
login returned 200 with a Set-Cookie the browser then threw away, so every
request after it came back 401.

The options are now set explicitly rather than inherited:

- Secure only when this server terminates TLS, or when the request arrived
  over https, so a deployment behind a TLS-terminating proxy still gets it.
- SameSite=Lax rather than None. Everything here is same-site, and None
  cannot be used without Secure.
- HttpOnly, which gorilla did not set. Nothing reads this cookie from
  JavaScript.

CORS is now credential-aware whether or not the UI is bundled. A wildcard
origin cannot carry credentials at all, so pointing a browser at the Vite
dev server while a bundled binary served the API silently dropped the
cookie in the same way.

Rejections now say why, under THOUGHTS_VERBOSE=true. The useful part is
whether a cookie arrived at all: none means the browser never stored it,
one that names nobody means it failed to decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Descriptions were single-line inputs, so anything longer than the box
scrolled sideways out of sight - the one thing you most want to read back
before creating a board. They are textareas that grow with their content
now, with a character count that only speaks up near the limit.

Sized in JS rather than with CSS field-sizing, which only Chrome supports.
It also needs its own element and a merged ref: callers spread a
react-hook-form field in, and that carries a ref of its own which was
landing after ours and winning, leaving nothing to measure.

The AI feature was an outline button in a row of three, hiding a bare
popover. It is now a panel of its own with a gradient edge that spins while
it thinks, a wand that waves, and one-click theme suggestions so nobody has
to invent a prompt to find out what it does. Generating shows shimmering
placeholder columns when the board is empty, and leaves columns you already
have in place, dimmed - they are only replaced if the generation succeeds.

Templates were a dropdown of bare names, so picking one was a guess. Each
now shows its columns as chips in the colours they will be on the board.

The rest of the screen: a wider dialog, columns as cards carrying their
board accent, a count against the limit, a real empty state, hover-revealed
remove, and the visibility toggle demoted from a bordered block to a line.

Three fixes found while verifying this:

- The AI prompt sat inside the create-retro form. HTML has no nested forms,
  so the browser dropped the inner one and its submit button belonged to the
  outer form: pressing Enter on a theme, or clicking Generate, submitted the
  retro instead of generating anything.
- The generate call had no timeout, so an unreachable provider left someone
  watching a spinner. A bad key took two minutes to come back.
- A generated template could exceed what the create endpoint accepts. The
  response is clamped to five columns and the field lengths, and max tokens
  raised from 250, which could not fit five columns and truncated the JSON
  into a parse failure.

Also stops router.invalidate() firing on first mount, where beforeLoad ran
before the router had its context and threw on every load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GIF search returned nothing because the response shape was wrong. Klipy
nests size first and then format, under "file":

  file.md.gif   not   files.gif.md

Every item therefore mapped to an empty URL and was filtered out as an ad.
The canned payload in the test encoded the same wrong guess, so it passed
while the feature returned zero results. It is now shaped from a real
response, and there is a contract test that talks to the live API, skipped
unless THOUGHTS_GIF_API_KEY is set:

  THOUGHTS_GIF_API_KEY=... go test ./cmd/thoughts/gif/ -run Live

Creating a retro:
- Column title and description are visible form fields with labels, rather
  than borderless text that gave no hint it could be typed in.
- The accent colour is gone from the column cards.
- Any column can be removed, including the last: the minimum belongs to
  validation on submit, not to a disabled button with no explanation. Added
  a Clear button to empty them in one go.
- Remove is always visible rather than appearing on hover.

Tags: Tab now commits what you have typed instead of moving focus away and
discarding it, the way it works anywhere else you type a list of labels.
With nothing typed, Tab moves on as normal.

Stages: moving on used to mean knowing you could click the next step in what
looked like a progress indicator. There is a button for it now, labelled
with what happens next ("Start grouping"), and going back is a quiet icon
beside it. The rail is read-only.

"Brainstorm" is "Reflect" in the UI. It says what people are doing in that
stage rather than naming a meeting format, and it does not collide with
"Group" the way "Gather" would. Wire values are unchanged; the labels now
live in one place so the board, the rail and the home page cannot drift.

Em dashes are gone from the interface copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tab used to commit whatever raw text you had typed, and the suggestion
list could only be reached with the mouse: our own input owned the
keydown handler, so cmdk's list never saw an arrow key.

Arrow keys now move through the list and Tab or Enter takes the
highlighted entry, so typing "mob" and tabbing gives you the existing
"mobile" rather than a near-duplicate. Existing tags rank above the
"Add ..." fallback for the same reason.

Owning the list outright left cmdk with no users, so it and the shadcn
command primitive go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AnimatePresence's popLayout mode clones every child with a ref of its
own so it can measure it. In DraggableNote and DroppableNoteGroup that
clone's ref landed in the trailing props spread and overwrote dnd-kit's
setNodeRef, so neither the dragged note nor the groups it can be dropped
on ever registered a DOM node. Nothing moved under the cursor because
dnd-kit had nothing to move.

Compose those refs and destructure them out of the spread, and carry the
dragged card in a DragOverlay: a note has a layoutId, so motion owns its
transform and dnd-kit's could never win even once the ref lands. What
stays behind is the dimmed gap it left.

Registering the group droppables meant a group, which fills its column,
beat that column on overlap every time, leaving no way to drop a note
back into open space. Collision detection now asks where the pointer is
instead, and taking a note out of a group gets a button on the note as
the reliable route.

Also drop the overlay's drop animation, which flew the card back to the
slot it started in, and stop the note lists using popLayout: it tears a
note out of the flow to animate it away while its layoutId is gliding it
into the new column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Earlier on this branch I read the repeated user rows as a bug and made
the name the identity, reusing a row on login and adding a unique index
to keep it that way. That is not the model: a name here is a label, not
an account. Two people called Alex are two people, and one person
entering their name twice is two sessions.

Worse, the migration that came with it merged existing users by name and
deleted the duplicates, which is destructive and cannot be undone. It is
removed, and a new migration drops the index for anyone who already ran
it.

The test that locked in the wrong behaviour is replaced with one that
locks in the right one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Too many of them narrated the code rather than saying anything it could
not. Kept the ones recording a quirk, a rejected alternative, or a value
that has to stay in step with another file, and cut or shortened the
rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carries over the architecture and conventions, and adds the two things
this branch got wrong: that a name is a label rather than an account,
and that comments should be sparing and earn their place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The destructive migration it was undoing is gone from this branch, so
merged history never creates the index and there is nothing to drop.

A dev database that ran the removed migration still has the index, and
still has its goose version recorded. goose up copes with the orphan
version, but a second user of the same name will fail on the constraint
until it is cleared by hand:

    drop index if exists unique_user_name;
    delete from goose_db_version where version_id = 20260810120000;

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fast refresh only works for a module that exports components and nothing
else, so the variant definitions, the form-field hook and the shared
textarea class move to modules of their own, and main.tsx keeps only the
render call.

Also gives vote.tsx the setter it reads in an effect. It comes from
useState so its identity is stable, and the dependency array was the
only thing claiming otherwise.

Lint is now clean rather than merely passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Down from around 510 added lines to 130. What is left names a library or
browser quirk, says why a reasonable-looking alternative is wrong here,
or flags a value that has to track another file. Everything restating
the code, narrating a layout or justifying an unremarkable choice is
gone.

CLAUDE.md now says the default is no comment, and that anything staying
has to carry information the code cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ellgreen
ellgreen merged commit abe8e2d into main Aug 11, 2026
2 checks passed
@ellgreen
ellgreen deleted the cleanup-and-polish branch August 11, 2026 14:35
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.

1 participant