@lovek4d/kickflip 0.5.0 → 0.5.3

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 (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -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, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
6
+ const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
7
7
 
8
8
  const mainSheet = $('#mainLogSheet');
9
9
  const mainRetry = $('#mainLogRetry');
@@ -116,20 +116,29 @@ BoardView.part((B) => {
116
116
  }
117
117
  ev.preventDefault();
118
118
  const pull = state.board?.main?.pull;
119
- if (!pull || pull.running || state.mainPull.sending) return;
119
+ if (!pull || pull.running || BoardUI.pending.has('main:pull')) return;
120
120
  if (pull.error && !matchMedia('(hover: hover)').matches) {
121
121
  state.mainPull.open = !state.mainPull.open;
122
122
  paintMain();
123
123
  return;
124
124
  }
125
- state.mainPull = { sending: true, open: false };
125
+ state.mainPull = { open: false };
126
+ // Spins until a board shows the pull running or changed (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
127
+ // A pull that brought no commits changes nothing a board shows, so its answer ends the spinner; one that did waits
128
+ // for Main to rebuild.
129
+ const mainFace = (main) => JSON.stringify([main?.pull, main?.sha, main?.state, main?.at]);
130
+ const before = mainFace(state.board.main);
131
+ BoardUI.pending.start('main:pull', (board) => mainFace(board.main) !== before, B.stillWaiting);
126
132
  paintMain();
127
133
  try {
128
134
  const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
129
135
  const out = await response.json();
130
136
  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;
137
+ if (out.commits) BoardUI.pending.answered('main:pull'); else BoardUI.pending.drop('main:pull');
138
+ } catch (error) {
139
+ BoardUI.pending.drop('main:pull');
140
+ if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?');
141
+ }
133
142
  if (B.alive()) { paintMain(); B.reload(); }
134
143
  });
135
144
 
@@ -216,18 +225,16 @@ BoardView.part((B) => {
216
225
  }
217
226
  }
218
227
  if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
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).
220
- if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
228
+ rows.push(['Status', esc(BoardUI.statusWord(it))]);
221
229
  // 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.
222
230
  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]]) {
223
231
  if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
224
232
  }
225
233
  if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
226
234
  if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
227
- // docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
228
235
  const external = [
229
- it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
230
- // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
236
+ // A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets a live one; elsewhere it is muted, with a tooltip and a tap toast.
237
+ it.worktree && !state.board.onHost ? `<a aria-disabled="true" title="not on kickflip host" data-tip="not on kickflip host">Open in VS Code</a>` : '',
231
238
  it.worktree && state.board.onHost ? `<a href="vscode://file${esc(encodeURI(it.worktree))}" title="Open the worktree in VS Code">Open in VS Code ${LINK_ARROW}</a>` : '',
232
239
  installButton(it),
233
240
  pr?.state === 'OPEN' ? `<a${classes(it, BUTTON.github)} href="${esc(pr.url)}" target="_blank" rel="noopener">${pr.isDraft ? 'Review draft on GitHub' : 'Review on GitHub'} ${LINK_ARROW}</a>` : '',
@@ -269,7 +276,7 @@ BoardView.part((B) => {
269
276
  ? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
270
277
  : `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
271
278
  'card-build': (it, ref) => (it.blockedBy?.length
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.`
279
+ ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
273
280
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
274
281
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
275
282
  : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
@@ -282,12 +289,16 @@ BoardView.part((B) => {
282
289
  };
283
290
  const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
284
291
 
292
+ // The reader sheet opens it (app-reader.js); docs/adr/2026-11-05-0830-notes-is-a-button-on-the-hold-row.md.
293
+ const notesButton = (it) => (it.hasNotes ? `<a class="button" href="notes.html?item=${encodeURIComponent(it.key)}">Notes</a>` : '');
294
+
285
295
  function actions(it) {
286
296
  const key = esc(it.key);
287
297
  const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
288
298
  const hold = it.kind !== 'work' || !it.holdable ? ''
289
- : it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
290
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it)}`)}</div>`;
299
+ : it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
300
+ // A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
301
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
291
302
  }
292
303
 
293
304
  // 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).
@@ -383,6 +394,7 @@ BoardView.part((B) => {
383
394
  state.openItems.add(key);
384
395
  const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
385
396
  markSeen(it);
397
+ B.renderBanner(state.board);
386
398
  const unread = el.querySelector('summary .card-unread');
387
399
  const line = unread?.parentNode;
388
400
  unread?.remove();
@@ -396,12 +408,6 @@ BoardView.part((B) => {
396
408
  store.set('openItems', [...state.openItems]);
397
409
  }, true);
398
410
 
399
- function redrawActions(key) {
400
- const it = state.board.items.find((i) => i.key === key);
401
- const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
402
- if (it && row) row.outerHTML = actions(it);
403
- }
404
-
405
411
  B.on(B.root, 'click', async (ev) => {
406
412
  const log = ev.target.closest('[data-try-log]');
407
413
  if (log && state.openItems.has(log.dataset.tryLog)) {
@@ -410,6 +416,8 @@ BoardView.part((B) => {
410
416
  card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
411
417
  return;
412
418
  }
419
+ const off = ev.target.closest('a[data-tip]');
420
+ if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
413
421
  const button = ev.target.closest('button[data-try]');
414
422
  if (!button) return;
415
423
  ev.preventDefault();
@@ -417,13 +425,22 @@ BoardView.part((B) => {
417
425
  const key = button.dataset.try;
418
426
  const it = state.board.items.find((i) => i.key === key);
419
427
  if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
420
- // Serve / Build waits until the hub has started the build; the card's chip then says it runs.
421
- const post = (async () => {
428
+ // Serve / Build spins until a board shows the card's build changed: running, or already done
429
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
430
+ const before = JSON.stringify(it.try);
431
+ const id = `${key}:try`;
432
+ BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
433
+ B.render();
434
+ try {
422
435
  const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
423
436
  const out = await r.json();
424
437
  if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
425
- })();
426
- try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
438
+ BoardUI.pending.answered(id);
439
+ } catch (error) {
440
+ BoardUI.pending.drop(id);
441
+ B.render();
442
+ toast(error.message || 'Could not start. Is the host awake?');
443
+ }
427
444
  B.reload();
428
445
  });
429
446
 
@@ -445,6 +462,7 @@ BoardView.part((B) => {
445
462
  if (!b) return;
446
463
  ev.preventDefault();
447
464
  const { key, confirm: act } = b.dataset;
465
+ if (b.classList.contains('nudging')) return;
448
466
  const it = state.board.items.find((i) => i.key === key);
449
467
  if (it && act === 'hold') { openHold(it); return; }
450
468
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
@@ -475,11 +493,17 @@ BoardView.part((B) => {
475
493
  if (act === 'finish') B.syncDone(key);
476
494
  return;
477
495
  }
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') ?? [] };
496
+ // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
497
+ // changed, the builder no longer stalled or the card gone (style.css .nudging).
498
+ const id = `${key}:nudge`;
499
+ const { progress, at } = it.builder;
500
+ BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
501
+ B.render();
480
502
  try {
481
- await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
482
- } catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
503
+ const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
504
+ BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
505
+ } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
506
+ B.render();
483
507
  });
484
508
 
485
509
  Object.assign(B, { captureStrips, moveStrips, detail, card });
@@ -86,13 +86,31 @@ BoardView.part((B) => {
86
86
 
87
87
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
88
88
 
89
- // A card this browser has never seen starts from now, so a first load badges nothing.
90
- const seen = Object.assign(Object.create(null), store.get('seenThreads', {}));
91
- const latestOf = (it) => [new Date().toISOString(), ...(it?.othersAt || [])].sort().pop();
92
- function markSeen(it, onlyNew = false) {
93
- if (!it || (onlyNew && seen[it.key])) return;
94
- seen[it.key] = latestOf(it);
95
- store.set('seenThreads', seen);
89
+ // Read marks live on the board, one set for every device: each card's readAt, the newest message the owner has seen
90
+ // on it (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). seen is card key → the later of the board's mark
91
+ // and this page's own, so a card read here reads at once, before the board comes back with it. seenBefore is an open
92
+ // card's mark from before it opened, where its thread's unread lines start (app-threads.js); a card closing drops it,
93
+ // and a board left and opened again in this page keeps it.
94
+ const seen = Object.create(null);
95
+ const seenBefore = new Map(B.place?.seenBefore);
96
+ B.keep(() => ({ seenBefore: [...seenBefore] }));
97
+ function takeMarks(items) {
98
+ for (const it of items) if (String(it.readAt || '') > (seen[it.key] || '')) seen[it.key] = it.readAt;
99
+ }
100
+ // [[card key, time]] → each card read up to its time, here at once and on the board for every device.
101
+ function markRead(pairs) {
102
+ const marks = Object.fromEntries(pairs.filter(([key, at]) => at && String(at) > (seen[key] || '')));
103
+ if (!Object.keys(marks).length) return;
104
+ Object.assign(seen, marks);
105
+ fetch(B.site.url('api/read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ marks }) }).catch(() => {});
106
+ }
107
+ // The newest message card it shows: its counted messages (domain.mjs othersAt), its questions' asks and its answer.
108
+ const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || []), it.ask?.answerAt].filter(Boolean).map(String).sort().pop();
109
+ // Card it, open, reads up to the newest message it shows.
110
+ function markSeen(it) {
111
+ if (!it) return;
112
+ if (!seenBefore.has(it.key)) seenBefore.set(it.key, seen[it.key] || null);
113
+ markRead([[it.key, newestOf(it)]]);
96
114
  }
97
115
 
98
116
  // The ui-links.js linkContext for text shown on item `key`'s card or thread.
@@ -105,5 +123,5 @@ BoardView.part((B) => {
105
123
  const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
106
124
  const notice = dialogs.notice;
107
125
 
108
- Object.assign(B, { steady, restorePlace, plural, seen, markSeen, links, svg, confirm, notice });
126
+ Object.assign(B, { steady, restorePlace, plural, seen, seenBefore, takeMarks, markRead, markSeen, links, svg, confirm, notice });
109
127
  });
@@ -77,7 +77,8 @@ BoardView.part((B) => {
77
77
  } catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
78
78
  finally { state.waking = false; if (B.alive()) renderStatus(); }
79
79
  }
80
- const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online') wake(); };
80
+ // A shared person's tap never wakes the owner's session (shell.js .shared-view).
81
+ const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online' && !document.documentElement.classList.contains('shared-view')) wake(); };
81
82
  B.on(B.root, 'click', wakeOnTap, true);
82
83
  B.on(B.root, 'submit', wakeOnTap, true);
83
84
 
@@ -1,4 +1,4 @@
1
- // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
1
+ // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
2
  // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
3
3
  // Needs app-core.js and app-cards.js.
4
4
  BoardView.part((B) => {
@@ -25,7 +25,7 @@ BoardView.part((B) => {
25
25
  const n = left + (older || 0);
26
26
  const words = `${n} older card${n === 1 ? '' : 's'}`;
27
27
  const fetches = older !== 0 && shown + STEP > cards.length;
28
- return cards.slice(0, shown).map((it) => B.card(it)).join('')
28
+ return waveGroups(laneId, cards.slice(0, shown))
29
29
  + (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
30
30
  }
31
31
 
@@ -97,7 +97,7 @@ BoardView.part((B) => {
97
97
  if (lane.hideEmpty && !items.length) return '';
98
98
  const expanded = laneState[lane.id] ?? lane.open;
99
99
  const open = items.filter((i) => !i.fold);
100
- const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
100
+ const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
101
101
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
102
102
  <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
103
103
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
@@ -106,8 +106,36 @@ BoardView.part((B) => {
106
106
  }).join('');
107
107
  }
108
108
 
109
+ // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
110
+ // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
111
+ // band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
112
+ // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
113
+ // the lane's own number bold in its colour and a tooltip that spells the counts out.
114
+ // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
115
+ let countsShown = null;
116
+ const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
117
+ function waveCounts(band, laneId) {
118
+ const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
119
+ const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
120
+ const here = laneId === 'stale' ? 'progress' : laneId;
121
+ const id = `${laneId}/${band.slug}`;
122
+ const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
123
+ const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
124
+ return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
125
+ }
126
+ function waveGroups(laneId, cards) {
127
+ const bands = state.board.waveBands || [];
128
+ const inBand = (b) => cards.filter((i) => b.keys.includes(i.key));
129
+ const grouped = bands.filter((b) => inBand(b).length);
130
+ if (!grouped.length) return cards.map((it) => B.card(it)).join('');
131
+ const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
132
+ const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
133
+ return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
134
+ + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
135
+ }
136
+
109
137
  // docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
110
- // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under Not in a wave. A band's open state lives
138
+ // Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
111
139
  // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
112
140
  // any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
113
141
  // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
@@ -125,33 +153,25 @@ BoardView.part((B) => {
125
153
  const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
126
154
  const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
127
155
 
128
- // docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
129
- // Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
130
- // arrive (domain.mjs laneOrder), then its wave bands and loose cards.
131
- function todoRows(d, shown, all) {
132
- const queued = all.filter((i) => i.queued);
133
- const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
134
- return head + bandRows(d, shown, all.filter((i) => !i.queued));
135
- }
136
-
137
- function bandRows(d, shown, todo) {
138
- const bands = d.waveBands || [];
139
- const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
140
- const banded = new Set(bands.flatMap((b) => b.keys));
156
+ // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
157
+ function todoRows(d, shown, todo) {
158
+ const byKey = new Map(shown.map((i) => [i.key, i]));
159
+ const bands = (d.waveBands || []).filter((b) => b.keys.some((k) => byKey.get(k)?.lane === 'todo'));
160
+ const banded = new Set((d.waveBands || []).flatMap((b) => b.keys));
141
161
  const loose = todo.filter((i) => !banded.has(i.key));
142
162
  const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
143
163
  if (!bands.length) return loose.map((it) => B.card(it)).join('');
144
164
  return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
145
- + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
165
+ + (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
146
166
  }
147
167
 
148
168
  function bandHtml(band, cards, open) {
149
169
  const todo = cards.filter((i) => i.lane === 'todo');
150
170
  const done = cards.filter((i) => i.lane === 'done');
151
- const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
171
+ const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
152
172
  const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
153
173
  const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
154
- const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
174
+ const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
155
175
  const next = band.next;
156
176
  const go = !next ? '' : next.ready
157
177
  ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
@@ -159,7 +179,7 @@ BoardView.part((B) => {
159
179
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
160
180
  const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
161
181
  + `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
162
- + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
182
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
163
183
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
164
184
  const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
165
185
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
@@ -220,8 +240,14 @@ BoardView.part((B) => {
220
240
  const pb = ev.target.closest('button[data-plan]');
221
241
  if (pb) {
222
242
  if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
223
- try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
224
- catch { B.notice('Could not send. Is the host awake?'); }
243
+ // Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
244
+ const keys = pb.dataset.plan.split(' ');
245
+ BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
246
+ try {
247
+ const out = await waitOn(pb, B.send('plan-waves', { keys }));
248
+ BoardUI.pending.answered('todo:plan', out.board.generatedAt);
249
+ B.render();
250
+ } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
225
251
  return;
226
252
  }
227
253
  const tb = ev.target.closest('button[data-triage]');
@@ -264,6 +290,12 @@ BoardView.part((B) => {
264
290
  for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
265
291
  B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
266
292
  B.on(B.root, 'click', async (ev) => {
293
+ const counts = ev.target.closest?.('[data-counts]');
294
+ if (counts || countsShown) {
295
+ countsShown = counts && counts.dataset.counts !== countsShown ? counts.dataset.counts : null;
296
+ B.render();
297
+ if (counts) return;
298
+ }
267
299
  if (pressed) { pressed = false; ev.preventDefault(); return; }
268
300
  if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
269
301
  const dots = ev.target.closest('button[data-band-menu]');
@@ -1,8 +1,16 @@
1
- // The board view's Needs you box: one line per thing waiting on the owner (domain.mjs needsOf), led by the board
2
- // session's pending prompts, answered in place (prompts.js). Needs app-core.js.
1
+ // The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
2
+ // (prompts.js). It draws twice, and the stylesheet shows one by width
3
+ // (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
4
+ // - on a phone, #needsDeck at the top of the board: one thing at a time, a question's options drafting its answer on the tap
5
+ // (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
6
+ // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
7
+ // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
8
+ // a line that only asks questions, and with nothing else left the orange section is gone. A link into those
9
+ // questions, wherever it sits on the board, picks its topic's pill there instead of opening the page.
10
+ // Needs app-core.js.
3
11
  BoardView.part((B) => {
4
12
  const { $, plural, state, toast } = B;
5
- const { esc, promptItem } = BoardUI;
13
+ const { esc, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
6
14
 
7
15
  const prompts = PromptAnswers.wire(B, {
8
16
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -11,13 +19,21 @@ BoardView.part((B) => {
11
19
  after: () => B.loadBoard(),
12
20
  });
13
21
 
22
+ // From 900px, the pane beside the lanes, the same width the stylesheet splits the board at.
23
+ const wide = matchMedia('(min-width: 900px)');
24
+
14
25
  function renderNeeds(d) {
26
+ const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
27
+ renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
28
+ renderDeck(d, needs);
29
+ }
30
+
31
+ function renderList(needs) {
15
32
  const parts = (line) => [
16
33
  line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
17
34
  line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
18
35
  line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
19
36
  ].filter(Boolean).join(' · ');
20
- const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
21
37
  const w = $('#waiting');
22
38
  w.hidden = needs.length === 0;
23
39
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
@@ -28,5 +44,114 @@ BoardView.part((B) => {
28
44
  : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
29
45
  }
30
46
 
47
+ // The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
48
+ // order; any other line as itself. → [{id, what, html}]
49
+ function deckOf(d, needs) {
50
+ const items = d.items || [];
51
+ const owner = (d.owner || []).filter((q) => q.state === 'open');
52
+ const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
53
+ return needs.flatMap((line) => {
54
+ if (line.prompt) return [{ id: `p:${line.prompt.id}`, what: 'Claude asks', html: `<ul class="deck-prompt">${promptItem(line.prompt, prompts.state(line.prompt))}</ul>` }];
55
+ const what = line.key ? line.title.split(':')[0] : line.title;
56
+ const topic = line.questions && topicOf(line);
57
+ const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
58
+ const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
59
+ if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
60
+ const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
61
+ : line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
62
+ : line.action ? [line.action.label, line.action.url, '']
63
+ : line.review ? ['Open the review', line.url, ` data-review-open="${line.review}"`]
64
+ : ['Open', line.url, ''];
65
+ const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
66
+ return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
67
+ html: `<h3>${line.key ? `<a href="${esc(line.url)}">${esc(title)}</a>` : esc(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
68
+ });
69
+ }
70
+ const questionHtml = (q) => {
71
+ const picked = getDraft(q.id).choice;
72
+ return `<h3>${esc(q.question)}</h3>${q.context ? `<p class="ctx">${esc(q.context)}</p>` : ''}
73
+ ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
74
+ <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${esc(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
75
+ };
76
+
77
+ // The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
78
+ let showing = null;
79
+ let deck = [];
80
+ function renderDeck(d, needs) {
81
+ const el = $('#needsDeck');
82
+ const before = deck.findIndex((x) => x.id === showing);
83
+ deck = deckOf(d, needs);
84
+ el.hidden = deck.length === 0;
85
+ if (!deck.length) { el.innerHTML = ''; showing = null; return; }
86
+ const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
87
+ showing = now.id;
88
+ const open = (d.owner || []).filter((q) => q.state === 'open').length;
89
+ const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
90
+ BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
91
+ <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${esc(now.what)}</p>${now.html}</div>
92
+ ${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-skip>Skip →</button></div>` : ''}
93
+ ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
94
+ }
95
+
96
+ B.on(B.root, 'click', (ev) => {
97
+ if (!ev.target.closest?.('#needsDeck')) return;
98
+ if (ev.target.closest('[data-deck-skip]')) {
99
+ const at = deck.findIndex((x) => x.id === showing);
100
+ showing = deck[(at + 1) % deck.length].id;
101
+ renderNeeds(state.board);
102
+ return;
103
+ }
104
+ const opt = ev.target.closest('button[data-deck-pick]');
105
+ if (!opt) return;
106
+ const id = opt.closest('[data-deck]').dataset.deck.slice(2);
107
+ setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
108
+ const at = deck.findIndex((x) => x.id === showing);
109
+ showing = deck[(at + 1) % deck.length].id;
110
+ renderNeeds(state.board);
111
+ });
112
+
113
+ // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
114
+ let questions = null;
115
+ function fitPane() {
116
+ if (wide.matches === Boolean(questions)) return;
117
+ if (state.board) renderNeeds(state.board);
118
+ if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
119
+ $('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
120
+ const slot = $('#needsPane .needs-questions');
121
+ questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
122
+ questions.mount();
123
+ }
124
+ // From 900px, a link into open questions for a topic the pane holds picks that topic's pill there, rather than
125
+ // navigating to the page. It runs before the router's listener, which skips a prevented click.
126
+ B.on(B.root, 'click', (ev) => {
127
+ if (ev.defaultPrevented || ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || !questions) return;
128
+ const a = ev.target.closest?.('a[href]');
129
+ if (!a || (a.target && a.target !== '_self')) return;
130
+ const home = new URL(`${B.site.root}/`, location.href);
131
+ const url = new URL(a.getAttribute('href'), home);
132
+ if (url.pathname !== new URL('questions.html', home).pathname || url.hash.length < 2) return;
133
+ const id = decodeURIComponent(url.hash.slice(1));
134
+ const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
135
+ const pill = topic && questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
136
+ if (!pill) return;
137
+ ev.preventDefault();
138
+ pill.click();
139
+ const picked = questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
140
+ picked.focus();
141
+ picked.scrollIntoView({ block: 'nearest' });
142
+ });
143
+ // A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
144
+ // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
145
+ B.on(B.root, 'dblclick', (ev) => {
146
+ const pill = questions && ev.target.closest?.('button[data-topic]');
147
+ if (!pill || !questions.slot.contains(pill)) return;
148
+ const id = pill.dataset.topic;
149
+ if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
150
+ else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
151
+ });
152
+ fitPane();
153
+ B.on(wide, 'change', fitPane);
154
+ B.atUnmount(() => { questions?.unmount(); questions = null; });
155
+
31
156
  Object.assign(B, { renderNeeds });
32
157
  });
@@ -1,4 +1,4 @@
1
- // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log →"
1
+ // The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log"
2
2
  // (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
3
3
  // place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
4
4
  // and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
@@ -8,8 +8,8 @@ BoardView.part((B) => {
8
8
  // The hub serves every site from one origin, so each board keeps its own key.
9
9
  const KEY = 'boardSnapshot:' + B.site.url('');
10
10
  // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
11
- // dropping first, so they leave room in the browser's 5 MB of storage for the drafts and seen marks, which would
12
- // otherwise fail to save.
11
+ // dropping first, so they leave room in the browser's 5 MB of storage for the drafts, which would otherwise fail to
12
+ // save.
13
13
  const BUDGET = 3 * SNAPSHOT_MAX;
14
14
  const ORDER = 'boardSnapshots';
15
15
  const BUILD = document.documentElement.dataset.build;
@@ -91,8 +91,8 @@ BoardView.part((B) => {
91
91
  }
92
92
  const endFlash = () => { before = null; };
93
93
 
94
- // Since the page last drew it, a last visit's board included, a card that changed lanes, or a work card that takes
95
- // the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
94
+ // Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
95
+ // the place of a drawn New task (its fromAsk), or a work card or New task that joins Todo at the bottom, flashes once; any other card whose updateSign changed takes a brief
96
96
  // tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
97
97
  // card's next change unsigned until the first board load started after send settles.
98
98
  let drawn = new Map();
@@ -109,7 +109,8 @@ BoardView.part((B) => {
109
109
  const now = Date.now();
110
110
  for (const it of state.board.items) {
111
111
  const was = drawn.get(it.key);
112
- const moved = was ? was.lane !== it.lane : Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`);
112
+ const arrived = !was && drawn.size > 0 && it.lane === 'todo' && (it.isNew || (it.kind === 'work' && !it.fromAsk));
113
+ const moved = was ? was.lane !== it.lane : arrived || (Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`));
113
114
  const updated = Boolean(was) && was.sign !== updateSign(it);
114
115
  const own = ownChanges.get(it.key);
115
116
  if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }