The direction's "done" is two people and one agent on one board, each seeing the others — seeing the moves is the live-board issue; seeing the others is this one. Make the board say who is here right now and who just moved that card.
Done looks like
[ ] src/presence.ts (new) sits beside the bus and holds one concept: who is subscribed to which board. join(boardId, who): () => void returns the leave function, list(boardId) returns the people currently there as plain data. It is fed only by the event stream's subscribe/unsubscribe, so a person who closes the tab is gone within one heartbeat (~30 s at worst), never forever.
[ ] The board page server-renders the people who are there at first paint and updates it within a second of someone arriving or leaving, in a fixed-height slot so nothing shifts. A signed-in person shows their forge username, escaped; everyone else is "Guest", and several guests read as "2 guests" rather than a list of identical words.
[ ] Nothing leaks: the list carries names and a count only — never a user id, never a signature, never an address — and it is scoped to one board, so being on one board tells you nothing about another.
[ ] Attribution on the move: a card changed by someone else is briefly marked with who did it (the by already carried on bus events), announced politely for a screen reader (aria-live="polite"), and the mark fades without a layout shift. Your own changes are not marked.
[ ] The count is honest under churn: a reconnecting stream does not double-count a person, and a stream that dies without closing is reaped by the heartbeat.
[ ] README.md says what presence means here (who has the board open, this process only) and what it does not mean.
[ ] Tests: two subscribers on a board list two people and one leaving drops to one; a subscriber on another board is not listed; a name containing <script> renders escaped; unsubscribing twice is harmless; the presence frame reaches a connected client.
[ ] No new dependency, no layout shift, bun test passes.
Presence held in a map that never forgets is both a memory leak and a lie on the page. A forge username rendered unescaped is stored XSS from an identity we did not choose. Polling a /presence endpoint instead of riding the stream doubles every board's request count for a decoration. Marking your own moves makes a quiet board look busy.
Blocked by identity (a name to show) and by the live board (the stream that presence rides on).
Suggested brief: scout to build; juniper to review the pull — it is small, visible, and has to survive a keyboard, a screen reader and a 360px phone.
The direction's "done" is two people and one agent on one board, each seeing the others — seeing the moves is the live-board issue; seeing the others is this one. Make the board say who is here right now and who just moved that card.
Done looks like
src/presence.ts(new) sits beside the bus and holds one concept: who is subscribed to which board.join(boardId, who): () => voidreturns the leave function,list(boardId)returns the people currently there as plain data. It is fed only by the event stream's subscribe/unsubscribe, so a person who closes the tab is gone within one heartbeat (~30 s at worst), never forever.byalready carried on bus events), announced politely for a screen reader (aria-live="polite"), and the mark fades without a layout shift. Your own changes are not marked.README.mdsays what presence means here (who has the board open, this process only) and what it does not mean.<script>renders escaped; unsubscribing twice is harmless; the presence frame reaches a connected client.bun testpasses.Files
src/presence.ts(new),src/presence.test.ts(new),src/bus.ts,src/routes.ts,src/pages/board.ts,static/board.<new hash>.js(and theBOARD_JShash),README.md.Risks
Presence held in a map that never forgets is both a memory leak and a lie on the page. A forge username rendered unescaped is stored XSS from an identity we did not choose. Polling a
/presenceendpoint instead of riding the stream doubles every board's request count for a decoration. Marking your own moves makes a quiet board look busy.Blocked by identity (a name to show) and by the live board (the stream that presence rides on).
Suggested brief: scout to build; juniper to review the pull — it is small, visible, and has to survive a keyboard, a screen reader and a 360px phone.