@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
@@ -3,7 +3,7 @@
3
3
  // app-core.js and app-threads.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, store, state, markSeen, seen, toast, links } = B;
6
- const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, BUTTON, LINK_ARROW } = BoardUI;
6
+ const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
7
7
 
8
8
  const mainSheet = $('#mainLogSheet');
9
9
  const mainRetry = $('#mainLogRetry');
@@ -57,11 +57,10 @@ BoardView.part((B) => {
57
57
  }
58
58
  mainState = main.state;
59
59
  }
60
+ // Retry waits (ui.js BUSY's look) from the tap until main is up again or its build fails.
60
61
  const busy = postingMain || followMainAfter !== null || main.state === 'running';
61
62
  mainRetry.disabled = busy;
62
- mainRetry.setAttribute('aria-disabled', String(busy));
63
- mainRetry.classList.toggle('main-log-going', busy);
64
- mainRetry.innerHTML = busy ? `<span class="try-spin" aria-hidden="true"></span>${esc(words.going)}` : 'Retry';
63
+ if (busy) mainRetry.setAttribute('aria-busy', 'true'); else mainRetry.removeAttribute('aria-busy');
65
64
  }
66
65
  B.renderMainLog = renderMainLog;
67
66
  B.on(B.root, 'click', (ev) => {
@@ -99,8 +98,43 @@ BoardView.part((B) => {
99
98
  }
100
99
  });
101
100
 
101
+ // Main's refresh icon pulls now, spinning until the server answers. After a failed pull, a hover pointer's click
102
+ // pulls again while its reason shows on hover; a touch tap opens the reason instead, and a tap anywhere closes it.
103
+ // An open reason shifts left as far as it would run past the screen's right edge, less the 16px gutter.
104
+ function paintMain() {
105
+ BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(state.board?.main, state.mainPull));
106
+ const why = $('#tryMain .main-pull-why.open');
107
+ if (!why) return;
108
+ const over = why.getBoundingClientRect().right - (innerWidth - 16);
109
+ if (over > 0) why.style.left = `${-over}px`;
110
+ }
111
+ B.on(B.root, 'click', async (ev) => {
112
+ const button = ev.target.closest('button[data-main-pull]');
113
+ if (!button) {
114
+ if (state.mainPull.open) { state.mainPull.open = false; paintMain(); }
115
+ return;
116
+ }
117
+ ev.preventDefault();
118
+ const pull = state.board?.main?.pull;
119
+ if (!pull || pull.running || state.mainPull.sending) return;
120
+ if (pull.error && !matchMedia('(hover: hover)').matches) {
121
+ state.mainPull.open = !state.mainPull.open;
122
+ paintMain();
123
+ return;
124
+ }
125
+ state.mainPull = { sending: true, open: false };
126
+ paintMain();
127
+ try {
128
+ const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
129
+ const out = await response.json();
130
+ if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
131
+ } catch (error) { if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?'); }
132
+ state.mainPull.sending = false;
133
+ if (B.alive()) { paintMain(); B.reload(); }
134
+ });
135
+
102
136
  // Hold's sheet: the shared duration picker in place of a confirm, since a chip is already a deliberate tap
103
- // (docs/adr/0118-a-card-can-be-held-until-a-time.md). A chip or Custom sends card-hold {key, hold: true, for}.
137
+ // (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md). A chip or Custom sends card-hold {key, hold: true, for}.
104
138
  const holdSheet = $('#holdSheet');
105
139
  let holdPicking = null;
106
140
  const localInput = (d) => {
@@ -117,9 +151,9 @@ BoardView.part((B) => {
117
151
  function openHold(it) {
118
152
  $('#holdTitle').textContent = `Hold ${it.ref}`;
119
153
  $('#holdLede').textContent = `${it.lane === 'todo' ? 'It won’t start' : 'Claude stops work on it'} until then. Forever holds it until you unhold it.`;
120
- $('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices || []);
121
- const input = $('#holdPicker [data-hold-at]');
122
154
  const now = Date.now();
155
+ $('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices, new Date(now));
156
+ const input = $('#holdPicker [data-hold-at]');
123
157
  input.min = localInput(new Date(now + 60_000));
124
158
  input.max = localInput(new Date(now + 30 * 86_400_000));
125
159
  input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
@@ -127,10 +161,12 @@ BoardView.part((B) => {
127
161
  holdSheet.showModal();
128
162
  $('#holdTitle').focus({ preventScroll: true });
129
163
  }
164
+ // The card's Hold waits while the server records the hold (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
130
165
  async function hold(key, choice) {
131
166
  closeHold();
132
- try { await B.sentHere(key, () => B.send('card-hold', { key, hold: true, for: choice })); }
133
- catch { toast('Could not hold. Is the host awake?'); }
167
+ const button = $(`button[data-confirm="hold"][data-key="${CSS.escape(key)}"]`);
168
+ try { await waitOn(button, B.sentHere(key, () => B.send('card-hold', { key, hold: true, for: choice }))); }
169
+ catch (e) { B.failed(e); }
134
170
  }
135
171
  B.on($('#holdClose'), 'click', closeHold);
136
172
  B.on(holdSheet, 'click', (ev) => {
@@ -180,15 +216,15 @@ BoardView.part((B) => {
180
216
  }
181
217
  }
182
218
  if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
183
- // building is an implementer at work, since Build means the package (docs/adr/0166-a-todo-cards-button-reads-implement.md).
219
+ // building is an implementer at work, since Build means the package (docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
184
220
  if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
185
- // docs/adr/0156-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
221
+ // docs/adr/2026-11-03-0526-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
186
222
  for (const [label, list] of [['Parent', it.parent ? [it.parent] : []], ['Children', it.children], ['Related to', it.related], ['Blocked by', it.blockedBy], ['Waits on it', it.waitsOnIt]]) {
187
223
  if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
188
224
  }
189
225
  if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
190
226
  if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
191
- // docs/adr/0062-notes-is-a-link-in-the-details-list.md
227
+ // docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
192
228
  const external = [
193
229
  it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
194
230
  // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
@@ -208,7 +244,7 @@ BoardView.part((B) => {
208
244
 
209
245
  const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
210
246
  // A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
211
- // (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
247
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
212
248
  const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
213
249
  const pushedWords = (it) => [`Pushed ${it.ref}${it.pushed.host ? ` to ${it.pushed.host}` : ''}`, it.pushed.at && ago(it.pushed.at)].filter(Boolean).join(' · ');
214
250
  B.on(B.root, 'click', (ev) => {
@@ -223,18 +259,21 @@ BoardView.part((B) => {
223
259
  });
224
260
  });
225
261
 
226
- const sendingMerge = new Set();
227
- const sendingBuild = new Set();
262
+ const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
228
263
  const CONFIRM = {
229
264
  resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
230
265
  nudge: () => 'Nudge Claude to check this implementer?',
231
- grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
232
- merge: (it, ref) => `Merge ${ref}? Claude squash-merges #${it.pr?.number} if it is still approved, green and clean.`,
266
+ // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
267
+ merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
268
+ : openPRs(it).length > 1
269
+ ? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
270
+ : `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
233
271
  'card-build': (it, ref) => (it.blockedBy?.length
234
272
  ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It waits in In Progress until then.`
235
273
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
236
274
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
237
- : `Close ${ref}? It moves to Done and Claude stops work on it.${it.pr?.state === 'OPEN' ? ' Its PR stays open on GitHub.' : it.pushed ? ` Its branch stays on ${it.pushed.host || 'the remote'}.` : ''}`),
275
+ : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
276
+ : `Close ${ref}? It moves to Done and Claude stops work on it.${openPRs(it).length ? ` Its PR${openPRs(it).length > 1 ? 's stay' : ' stays'} open on GitHub.` : it.pushed ? ` Its branch stays on ${it.pushed.host || 'the remote'}.` : ''}`),
238
277
  };
239
278
 
240
279
  const classes = (it, button, base = '') => {
@@ -248,36 +287,101 @@ BoardView.part((B) => {
248
287
  const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
249
288
  const hold = it.kind !== 'work' || !it.holdable ? ''
250
289
  : it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
251
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it, sendingBuild.has(it.key))}`)}</div>`;
290
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it)}`)}</div>`;
252
291
  }
253
292
 
254
- // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md).
293
+ // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
255
294
  const ACTION_BUTTONS = {
256
295
  [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
257
296
  [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
258
- [BUTTON.grill]: (it) => (it.grilling ? '<button disabled>Writing questions…</button>' : `<button data-confirm="grill" data-key="${esc(it.key)}">Grill</button>`),
259
297
  };
260
298
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
261
299
 
262
- // The latest bump among New cards takes no second bump (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md).
300
+ // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
263
301
  const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
264
- function card(it) {
302
+ // band {step, actions} draws the card in a wave band (app-lanes.js).
303
+ // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
304
+ // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
305
+ function card(it, band = {}) {
306
+ const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
307
+ const unread = unseen(it.othersAt) + Math.min(1, unseen(it.questions?.askedAt));
265
308
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
266
309
  <summary class="work">
267
310
  <span class="status ${it.lane}"></span>
268
- <div class="title-wrap">${workLines(it, { unread: state.openItems.has(it.key) ? 0 : unreadCount(it.othersAt, seen[it.key]), top: latestBump(it) })}</div>
311
+ <div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
269
312
  </summary>
270
313
  ${state.openItems.has(it.key) ? detail(it) : ''}
271
314
  </details>`;
272
315
  }
273
316
 
317
+ // The phase bars move as a card's reached count changes across redraws
318
+ // (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md,
319
+ // docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md). phases is each card's last drawn count by
320
+ // key, null for no strip; the first draw only records it, so a loaded board stays still.
321
+ const phases = new Map();
322
+ let drawn = false;
323
+ const PHASE_STEP = 40;
324
+ const play = (el, frames, opts, hold = 'backwards') => el.animate(frames, { fill: hold, easing: 'ease-out', ...opts });
325
+ const GROW = [{ opacity: 0, transform: 'scaleX(0)', transformOrigin: 'left' }, { opacity: 1, transform: 'none', transformOrigin: 'left' }];
326
+ const PHASE_SWEEP = 120;
327
+ // --bar-fill is the strip's fill, the muted one on a dimmed strip (style.css).
328
+ const SWEEP = 'linear-gradient(var(--bar-fill), var(--bar-fill))';
329
+ const sweepFrame = (size) => ({ backgroundColor: 'var(--line)', backgroundImage: SWEEP, backgroundRepeat: 'no-repeat', backgroundPosition: 'left', backgroundSize: size });
330
+ const stripOf = (card) => {
331
+ const bars = card?.querySelector('.card-time .phase-bars');
332
+ return bars && { sep: bars.previousElementSibling?.matches('.sep') ? bars.previousElementSibling : null, bars };
333
+ };
334
+ const cardEl = (key) => $(`details.item[data-key="${CSS.escape(key)}"]`);
335
+ // Before lanes redraw: each drawn strip, kept so a build that ends can still leave.
336
+ function captureStrips() {
337
+ const strips = new Map();
338
+ for (const [key, reached] of phases) {
339
+ const strip = typeof reached === 'number' && stripOf(cardEl(key));
340
+ if (strip) strips.set(key, { sep: strip.sep?.cloneNode(true), bars: strip.bars.cloneNode(true) });
341
+ }
342
+ return strips;
343
+ }
344
+ // Sweeps bars from to before to full, one after another, starting after delay.
345
+ const sweep = (bars, from, to, delay) => [...bars.querySelectorAll('i')].slice(from, to)
346
+ .forEach((bar, k) => play(bar, [sweepFrame('0% 100%'), sweepFrame('100% 100%')], { duration: 220, delay: delay + k * PHASE_SWEEP, easing: 'ease-in-out' }));
347
+ // After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
348
+ // entering strip grows in one bar at a time, then sweeps each reached bar.
349
+ function moveStrips(items, before) {
350
+ const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.reached ?? null]));
351
+ const still = reduceMotion.matches || !drawn;
352
+ for (const [key, reached] of next) {
353
+ const kind = still ? null : phaseMotion(phases.get(key), reached);
354
+ if (!kind) continue;
355
+ const strip = stripOf(cardEl(key));
356
+ if (kind === 'leave') {
357
+ const gone = before.get(key);
358
+ const row = cardEl(key)?.querySelector('.card-time');
359
+ if (!gone || !row) continue;
360
+ const bars = [...gone.bars.querySelectorAll('i')].reverse();
361
+ row.append(...[gone.sep, gone.bars].filter(Boolean));
362
+ Promise.all(bars.map((bar, k) => play(bar, [...GROW].reverse(), { duration: 120, delay: k * PHASE_STEP, easing: 'ease-in' }, 'forwards').finished))
363
+ .then(() => play(gone.sep ?? gone.bars, [{ opacity: 1 }, { opacity: 0 }], { duration: 100 }, 'forwards').finished)
364
+ .finally(() => { gone.sep?.remove(); gone.bars.remove(); });
365
+ } else if (strip && kind === 'enter') {
366
+ if (strip.sep) play(strip.sep, [{ opacity: 0 }, { opacity: 1 }], { duration: 120 });
367
+ [...strip.bars.querySelectorAll('i')].forEach((bar, k) => play(bar, GROW, { duration: 140, delay: 60 + k * PHASE_STEP }));
368
+ sweep(strip.bars, 0, reached, 60 + BUILD_PHASES.length * PHASE_STEP + 140);
369
+ } else if (strip && kind === 'fill') {
370
+ sweep(strip.bars, phases.get(key), reached, 0);
371
+ }
372
+ }
373
+ phases.clear();
374
+ for (const [key, reached] of next) phases.set(key, reached);
375
+ drawn = true;
376
+ }
377
+
274
378
  B.on(B.root, 'toggle', (ev) => {
275
379
  const el = ev.target;
276
380
  if (!el.matches?.('details.item')) return;
277
381
  const key = el.dataset.key;
278
382
  if (el.open) {
279
383
  state.openItems.add(key);
280
- const it = state.board.items.find((i) => i.key === key);
384
+ const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
281
385
  markSeen(it);
282
386
  const unread = el.querySelector('summary .card-unread');
283
387
  const line = unread?.parentNode;
@@ -313,29 +417,27 @@ BoardView.part((B) => {
313
417
  const key = button.dataset.try;
314
418
  const it = state.board.items.find((i) => i.key === key);
315
419
  if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
316
- button.disabled = true;
317
- try {
420
+ // Serve / Build waits until the hub has started the build; the card's chip then says it runs.
421
+ const post = (async () => {
318
422
  const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
319
423
  const out = await r.json();
320
424
  if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
321
- } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
425
+ })();
426
+ try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
322
427
  B.reload();
323
428
  });
324
429
 
325
430
  // A work card's bump icon bumps it, or unbumps a bumped one, with no confirm
326
- // (docs/adr/0145-build-on-a-blocked-card-queues-it.md), and the card moves at once
327
- // (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md).
431
+ // (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md). The icon pulses until the server has recorded it, and the card
432
+ // moves as its answer lands (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
328
433
  B.on(B.root, 'click', async (ev) => {
329
434
  const b = ev.target.closest('button[data-card-bump]');
330
435
  if (!b) return;
331
436
  ev.preventDefault();
332
437
  const key = b.dataset.cardBump;
333
438
  const unbump = b.hasAttribute('data-bumped');
334
- const icon = `button[data-card-bump="${CSS.escape(key)}"]`;
335
- const sending = B.sentHere(key, () => B.send(unbump ? 'card-unbump' : 'card-bump', { key }, B.drawItem(key, (it) => ({ ...it, bumpedAt: unbump ? null : new Date().toISOString() }))));
336
- B.fadeBump(icon, !unbump);
337
- try { await sending; toast(unbump ? 'Unbumped' : 'Moved to the top'); }
338
- catch { B.fadeBump(icon, unbump); toast(B.FAILED); }
439
+ try { await waitOn(b, B.sentHere(key, () => B.send(unbump ? 'card-unbump' : 'card-bump', { key }))); toast(unbump ? 'Unbumped' : 'Moved to the top'); }
440
+ catch (e) { B.failed(e); }
339
441
  });
340
442
 
341
443
  B.on(B.root, 'click', async (ev) => {
@@ -346,48 +448,39 @@ BoardView.part((B) => {
346
448
  const it = state.board.items.find((i) => i.key === key);
347
449
  if (it && act === 'hold') { openHold(it); return; }
348
450
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
349
- if (act === 'resume') {
350
- try { await B.sentHere(key, () => B.send('card-hold', { key, hold: false })); }
351
- catch { toast('Could not resume. Is the host awake?'); }
352
- return;
353
- }
354
- // docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md
355
- if (act === 'merge') {
356
- sendingMerge.add(key);
357
- redrawActions(key);
358
- try { await B.sentHere(key, () => B.send('card-merge', { key })); }
359
- catch { toast('Could not send. Is the host awake?'); }
360
- sendingMerge.delete(key);
361
- redrawActions(key);
362
- return;
363
- }
364
- if (act === 'card-build') {
365
- sendingBuild.add(key);
366
- redrawActions(key);
367
- try { await B.sentHere(key, () => B.send('card-build', { key })); }
368
- catch { toast('Could not send. Is the host awake?'); }
369
- sendingBuild.delete(key);
370
- redrawActions(key);
371
- return;
372
- }
373
- // docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
374
- // The card closes first, or app-core.js steady would anchor it and scroll the page after it into Done.
375
- if (act === 'finish') {
376
- const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
377
- if (card) card.open = false;
378
- try { await B.sentHere(key, () => (it.kind === 'ask' ? B.send('ask-done', { id: it.id }) : B.send('card-done', { key }))); }
379
- catch { toast('Could not close. Is the host awake?'); }
380
- B.syncDone(key);
451
+ // Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
452
+ // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
453
+ const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
454
+ finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
455
+ if (sent) {
456
+ // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
457
+ // anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
458
+ const close = act === 'finish' ? () => {
459
+ const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
460
+ if (card) BoardUI.closeDetails(card);
461
+ state.openItems.delete(key);
462
+ store.set('openItems', [...state.openItems]);
463
+ } : null;
464
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
465
+ // In Review, or no longer merging there, which is its refusal.
466
+ const pend = act === 'merge' ? `${key}:merge` : null;
467
+ if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
468
+ try {
469
+ const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
470
+ if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
471
+ } catch (e) {
472
+ if (pend) { BoardUI.pending.drop(pend); B.render(); }
473
+ B.failed(e);
474
+ }
475
+ if (act === 'finish') B.syncDone(key);
381
476
  return;
382
477
  }
383
- const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
384
- row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
385
- b.disabled = true;
478
+ // The tapped button waits and the rest of its action row is disabled until the server answers.
479
+ const group = { group: $(`.actions[data-actions="${CSS.escape(key)}"]`)?.querySelectorAll('button') ?? [] };
386
480
  try {
387
- if (act === 'nudge') { await B.sentHere(key, () => B.send('builder-nudge', { key })); b.textContent = 'Sent'; toast('Nudge sent'); }
388
- else { await B.sentHere(key, () => B.send('grill-more', { key })); B.loadThread(key, { force: true }); }
481
+ await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
389
482
  } catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
390
483
  });
391
484
 
392
- Object.assign(B, { detail, card });
485
+ Object.assign(B, { captureStrips, moveStrips, detail, card });
393
486
  });
@@ -36,7 +36,7 @@ BoardView.part((B) => {
36
36
  }
37
37
 
38
38
  // Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
39
- // moved, in the same task so nothing visibly jumps (docs/adr/0063-an-open-card-moves-at-once-and-the-page-follows-it.md).
39
+ // moved, in the same task so nothing visibly jumps (docs/adr/2026-10-30-1637-an-open-card-moves-at-once-and-the-page-follows-it.md).
40
40
  // The anchor is the field being typed in, else the title row of the open card on screen, else there is none. A field
41
41
  // in a dialog doesn't move with the page, so the card anchors then.
42
42
  function anchorOf() {
@@ -95,27 +95,6 @@ BoardView.part((B) => {
95
95
  store.set('seenThreads', seen);
96
96
  }
97
97
 
98
- // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
99
- // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
100
- const quiet = new Map();
101
- function quieted(draftKey, ta) {
102
- const until = quiet.get(draftKey) || 0;
103
- if (until <= Date.now()) return false;
104
- if (until !== Infinity) ta.value = '';
105
- return true;
106
- }
107
- async function sendDraft(draftKey, ta, current, send) {
108
- ta.blur();
109
- quiet.set(draftKey, Infinity);
110
- try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
111
- store.set(draftKey, '');
112
- const now = current();
113
- if (now) now.value = '';
114
- quiet.set(draftKey, Date.now() + 1000);
115
- }
116
-
117
- // A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
118
- const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
119
98
  // The ui-links.js linkContext for text shown on item `key`'s card or thread.
120
99
  const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
121
100
  const svg = (body, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
@@ -126,5 +105,5 @@ BoardView.part((B) => {
126
105
  const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
127
106
  const notice = dialogs.notice;
128
107
 
129
- Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
108
+ Object.assign(B, { steady, restorePlace, plural, seen, markSeen, links, svg, confirm, notice });
130
109
  });
@@ -1,8 +1,8 @@
1
1
  // The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
2
- // header's GitHub line (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md);
3
- // the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/0040-one-board-registry-for-every-hub-feature.md);
2
+ // header's GitHub line (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md);
3
+ // the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md);
4
4
  // and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
5
- // (docs/adr/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
5
+ // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
6
6
  BoardView.part((B) => {
7
7
  const { $, state, toast } = B;
8
8
  const { statusLine, freshSig, nextFresh, githubLine, remoteLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
@@ -16,7 +16,14 @@ BoardView.part((B) => {
16
16
  $('#setup').innerHTML = setupCard(d.setup);
17
17
  if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
18
18
  }
19
- const renderStatus = () => { $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking }); };
19
+ // The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
20
+ const renderStatus = () => {
21
+ $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
22
+ const moon = $('.away-moon');
23
+ const away = Boolean(B.away?.());
24
+ moon.classList.toggle('on', away);
25
+ moon.setAttribute('aria-hidden', String(!away));
26
+ };
20
27
  // A board load and its open threads landed; via is ui-header.js nextFresh's.
21
28
  function landed(via) {
22
29
  state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
@@ -100,7 +107,7 @@ BoardView.part((B) => {
100
107
  B.on($('.nav'), 'click', (ev) => {
101
108
  if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
102
109
  }, true);
103
- // A load can return in milliseconds, so the spin and "Reconnecting…" hold long enough to read.
110
+ // A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
104
111
  const RELOAD_MIN = 700;
105
112
  async function reloading(load) {
106
113
  controls.reloading = true;