@lovek4d/kickflip 0.3.1 → 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 (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -1,9 +1,9 @@
1
- // The board view's lanes and the Waves section above them: each lane's header and cards, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and the wave rows that filter the lanes and start a wave's triage.
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,
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) => {
5
5
  const { $, store, state } = B;
6
- const { esc } = BoardUI;
6
+ const { esc, waitOn } = BoardUI;
7
7
  const LANES = [
8
8
  { id: 'review', name: 'In Review', open: true },
9
9
  { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
@@ -12,17 +12,44 @@ BoardView.part((B) => {
12
12
  { id: 'done', name: 'Done', open: false, capped: true },
13
13
  { id: 'asks', name: 'Asks', open: false, capped: true },
14
14
  ];
15
- // docs/adr/0144-answered-asks-have-their-own-section-below-done.md
15
+ // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
16
16
  // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
17
- // open stays shown past the cap. Show more says how many are left (docs/adr/0012-lists-never-cut-silently.md).
17
+ // open stays shown past the cap. Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
18
+ // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
18
19
  const STEP = 10;
19
20
  const moreTaps = new Map();
20
- function cappedRows(laneId, cards) {
21
+ function cappedRows(laneId, cards, older = 0) {
21
22
  const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
22
23
  const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
23
- const left = cards.length - shown;
24
+ const left = Math.max(0, cards.length - shown);
25
+ const n = left + (older || 0);
26
+ const words = `${n} older card${n === 1 ? '' : 's'}`;
27
+ const fetches = older !== 0 && shown + STEP > cards.length;
24
28
  return cards.slice(0, shown).map((it) => B.card(it)).join('')
25
- + (left > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}">Show more <span class="n">· ${left} older card${left === 1 ? '' : 's'}</span></button>` : '');
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
+ }
31
+
32
+ // docs/adr/2026-11-04-0307-the-board-keeps-the-newest-100-done-cards.md
33
+ // Done shows the board's Done cards, then those paged in from the archive (GET /api/done), 100 a page, newest first.
34
+ // The board says how many the archive holds (doneArchived), so Done's count is the true total before any page is fetched.
35
+ const { archived } = state;
36
+ const olderThanBoard = (d) => Math.max(0, d.doneArchived - archived.cards.filter((c) => !d.items.some((i) => i.key === c.key)).length);
37
+ function doneRows(d, open) {
38
+ const onBoard = new Set(d.items.map((i) => i.key));
39
+ const cards = [...open, ...archived.cards.filter((c) => !onBoard.has(c.key))].sort((a, b) => String(b.sortAt || '').localeCompare(String(a.sortAt || '')));
40
+ return cappedRows('done', cards, olderThanBoard(d));
41
+ }
42
+ function doneCount(d, items) {
43
+ const onBoard = new Set(d.items.map((i) => i.key));
44
+ const older = olderThanBoard(d);
45
+ return `${items.length + archived.cards.filter((c) => !onBoard.has(c.key)).length + older}`;
46
+ }
47
+ async function fetchOlder() {
48
+ const last = archived.cards.at(-1);
49
+ const r = await fetch(B.site.url(`api/done${last ? `?before=${encodeURIComponent(`${last.sortAt}|${last.key}`)}` : ''}`), { cache: 'no-store' });
50
+ if (!r.ok) throw new Error(`done ${r.status}`);
51
+ const page = await r.json();
52
+ archived.cards.push(...page.cards);
26
53
  }
27
54
 
28
55
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
@@ -47,79 +74,160 @@ BoardView.part((B) => {
47
74
  }).join('');
48
75
  }
49
76
 
50
- // shown is the board's items after the wave filter and pending deletes.
77
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md: the Auto Review lane leads the lanes. The PRs
78
+ // the owner has open are kept in memory, as cards are.
79
+ const openReviews = new Set();
80
+ const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
81
+ open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
82
+ // Opens PR number's review and its lane or fold, for a Needs you line or a push's #review=<number>.
83
+ function openReview(number) {
84
+ openReviews.add(number);
85
+ const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
86
+ if (state.board?.reviews?.earlier.some((c) => c.number === number)) store.set(FOLDS['reviews-earlier'].store, true);
87
+ B.render();
88
+ $(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
89
+ }
90
+
91
+ // shown is the board's items less pending deletes.
51
92
  function lanesHtml(d, shown) {
52
93
  const laneState = store.get('lanes', {});
53
- return LANES.map((lane) => {
94
+ return reviewsHtml(d) + LANES.map((lane) => {
54
95
  // Items arrive in each lane's fixed order (domain.mjs laneOrder).
55
96
  const items = shown.filter((i) => i.lane === lane.id);
56
97
  if (lane.hideEmpty && !items.length) return '';
57
98
  const expanded = laneState[lane.id] ?? lane.open;
58
99
  const open = items.filter((i) => !i.fold);
59
- const rows = (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) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
60
101
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
61
- <summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
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>
62
103
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
63
104
  ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
64
105
  </details>`;
65
106
  }).join('');
66
107
  }
67
108
 
68
- // The Waves section: a lane of one row per open wave (domain.mjs waveRowsOf). Tapping a row shows only its cards;
69
- // Notes and Triage act without filtering. A lineup has them at the row's foot, and a wave file has only Triage, at
70
- // the end of its name line.
109
+ // 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
111
+ // in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
112
+ // 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
+ // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
114
+ let menu = null;
115
+ let goalShown = null;
71
116
  const triaging = new Set();
72
117
  const triageTopic = (r) => `${r.kind}:${r.slug}`;
73
118
  function triageLink(r) {
74
- if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>Triaging…</span>';
119
+ if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>triaging…</span>';
75
120
  if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
76
121
  return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
77
122
  }
78
- function waveRow(r) {
79
- const on = r.slug === state.waveFilter;
80
- const lineup = r.kind === 'lineup';
81
- const links = lineup ? `<div class="wv-links"><span class="external-link"><a href="notes.html?wave=${encodeURIComponent(r.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-link"><span class="external-separator" aria-hidden="true"> · </span>${triageLink(r)}</span></div>` : '';
82
- return `<div class="wv-row${on ? ' on' : ''}" data-wave-row="${esc(r.slug)}">
83
- <div class="wv-id"><button type="button" class="wv-name" aria-pressed="${on}">${BoardUI.WAVE_ICON}<span>${esc(r.title)}</span></button><span class="muted small">${r.merged} of ${r.total} merged</span>${lineup ? '' : `<span class="wv-end">${triageLink(r)}</span>`}</div>
84
- ${r.goal ? `<div class="wv-note"><b>Goal</b> ${esc(r.goal)}</div>` : ''}
85
- ${r.order ? `<div class="wv-note"><b>Order</b> ${r.order.map((o) => esc(o.ref)).join(' → ')}</div>` : ''}
86
- ${links}
87
- </div>`;
123
+ const bandStore = (slug) => FOLDS[`band:${slug}`].store;
124
+ const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
125
+ const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
126
+ 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
+
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));
88
135
  }
89
- function renderWaves(d) {
90
- const rows = d.waveRows || [];
91
- if (state.waveFilter && !rows.some((r) => r.slug === state.waveFilter)) { state.waveFilter = null; store.set('wave', null); }
92
- const open = store.get('lanes', {}).waves ?? true;
93
- const shown = rows.find((r) => r.slug === state.waveFilter);
94
- BoardUI.setHTML($('#waves'), rows.length ? `<details class="lane" data-lane="waves" ${open ? 'open' : ''}>
95
- <summary><span class="lane-name">Waves</span><span class="count">${rows.length}</span></summary>
96
- <div class="list">${rows.map(waveRow).join('')}</div>
97
- </details>${shown ? `<div class="wv-showing muted small">Showing ${esc(shown.title)} only · <button type="button" class="wv-all">Show all</button></div>` : ''}` : '');
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));
141
+ const loose = todo.filter((i) => !banded.has(i.key));
142
+ const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
143
+ if (!bands.length) return loose.map((it) => B.card(it)).join('');
144
+ 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>` : '');
146
+ }
147
+
148
+ function bandHtml(band, cards, open) {
149
+ const todo = cards.filter((i) => i.lane === 'todo');
150
+ 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]}`);
152
+ const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
153
+ 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>` : '';
155
+ const next = band.next;
156
+ const go = !next ? '' : next.ready
157
+ ? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
158
+ : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
159
+ const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
160
+ const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
161
+ + `<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>`;
163
+ if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
164
+ 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
+ const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
166
+ const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
167
+ return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
168
+ }
169
+
170
+ // A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
171
+ // the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
172
+ function cardMenu(slug, key, at, count) {
173
+ const shown = menu === key;
174
+ const item = (by, label) => {
175
+ const off = by < 0 ? at === 0 : at === count - 1;
176
+ return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
177
+ };
178
+ return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
179
+ + (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
180
+ }
181
+
182
+ // Opens the band holding card key or named slug, for a #item= link or a Needs you line.
183
+ function openBand({ key, slug }) {
184
+ const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
185
+ if (band) store.set(bandStore(band.slug), true);
186
+ return band;
98
187
  }
99
- const showWave = (slug) => { state.waveFilter = slug; store.set('wave', slug); B.render(); };
100
188
 
101
189
  B.on(B.root, 'toggle', (ev) => {
102
190
  const el = ev.target;
103
191
  if (el.matches?.('details.lane')) {
104
192
  const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
105
193
  } else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
194
+ else if (el.matches?.('details.rv-card, details.rv-line')) {
195
+ if (el.open) openReviews.add(Number(el.dataset.review)); else openReviews.delete(Number(el.dataset.review));
196
+ }
106
197
  }, true);
107
198
 
108
199
  B.on(B.root, 'click', async (ev) => {
200
+ const answer = ev.target.closest('button[data-review-answer]');
201
+ if (answer) {
202
+ // The tapped answer waits, and the review's other answer is disabled, until the server records it.
203
+ const pr = Number(answer.dataset.review);
204
+ try { await waitOn(answer, B.send('review-answer', { pr, answer: answer.dataset.reviewAnswer }), { group: answer.parentNode.querySelectorAll('button') }); }
205
+ catch { B.notice('Could not send. Is the host awake?'); }
206
+ return;
207
+ }
208
+ const review = ev.target.closest('a[data-review-open]');
209
+ if (review) { ev.preventDefault(); openReview(Number(review.dataset.reviewOpen)); return; }
109
210
  const more = ev.target.closest('[data-lane-more]');
110
- if (more) { moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1); B.render(); return; }
211
+ if (more) {
212
+ if (more.hasAttribute('data-fetch')) {
213
+ try { await waitOn(more, fetchOlder()); } catch { B.toast('Could not load older cards. Is the host awake?'); return; }
214
+ if (!B.alive()) return;
215
+ }
216
+ moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1);
217
+ B.render();
218
+ return;
219
+ }
111
220
  const pb = ev.target.closest('button[data-plan]');
112
221
  if (pb) {
113
222
  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;
114
- pb.disabled = true;
115
- try { await B.send('plan-waves', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
116
- catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
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?'); }
117
225
  return;
118
226
  }
119
227
  const tb = ev.target.closest('button[data-triage]');
120
228
  if (tb) {
121
229
  const topic = tb.dataset.triage;
122
- const row = state.board.waveRows.find((r) => triageTopic(r) === topic);
230
+ const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
123
231
  const asks = row.kind === 'lineup'
124
232
  ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
125
233
  : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
@@ -131,12 +239,58 @@ BoardView.part((B) => {
131
239
  finally { triaging.delete(topic); B.render(); }
132
240
  return;
133
241
  }
134
- const go = ev.target.closest('a[data-wave-show]');
135
- if (go) { ev.preventDefault(); showWave(go.dataset.waveShow); $('#waves').scrollIntoView({ block: 'start' }); return; }
136
- if (ev.target.closest('.wv-all')) { showWave(null); return; }
137
- const row = ev.target.closest('.wv-row');
138
- if (row && !ev.target.closest('a')) showWave(row.dataset.waveRow !== state.waveFilter ? row.dataset.waveRow : null);
242
+ const show = ev.target.closest('a[data-band-show]');
243
+ if (show) {
244
+ ev.preventDefault();
245
+ if (!openBand({ slug: show.dataset.bandShow })) return;
246
+ const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
247
+ B.render();
248
+ $(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
249
+ }
250
+ });
251
+
252
+ // A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
253
+ // a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
254
+ let press = null;
255
+ let pressed = false;
256
+ const endPress = () => { press?.(); press = null; };
257
+ B.on(B.root, 'pointerdown', (ev) => {
258
+ pressed = false;
259
+ const name = ev.target.closest?.('[data-goal]');
260
+ if (!name || ev.pointerType === 'mouse') return;
261
+ endPress();
262
+ press = B.later(450, () => { press = null; pressed = true; goalShown = name.dataset.goal; B.render(); });
263
+ });
264
+ for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
265
+ B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
266
+ B.on(B.root, 'click', async (ev) => {
267
+ if (pressed) { pressed = false; ev.preventDefault(); return; }
268
+ if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
269
+ const dots = ev.target.closest('button[data-band-menu]');
270
+ if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
271
+ const mv = ev.target.closest('button[data-band-move]');
272
+ if (mv) {
273
+ ev.preventDefault();
274
+ if (mv.disabled) return;
275
+ const { band, key } = mv.dataset;
276
+ try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
277
+ catch { B.toast('Could not move it. Is the host awake?'); }
278
+ menu = null;
279
+ B.render();
280
+ return;
281
+ }
282
+ if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
283
+ const head = ev.target.closest('[data-band-toggle]');
284
+ if (head && !ev.target.closest('a, button')) {
285
+ const slug = head.dataset.bandToggle;
286
+ store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
287
+ B.render();
288
+ }
289
+ });
290
+ B.on(B.root, 'keydown', (ev) => {
291
+ const head = ev.target.closest?.('[data-band-toggle]');
292
+ if (head && ev.target === head && (ev.key === 'Enter' || ev.key === ' ')) { ev.preventDefault(); head.click(); }
139
293
  });
140
294
 
141
- Object.assign(B, { FOLDS, lanesHtml, renderWaves });
295
+ Object.assign(B, { FOLDS, lanesHtml, openReview, openBand });
142
296
  });
@@ -23,7 +23,8 @@ BoardView.part((B) => {
23
23
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
24
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
25
25
  : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
- : line.waveRow ? `<li><a href="#waves" data-wave-show="${esc(line.waveRow)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
+ : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${esc(line.title)}</a> <span class="muted small">· review held for you</span></li>`
27
+ : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
27
28
  : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
28
29
  }
29
30
 
@@ -1,6 +1,6 @@
1
1
  // The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
2
- // board lands, flashing the lines the fresh board changed (docs/adr/0028-the-board-opens-on-the-last-visit.md), and
3
- // the sign on a card that changed lanes or was updated (docs/adr/0135-an-updated-card-takes-a-brief-tint.md).
2
+ // board lands, flashing the lines the fresh board changed (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md), and
3
+ // the sign on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
4
4
  // Needs app-core.js and ui-snapshot.js; app.js paints it.
5
5
  BoardView.part((B) => {
6
6
  const { $$, state } = B;
@@ -1,7 +1,7 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
4
+ const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, protoNotice, doneButton, waitOn } = BoardUI;
5
5
 
6
6
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
7
 
@@ -54,13 +54,14 @@ BoardView.part((B) => {
54
54
  // The reply form is built once per card, so a refresh never drops what is being typed.
55
55
  if (!el.querySelector('.msgs')) {
56
56
  const draft = store.get('draft:' + key, '');
57
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"><textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>
57
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>`)}
58
58
  <div class="pending-files" data-files></div>
59
59
  <div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
60
60
  BoardAttach.paint('item:' + key, B.site.root);
61
61
  }
62
62
  syncDone(el, key);
63
63
  B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
64
+ for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
64
65
  B.flashChanged?.(el);
65
66
  // A thread's request is answered by a later thread line, so only a pending one changes the eye.
66
67
  const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
@@ -105,10 +106,8 @@ BoardView.part((B) => {
105
106
  const b = ev.target.closest('form[data-reply] button[data-quick]');
106
107
  if (!b) return;
107
108
  const key = b.closest('form').dataset.reply;
108
- b.disabled = true;
109
- try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
109
+ try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
110
110
  catch { toast('Could not send'); }
111
- b.disabled = false;
112
111
  });
113
112
  B.on(B.root, 'click', async (ev) => {
114
113
  const b = ev.target.closest('form[data-reply] button[data-proto]');
@@ -121,17 +120,17 @@ BoardView.part((B) => {
121
120
  const n = protoNotice(pending, latest);
122
121
  if (!n.bump) { B.notice(n.text); return; }
123
122
  if (!(await B.confirm(n.text))) return;
124
- await B.bumpAsk(pending);
123
+ await B.bumpAsk(pending, b);
125
124
  return;
126
125
  }
127
126
  if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
128
- b.disabled = true;
129
127
  try {
130
- await B.sentHere(key, () => B.send('prototype-request', { key }));
128
+ await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
129
+ b.disabled = true;
131
130
  b.title = b.ariaLabel = 'Prototypes asked for';
132
131
  toast('Asked Claude for prototypes');
133
132
  loadThread(key, { force: true });
134
- } catch { b.disabled = false; toast('Could not send'); }
133
+ } catch { toast('Could not send'); }
135
134
  });
136
135
  B.on(B.root, 'submit', async (ev) => {
137
136
  const f = ev.target.closest('form[data-reply]');
@@ -144,14 +143,12 @@ BoardView.part((B) => {
144
143
  const files = BoardAttach.files(id, B.site.root);
145
144
  if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
146
145
  const key = f.dataset.reply;
147
- const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
148
146
  try {
149
- await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
150
- () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) })));
147
+ await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
148
+ () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) }))));
151
149
  BoardAttach.clear(id, B.site.root);
152
- btn.disabled = false;
153
150
  loadThread(f.dataset.reply, { force: true });
154
- } catch { B.notice('Could not send. Is the host awake?'); btn.disabled = false; }
151
+ } catch { B.notice('Could not send. Is the host awake?'); }
155
152
  });
156
153
  // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
157
154
  B.on(B.root, 'attach-change', (ev) => {
@@ -41,7 +41,9 @@ window.BoardBoot = (function () {
41
41
  <script src="/_hub/app/ui.js"></script>
42
42
  <script src="/_hub/app/ui-links.js"></script>
43
43
  <script src="/_hub/app/ui-enums.js"></script>
44
+ <script src="/_hub/app/ui-phase-bars.js"></script>
44
45
  <script src="/_hub/app/ui-order.js"></script>
46
+ <script src="/_hub/app/ui-patch.js"></script>
45
47
  <script src="/_hub/app/ui-threads.js"></script>
46
48
  <script src="/_hub/app/ui-cards.js"></script>
47
49
  <script src="/_hub/app/ui-header.js"></script>
@@ -77,7 +79,6 @@ window.BoardBoot = (function () {
77
79
  <script src="/_hub/app/view-notes.js"></script>
78
80
  <script src="/_hub/app/view-hub.js"></script>
79
81
  <script src="/_hub/app/view-hub-settings.js"></script>
80
- <script src="/_hub/app/view-polling.js"></script>
81
82
  <script src="/_hub/app/view-frame.js"></script>
82
83
  <script src="/_hub/app/router.js"></script>
83
84
  <script src="/_hub/app/shell.js"></script>