@lovek4d/kickflip 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -1,4 +1,4 @@
1
- // The header's bulb and its review sheet: the board's last architecture review and Run, which confirms and asks the
1
+ // The bulb at the foot of the board and its review sheet: the board's last architecture review and Run, which confirms and asks the
2
2
  // board server to start one (docs/adr/2026-11-02-1813-the-bulb-runs-an-architecture-review-of-the-boards-repo.md). The bulb glows
3
3
  // while a review runs. Needs app-core.js.
4
4
  BoardView.part((B) => {
@@ -3,15 +3,15 @@
3
3
  // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
4
4
  BoardView.part((B) => {
5
5
  const { $, state, toast } = B;
6
- const { waitOn } = BoardUI;
6
+ const { waitOn, keyedSend } = BoardUI;
7
7
 
8
- // ⌘↵ (Ctrl+↵ off a Mac) sends a thread reply or an edit from its box; the ask's own is new-task.js's.
8
+ // ⌘↵ and ⌥↵ press a thread reply's or an edit's send buttons from its box (ui.js keyedSend); the ask's own are new-task.js's.
9
9
  B.on(B.root, 'keydown', (ev) => {
10
- if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
11
- const form = ev.target instanceof HTMLTextAreaElement ? ev.target.closest('form[data-reply], form.edit-form') : null;
12
- if (!form) return;
10
+ const form = ev.target.tagName === 'TEXTAREA' ? ev.target.closest('form[data-reply], form.edit-form') : null;
11
+ const button = form && keyedSend(ev, form);
12
+ if (!button) return;
13
13
  ev.preventDefault();
14
- form.requestSubmit();
14
+ form.requestSubmit(button);
15
15
  });
16
16
  BoardNewTask.wire(B);
17
17
 
@@ -1,18 +1,13 @@
1
- // The answer banner at the top of the board view: asks Claude answered without making work, whose answer this
2
- // browser hasn't seen (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md). Needs app-core.js.
1
+ // The answer banner at the top of the board view: asks Claude answered without making work, whose answer came after the
2
+ // card's read mark (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md,
3
+ // docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). Needs app-core.js.
3
4
  BoardView.part((B) => {
4
- const { $, store, state, seen, markSeen, plural, svg } = B;
5
+ const { $, state, seen, markSeen, markRead, plural, svg } = B;
5
6
  const { esc } = BoardUI;
6
- const DISMISSED = 'dismissedAnswers';
7
7
 
8
- function unseenAnswers(d) {
9
- const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
10
- return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at));
11
- }
8
+ const unseenAnswers = (d) => (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
12
9
 
13
10
  function renderBanner(d) {
14
- // A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
15
- for (const it of d.items) if (it.isNew) markSeen(it, true);
16
11
  const shown = unseenAnswers(d);
17
12
  const el = $('#answers');
18
13
  el.hidden = !shown.length;
@@ -28,10 +23,9 @@ BoardView.part((B) => {
28
23
  B.on($('#answers'), 'click', (ev) => {
29
24
  const d = state.board;
30
25
  if (!d) return;
26
+ // ✕ marks each answer shown read, on every device.
31
27
  if (ev.target.closest('.answer-dismiss')) {
32
- const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
33
- for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
34
- store.set(DISMISSED, dismissed);
28
+ markRead(unseenAnswers(d).map((a) => [a.key, a.at]));
35
29
  renderBanner(d);
36
30
  return;
37
31
  }
@@ -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, escText, 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');
@@ -41,7 +41,7 @@ BoardView.part((B) => {
41
41
  if (!mainSheet.open || mainSheet.classList.contains('closing') || !main) return;
42
42
  const words = tryWords(main.kind);
43
43
  $('#mainLogTitle').textContent = `Main’s ${words.failed.toLowerCase()}`;
44
- $('#mainLogSource').innerHTML = `<code>.kickflip/build</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
44
+ $('#mainLogSource').innerHTML = `<code>${esc(main.script)}</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
45
45
  // Only a load begun after the POST resolved can settle this retry.
46
46
  const fresh = !postingMain && load !== null && (followMainAfter === null || load >= followMainAfter);
47
47
  if (fresh) {
@@ -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
 
@@ -206,7 +215,7 @@ BoardView.part((B) => {
206
215
  const pr = it.pr;
207
216
  const ctx = links(it.key);
208
217
  const rows = [];
209
- rows.push(['Wave', esc(it.waveTitle)]);
218
+ rows.push(['Wave', escText(it.waveTitle)]);
210
219
  if (pr) {
211
220
  const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
212
221
  if (pr.state === 'OPEN') {
@@ -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>` : '',
@@ -268,8 +275,8 @@ BoardView.part((B) => {
268
275
  : openPRs(it).length > 1
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
- '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.`
278
+ 'card-build': (it, ref) => (it.blocked
279
+ ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${[it.blockedBy.length && `${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done`, it.blockedUntil].filter(Boolean).join(' and ')}? 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,20 +289,33 @@ 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 Close, right of Hold; an In Review card's Done is in its thread box
301
+ // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
302
+ const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
303
+ // 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).
304
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
291
305
  }
292
306
 
293
307
  // 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).
294
308
  const ACTION_BUTTONS = {
295
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
296
- [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
309
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
297
310
  };
298
311
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
312
+ // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
313
+ // On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
314
+ // the viewer on the latest round, solid orange while a round waits on the owner's pick.
315
+ const prototypesButton = (it) => {
316
+ const latest = it.prototypes?.rounds.at(-1);
317
+ return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
318
+ };
299
319
 
300
320
  // 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).
301
321
  const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
@@ -347,7 +367,7 @@ BoardView.part((B) => {
347
367
  // After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
348
368
  // entering strip grows in one bar at a time, then sweeps each reached bar.
349
369
  function moveStrips(items, before) {
350
- const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.reached ?? null]));
370
+ const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.done ?? null]));
351
371
  const still = reduceMotion.matches || !drawn;
352
372
  for (const [key, reached] of next) {
353
373
  const kind = still ? null : phaseMotion(phases.get(key), reached);
@@ -381,8 +401,10 @@ BoardView.part((B) => {
381
401
  const key = el.dataset.key;
382
402
  if (el.open) {
383
403
  state.openItems.add(key);
404
+ state.restored.delete(key);
384
405
  const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
385
406
  markSeen(it);
407
+ B.renderBanner(state.board);
386
408
  const unread = el.querySelector('summary .card-unread');
387
409
  const line = unread?.parentNode;
388
410
  unread?.remove();
@@ -396,12 +418,6 @@ BoardView.part((B) => {
396
418
  store.set('openItems', [...state.openItems]);
397
419
  }, true);
398
420
 
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
421
  B.on(B.root, 'click', async (ev) => {
406
422
  const log = ev.target.closest('[data-try-log]');
407
423
  if (log && state.openItems.has(log.dataset.tryLog)) {
@@ -410,6 +426,8 @@ BoardView.part((B) => {
410
426
  card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
411
427
  return;
412
428
  }
429
+ const off = ev.target.closest('a[data-tip]');
430
+ if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
413
431
  const button = ev.target.closest('button[data-try]');
414
432
  if (!button) return;
415
433
  ev.preventDefault();
@@ -417,13 +435,22 @@ BoardView.part((B) => {
417
435
  const key = button.dataset.try;
418
436
  const it = state.board.items.find((i) => i.key === key);
419
437
  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 () => {
438
+ // Serve / Build spins until a board shows the card's build changed: running, or already done
439
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
440
+ const before = JSON.stringify(it.try);
441
+ const id = `${key}:try`;
442
+ BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
443
+ B.render();
444
+ try {
422
445
  const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
423
446
  const out = await r.json();
424
447
  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?'); }
448
+ BoardUI.pending.answered(id);
449
+ } catch (error) {
450
+ BoardUI.pending.drop(id);
451
+ B.render();
452
+ toast(error.message || 'Could not start. Is the host awake?');
453
+ }
427
454
  B.reload();
428
455
  });
429
456
 
@@ -440,11 +467,16 @@ BoardView.part((B) => {
440
467
  catch (e) { B.failed(e); }
441
468
  });
442
469
 
443
- B.on(B.root, 'click', async (ev) => {
470
+ B.on(B.root, 'click', (ev) => {
444
471
  const b = ev.target.closest('button[data-confirm]');
445
472
  if (!b) return;
446
473
  ev.preventDefault();
474
+ confirmTap(b);
475
+ });
476
+ // A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
477
+ async function confirmTap(b) {
447
478
  const { key, confirm: act } = b.dataset;
479
+ if (b.classList.contains('nudging')) return;
448
480
  const it = state.board.items.find((i) => i.key === key);
449
481
  if (it && act === 'hold') { openHold(it); return; }
450
482
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
@@ -472,15 +504,21 @@ BoardView.part((B) => {
472
504
  if (pend) { BoardUI.pending.drop(pend); B.render(); }
473
505
  B.failed(e);
474
506
  }
475
- if (act === 'finish') B.syncDone(key);
507
+ if (act === 'finish') B.syncSends(key);
476
508
  return;
477
509
  }
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') ?? [] };
510
+ // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
511
+ // changed, the builder no longer stalled or the card gone (style.css .nudging).
512
+ const id = `${key}:nudge`;
513
+ const { progress, at } = it.builder;
514
+ 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);
515
+ B.render();
480
516
  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?'); }
483
- });
517
+ const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
518
+ BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
519
+ } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
520
+ B.render();
521
+ }
484
522
 
485
- Object.assign(B, { captureStrips, moveStrips, detail, card });
523
+ Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
486
524
  });
@@ -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,34 +77,34 @@ 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
 
84
85
  state.link = 'online';
85
86
  const controls = { wired: false, boardSettled: false, reloading: false };
86
87
  function applyControls() {
87
- const on = headerControls({ link: state.link, ...controls });
88
+ const on = headerControls(controls);
88
89
  $('#refresh').disabled = !on.refresh;
89
90
  $('#refresh').classList.toggle('spinning', on.spin);
90
91
  const gear = $('#settingsLink');
91
92
  if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
92
93
  }
93
94
  const headerWired = () => { controls.wired = true; applyControls(); };
94
- // style.css dims the offline view from .view-board.offline.
95
+ // Offline, the board stays as it was and reads as before; only the header time turns red, and style.css holds the
96
+ // buttons that send from .view-board.offline (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
95
97
  function setLink(link) {
96
- const was = state.link;
97
98
  state.link = link;
98
99
  if (link !== 'reconnecting') controls.boardSettled = true;
99
- const off = link !== 'online';
100
- B.root.classList.toggle('offline', off);
101
- if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
100
+ B.root.classList.toggle('offline', link !== 'online');
102
101
  applyControls();
103
102
  renderStatus();
104
103
  }
104
+ BoardUI.holdSends(B.on, B.root, () => state.link !== 'online', () => B.toast(BoardUI.WAITING));
105
105
  const linkUp = () => setLink('online');
106
106
  const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
107
- B.on($('.nav'), 'click', (ev) => {
107
+ B.on($('#settingsLink'), 'click', (ev) => {
108
108
  if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
109
109
  }, true);
110
110
  // A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.