@lovek4d/kickflip 0.1.0 → 0.4.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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -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 +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,8 +1,202 @@
1
- // Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation, and opening it. Needs
1
+ // Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation or
2
+ // Hold's sheet, and opening it. Needs
2
3
  // app-core.js and app-threads.js.
3
4
  BoardView.part((B) => {
4
5
  const { $, store, state, markSeen, seen, toast, links } = B;
5
- const { esc, REVIEW, reviewAge, unreadCount, workLines, linkText, mergeButton, 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
+
8
+ const mainSheet = $('#mainLogSheet');
9
+ const mainRetry = $('#mainLogRetry');
10
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
11
+ let postingMain = false;
12
+ let followMainAfter = null;
13
+ let mainState = null;
14
+ let logRequest = 0;
15
+ function mainReason(text = '') {
16
+ $('#mainLogReason').textContent = text;
17
+ $('#mainLogReason').hidden = !text;
18
+ }
19
+ function closeMainLog() {
20
+ if (!mainSheet.open || mainSheet.classList.contains('closing')) return;
21
+ logRequest++;
22
+ if (reduceMotion.matches) { mainSheet.close(); return; }
23
+ mainSheet.classList.add('closing');
24
+ B.later(180, () => { mainSheet.classList.remove('closing'); mainSheet.close(); });
25
+ }
26
+ async function loadMainLog() {
27
+ const request = ++logRequest;
28
+ const pre = $('#mainLogLines');
29
+ try {
30
+ const response = await fetch(B.site.url('api/try/main/log'), { cache: 'no-store' });
31
+ const out = await response.json();
32
+ if (!response.ok) throw new Error(out.error || 'Could not read main’s log.');
33
+ if (!B.alive() || request !== logRequest || !mainSheet.open) return;
34
+ pre.innerHTML = out.lines.length ? out.lines.map((line) => esc(line)).join('\n') : 'No log was written.';
35
+ pre.scrollTop = pre.scrollHeight;
36
+ } catch (error) {
37
+ if (B.alive() && request === logRequest && mainSheet.open) mainReason(error.message || 'Could not read main’s log.');
38
+ }
39
+ }
40
+ function renderMainLog(main, load = null) {
41
+ if (!mainSheet.open || mainSheet.classList.contains('closing') || !main) return;
42
+ const words = tryWords(main.kind);
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>`;
45
+ // Only a load begun after the POST resolved can settle this retry.
46
+ const fresh = !postingMain && load !== null && (followMainAfter === null || load >= followMainAfter);
47
+ if (fresh) {
48
+ const followed = followMainAfter !== null || mainState === 'running';
49
+ if (main.state === 'ready' && followed) {
50
+ followMainAfter = null;
51
+ closeMainLog();
52
+ toast(`Main is up again at ${main.sha}.`);
53
+ } else if (main.state === 'failed' && followed) {
54
+ followMainAfter = null;
55
+ mainReason();
56
+ loadMainLog();
57
+ }
58
+ mainState = main.state;
59
+ }
60
+ // Retry waits (ui.js BUSY's look) from the tap until main is up again or its build fails.
61
+ const busy = postingMain || followMainAfter !== null || main.state === 'running';
62
+ mainRetry.disabled = busy;
63
+ if (busy) mainRetry.setAttribute('aria-busy', 'true'); else mainRetry.removeAttribute('aria-busy');
64
+ }
65
+ B.renderMainLog = renderMainLog;
66
+ B.on(B.root, 'click', (ev) => {
67
+ if (!ev.target.closest('[data-main-log]') || !state.board?.main || mainSheet.open) return;
68
+ ev.preventDefault();
69
+ mainReason();
70
+ if (!postingMain) followMainAfter = null;
71
+ mainState = state.board.main.state;
72
+ mainSheet.classList.remove('closing');
73
+ mainSheet.showModal();
74
+ $('#mainLogLines').textContent = 'Loading…';
75
+ renderMainLog(state.board.main);
76
+ loadMainLog();
77
+ });
78
+ B.on($('#mainLogClose'), 'click', closeMainLog);
79
+ B.on(mainSheet, 'click', (ev) => { if (ev.target === mainSheet) closeMainLog(); });
80
+ B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
81
+ B.on(mainRetry, 'click', async () => {
82
+ if (mainRetry.disabled) return;
83
+ postingMain = true;
84
+ mainReason();
85
+ renderMainLog(state.board.main);
86
+ try {
87
+ const response = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'main' }) });
88
+ const out = await response.json();
89
+ if (!response.ok) throw new Error(out.error || 'The hub refused the build.');
90
+ if (!B.alive()) return;
91
+ followMainAfter = B.boardLoad + 1;
92
+ } catch (error) {
93
+ if (B.alive()) mainReason(error.message || 'Could not start. Is the host awake?');
94
+ } finally { postingMain = false; }
95
+ if (B.alive()) {
96
+ renderMainLog(state.board.main);
97
+ B.reload();
98
+ }
99
+ });
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
+
136
+ // Hold's sheet: the shared duration picker in place of a confirm, since a chip is already a deliberate tap
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}.
138
+ const holdSheet = $('#holdSheet');
139
+ let holdPicking = null;
140
+ const localInput = (d) => {
141
+ const pad = (n) => String(n).padStart(2, '0');
142
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
143
+ };
144
+ function closeHold() {
145
+ if (!holdSheet.open || holdSheet.classList.contains('closing')) return;
146
+ holdPicking = null;
147
+ if (reduceMotion.matches) { holdSheet.close(); return; }
148
+ holdSheet.classList.add('closing');
149
+ B.later(180, () => { holdSheet.classList.remove('closing'); holdSheet.close(); });
150
+ }
151
+ function openHold(it) {
152
+ $('#holdTitle').textContent = `Hold ${it.ref}`;
153
+ $('#holdLede').textContent = `${it.lane === 'todo' ? 'It won’t start' : 'Claude stops work on it'} until then. Forever holds it until you unhold it.`;
154
+ const now = Date.now();
155
+ $('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices, new Date(now));
156
+ const input = $('#holdPicker [data-hold-at]');
157
+ input.min = localInput(new Date(now + 60_000));
158
+ input.max = localInput(new Date(now + 30 * 86_400_000));
159
+ input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
160
+ holdSheet.classList.remove('closing');
161
+ holdSheet.showModal();
162
+ $('#holdTitle').focus({ preventScroll: true });
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).
165
+ async function hold(key, choice) {
166
+ closeHold();
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); }
170
+ }
171
+ B.on($('#holdClose'), 'click', closeHold);
172
+ B.on(holdSheet, 'click', (ev) => {
173
+ if (ev.target === holdSheet) { closeHold(); return; }
174
+ const chip = ev.target.closest('[data-hold-for]');
175
+ if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
176
+ const input = ev.target.closest('[data-hold-at]');
177
+ if (!input) return;
178
+ holdPicking = input;
179
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
180
+ });
181
+ B.on(holdSheet, 'cancel', (ev) => { ev.preventDefault(); closeHold(); });
182
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom sends first and the chip's is lost.
183
+ B.on(holdSheet, 'pointerdown', (ev) => {
184
+ if (!ev.target.closest('[data-hold-for]')) return;
185
+ const input = $('#holdPicker [data-hold-at]');
186
+ holdPicking = null;
187
+ if (input) input.value = input.defaultValue;
188
+ });
189
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab sends nothing.
190
+ function holdCustom(input) {
191
+ if (!input.value || (input !== holdPicking && input.value === input.defaultValue) || !input.checkValidity()) return;
192
+ const at = new Date(input.value);
193
+ if (Number.isNaN(at.getTime())) return;
194
+ holdPicking = null;
195
+ input.defaultValue = input.value;
196
+ hold(input.dataset.holdAt, at.toISOString());
197
+ }
198
+ B.on(holdSheet, 'change', (ev) => { if (ev.target.matches?.('[data-hold-at]') && document.activeElement !== ev.target) holdCustom(ev.target); });
199
+ B.on(holdSheet, 'focusout', (ev) => { if (ev.target.matches?.('[data-hold-at]')) holdCustom(ev.target); });
6
200
 
7
201
  function detail(it) {
8
202
  if (it.kind === 'ask') {
@@ -22,17 +216,22 @@ BoardView.part((B) => {
22
216
  }
23
217
  }
24
218
  if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
25
- if (it.status) rows.push(['Status', esc(it.status)]);
26
- if (it.deps.length) rows.push(['Waits on', esc(it.deps.join(', '))]);
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)]);
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.
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]]) {
223
+ if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
224
+ }
27
225
  if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
28
- if (it.slack) rows.push(['Slack', `<a href="${esc(it.slack)}" target="_blank" rel="noopener">thread</a>`]);
29
- if (it.from) rows.push(['Filed from', esc(it.from)]);
30
226
  if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
31
- // 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
32
228
  const external = [
33
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.
231
+ 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>` : '',
34
232
  installButton(it),
35
233
  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>` : '',
234
+ it.pushed ? pushedLink(it) : '',
36
235
  ].filter(Boolean);
37
236
  const externalRow = external.length ? `<dt class="external-links-label">Links</dt><dd class="external-links">${external.map((html, i) => `<span class="external-link">${i ? '<span class="external-separator" aria-hidden="true"> · </span>' : ''}${html}</span>`).join('')}</dd>` : '';
38
237
 
@@ -43,20 +242,38 @@ BoardView.part((B) => {
43
242
  </div>`;
44
243
  }
45
244
 
46
- // docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md
47
- const sendingHold = new Map();
48
- const sendingMerge = new Set();
49
- const refFor = (it) => (it.kind !== 'work' || it.internal ? it.ref : it.linear?.[0]?.id || (it.pr ? `#${it.pr.number}` : it.ref));
245
+ const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
246
+ // A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
247
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
248
+ const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
249
+ const pushedWords = (it) => [`Pushed ${it.ref}${it.pushed.host ? ` to ${it.pushed.host}` : ''}`, it.pushed.at && ago(it.pushed.at)].filter(Boolean).join(' · ');
250
+ B.on(B.root, 'click', (ev) => {
251
+ const link = ev.target.closest('[data-copy-branch]');
252
+ if (!link) return;
253
+ ev.preventDefault();
254
+ navigator.clipboard?.writeText(link.dataset.copyBranch).catch(() => {});
255
+ link.textContent = 'Copied the branch name';
256
+ B.later(1600, () => {
257
+ const it = state.board?.items.find((i) => i.pushed?.branch === link.dataset.copyBranch);
258
+ if (link.isConnected && it) link.textContent = pushedWords(it);
259
+ });
260
+ });
261
+
262
+ const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
50
263
  const CONFIRM = {
51
- hold: (it, ref) => `Hold ${ref}? ${it.lane === 'todo' ? "It won't start" : 'Claude stops work on it'} until you resume it.`,
52
- resume: (it, ref) => `Resume ${ref}? It can start again once nothing blocks it.`,
53
- bump: (it, ref) => `Move ${ref} to the top of its lane?`,
54
- nudge: () => 'Nudge Claude to check this builder?',
264
+ resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
265
+ nudge: () => 'Nudge Claude to check this implementer?',
55
266
  grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
56
- merge: (it, ref) => `Merge ${ref}? Claude squash-merges #${it.pr?.number} if it is still approved, green and clean.`,
57
- build: (it, ref) => `Build ${ref}? Claude replies with a short plan, then builds it as a draft.`,
267
+ prototype: (it, ref) => `Ask Claude for prototypes of ${ref}?`,
268
+ merge: (it, ref) => (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.`),
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.`
273
+ : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
58
274
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
59
- : `Close ${ref}? It moves to Done and Claude stops work on it.${it.pr?.state === 'OPEN' ? ' Its PR stays open on GitHub.' : ''}`),
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'}.` : ''}`),
60
277
  };
61
278
 
62
279
  const classes = (it, button, base = '') => {
@@ -67,48 +284,109 @@ BoardView.part((B) => {
67
284
 
68
285
  function actions(it) {
69
286
  const key = esc(it.key);
70
- const ask = (act, label) => `<button data-confirm="${act}" data-key="${key}">${label}</button>`;
71
- const holding = sendingHold.has(it.key) ? sendingHold.get(it.key) : it.holdPending;
287
+ const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
72
288
  const hold = it.kind !== 'work' || !it.holdable ? ''
73
- : holding != null ? `<button disabled>${holding ? 'Holding…' : 'Resuming…'}</button>`
74
- : it.held ? ask('resume', 'Resume') : ask('hold', 'Hold');
75
- const build = it.kind === 'followup' && it.lane !== 'done' ? `<button class="build" data-confirm="build" data-key="${key}">Build</button>` : '';
76
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${build}${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}`)}</div>`;
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>`;
77
291
  }
78
292
 
79
- // 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).
80
294
  const ACTION_BUTTONS = {
81
295
  [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
82
296
  [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
83
- [BUTTON.grill]: (it) => (it.grilling ? '<button disabled>Writing questions…</button>' : `<button data-confirm="grill" data-key="${esc(it.key)}">Grill</button>`),
297
+ // Grill and Prototype each ask for a planner; both wait while either is under way.
298
+ [BUTTON.grill]: (it) => ['grill', 'prototype'].map((act) => `<button data-confirm="${act}" data-key="${esc(it.key)}"${it.grilling || it.prototyping ? ' disabled' : ''}>${act === 'grill' ? 'Grill' : 'Prototype'}</button>`).join(''),
84
299
  };
85
300
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
86
301
 
87
- // The latest bump takes no second bump: among New cards for a New card (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md),
88
- // in its lane for any other card (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md).
89
- const latestBump = (it) => Boolean(it.bumpedAt) && !state.board.items.some((i) => (it.isNew ? i.isNew : i.lane === it.lane) && (i.bumpedAt || '') > it.bumpedAt);
90
- function card(it) {
302
+ // 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).
303
+ const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
304
+ // band {step, actions} draws the card in a wave band (app-lanes.js).
305
+ // Its open questions asked since this browser last opened it count as new, as its messages do.
306
+ function card(it, band = {}) {
307
+ const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
91
308
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
92
309
  <summary class="work">
93
310
  <span class="status ${it.lane}"></span>
94
- <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: unseen(it.othersAt), newQuestions: unseen(it.questions?.askedAt), top: latestBump(it), ...band })}</div>
95
312
  </summary>
96
313
  ${state.openItems.has(it.key) ? detail(it) : ''}
97
314
  </details>`;
98
315
  }
99
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
+
100
378
  B.on(B.root, 'toggle', (ev) => {
101
379
  const el = ev.target;
102
380
  if (!el.matches?.('details.item')) return;
103
381
  const key = el.dataset.key;
104
382
  if (el.open) {
105
383
  state.openItems.add(key);
106
- 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);
107
385
  markSeen(it);
108
- const unread = el.querySelector('summary .chip.unread');
109
- const row = unread?.parentNode;
386
+ const unread = el.querySelector('summary .card-unread');
387
+ const line = unread?.parentNode;
110
388
  unread?.remove();
111
- if (row && !row.children.length) row.remove();
389
+ if (line && !line.children.length) line.remove();
112
390
  if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
113
391
  if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
114
392
  B.loadThread(key, { maxAge: 10000 });
@@ -139,13 +417,27 @@ BoardView.part((B) => {
139
417
  const key = button.dataset.try;
140
418
  const it = state.board.items.find((i) => i.key === key);
141
419
  if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
142
- button.disabled = true;
143
- try {
420
+ // Serve / Build waits until the hub has started the build; the card's chip then says it runs.
421
+ const post = (async () => {
144
422
  const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
145
423
  const out = await r.json();
146
424
  if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
147
- } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
148
- if (B.alive()) B.loadBoard();
425
+ })();
426
+ try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
427
+ B.reload();
428
+ });
429
+
430
+ // A work card's bump icon bumps it, or unbumps a bumped one, with no confirm
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).
433
+ B.on(B.root, 'click', async (ev) => {
434
+ const b = ev.target.closest('button[data-card-bump]');
435
+ if (!b) return;
436
+ ev.preventDefault();
437
+ const key = b.dataset.cardBump;
438
+ const unbump = b.hasAttribute('data-bumped');
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); }
149
441
  });
150
442
 
151
443
  B.on(B.root, 'click', async (ev) => {
@@ -154,47 +446,32 @@ BoardView.part((B) => {
154
446
  ev.preventDefault();
155
447
  const { key, confirm: act } = b.dataset;
156
448
  const it = state.board.items.find((i) => i.key === key);
157
- if (!it || !(await B.confirm(CONFIRM[act](it, refFor(it))))) return;
158
- if (act === 'hold' || act === 'resume') {
159
- sendingHold.set(key, act === 'hold');
160
- redrawActions(key);
161
- try { await B.site.send('card-hold', { key, hold: act === 'hold' }); }
162
- catch { toast(`Could not ${act}. Is the host awake?`); }
163
- sendingHold.delete(key);
164
- B.loadBoard();
165
- return;
166
- }
167
- // docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md
168
- if (act === 'merge') {
169
- sendingMerge.add(key);
170
- redrawActions(key);
171
- try { await B.site.send('card-merge', { key }); }
172
- catch { toast('Could not send. Is the host awake?'); }
173
- sendingMerge.delete(key);
174
- B.loadBoard();
449
+ if (it && act === 'hold') { openHold(it); return; }
450
+ if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
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 = { grill: ['grill-more', { key }], prototype: ['prototype-request', { key }], 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
+ try { await waitOn(b, B.sentHere(key, () => B.send(...sent, close))); if (act === 'grill' || act === 'prototype') B.loadThread(key, { force: true }); }
465
+ catch (e) { B.failed(e); }
466
+ if (act === 'finish') B.syncDone(key);
175
467
  return;
176
468
  }
177
- // docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md
178
- if (act === 'finish') {
179
- B.sendingDone.add(key);
180
- B.syncDone(key);
181
- try { await (it.kind === 'ask' ? B.site.send('ask-done', { id: it.id }) : B.site.send('card-done', { key })); }
182
- catch { toast('Could not close. Is the host awake?'); }
183
- B.sendingDone.delete(key);
184
- await B.loadBoard();
185
- B.syncDone(key);
186
- return;
187
- }
188
- const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
189
- row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
190
- b.disabled = true;
469
+ // The tapped button waits and the rest of its action row is disabled until the server answers.
470
+ const group = { group: $(`.actions[data-actions="${CSS.escape(key)}"]`)?.querySelectorAll('button') ?? [] };
191
471
  try {
192
- if (act === 'bump') { await B.site.send('card-bump', { key }); toast('Moved to the top'); }
193
- else if (act === 'nudge') { await B.site.send('builder-nudge', { key }); b.textContent = 'Sent'; toast('Nudge sent'); }
194
- else { await B.site.send(act === 'grill' ? 'grill-more' : 'build-followup', { key }); B.loadThread(key, { force: true }); }
195
- } catch { toast('Could not send. Is the host awake?'); }
196
- B.loadBoard();
472
+ await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
473
+ } catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
197
474
  });
198
475
 
199
- Object.assign(B, { detail, card });
476
+ Object.assign(B, { captureStrips, moveStrips, detail, card });
200
477
  });
@@ -6,12 +6,16 @@ BoardView.part((B) => {
6
6
  const a = document.activeElement;
7
7
  return a && B.root.contains(a) && a.matches('textarea, input') ? a : null;
8
8
  };
9
- // → a function finding field after a repaint: the same node, else the box drawn anew in its place (an edit box from
10
- // state.edits, a reply box from its draft), found by its form.
11
- function finder(field) {
9
+ // → the selector of field's box as drawn anew (an edit box from state.edits, a reply box from its draft), by its form;
10
+ // null for any other field.
11
+ function boxOf(field) {
12
12
  const form = field.closest('form[data-reply], form[data-edit-form]');
13
- const again = !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
13
+ return !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
14
14
  : `form[data-edit-form="${CSS.escape(form.dataset.editForm)}"] textarea`;
15
+ }
16
+ // → a function finding field after a repaint: the same node, else the box drawn anew in its place.
17
+ function finder(field) {
18
+ const again = boxOf(field);
15
19
  return () => (field.isConnected ? field : again && $(again));
16
20
  }
17
21
 
@@ -32,7 +36,7 @@ BoardView.part((B) => {
32
36
  }
33
37
 
34
38
  // Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
35
- // 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).
36
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
37
41
  // in a dialog doesn't move with the page, so the card anchors then.
38
42
  function anchorOf() {
@@ -50,6 +54,36 @@ BoardView.part((B) => {
50
54
  const after = before == null ? null : find()?.getBoundingClientRect().top;
51
55
  if (after != null && after !== before) scrollBy(0, after - before);
52
56
  }
57
+ // Where the owner left the board, kept for its next mount in this page: the page's scroll and the box they were typing
58
+ // in, as it unmounts or at their last press outside the board, such as on a sidebar row, which takes the focus first.
59
+ // The header's logo, which opens the sidebar, counts as outside.
60
+ let held = null;
61
+ B.on(document, 'pointerdown', (e) => {
62
+ const outside = !B.root.contains(e.target) || e.target.closest?.('.top a.hub');
63
+ held = outside ? focusedField() || held : null;
64
+ }, true);
65
+ // A board left before its first render keeps the place it hadn't returned to yet.
66
+ let place = B.place;
67
+ B.keep(() => {
68
+ if (place) return { y: place.y, box: place.box };
69
+ const field = focusedField() || (held?.isConnected ? held : null);
70
+ const selector = field && boxOf(field);
71
+ return { y: scrollY, box: selector ? { selector, selection: [field.selectionStart, field.selectionEnd, field.selectionDirection], top: field.scrollTop } : null };
72
+ });
73
+ // Only the first call restores, and none when the URL names a card (linked). A phone's keyboard would open for the
74
+ // focus, so only a mouse or trackpad gets the box back (sidebar.js DESKTOP).
75
+ function restorePlace({ linked = false } = {}) {
76
+ const p = place;
77
+ place = null;
78
+ if (!p || linked) return;
79
+ scrollTo(0, p.y);
80
+ const box = p.box && $(p.box.selector);
81
+ if (!box || box.closest('details:not([open])') || !matchMedia('(hover: hover) and (pointer: fine)').matches) return;
82
+ box.focus({ preventScroll: true });
83
+ box.setSelectionRange(...p.box.selection);
84
+ box.scrollTop = p.box.top;
85
+ }
86
+
53
87
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
54
88
 
55
89
  // A card this browser has never seen starts from now, so a first load badges nothing.
@@ -80,8 +114,6 @@ BoardView.part((B) => {
80
114
  quiet.set(draftKey, Date.now() + 1000);
81
115
  }
82
116
 
83
- // A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
84
- const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
85
117
  // The ui-links.js linkContext for text shown on item `key`'s card or thread.
86
118
  const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
87
119
  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>`;
@@ -92,5 +124,5 @@ BoardView.part((B) => {
92
124
  const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
93
125
  const notice = dialogs.notice;
94
126
 
95
- Object.assign(B, { steady, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
127
+ Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, links, svg, confirm, notice });
96
128
  });