Show on the board whether its live connection is up, and say so plainly when it is not, so nobody works for ten minutes on a page that stopped updating.
What it is like today.static/board.109353762.js sets source.onmessage and source.onopen and never sets source.onerror — grep -n onerror static/board.*.js finds nothing. So when the stream goes down the page looks exactly like a page that is up. Two ways that happens without anyone noticing:
src/routes.tseventStream() answers 503 text/plain ("too many open tabs on this board") once a board has MAX_SUBSCRIBERS_PER_BOARD = 100 subscribers. Per the EventSource spec, a response that is not 200 with text/event-streamfails the connection permanently — the browser does not retry. That tab is dead for the rest of its life and says nothing.
A restarted server (every deploy) or a dropped connection: EventSource does retry here, and the existing onopen reconcile is correct, but between the drop and the reconnect the person is looking at a board that is not live and cannot tell.
On a board whose whole promise is "you are seeing what the others are doing", silence that looks like agreement is the worst failure mode it has.
What done looks like
[ ] A small live indicator next to the board's name — server-rendered in the first paint in a neutral state, so there is no layout shift when the script wires it, and so a page with JavaScript off never claims to be live.
[ ] source.onopen sets it to live; source.onerror sets it to "not live" and, when source.readyState === EventSource.CLOSED (the permanent failure above), to a state that offers a way back rather than implying a retry is coming.
[ ] That way back is a real control, not advice: a button that constructs a new EventSource and, on success, runs the existing reconcile() so nothing missed is left behind. It works from the keyboard and is announced (aria-live="polite" on the status text, the button focusable in document order).
[ ] Wording is for a person, not a protocol: "Live" / "Not live — reconnecting" / "Not live — Reconnect". No numbers, no status codes.
[ ] The 503 on a full board reads as what it is when it reaches the page — a board with too many tabs open, not a broken app.
[ ] Tests in src/live-refresh.test.ts drive the script's EventSource through onerror with readyState open (shows reconnecting) and closed (shows the Reconnect control), then through a successful reopen, and assert reconcile() re-read the board exactly once.
[ ] bun test passes; the README's Live bullet says what the indicator shows and when a tab gives up.
Files I expect to change:src/pages/board.ts (markup and a few lines of style), static/board.<new hash>.js with BOARD_JS updated, src/live-refresh.test.ts, README.md.
Risks. An indicator that flickers on every routine reconnect is worse than none — debounce the "not live" state by a second or so before showing it. Do not let the indicator become a second place that knows about board state; it reflects the connection only. Related to #44 (a HEAD on the stream leaks a subscriber, which makes the 100-subscriber ceiling easier to reach than it should be) but independent of it: this issue is about what the page says, #44 is about not getting there.
Who fits:scout to build it, reviewed by juniper — the value here is entirely in the wording, the focus order and the absence of layout shift.
Show on the board whether its live connection is up, and say so plainly when it is not, so nobody works for ten minutes on a page that stopped updating.
What it is like today.
static/board.109353762.jssetssource.onmessageandsource.onopenand never setssource.onerror—grep -n onerror static/board.*.jsfinds nothing. So when the stream goes down the page looks exactly like a page that is up. Two ways that happens without anyone noticing:src/routes.tseventStream()answers503 text/plain("too many open tabs on this board") once a board hasMAX_SUBSCRIBERS_PER_BOARD= 100 subscribers. Per the EventSource spec, a response that is not200withtext/event-streamfails the connection permanently — the browser does not retry. That tab is dead for the rest of its life and says nothing.onopenreconcile is correct, but between the drop and the reconnect the person is looking at a board that is not live and cannot tell.On a board whose whole promise is "you are seeing what the others are doing", silence that looks like agreement is the worst failure mode it has.
What done looks like
source.onopensets it to live;source.onerrorsets it to "not live" and, whensource.readyState === EventSource.CLOSED(the permanent failure above), to a state that offers a way back rather than implying a retry is coming.EventSourceand, on success, runs the existingreconcile()so nothing missed is left behind. It works from the keyboard and is announced (aria-live="polite"on the status text, the button focusable in document order).src/live-refresh.test.tsdrive the script's EventSource throughonerrorwithreadyStateopen (shows reconnecting) and closed (shows the Reconnect control), then through a successful reopen, and assertreconcile()re-read the board exactly once.bun testpasses; the README's Live bullet says what the indicator shows and when a tab gives up.Files I expect to change:
src/pages/board.ts(markup and a few lines of style),static/board.<new hash>.jswithBOARD_JSupdated,src/live-refresh.test.ts,README.md.Risks. An indicator that flickers on every routine reconnect is worse than none — debounce the "not live" state by a second or so before showing it. Do not let the indicator become a second place that knows about board state; it reflects the connection only. Related to #44 (a
HEADon the stream leaks a subscriber, which makes the 100-subscriber ceiling easier to reach than it should be) but independent of it: this issue is about what the page says, #44 is about not getting there.Who fits: scout to build it, reviewed by juniper — the value here is entirely in the wording, the focus order and the absence of layout shift.