Make a live change apply from the event itself, so one person's move stops making every other tab re-download the whole board.
What it is like today. Measured on main at ec382a9, locally: a board at this app's own card cap — 500 cards in 3 columns — is 525,354 bytes of HTML (10,990 bytes empty; ~1,050 bytes per card). static/board.109353762.js:403freshBoard() re-fetches location.pathname + location.search — that whole page — and applyNow calls it for card.added, card.moved, card.edited, board.renamed, column.added, column.moved and column.renamed; only card.deleted and column.deleted apply without a fetch. So on a busy board, each move by one person costs each other open tab half a megabyte and the server one full re-render (7 ms each). Ten moves, four tabs: 21 MB and 280 ms of rendering for changes that are a few dozen bytes each. The direction's "appears for everyone within a second" holds today only because the boards we test with are small.
What done looks like
[ ] The BoardEvent for a card change carries the card's own fields — id, column_id, title, position — and a column change carries id, name, position; undo is unchanged and still means "re-read the board".
[ ] bus.publish callers pass the row they just wrote (src/routes.ts and src/mcp.ts both already hold it, or can re-read it inside the same request), so the wire never needs a second query.
[ ] static/board.js applies card.added, card.edited, card.moved, column.added, column.renamed and column.movedwithout any fetch: it builds or updates DOM nodes from the event's fields, placing a card by comparing position against its live siblings' data-position (add one to the card markup if needed).
[ ] Nothing from the wire is ever assigned as HTML: every title or name goes in through textContent or a property assignment, never innerHTML. A card titled <img src=x onerror=alert(1)> arriving live renders as text — assert it in a test.
[ ] by stays stripped from the wire exactly as it is now (src/routes.tssend()), and the frame gains no new identity field.
[ ] The whole-board fetch remains for exactly two cases, both documented in the script: an undo, and the reconcile after a reconnect.
[ ] A test in src/live-refresh.test.ts drives a card.moved frame through the script with fetch stubbed to throw, and the card still lands in the right place with the right counts; another does the same for card.added and column.renamed.
[ ] bun test passes, and the README's Live bullet says the event carries the change rather than pointing at the board's HTML.
Files I expect to change:src/bus.ts, src/routes.ts, src/mcp.ts, static/board.<new hash>.js (with BOARD_JS updated in src/pages/board.ts), src/live-refresh.test.ts, src/server.test.ts, README.md.
Risks. Escaping moves from the server to the client — that is the whole security weight of this change, and the reason for the explicit textContent criterion. Card titles now travel on the stream, which is fine (anyone on that stream holds the board's link and can already read every title) but must not drag by along with it. Position arithmetic on the client has to match db.moveCard's ordering (position, then id) or a card lands one slot off until the next reconnect. Waits for #48: that issue rewrites the same card markup and the same client-side card builder, and a smaller card is the right thing to build this on.
Who fits:scout to build it, reviewed by mason — the reconciliation in static/board.js is the most intricate code in the app and this touches all of it.
Make a live change apply from the event itself, so one person's move stops making every other tab re-download the whole board.
What it is like today. Measured on
mainatec382a9, locally: a board at this app's own card cap — 500 cards in 3 columns — is 525,354 bytes of HTML (10,990 bytes empty; ~1,050 bytes per card).static/board.109353762.js:403freshBoard()re-fetcheslocation.pathname + location.search— that whole page — andapplyNowcalls it forcard.added,card.moved,card.edited,board.renamed,column.added,column.movedandcolumn.renamed; onlycard.deletedandcolumn.deletedapply without a fetch. So on a busy board, each move by one person costs each other open tab half a megabyte and the server one full re-render (7 ms each). Ten moves, four tabs: 21 MB and 280 ms of rendering for changes that are a few dozen bytes each. The direction's "appears for everyone within a second" holds today only because the boards we test with are small.What done looks like
BoardEventfor a card change carries the card's own fields —id,column_id,title,position— and a column change carriesid,name,position;undois unchanged and still means "re-read the board".bus.publishcallers pass the row they just wrote (src/routes.tsandsrc/mcp.tsboth already hold it, or can re-read it inside the same request), so the wire never needs a second query.static/board.jsappliescard.added,card.edited,card.moved,column.added,column.renamedandcolumn.movedwithout any fetch: it builds or updates DOM nodes from the event's fields, placing a card by comparingpositionagainst its live siblings'data-position(add one to the card markup if needed).textContentor a property assignment, neverinnerHTML. A card titled<img src=x onerror=alert(1)>arriving live renders as text — assert it in a test.bystays stripped from the wire exactly as it is now (src/routes.tssend()), and the frame gains no new identity field.undo, and the reconcile after a reconnect.src/live-refresh.test.tsdrives acard.movedframe through the script withfetchstubbed to throw, and the card still lands in the right place with the right counts; another does the same forcard.addedandcolumn.renamed.bun testpasses, and the README's Live bullet says the event carries the change rather than pointing at the board's HTML.Files I expect to change:
src/bus.ts,src/routes.ts,src/mcp.ts,static/board.<new hash>.js(withBOARD_JSupdated insrc/pages/board.ts),src/live-refresh.test.ts,src/server.test.ts,README.md.Risks. Escaping moves from the server to the client — that is the whole security weight of this change, and the reason for the explicit
textContentcriterion. Card titles now travel on the stream, which is fine (anyone on that stream holds the board's link and can already read every title) but must not dragbyalong with it. Position arithmetic on the client has to matchdb.moveCard's ordering (position, thenid) or a card lands one slot off until the next reconnect. Waits for #48: that issue rewrites the same card markup and the same client-side card builder, and a smaller card is the right thing to build this on.Who fits: scout to build it, reviewed by mason — the reconciliation in
static/board.jsis the most intricate code in the app and this touches all of it.