@lovek4d/kickflip 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -6,8 +6,8 @@
6
6
  // view in place through router.js; a link to anything that isn't a page loads it. Leaving a view whose root holds
7
7
  // [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
8
8
  // (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
9
- // leaves is unmounted; one left fresh whose stream sent no change since paints live when it comes back, and any other
10
- // paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
9
+ // leaves is unmounted, and paints live from its saved copy when it comes back
10
+ // (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
11
11
  // A push tap reaches it from hub/sw.js as {open: url}, and a page framed inside it hands it a view's URL through
12
12
  // AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
13
13
  // AppShell.reload() reloads the app past the shell the service worker keeps.
@@ -44,6 +44,9 @@ window.AppShell = (function () {
44
44
  }
45
45
 
46
46
  async function start() {
47
+ // The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
48
+ // offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
49
+ history.scrollRestoration = 'manual';
47
50
  // A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
48
51
  if (outer()?.open(location.href, { frames: false })) return;
49
52
  // The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
@@ -57,6 +60,20 @@ window.AppShell = (function () {
57
60
  // A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
58
61
  bootCode = data?.code || null;
59
62
  data ||= read(DATA_KEY);
63
+ // A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
64
+ // the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
65
+ // (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
66
+ // which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
67
+ const shared = data?.shared || null;
68
+ document.documentElement.classList.toggle('shared-view', Boolean(shared));
69
+ const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
70
+ function levelHere() {
71
+ if (!shared || !current) return;
72
+ const here = sharedHere(current.route);
73
+ document.documentElement.classList.toggle('shared-full', here.full);
74
+ const strip = current.root.querySelector('.view-only-strip');
75
+ if (strip) strip.innerHTML = BoardView.sharedStrip(here);
76
+ }
60
77
  sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
61
78
  const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
62
79
  const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
@@ -68,13 +85,8 @@ window.AppShell = (function () {
68
85
  listeners.get(route).add(fn);
69
86
  return () => listeners.get(route)?.delete(fn);
70
87
  };
71
- // route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
72
- // a dropped stream may have missed a change, so its return forgets them all.
73
- const left = new Map();
74
- let streamUp = false;
75
88
  let current = null;
76
89
  function changed(route) {
77
- left.delete(route);
78
90
  for (const fn of listeners.get(route) || []) fn();
79
91
  }
80
92
 
@@ -90,8 +102,7 @@ window.AppShell = (function () {
90
102
  const cur = es = new EventSource('/_hub/stream');
91
103
  cur.addEventListener('open', () => {
92
104
  tries = 0;
93
- streamUp = true;
94
- if (opened) { left.clear(); if (current) changed(current.route); }
105
+ if (opened && current) changed(current.route);
95
106
  opened = true;
96
107
  });
97
108
  cur.addEventListener('change', (e) => {
@@ -103,10 +114,10 @@ window.AppShell = (function () {
103
114
  try { data = JSON.parse(e.data); } catch { return; }
104
115
  codeSeen(data.code);
105
116
  sidebar.setData(data);
117
+ levelHere();
106
118
  current?.v?.B?.renderStatus?.();
107
119
  });
108
120
  cur.addEventListener('error', () => {
109
- streamUp = false;
110
121
  if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
111
122
  retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
112
123
  });
@@ -116,7 +127,9 @@ window.AppShell = (function () {
116
127
  // minute while present. While away the first one goes at once and the moon goes from this page at the same moment
117
128
  // (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
118
129
  let beatAt = 0;
130
+ // A shared person's taps are never the owner's.
119
131
  const beat = () => {
132
+ if (shared) return;
120
133
  const away = Boolean(data?.away);
121
134
  if (!away && Date.now() - beatAt < 60_000) return;
122
135
  beatAt = Date.now();
@@ -125,6 +138,14 @@ window.AppShell = (function () {
125
138
  };
126
139
  for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
127
140
 
141
+ // docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
142
+ // While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
143
+ // on the page moves.
144
+ document.addEventListener('focusin', (ev) => {
145
+ document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
146
+ });
147
+ document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
148
+
128
149
  // A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
129
150
  // rather than waiting for the stream's next one.
130
151
  async function refreshSidebar() {
@@ -173,38 +194,28 @@ window.AppShell = (function () {
173
194
  const board = view.kind === 'board';
174
195
  const hub = route === undefined;
175
196
  const siteTitle = hub ? 'Hub' : titleOf(route);
176
- if (current) {
177
- const st = current.v.B.state;
178
- const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
179
- current.v.unmount();
180
- if (keep) left.set(current.route, keep);
181
- }
197
+ if (current) current.v.unmount();
182
198
  const page = board ? null : AppViews[view.kind];
183
199
  const root = document.createElement('div');
184
200
  root.className = board ? 'view-board' : page.className;
185
- root.innerHTML = board ? BoardView.markup({ title: siteTitle }) : page.markup(view, { siteTitle });
201
+ root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
186
202
  if (current) current.root.replaceWith(root); else scrim.after(root);
187
203
  const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
188
204
  let v;
189
205
  if (board) {
190
- const live = streamUp ? left.get(route) || null : null;
191
- left.delete(route);
192
- v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
206
+ v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
193
207
  } else {
194
208
  v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
195
209
  reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
196
210
  loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
197
211
  }
198
212
  current = { view, route, root, v };
213
+ levelHere();
199
214
  document.title = board ? siteTitle : page.title(view, { siteTitle });
200
215
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
201
216
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
202
217
  scrollTo(0, 0);
203
- // Every other page of a board has the board's + and its sheet too; the hub's own pages aren't a board's.
204
- const plus = !board && boards().has(route);
205
- if (plus) root.insertAdjacentHTML('beforeend', BoardNewTask.markup());
206
218
  v.mount();
207
- if (plus) BoardNewTask.wire(v.B);
208
219
  if (modal) showSettings();
209
220
  }
210
221
 
@@ -230,8 +241,29 @@ window.AppShell = (function () {
230
241
  // go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
231
242
  // own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
232
243
  let router = null;
244
+ const wide = matchMedia('(min-width: 900px)');
245
+ // Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
246
+ // from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
247
+ // hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
248
+ // or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
249
+ function focusPane(id, tries = 50) {
250
+ if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
251
+ setTimeout(() => focusPane(id, tries - 1), 100);
252
+ }
233
253
  function go(view, href, how, from) {
234
254
  if (!AppRouter.mounted(view)) return false;
255
+ if (view.kind === 'questions' && wide.matches) {
256
+ const board = { kind: 'board', route: view.route, hash: '' };
257
+ const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
258
+ if (how === 'pop') history.replaceState(history.state, '', boardHref);
259
+ // On its own board it only opens the topic, so the URL, history and an open card stay as they are.
260
+ else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
261
+ focusPane(view.id);
262
+ sidebar.close();
263
+ return true;
264
+ }
265
+ return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
266
+ }
235
267
  if (view.kind === 'settings') {
236
268
  if (current?.view.kind === 'board' && current.route === view.route) {
237
269
  const here = AppRouter.viewOf(location.href, { boards: boards() });
@@ -283,7 +315,17 @@ window.AppShell = (function () {
283
315
  }
284
316
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
285
317
 
286
- router = AppRouter.listen({ boards, go });
318
+ let pane = null;
319
+ if (first.kind === 'questions' && wide.matches) {
320
+ pane = { id: first.id };
321
+ first = { kind: 'board', route: first.route, hash: '' };
322
+ history.replaceState(history.state, '', AppRouter.urlOf(first));
323
+ }
324
+ router = AppRouter.listen({ boards, go, wide: () => wide.matches });
325
+ // Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
326
+ wide.addEventListener('change', () => {
327
+ if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
328
+ });
287
329
  opener = (url, { replace = false, frames = true } = {}) => {
288
330
  const view = AppRouter.viewOf(url, { boards: boards() });
289
331
  if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
@@ -299,6 +341,7 @@ window.AppShell = (function () {
299
341
  navigator.serviceWorker?.startMessages?.();
300
342
 
301
343
  show(first);
344
+ if (pane) focusPane(pane.id);
302
345
  openStream();
303
346
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
304
347
  addEventListener('pageshow', openStream);
@@ -307,7 +350,6 @@ window.AppShell = (function () {
307
350
  addEventListener('pagehide', (e) => {
308
351
  if (!e.persisted || !es) return;
309
352
  clearTimeout(retry);
310
- streamUp = false;
311
353
  es.close();
312
354
  });
313
355
  }
@@ -180,6 +180,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
180
180
  .sidebar .t-building { --tone: var(--color-notice); }
181
181
  .sidebar .t-ok { --tone: var(--color-success); }
182
182
  .sidebar .t-wait { --tone: var(--color-notice); }
183
+ .sidebar .t-paused { --tone: var(--muted); }
183
184
 
184
185
  @media (min-width: 900px) {
185
186
  body.has-sidebar { padding-left: 272px; }
@@ -1,5 +1,5 @@
1
1
  // The sidebar's behaviour on a page with <aside id="sidebar" class="sidebar"> and <div id="sidebarScrim"
2
- // class="sidebar-scrim">, after ui-phase-bars.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
2
+ // class="sidebar-scrim">, after ui-progress-bar.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
3
3
  // Sidebar.mount({here, data}) paints `data` or the last copy this device stored; the app shell hands it each new copy
4
4
  // through setData from its one stream (shell.js). The page header's logo (a.hub) toggles the sidebar at every
5
5
  // width, with what waits on the other sites in its accessible label. Below 900px the sidebar is a drawer: the logo or a swipe in from the
@@ -17,8 +17,8 @@
17
17
  // logo does, checkDevice reading this device's push subscription again, and keydown and pointerdown taking a framed
18
18
  // page's events (view-frame.js), as the document's own
19
19
  // A starting board's row (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md): its seconds
20
- // tick in place each second, and a repaint sets its running bar's pulse from the clock, so a new copy never restarts
21
- // it. When the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
20
+ // tick in place each second, and its running bar beats on every pulse's one clock (ui-progress-bar.js onePulse). When
21
+ // the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
22
22
  (function () {
23
23
  const DATA_KEY = 'hub:sidebar';
24
24
  // app.html reads it before the first paint, so a collapsed rail never shows for a moment.
@@ -27,8 +27,7 @@
27
27
  // A phone has no keys to press, so it never shows the shortcuts sheet.
28
28
  const DESKTOP = '(hover: hover) and (pointer: fine)';
29
29
  const TWICE_MS = 1000;
30
- // style.css pulse-fill's period, and sidebar.css sb-join's length.
31
- const PULSE_MS = 1400;
30
+ // sidebar.css sb-join's length.
32
31
  const JOIN_MS = 2500;
33
32
  // paint() sets the bar's duration from this and its delay from switching.started.
34
33
  const IDLE_MS = 3000;
@@ -58,6 +57,8 @@
58
57
  if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
59
58
  let device = null;
60
59
  let update = null;
60
+ // The hub's version when this page booted; the row names the version only once it differs.
61
+ let bootVersion = data?.version ?? null;
61
62
  let updateHTML = '';
62
63
  const rail = matchMedia(RAIL);
63
64
  const html = document.documentElement;
@@ -70,7 +71,7 @@
70
71
  const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
71
72
  if (!logo) return;
72
73
  const n = othersWaiting(data, here);
73
- logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : data?.upgrade ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
74
+ logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
74
75
  }
75
76
  // switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
76
77
  let switching = null;
@@ -88,7 +89,6 @@
88
89
  }
89
90
  function paintStarts() {
90
91
  const now = Date.now();
91
- for (const bar of aside.querySelectorAll('.sb-starting .phase-bars i.now')) bar.style.animationDelay = `-${now % PULSE_MS}ms`;
92
92
  for (const [route, at] of joined) {
93
93
  const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
94
94
  if (!row) { joined.delete(route); continue; }
@@ -101,7 +101,7 @@
101
101
  }
102
102
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
103
103
  function paint() {
104
- html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
104
+ html.classList.toggle('sb-update', Boolean(update));
105
105
  if (!data) return;
106
106
  const scroll = aside.querySelector('.sb-scroll');
107
107
  const top = painted ? scroll?.scrollTop : undefined;
@@ -113,11 +113,14 @@
113
113
  bar.style.animationDelay = `${switching.started - performance.now()}ms`;
114
114
  bar.style.animationDuration = `${IDLE_MS}ms`;
115
115
  }
116
+ // A shared person's sidebar has no update row (ui-sidebar.js).
116
117
  const fresh = aside.querySelector('.sb-update');
117
- if (held) fresh.replaceWith(held);
118
- const row = updateRow(update, data.upgrade);
119
- if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
120
- updateHTML = row;
118
+ if (fresh) {
119
+ if (held) fresh.replaceWith(held);
120
+ const row = updateRow(update, data.version && data.version !== bootVersion ? data.version : null);
121
+ if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
122
+ updateHTML = row;
123
+ }
121
124
  const list = aside.querySelector('.sb-scroll');
122
125
  if (list) list.scrollTop = top ?? list.scrollHeight;
123
126
  paintStarts();
@@ -128,6 +131,7 @@
128
131
  if (!next) return;
129
132
  const starting = new Set(startsRunning().map((s) => s.route));
130
133
  for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
134
+ bootVersion ??= next.version ?? null;
131
135
  data = next;
132
136
  write(DATA_KEY, JSON.stringify(data));
133
137
  paint();
@@ -192,28 +196,10 @@
192
196
  if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
193
197
  return;
194
198
  }
195
- const upgradeApp = e.target.closest?.('[data-app-upgrade]');
196
- if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
197
199
  if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
198
200
  const failed = e.target.closest?.('[data-failed-start]');
199
201
  if (failed) openFailed(failed.dataset.failedStart);
200
202
  });
201
- // Upgrade asks the hub to run kickflip update (docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). The row
202
- // waits (ui.js waitOn) until the hub answers with what it did, then while that update waits or runs (ui-sidebar.js
203
- // updateRow), and a refusal shows as a failed update.
204
- function upgrade(button) {
205
- BoardUI.waitOn(button, (async () => {
206
- let next;
207
- try {
208
- const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
209
- const body = await response.json().catch(() => ({}));
210
- next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
211
- } catch {
212
- next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
213
- }
214
- setData({ ...data, upgrade: next });
215
- })());
216
- }
217
203
  // A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
218
204
  // record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
219
205
  function openFailed(route) {