@lovek4d/kickflip 0.5.3 → 0.6.1

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 (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  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-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -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, 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) {
@@ -215,7 +215,7 @@ BoardView.part((B) => {
215
215
  const pr = it.pr;
216
216
  const ctx = links(it.key);
217
217
  const rows = [];
218
- rows.push(['Wave', esc(it.waveTitle)]);
218
+ rows.push(['Wave', escText(it.waveTitle)]);
219
219
  if (pr) {
220
220
  const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
221
221
  if (pr.state === 'OPEN') {
@@ -269,14 +269,13 @@ BoardView.part((B) => {
269
269
  const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
270
270
  const CONFIRM = {
271
271
  resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
272
- nudge: () => 'Nudge Claude to check this implementer?',
273
272
  // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
274
273
  merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
275
274
  : openPRs(it).length > 1
276
275
  ? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
277
276
  : `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
278
- 'card-build': (it, ref) => (it.blockedBy?.length
279
- ? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
277
+ 'card-build': (it, ref) => (it.blocked
278
+ ? `${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.`
280
279
  : `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
281
280
  finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
282
281
  : it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
@@ -297,14 +296,18 @@ BoardView.part((B) => {
297
296
  const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
298
297
  const hold = it.kind !== 'work' || !it.holdable ? ''
299
298
  : it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
299
+ // A Todo card's Close, right of Hold; an In Review card's Done is in its thread box
300
+ // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
301
+ const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
300
302
  // A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
301
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
303
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${mergeButton(it)}`)}</div>`;
302
304
  }
303
305
 
304
306
  // 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).
307
+ // Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
308
+ // (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
305
309
  const ACTION_BUTTONS = {
306
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
307
- [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
310
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
308
311
  };
309
312
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
310
313
 
@@ -358,7 +361,7 @@ BoardView.part((B) => {
358
361
  // After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
359
362
  // entering strip grows in one bar at a time, then sweeps each reached bar.
360
363
  function moveStrips(items, before) {
361
- const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.reached ?? null]));
364
+ const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.done ?? null]));
362
365
  const still = reduceMotion.matches || !drawn;
363
366
  for (const [key, reached] of next) {
364
367
  const kind = still ? null : phaseMotion(phases.get(key), reached);
@@ -392,6 +395,7 @@ BoardView.part((B) => {
392
395
  const key = el.dataset.key;
393
396
  if (el.open) {
394
397
  state.openItems.add(key);
398
+ state.restored.delete(key);
395
399
  const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
396
400
  markSeen(it);
397
401
  B.renderBanner(state.board);
@@ -457,12 +461,15 @@ BoardView.part((B) => {
457
461
  catch (e) { B.failed(e); }
458
462
  });
459
463
 
460
- B.on(B.root, 'click', async (ev) => {
464
+ B.on(B.root, 'click', (ev) => {
461
465
  const b = ev.target.closest('button[data-confirm]');
462
466
  if (!b) return;
463
467
  ev.preventDefault();
468
+ confirmTap(b);
469
+ });
470
+ // A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
471
+ async function confirmTap(b) {
464
472
  const { key, confirm: act } = b.dataset;
465
- if (b.classList.contains('nudging')) return;
466
473
  const it = state.board.items.find((i) => i.key === key);
467
474
  if (it && act === 'hold') { openHold(it); return; }
468
475
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
@@ -470,41 +477,27 @@ BoardView.part((B) => {
470
477
  // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
471
478
  const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
472
479
  finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
473
- if (sent) {
474
- // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
475
- // 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).
476
- const close = act === 'finish' ? () => {
477
- const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
478
- if (card) BoardUI.closeDetails(card);
479
- state.openItems.delete(key);
480
- store.set('openItems', [...state.openItems]);
481
- } : null;
482
- // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
483
- // In Review, or no longer merging there, which is its refusal.
484
- const pend = act === 'merge' ? `${key}:merge` : null;
485
- if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
486
- try {
487
- const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
488
- if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
489
- } catch (e) {
490
- if (pend) { BoardUI.pending.drop(pend); B.render(); }
491
- B.failed(e);
492
- }
493
- if (act === 'finish') B.syncDone(key);
494
- return;
495
- }
496
- // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
497
- // changed, the builder no longer stalled or the card gone (style.css .nudging).
498
- const id = `${key}:nudge`;
499
- const { progress, at } = it.builder;
500
- BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
501
- B.render();
480
+ // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
481
+ // 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).
482
+ const close = act === 'finish' ? () => {
483
+ const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
484
+ if (card) BoardUI.closeDetails(card);
485
+ state.openItems.delete(key);
486
+ store.set('openItems', [...state.openItems]);
487
+ } : null;
488
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
489
+ // In Review, or no longer merging there, which is its refusal.
490
+ const pend = act === 'merge' ? `${key}:merge` : null;
491
+ if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
502
492
  try {
503
- const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
504
- BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
505
- } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
506
- B.render();
507
- });
493
+ const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
494
+ if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
495
+ } catch (e) {
496
+ if (pend) { BoardUI.pending.drop(pend); B.render(); }
497
+ B.failed(e);
498
+ }
499
+ if (act === 'finish') B.syncSends(key);
500
+ }
508
501
 
509
- Object.assign(B, { captureStrips, moveStrips, detail, card });
502
+ Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
510
503
  });
@@ -85,27 +85,26 @@ BoardView.part((B) => {
85
85
  state.link = 'online';
86
86
  const controls = { wired: false, boardSettled: false, reloading: false };
87
87
  function applyControls() {
88
- const on = headerControls({ link: state.link, ...controls });
88
+ const on = headerControls(controls);
89
89
  $('#refresh').disabled = !on.refresh;
90
90
  $('#refresh').classList.toggle('spinning', on.spin);
91
91
  const gear = $('#settingsLink');
92
92
  if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
93
93
  }
94
94
  const headerWired = () => { controls.wired = true; applyControls(); };
95
- // 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).
96
97
  function setLink(link) {
97
- const was = state.link;
98
98
  state.link = link;
99
99
  if (link !== 'reconnecting') controls.boardSettled = true;
100
- const off = link !== 'online';
101
- B.root.classList.toggle('offline', off);
102
- if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
100
+ B.root.classList.toggle('offline', link !== 'online');
103
101
  applyControls();
104
102
  renderStatus();
105
103
  }
104
+ BoardUI.holdSends(B.on, B.root, () => state.link !== 'online', () => B.toast(BoardUI.WAITING));
106
105
  const linkUp = () => setLink('online');
107
106
  const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
108
- B.on($('.nav'), 'click', (ev) => {
107
+ B.on($('#settingsLink'), 'click', (ev) => {
109
108
  if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
110
109
  }, true);
111
110
  // A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
@@ -1,25 +1,24 @@
1
1
  // The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
2
- // Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
2
+ // Done's finished-wave folds, and Todo's wave bands with their Implement 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, waitOn } = BoardUI;
6
+ const { esc, escText, waitOn } = BoardUI;
7
7
  const LANES = [
8
8
  { id: 'review', name: 'In Review', open: true },
9
- { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
10
9
  { id: 'progress', name: 'In Progress', open: true },
11
10
  { id: 'todo', name: 'Todo', open: true },
12
11
  { id: 'done', name: 'Done', open: false, capped: true },
13
12
  { id: 'asks', name: 'Asks', open: false, capped: true },
14
13
  ];
15
14
  // docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
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/2026-10-29-1639-lists-never-cut-silently.md).
15
+ // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner opened
16
+ // this visit stays shown past the cap; one open from before a reload does not (state.restored). Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
18
17
  // older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
19
18
  const STEP = 10;
20
19
  const moreTaps = new Map();
21
20
  function cappedRows(laneId, cards, older = 0) {
22
- const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
21
+ const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key) && !state.restored.has(it.key));
23
22
  const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
24
23
  const left = Math.max(0, cards.length - shown);
25
24
  const n = left + (older || 0);
@@ -55,11 +54,11 @@ BoardView.part((B) => {
55
54
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
56
55
  // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
57
56
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
58
- // A fold's header reads like a lane's: its name, then its count, then an optional note.
59
- function fold(id, list, name, note = '') {
57
+ // A fold's header reads like a lane's: its name, then its count.
58
+ function fold(id, list, name) {
60
59
  if (!list.length) return '';
61
60
  return `<details class="fold" data-fold="${esc(id)}" ${store.get(FOLDS[id].store, false) ? 'open' : ''}>
62
- <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span>${note ? `<span class="count">· ${note}</span>` : ''}</summary>
61
+ <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span></summary>
63
62
  <div class="fold-list">${list.map((it) => B.card(it)).join('')}</div>
64
63
  </details>`;
65
64
  }
@@ -69,8 +68,7 @@ BoardView.part((B) => {
69
68
  const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
70
69
  return slugs.map((slug) => {
71
70
  const w = d.waves.find((x) => x.slug === slug);
72
- const { dropped } = w.finished;
73
- return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), esc(w.title), dropped ? `${dropped} dropped` : '');
71
+ return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), escText(w.title));
74
72
  }).join('');
75
73
  }
76
74
 
@@ -94,7 +92,6 @@ BoardView.part((B) => {
94
92
  return reviewsHtml(d) + LANES.map((lane) => {
95
93
  // Items arrive in each lane's fixed order (domain.mjs laneOrder).
96
94
  const items = shown.filter((i) => i.lane === lane.id);
97
- if (lane.hideEmpty && !items.length) return '';
98
95
  const expanded = laneState[lane.id] ?? lane.open;
99
96
  const open = items.filter((i) => !i.fold);
100
97
  const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
@@ -109,19 +106,18 @@ BoardView.part((B) => {
109
106
  // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
110
107
  // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
111
108
  // band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
112
- // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
113
- // the lane's own number bold in its colour and a tooltip that spells the counts out.
109
+ // counts its wave's cards in Todo, In Progress, In Review and Done as x/y/k/z, from every lane, with
110
+ // the lane's own number bold in that lane's colour (style.css: review blue, progress orange, Todo and Done gray) and a tooltip that spells the counts out.
114
111
  // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
115
112
  let countsShown = null;
116
113
  const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
117
114
  function waveCounts(band, laneId) {
118
- const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
115
+ const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane), new Map());
119
116
  const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
120
- const here = laneId === 'stale' ? 'progress' : laneId;
121
117
  const id = `${laneId}/${band.slug}`;
122
118
  const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
123
119
  const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
124
- return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
120
+ return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === laneId ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
125
121
  }
126
122
  function waveGroups(laneId, cards) {
127
123
  const bands = state.board.waveBands || [];
@@ -130,7 +126,7 @@ BoardView.part((B) => {
130
126
  if (!grouped.length) return cards.map((it) => B.card(it)).join('');
131
127
  const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
132
128
  const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
133
- return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
129
+ return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, escText(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
134
130
  + (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
135
131
  }
136
132
 
@@ -141,16 +137,9 @@ BoardView.part((B) => {
141
137
  // card whose ⋯ menu is open, goalShown the band whose goal popover is open.
142
138
  let menu = null;
143
139
  let goalShown = null;
144
- const triaging = new Set();
145
- const triageTopic = (r) => `${r.kind}:${r.slug}`;
146
- function triageLink(r) {
147
- 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>';
148
- if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
149
- return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
150
- }
151
140
  const bandStore = (slug) => FOLDS[`band:${slug}`].store;
152
141
  const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
153
- const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
142
+ const LANE_WORDS = { progress: 'in progress', review: 'in review' };
154
143
  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>';
155
144
 
156
145
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
@@ -169,8 +158,8 @@ BoardView.part((B) => {
169
158
  const todo = cards.filter((i) => i.lane === 'todo');
170
159
  const done = cards.filter((i) => i.lane === 'done');
171
160
  const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
172
- const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
173
- const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
161
+ const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
162
+ const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
174
163
  const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
175
164
  const next = band.next;
176
165
  const go = !next ? '' : next.ready
@@ -178,8 +167,8 @@ BoardView.part((B) => {
178
167
  : `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
179
168
  const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
180
169
  const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
181
- + `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
182
- + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
170
+ + `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
171
+ + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
183
172
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
184
173
  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>` : '';
185
174
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
@@ -250,21 +239,6 @@ BoardView.part((B) => {
250
239
  } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
251
240
  return;
252
241
  }
253
- const tb = ev.target.closest('button[data-triage]');
254
- if (tb) {
255
- const topic = tb.dataset.triage;
256
- const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
257
- const asks = row.kind === 'lineup'
258
- ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
259
- : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
260
- if (!(await B.confirm(asks))) return;
261
- triaging.add(topic);
262
- B.render();
263
- try { await B.send('wave-triage', { [row.kind]: row.slug }); }
264
- catch { B.notice('Could not send. Is the host awake?'); }
265
- finally { triaging.delete(topic); B.render(); }
266
- return;
267
- }
268
242
  const show = ev.target.closest('a[data-band-show]');
269
243
  if (show) {
270
244
  ev.preventDefault();
@@ -5,12 +5,14 @@
5
5
  // (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
6
6
  // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
7
7
  // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
8
- // a line that only asks questions, and with nothing else left the orange section is gone. A link into those
9
- // questions, wherever it sits on the board, picks its topic's pill there instead of opening the page.
8
+ // a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
9
+ // here at that width (shell.js go, focusQuestion), and a pill's double-click focuses its card on the board
10
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md).
11
+ // Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
10
12
  // Needs app-core.js.
11
13
  BoardView.part((B) => {
12
14
  const { $, plural, state, toast } = B;
13
- const { esc, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
15
+ const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
14
16
 
15
17
  const prompts = PromptAnswers.wire(B, {
16
18
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -26,22 +28,33 @@ BoardView.part((B) => {
26
28
  const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
27
29
  renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
28
30
  renderDeck(d, needs);
31
+ renderAsk(d);
32
+ }
33
+
34
+ // The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
35
+ function renderAsk(d) {
36
+ const open = (d.owner || []).filter((q) => q.state === 'open').length;
37
+ const ask = $('#questionsLink');
38
+ const badge = ask.querySelector('.q-badge');
39
+ badge.hidden = !open;
40
+ badge.textContent = open ? String(open) : '';
41
+ ask.setAttribute('aria-label', open ? `Open questions, ${open} open` : 'Open questions');
29
42
  }
30
43
 
31
44
  function renderList(needs) {
32
45
  const parts = (line) => [
33
46
  line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
34
47
  line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
35
- line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
48
+ line.action ? `<a href="${esc(line.action.url)}">${escText(line.action.label)}</a>` : '',
36
49
  ].filter(Boolean).join(' · ');
37
50
  const w = $('#waiting');
38
51
  w.hidden = needs.length === 0;
39
52
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
40
53
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
41
- : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
42
- : 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>`
43
- : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
44
- : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
54
+ : line.key ? `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
55
+ : line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${escText(line.title)}</a> <span class="muted small">· review held for you</span></li>`
56
+ : line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${escText(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
57
+ : `<li><a href="${esc(line.url)}">${escText(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
45
58
  }
46
59
 
47
60
  // The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
@@ -64,14 +77,14 @@ BoardView.part((B) => {
64
77
  : ['Open', line.url, ''];
65
78
  const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
66
79
  return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
67
- html: `<h3>${line.key ? `<a href="${esc(line.url)}">${esc(title)}</a>` : esc(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
80
+ html: `<h3>${line.key ? `<a href="${esc(line.url)}">${escText(title)}</a>` : escText(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
68
81
  });
69
82
  }
70
83
  const questionHtml = (q) => {
71
84
  const picked = getDraft(q.id).choice;
72
- return `<h3>${esc(q.question)}</h3>${q.context ? `<p class="ctx">${esc(q.context)}</p>` : ''}
85
+ return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
73
86
  ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
74
- <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${esc(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
87
+ <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
75
88
  };
76
89
 
77
90
  // The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
@@ -88,7 +101,7 @@ BoardView.part((B) => {
88
101
  const open = (d.owner || []).filter((q) => q.state === 'open').length;
89
102
  const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
90
103
  BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
91
- <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${esc(now.what)}</p>${now.html}</div>
104
+ <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
92
105
  ${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-skip>Skip →</button></div>` : ''}
93
106
  ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
94
107
  }
@@ -121,25 +134,17 @@ BoardView.part((B) => {
121
134
  questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
122
135
  questions.mount();
123
136
  }
124
- // From 900px, a link into open questions for a topic the pane holds picks that topic's pill there, rather than
125
- // navigating to the page. It runs before the router's listener, which skips a prevented click.
126
- B.on(B.root, 'click', (ev) => {
127
- if (ev.defaultPrevented || ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || !questions) return;
128
- const a = ev.target.closest?.('a[href]');
129
- if (!a || (a.target && a.target !== '_self')) return;
130
- const home = new URL(`${B.site.root}/`, location.href);
131
- const url = new URL(a.getAttribute('href'), home);
132
- if (url.pathname !== new URL('questions.html', home).pathname || url.hash.length < 2) return;
133
- const id = decodeURIComponent(url.hash.slice(1));
137
+ // The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
138
+ // or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
139
+ // is no pane or it hasn't loaded, so the shell asks again.
140
+ function focusQuestion(id) {
134
141
  const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
135
- const pill = topic && questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
136
- if (!pill) return;
137
- ev.preventDefault();
138
- pill.click();
139
- const picked = questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
140
- picked.focus();
141
- picked.scrollIntoView({ block: 'nearest' });
142
- });
142
+ if (!questions?.B.openTopic(topic?.id ?? id)) return false;
143
+ const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
144
+ pill?.focus();
145
+ pill?.scrollIntoView({ block: 'nearest' });
146
+ return true;
147
+ }
143
148
  // A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
144
149
  // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
145
150
  B.on(B.root, 'dblclick', (ev) => {
@@ -153,5 +158,5 @@ BoardView.part((B) => {
153
158
  B.on(wide, 'change', fitPane);
154
159
  B.atUnmount(() => { questions?.unmount(); questions = null; });
155
160
 
156
- Object.assign(B, { renderNeeds });
161
+ Object.assign(B, { renderNeeds, focusQuestion });
157
162
  });