@lovek4d/kickflip 0.3.1 → 0.5.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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
@@ -44,6 +44,7 @@
44
44
  <script src="ui.js"></script>
45
45
  <script src="ui-links.js"></script>
46
46
  <script src="ui-enums.js"></script>
47
+ <script src="ui-phase-bars.js"></script>
47
48
  <script src="ui-cards.js"></script>
48
49
  <script src="ui-page-header.js"></script>
49
50
  <script src="ui-sidebar.js"></script>
@@ -1,4 +1,4 @@
1
- // The app shell's router (docs/adr/0055-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
1
+ // The app shell's router (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
2
2
  // names its URL, so a push link, a reload and a shared link all land on the same view. viewOf and urlOf are pure;
3
3
  // ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
4
4
  // A view is {kind, …}:
@@ -8,8 +8,9 @@
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
9
  // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
10
10
  // redirects there
11
- // hub {hash, w} /_hub/ with a settings modal named by hash; legacy /_hub/settings and
12
- // /_hub/settings/polling?w=<HH:MM> open the same modals (ADR 0075)
11
+ // hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
12
+ // ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
13
+ // and the old Polling page, /_hub/settings/polling, opens Quiet hours
13
14
  // frame {route, url} any other page under a route, a folder or an .html file: other sites, prototypes and
14
15
  // every other board page, shown in a frame
15
16
  // Any other URL, such as an image or another origin, is no view (null) and loads as itself.
@@ -29,8 +30,8 @@ globalThis.AppRouter = (function () {
29
30
  const p = u.pathname;
30
31
  if (p === '/') return { kind: 'start' };
31
32
  if (['/_hub/', '/_hub/settings', '/_hub/settings/polling'].includes(p)) {
32
- const hash = p === '/_hub/settings/polling' ? '#polling' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
33
- return { kind: 'hub', ...(hash ? { hash } : {}), ...(hash === '#polling' && u.searchParams.get('w') ? { w: u.searchParams.get('w') } : {}) };
33
+ const hash = p === '/_hub/settings/polling' ? '#quiet' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
34
+ return { kind: 'hub', ...(hash ? { hash } : {}) };
34
35
  }
35
36
  const m = /^\/([^/]+)\/(.*)$/.exec(p);
36
37
  let route = null;
@@ -60,7 +61,7 @@ globalThis.AppRouter = (function () {
60
61
  case 'settings': return `${r}settings.html`;
61
62
  case 'questions': return `${r}questions.html${view.id ? `#${encodeURIComponent(view.id)}` : ''}`;
62
63
  case 'notes': return `${r}notes.html${view.wave ? `?wave=${encodeURIComponent(view.wave)}` : view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
63
- case 'hub': return `/_hub/${view.w ? `?w=${encodeURIComponent(view.w)}` : ''}${view.hash || ''}`;
64
+ case 'hub': return `/_hub/${view.hash || ''}`;
64
65
  default: return view.url;
65
66
  }
66
67
  }
@@ -77,7 +78,7 @@ globalThis.AppRouter = (function () {
77
78
  return { href: u.href, pushed };
78
79
  }
79
80
 
80
- // The page tree (docs/adr/0058-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
81
+ // The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
81
82
  // to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
82
83
  function questionOf(view, boards) {
83
84
  if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
@@ -1,5 +1,5 @@
1
1
  // The rules editor on Board Settings and on the Hub page
2
- // (docs/adr/0039-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
2
+ // (docs/adr/2026-10-29-1639-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
3
3
  // reviewers list does; Save hands {rules: [{id?, text}], base: [the ids it loaded]} to the page's save(), whose
4
4
  // server merges it (hub/rules.mjs applyRules). It brings its own CSS, colours from colors.css only, and takes the page's
5
5
  // button classes. rows, payload, dirty and render are pure, for ui.test.mjs; mount touches the DOM.
@@ -110,10 +110,9 @@
110
110
  addBox.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); add(); } });
111
111
  addBox.addEventListener('input', mark);
112
112
  saveBtn.addEventListener('click', async () => {
113
- saveBtn.disabled = true;
114
- status.textContent = 'Saving…';
113
+ status.textContent = '';
115
114
  try {
116
- const r = await opts.save(payload(list));
115
+ const r = await BoardUI.waitOn(saveBtn, opts.save(payload(list)));
117
116
  list = rows(r.rules);
118
117
  editing = null;
119
118
  status.textContent = r.message || 'Saved.';
@@ -1,4 +1,4 @@
1
- // The app shell (app.html; docs/adr/0055-one-app-for-the-whole-hub.md): the sidebar, mounted once, beside one view at a
1
+ // The app shell (app.html; docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): the sidebar, mounted once, beside one view at a
2
2
  // time. A board's view is BoardView (app.js); Waiting on you and Notes are AppViews.questions and .notes,
3
3
  // the Hub page AppViews.hub with its settings modals, and every other page AppViews.frame. Board Settings mounts over the board
4
4
  // as a modal. Each page is {className, title(view, {siteTitle}), markup(view, {siteTitle}), create(root,
@@ -7,9 +7,10 @@
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
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/0028-the-board-opens-on-the-last-visit.md).
10
+ // paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.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
+ // AppShell.reload() reloads the app past the shell the service worker keeps.
13
14
  window.AppShell = (function () {
14
15
  const LAST_KEY = 'hub:lastBoard';
15
16
  const DATA_KEY = 'hub:sidebar';
@@ -111,7 +112,20 @@ window.AppShell = (function () {
111
112
  });
112
113
  }
113
114
 
114
- // A view that changed what the sidebar's foot rows say, such as notifications or polling, asks for a fresh copy
115
+ // A tap, key or scroll on any page tells the hub the owner is here (hub/presence.mjs ownerTracker), at most once a
116
+ // minute while present. While away the first one goes at once and the moon goes from this page at the same moment
117
+ // (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
118
+ let beatAt = 0;
119
+ const beat = () => {
120
+ const away = Boolean(data?.away);
121
+ if (!away && Date.now() - beatAt < 60_000) return;
122
+ beatAt = Date.now();
123
+ if (away) { data = { ...data, away: false }; current?.v?.B?.renderStatus?.(); }
124
+ fetch('/_hub/presence', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => { beatAt = 0; });
125
+ };
126
+ for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
127
+
128
+ // A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
115
129
  // rather than waiting for the stream's next one.
116
130
  async function refreshSidebar() {
117
131
  sidebar.checkDevice();
@@ -175,7 +189,7 @@ window.AppShell = (function () {
175
189
  if (board) {
176
190
  const live = streamUp ? left.get(route) || null : null;
177
191
  left.delete(route);
178
- v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause });
192
+ v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
179
193
  } else {
180
194
  v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
181
195
  reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
@@ -186,7 +200,11 @@ window.AppShell = (function () {
186
200
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
187
201
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
188
202
  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());
189
206
  v.mount();
207
+ if (plus) BoardNewTask.wire(v.B);
190
208
  if (modal) showSettings();
191
209
  }
192
210
 
@@ -254,7 +272,7 @@ window.AppShell = (function () {
254
272
  }
255
273
 
256
274
  // `/` opens this device's last board, else the board the hub picks (sidebar data's start), else the hub's page
257
- // (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
275
+ // (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
258
276
  let first = AppRouter.viewOf(location.href, { boards: boards() });
259
277
  if (first?.kind === 'start') {
260
278
  let last = null;
@@ -294,5 +312,5 @@ window.AppShell = (function () {
294
312
  });
295
313
  }
296
314
 
297
- return { start, open, codeSeen };
315
+ return { start, open, codeSeen, reload };
298
316
  })();
@@ -36,6 +36,8 @@
36
36
  .sidebar .sb-update-row:disabled { opacity: 1; cursor: default; }
37
37
  .sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 600; }
38
38
  .sidebar .sb-update-row:disabled .sb-go { background: transparent; color: var(--muted); padding-right: 0; font-weight: 400; }
39
+ /* While Reload or Upgrade runs, its pill keeps its blue with a spinner in place of its word (style.css, ui.js BUSY). */
40
+ .sidebar .sb-update-row[aria-busy="true"] .sb-go { background: var(--accent); color: var(--on-accent); padding-right: 10px; font-weight: 600; }
39
41
  html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
40
42
  html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
41
43
  content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
@@ -48,6 +50,21 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
48
50
  .sidebar .sb-new { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; }
49
51
  .sidebar .sb-starting { color: var(--muted); }
50
52
  .sidebar .sb-starting:hover, .sidebar .sb-starting:active { background: transparent; }
53
+ /* A starting board's steps on the board's phase bars, spread evenly across the row, its step and seconds below
54
+ (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). */
55
+ .sidebar .sb-steps { display: flex; margin: 6px 0 4px; }
56
+ .sidebar .sb-steps .phase-bars { flex: 1 1 auto; min-width: 0; display: flex; gap: 3px; }
57
+ .sidebar .sb-steps .phase-bars i { flex: 1 1 0; width: auto; min-width: 0; }
58
+ .sidebar .sb-step { font-variant-numeric: tabular-nums; }
59
+ /* At join the board's row fades in, ringed for a moment (sidebar.js). */
60
+ .sidebar .sb-joined { animation: sb-join 2.5s ease-out both; }
61
+ @keyframes sb-join {
62
+ 0% { opacity: 0; transform: translateY(4px); box-shadow: 0 0 0 1px var(--accent) inset; }
63
+ 20% { opacity: 1; transform: none; box-shadow: 0 0 0 1px var(--accent) inset; }
64
+ 80% { box-shadow: 0 0 0 1px var(--accent) inset; }
65
+ 100% { box-shadow: none; }
66
+ }
67
+ @media (prefers-reduced-motion: reduce) { .sidebar .sb-joined { animation: none; } }
51
68
  .sidebar .sb-failed { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; color: var(--text); }
52
69
  .sidebar .sb-failed .sb-title { color: var(--color-danger); }
53
70
  .sidebar .sb-failed:active { background: var(--chip); }
@@ -67,6 +84,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
67
84
  #failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
68
85
  }
69
86
  .sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
87
+ .sidebar .sb-row .icon.sb-start-icon { color: var(--accent); }
70
88
 
71
89
  #newBoardSheet .nb-form { min-width: 0; }
72
90
  #newBoardSheet .sheet-head { margin-bottom: 8px; }
@@ -143,7 +161,10 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
143
161
 
144
162
  /* A site's status (hub/home.mjs statusLine): the lane chips wrap on their own line, and the status word goes below them. */
145
163
  .sidebar .sb-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
146
- .sidebar .sb-ctx { font-size: 12px; color: var(--muted); }
164
+ .sidebar .sb-head { display: flex; align-items: baseline; gap: 8px; }
165
+ .sidebar .sb-head .sb-title { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
166
+ .sidebar .sb-ctx { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
167
+ .sidebar .sb-ctx.full { color: var(--color-danger); font-weight: 600; }
147
168
  .sidebar .sb-ctx.stale { opacity: .6; }
148
169
  .sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
149
170
  .sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
@@ -1,11 +1,11 @@
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-page-header.js and ui-sidebar.js (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
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).
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
6
6
  // left edge opens it, and the scrim, Escape, ✕, the drawer's own logo or a swipe back closes it. From 900px it is a
7
7
  // rail, which its own logo collapses and the page's logo brings back; this device remembers that, and <html> carries
8
- // sb-collapsed meanwhile (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
8
+ // sb-collapsed meanwhile (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
9
9
  // Switch mode: s pressed twice within a second, outside a text field, a contenteditable or an open dialog, shows each
10
10
  // row's key (ui-sidebar.js switchKeys), opening the drawer or showing a collapsed rail until it ends; the next key opens
11
11
  // that row through open(href). Escape, a click, a held modifier, typing in a field or 3 seconds with no key end it
@@ -16,6 +16,9 @@
16
16
  // → {setHere(route), setData(data), setUpdate(state), checkDevice(), close(), toggle(), keydown(e), pointerdown()}, toggle doing what the
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
+ // 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.
19
22
  (function () {
20
23
  const DATA_KEY = 'hub:sidebar';
21
24
  // app.html reads it before the first paint, so a collapsed rail never shows for a moment.
@@ -24,6 +27,9 @@
24
27
  // A phone has no keys to press, so it never shows the shortcuts sheet.
25
28
  const DESKTOP = '(hover: hover) and (pointer: fine)';
26
29
  const TWICE_MS = 1000;
30
+ // style.css pulse-fill's period, and sidebar.css sb-join's length.
31
+ const PULSE_MS = 1400;
32
+ const JOIN_MS = 2500;
27
33
  // paint() sets the bar's duration from this and its delay from switching.started.
28
34
  const IDLE_MS = 3000;
29
35
  const FIELD = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
@@ -46,7 +52,7 @@
46
52
  const aside = document.getElementById('sidebar');
47
53
  const scrim = document.getElementById('sidebarScrim');
48
54
  // ui.js replaces the BoardUI global when it loads after this, so the renderers are held here.
49
- const { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
55
+ const { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
50
56
  const none = { setHere() {}, setData() {}, setUpdate() {}, checkDevice() {}, close() {}, toggle() {}, keydown() {}, pointerdown() {} };
51
57
  if (!aside || !sidebar) return none;
52
58
  if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
@@ -69,6 +75,30 @@
69
75
  // switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
70
76
  let switching = null;
71
77
  let painted = false;
78
+ // joined: when each board that was starting joined, by route, through its fade. ticker: the seconds' interval while
79
+ // a start runs.
80
+ const joined = new Map();
81
+ let ticker = null;
82
+ const startsRunning = () => (data?.starting || []).filter((s) => !s.failed);
83
+ function tickSteps() {
84
+ for (const s of startsRunning()) {
85
+ const caption = aside.querySelector(`[data-starting="${s.route}"] .sb-step`);
86
+ if (caption) caption.textContent = startCaption(s);
87
+ }
88
+ }
89
+ function paintStarts() {
90
+ 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
+ for (const [route, at] of joined) {
93
+ const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
94
+ if (!row) { joined.delete(route); continue; }
95
+ row.classList.add('sb-joined');
96
+ row.style.animationDelay = `-${now - at}ms`;
97
+ }
98
+ const running = startsRunning().length > 0;
99
+ if (running && !ticker) ticker = setInterval(tickSteps, 1000);
100
+ if (!running && ticker) { clearInterval(ticker); ticker = null; }
101
+ }
72
102
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
73
103
  function paint() {
74
104
  html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
@@ -90,11 +120,14 @@
90
120
  updateHTML = row;
91
121
  const list = aside.querySelector('.sb-scroll');
92
122
  if (list) list.scrollTop = top ?? list.scrollHeight;
123
+ paintStarts();
93
124
  painted = true;
94
125
  logoCount();
95
126
  }
96
127
  function setData(next) {
97
128
  if (!next) return;
129
+ const starting = new Set(startsRunning().map((s) => s.route));
130
+ for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
98
131
  data = next;
99
132
  write(DATA_KEY, JSON.stringify(data));
100
133
  paint();
@@ -145,7 +178,7 @@
145
178
  const response = await fetch('/_hub/new-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
146
179
  if (!response.ok) throw new Error(await response.text());
147
180
  const { route } = await response.json();
148
- setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone, phase: selected.cloned ? 'starting' : 'cloning', askedAt: new Date().toISOString(), failed: false }] });
181
+ setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone, step: selected.cloned ? 'starting' : 'cloning', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false }] });
149
182
  if (rail.matches) collapse(false); else open();
150
183
  aside.querySelector(`[data-starting="${route}"]`)?.scrollIntoView({ block: 'nearest' });
151
184
  refresh().catch(() => {});
@@ -165,25 +198,24 @@
165
198
  const failed = e.target.closest?.('[data-failed-start]');
166
199
  if (failed) openFailed(failed.dataset.failedStart);
167
200
  });
168
- // Upgrade asks the hub to run kickflip update (docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md). The row
169
- // keeps its words until the hub answers with what it did, and a refusal shows as a failed update.
170
- let upgrading = false;
171
- async function upgrade(button) {
172
- if (upgrading) return;
173
- upgrading = true;
174
- button.disabled = true;
175
- let next;
176
- try {
177
- const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
178
- const body = await response.json().catch(() => ({}));
179
- next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
180
- } catch {
181
- next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
182
- } finally { upgrading = false; }
183
- setData({ ...data, upgrade: next });
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
+ })());
184
216
  }
185
- // A failed start's sheet (docs/adr/0130-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
186
- // record; Retry starts it again, and its row reads Starting… until the hub's next copy says otherwise.
217
+ // A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
218
+ // record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
187
219
  function openFailed(route) {
188
220
  const s = (data?.starting || []).find((x) => x.route === route && x.failed);
189
221
  if (!s || document.getElementById('failedStartSheet')) return;
@@ -195,13 +227,13 @@
195
227
  el.innerHTML = failedStart(s, { home: data.home });
196
228
  let sending = false;
197
229
  const done = () => { if (!sending) el.close(); };
198
- async function act(request, after) {
230
+ // The tapped button waits (ui.js waitOn), the other disabled, until the hub answers.
231
+ async function act(button, request, after) {
199
232
  sending = true;
200
- el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = true; });
201
233
  const refused = el.querySelector('.fs-refused');
202
234
  refused.hidden = true;
203
235
  try {
204
- const response = await fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options);
236
+ const response = await BoardUI.waitOn(button, fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options), { group: el.querySelectorAll('.fs-actions button') });
205
237
  if (!response.ok) throw new Error(await response.text());
206
238
  after();
207
239
  sending = false;
@@ -209,7 +241,6 @@
209
241
  refresh().catch(() => {});
210
242
  } catch (e) {
211
243
  sending = false;
212
- el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = false; });
213
244
  refused.textContent = e.message;
214
245
  refused.hidden = false;
215
246
  }
@@ -217,12 +248,14 @@
217
248
  el.addEventListener('click', (e) => {
218
249
  if (sending) return;
219
250
  if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
220
- if (e.target.closest('[data-failed-dismiss]')) {
221
- act({ options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
222
- } else if (e.target.closest('[data-failed-retry]')) {
223
- act({ path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
251
+ const dismiss = e.target.closest('[data-failed-dismiss]');
252
+ const retry = e.target.closest('[data-failed-retry]');
253
+ if (dismiss) {
254
+ act(dismiss, { options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
255
+ } else if (retry) {
256
+ act(retry, { path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
224
257
  const { error, ...rest } = s;
225
- const again = { ...rest, phase: 'starting', askedAt: new Date().toISOString(), failed: false };
258
+ const again = { ...rest, step: 'starting', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false };
226
259
  setData({ ...data, starting: data.starting.map((x) => (x.route === route ? again : x)) });
227
260
  });
228
261
  }