@lovek4d/kickflip 0.6.0 → 0.7.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 (68) hide show
  1. package/CONTEXT.md +42 -26
  2. package/SKILL.md +16 -12
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/event-lines.mjs +1 -0
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +7 -7
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -2
  9. package/board/arch-review.mjs +1 -1
  10. package/board/bin/board.mjs +70 -42
  11. package/board/domain.mjs +7 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +8 -9
  14. package/board/onboarding.mjs +1 -1
  15. package/board/prototype-page.mjs +1 -1
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-banner.js +1 -1
  18. package/board/public/app-cards.js +36 -48
  19. package/board/public/app-lanes.js +6 -9
  20. package/board/public/app-needs.js +107 -26
  21. package/board/public/app-reader.js +52 -42
  22. package/board/public/app-threads.js +14 -10
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +22 -12
  25. package/board/public/progress-review.js +4 -4
  26. package/board/public/prototype-kit.js +25 -11
  27. package/board/public/prototype-switcher.js +53 -33
  28. package/board/public/prototype.html +7 -4
  29. package/board/public/router.js +34 -6
  30. package/board/public/shell.js +23 -16
  31. package/board/public/sidebar.css +7 -2
  32. package/board/public/sidebar.js +3 -0
  33. package/board/public/style.css +69 -30
  34. package/board/public/ui-cards.js +61 -38
  35. package/board/public/ui-enums.js +12 -3
  36. package/board/public/ui-header.js +4 -6
  37. package/board/public/ui-hub.js +7 -4
  38. package/board/public/ui-links.js +1 -1
  39. package/board/public/ui-order.js +9 -6
  40. package/board/public/ui-page-header.js +1 -1
  41. package/board/public/ui-progress-bar.js +6 -7
  42. package/board/public/ui-questions.js +18 -3
  43. package/board/public/ui-sidebar.js +12 -5
  44. package/board/public/ui-snapshot.js +1 -1
  45. package/board/public/ui-threads.js +10 -12
  46. package/board/public/ui.js +7 -4
  47. package/board/public/view-hub.js +3 -1
  48. package/board/public/view-notes.js +1 -1
  49. package/board/public/view-questions.js +19 -3
  50. package/board/public/view-settings.js +60 -28
  51. package/board/server.mjs +19 -19
  52. package/board/stop.mjs +1 -1
  53. package/board/store.mjs +27 -25
  54. package/board/tickets.mjs +1 -1
  55. package/board/transcript.mjs +4 -2
  56. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  57. package/hub/front-door.mjs +1 -1
  58. package/hub/home.mjs +1 -1
  59. package/hub/hub.mjs +6 -6
  60. package/hub/sessions.mjs +2 -2
  61. package/hub/try.mjs +7 -7
  62. package/hub/upgrade.mjs +1 -1
  63. package/hub/usage.mjs +3 -3
  64. package/package.json +1 -1
  65. package/shared/card-prs.mjs +1 -1
  66. package/shared/machine-gate.mjs +2 -2
  67. package/shared/tailscale.mjs +1 -1
  68. package/shared/usage-gate.mjs +1 -1
@@ -8,4 +8,4 @@ What this card does:
8
8
  - Ask the owner what the project is for and which words they use for its parts; their wording goes in verbatim.
9
9
  - If the repo already has a CONTEXT.md or ADRs, extend them rather than start again.
10
10
  `,b=[{id:"onboard-build",title:"Prepare .kickflip/build",notes:p,needsNoBuild:!0},{id:"onboard-context",title:"Write the repo's CONTEXT.md glossary and first ADR",notes:u}];function T(e,i){try{return c("git",["-C",e,"ls-tree",`${i}:.kickflip`],{encoding:"utf8",stdio:["ignore","pipe","ignore"],timeout:3e3}).split(`
11
- `).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),state:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
11
+ `).some(r=>/^100755 blob \S+\tbuild(-.+)?$/.test(r))}catch{return!1}}o(T,"hasBuildScript");function y(e,{prefix:i="INT",hasBuild:s,now:r=new Date}){return b.filter(t=>!(t.needsNoBuild&&s)).map((t,a)=>{const n=`${t.id}-notes.md`;return l.writeFileSync(d.join(d.dirname(e),n),t.notes),{id:t.id,title:t.title,created:new Date(r.getTime()+a).toISOString(),phase:"grilling",ticket:`${i}-${a+1}`,plan:n}})}o(y,"onboardingItems");export{T as hasBuildScript,y as onboardingItems};
@@ -1 +1 @@
1
- var u=Object.defineProperty;var r=(t,e)=>u(t,"name",{value:e,configurable:!0});import o from"node:fs/promises";import c from"node:path";import{fileURLToPath as f}from"node:url";import{takeFile as d}from"./store.mjs";const g=c.join(c.dirname(f(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const i=`prototypes/${e.topic}/${a.key}.js`,s=await o.stat(d(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:s?.isFile()?`${i}?v=${$(s)}`:null,file:i}})),l=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#item=${encodeURIComponent(e.card)}`:"./",title:l,controls:!1},y={prototypeTitle:k(l),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await o.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,i)=>y[i])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
1
+ var d=Object.defineProperty;var r=(t,e)=>d(t,"name",{value:e,configurable:!0});import c from"node:fs/promises";import o from"node:path";import{fileURLToPath as u}from"node:url";import{takeFile as f}from"./store.mjs";const g=o.join(o.dirname(u(import.meta.url)),"public","prototype.html"),k=r(t=>String(t).replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"esc"),$=r(t=>`${Math.floor(t.mtimeMs).toString(36)}-${t.size.toString(36)}`,"versionOf"),p=r(t=>JSON.stringify(t).replace(/</g,"\\u003c").replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029"),"scriptJSON");async function O(t,e){const n=e.served?e.takes:await Promise.all(e.takes.map(async a=>{const l=`prototypes/${e.topic}/${a.key}.js`,i=await c.stat(f(t,e.topic,a.key)).catch(()=>null);return{key:a.key,label:a.label,src:i?.isFile()?`${l}?v=${$(i)}`:null,file:l}})),s=`${e.title} \xB7 prototype`,m={lead:"back",backHref:e.card?`./#selectedCard=${encodeURIComponent(e.card)}`:"./",title:s,controls:!1},y={prototypeTitle:k(s),prototypeHeader:`<!--header ${p(m).replace(/>/g,"\\u003e")}-->`,prototypeData:p({topic:e.topic,title:e.title,takes:n,...e.served?{served:e.served,states:e.states||null}:{}})};return(await c.readFile(g,"utf8")).replace(/\{\{(prototypeTitle|prototypeHeader|prototypeData)\}\}/g,(a,l)=>y[l])}r(O,"prototypePage");export{g as PROTOTYPE_TEMPLATE,k as esc,O as prototypePage,$ as versionOf};
@@ -16,7 +16,7 @@ BoardView.part((B) => {
16
16
  + `<button type="button" class="arch-run" data-arch-open="${esc(last.key)}">Open ${esc(last.ref)}</button>`;
17
17
  }
18
18
  const lastHtml = last ? `<div class="arch-last"><span>Last review: <b>${esc(last.wave)}</b> · ${esc(day(last.at))}</span>`
19
- + `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#item=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
19
+ + `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#selectedCard=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
20
20
  const waits = a.waits ? `<p class="arch-waits" role="status">${esc(a.waits)}</p>` : '';
21
21
  return `${lastHtml}${waits}<button type="button" class="primary arch-run" data-arch-run${starting ? BoardUI.BUSY : a.waits ? ' disabled' : ''}>Run a review of ${esc(a.repo)}</button>`
22
22
  + '<p class="muted">Claude reads the repo and makes a wave. Its first card asks you one question per finding.</p>';
@@ -36,7 +36,7 @@ BoardView.part((B) => {
36
36
 
37
37
  function openCard(key) {
38
38
  sheet.close();
39
- const hash = `#item=${encodeURIComponent(key)}`;
39
+ const hash = `#selectedCard=${encodeURIComponent(key)}`;
40
40
  if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange'));
41
41
  else location.hash = hash;
42
42
  }
@@ -33,7 +33,7 @@ BoardView.part((B) => {
33
33
  if (!open) return;
34
34
  const key = open.dataset.answer;
35
35
  markSeen(d.items.find((i) => i.key === key));
36
- const hash = `#item=${encodeURIComponent(key)}`;
36
+ const hash = `#selectedCard=${encodeURIComponent(key)}`;
37
37
  if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
38
38
  location.hash = hash;
39
39
  });
@@ -249,7 +249,7 @@ BoardView.part((B) => {
249
249
  </div>`;
250
250
  }
251
251
 
252
- const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
252
+ const cardLink = (l) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
253
253
  // A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
254
254
  // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
255
255
  const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
@@ -269,7 +269,6 @@ 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
@@ -301,24 +300,21 @@ BoardView.part((B) => {
301
300
  // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
302
301
  const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
303
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).
304
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
303
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${mergeButton(it)}`)}</div>`;
305
304
  }
306
305
 
307
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).
308
309
  const ACTION_BUTTONS = {
309
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
310
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
310
311
  };
311
312
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
312
- // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
313
- // On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
314
- // the viewer on the latest round, solid orange while a round waits on the owner's pick.
315
- const prototypesButton = (it) => {
316
- const latest = it.prototypes?.rounds.at(-1);
317
- return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
318
- };
319
313
 
320
- // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
321
- const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
314
+ // A bumped New card takes no second bump, which would only move it behind the earlier bumps
315
+ // (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md,
316
+ // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md).
317
+ const bumpedAsk = (it) => Boolean(it.isNew && it.bumpedAt);
322
318
  // band {step, actions} draws the card in a wave band (app-lanes.js).
323
319
  // Its open questions asked since this browser last opened it are one more unread message: the thread's "N new
324
320
  // questions" line (app-threads.js; docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
@@ -328,7 +324,7 @@ BoardView.part((B) => {
328
324
  return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
329
325
  <summary class="work">
330
326
  <span class="status ${it.lane}"></span>
331
- <div class="title-wrap">${workLines(it, { unread, top: latestBump(it), ...band })}</div>
327
+ <div class="title-wrap">${workLines(it, { unread, top: bumpedAsk(it), ...band })}</div>
332
328
  </summary>
333
329
  ${state.openItems.has(it.key) ? detail(it) : ''}
334
330
  </details>`;
@@ -395,6 +391,13 @@ BoardView.part((B) => {
395
391
  drawn = true;
396
392
  }
397
393
 
394
+ // A card the owner opens is the URL's selected card. A card restored open on load fires toggle too, so the tap is
395
+ // what counts, not the toggle.
396
+ B.on(B.root, 'click', (ev) => {
397
+ const card = ev.target.closest?.('details.item > summary')?.parentNode;
398
+ if (card && !card.open) B.saveFragment({ selectedCard: card.dataset.key });
399
+ });
400
+
398
401
  B.on(B.root, 'toggle', (ev) => {
399
402
  const el = ev.target;
400
403
  if (!el.matches?.('details.item')) return;
@@ -476,7 +479,6 @@ BoardView.part((B) => {
476
479
  // A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
477
480
  async function confirmTap(b) {
478
481
  const { key, confirm: act } = b.dataset;
479
- if (b.classList.contains('nudging')) return;
480
482
  const it = state.board.items.find((i) => i.key === key);
481
483
  if (it && act === 'hold') { openHold(it); return; }
482
484
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
@@ -484,40 +486,26 @@ BoardView.part((B) => {
484
486
  // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
485
487
  const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
486
488
  finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
487
- if (sent) {
488
- // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
489
- // 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).
490
- const close = act === 'finish' ? () => {
491
- const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
492
- if (card) BoardUI.closeDetails(card);
493
- state.openItems.delete(key);
494
- store.set('openItems', [...state.openItems]);
495
- } : null;
496
- // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
497
- // In Review, or no longer merging there, which is its refusal.
498
- const pend = act === 'merge' ? `${key}:merge` : null;
499
- if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
500
- try {
501
- const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
502
- if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
503
- } catch (e) {
504
- if (pend) { BoardUI.pending.drop(pend); B.render(); }
505
- B.failed(e);
506
- }
507
- if (act === 'finish') B.syncSends(key);
508
- return;
509
- }
510
- // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
511
- // changed, the builder no longer stalled or the card gone (style.css .nudging).
512
- const id = `${key}:nudge`;
513
- const { progress, at } = it.builder;
514
- BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
515
- B.render();
489
+ // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
490
+ // 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).
491
+ const close = act === 'finish' ? () => {
492
+ const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
493
+ if (card) BoardUI.closeDetails(card);
494
+ state.openItems.delete(key);
495
+ store.set('openItems', [...state.openItems]);
496
+ } : null;
497
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
498
+ // In Review, or no longer merging there, which is its refusal.
499
+ const pend = act === 'merge' ? `${key}:merge` : null;
500
+ if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
516
501
  try {
517
- const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
518
- BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
519
- } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
520
- B.render();
502
+ const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
503
+ if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
504
+ } catch (e) {
505
+ if (pend) { BoardUI.pending.drop(pend); B.render(); }
506
+ B.failed(e);
507
+ }
508
+ if (act === 'finish') B.syncSends(key);
521
509
  }
522
510
 
523
511
  Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
@@ -6,7 +6,6 @@ BoardView.part((B) => {
6
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 },
@@ -93,7 +92,6 @@ BoardView.part((B) => {
93
92
  return reviewsHtml(d) + LANES.map((lane) => {
94
93
  // Items arrive in each lane's fixed order (domain.mjs laneOrder).
95
94
  const items = shown.filter((i) => i.lane === lane.id);
96
- if (lane.hideEmpty && !items.length) return '';
97
95
  const expanded = laneState[lane.id] ?? lane.open;
98
96
  const open = items.filter((i) => !i.fold);
99
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);
@@ -108,19 +106,18 @@ BoardView.part((B) => {
108
106
  // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
109
107
  // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
110
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
111
- // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
109
+ // counts its wave's cards in Todo, In Progress, In Review and Done as x/y/k/z, from every lane, with
112
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.
113
111
  // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
114
112
  let countsShown = null;
115
113
  const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
116
114
  function waveCounts(band, laneId) {
117
- 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());
118
116
  const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
119
- const here = laneId === 'stale' ? 'progress' : laneId;
120
117
  const id = `${laneId}/${band.slug}`;
121
118
  const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
122
119
  const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
123
- 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>`;
124
121
  }
125
122
  function waveGroups(laneId, cards) {
126
123
  const bands = state.board.waveBands || [];
@@ -142,7 +139,7 @@ BoardView.part((B) => {
142
139
  let goalShown = null;
143
140
  const bandStore = (slug) => FOLDS[`band:${slug}`].store;
144
141
  const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
145
- const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
142
+ const LANE_WORDS = { progress: 'in progress', review: 'in review' };
146
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>';
147
144
 
148
145
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
@@ -173,7 +170,7 @@ BoardView.part((B) => {
173
170
  + `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
174
171
  + waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
175
172
  if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
176
- 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>` : '';
173
+ const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#selectedCard=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
177
174
  const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
178
175
  const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
179
176
  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>`;
@@ -191,7 +188,7 @@ BoardView.part((B) => {
191
188
  + (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
192
189
  }
193
190
 
194
- // Opens the band holding card key or named slug, for a #item= link or a Needs you line.
191
+ // Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
195
192
  function openBand({ key, slug }) {
196
193
  const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
197
194
  if (band) store.set(bandStore(band.slug), true);
@@ -1,18 +1,21 @@
1
1
  // The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
2
2
  // (prompts.js). It draws twice, and the stylesheet shows one by width
3
3
  // (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
4
- // - on a phone, #needsDeck at the top of the board: one thing at a time, a question's options drafting its answer on the tap
5
- // (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
4
+ // - on a phone, #needsDeck at the top of the board: one thing at a time, a question's option marked on the first tap,
5
+ // turning into Send “<label>”, and sent with the question's drafted note and files on a tap on it
6
+ // (docs/adr/2026-11-07-1212-a-deck-option-sends-on-a-second-tap.md), a prompt's buttons answering it, Prev and Next
7
+ // beside a dot for each thing waiting, and All open questions at its foot;
6
8
  // - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
7
9
  // (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
8
10
  // 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
+ // here at that width (shell.js go, focusQuestion), and two taps on a pill focus its card on the board
12
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
13
+ // Waiting on you until its ‹ Open questions (openViewer).
11
14
  // Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
12
15
  // Needs app-core.js.
13
16
  BoardView.part((B) => {
14
17
  const { $, plural, state, toast } = B;
15
- const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
18
+ const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
16
19
 
17
20
  const prompts = PromptAnswers.wire(B, {
18
21
  list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
@@ -29,6 +32,7 @@ BoardView.part((B) => {
29
32
  renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
30
33
  renderDeck(d, needs);
31
34
  renderAsk(d);
35
+ if (viewer) paintViewer(d);
32
36
  }
33
37
 
34
38
  // The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
@@ -80,11 +84,17 @@ BoardView.part((B) => {
80
84
  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>` }];
81
85
  });
82
86
  }
87
+ // The marked option, the question's drafted pick, reads Send “<label>”.
83
88
  const questionHtml = (q) => {
84
- const picked = getDraft(q.id).choice;
89
+ const marked = getDraft(q.id).choice;
90
+ const optionHtml = (o) => {
91
+ const armed = o.id === marked;
92
+ const label = armed ? `Send “${esc(o.label)}”` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
93
+ return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`;
94
+ };
85
95
  return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
86
96
  ${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
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>`;
97
+ <div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
88
98
  };
89
99
 
90
100
  // 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.
@@ -98,46 +108,73 @@ BoardView.part((B) => {
98
108
  if (!deck.length) { el.innerHTML = ''; showing = null; return; }
99
109
  const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
100
110
  showing = now.id;
111
+ const at = deck.indexOf(now);
101
112
  const open = (d.owner || []).filter((q) => q.state === 'open').length;
102
113
  const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
103
114
  BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
104
115
  <div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
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>` : ''}
116
+ ${deck.length > 1 ? `<div class="deck-bar"><button type="button" class="ghost" data-deck-prev${at === 0 ? ' disabled' : ''}>← Prev</button><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-next${at === deck.length - 1 ? ' disabled' : ''}>Next →</button></div>` : ''}
106
117
  ${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
107
118
  }
108
119
 
120
+ // The deck's index one step from the thing on screen; the ends hold.
121
+ const stepFrom = (step) => Math.min(Math.max(deck.findIndex((x) => x.id === showing) + step, 0), deck.length - 1);
109
122
  B.on(B.root, 'click', (ev) => {
110
123
  if (!ev.target.closest?.('#needsDeck')) return;
111
- if (ev.target.closest('[data-deck-skip]')) {
112
- const at = deck.findIndex((x) => x.id === showing);
113
- showing = deck[(at + 1) % deck.length].id;
124
+ const step = ev.target.closest('[data-deck-prev]') ? -1 : ev.target.closest('[data-deck-next]') ? 1 : 0;
125
+ if (step) {
126
+ showing = deck[stepFrom(step)].id;
114
127
  renderNeeds(state.board);
115
128
  return;
116
129
  }
117
130
  const opt = ev.target.closest('button[data-deck-pick]');
118
131
  if (!opt) return;
119
132
  const id = opt.closest('[data-deck]').dataset.deck.slice(2);
120
- setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
121
- const at = deck.findIndex((x) => x.id === showing);
122
- showing = deck[(at + 1) % deck.length].id;
123
- renderNeeds(state.board);
133
+ const choice = opt.dataset.deckPick;
134
+ const draft = getDraft(id);
135
+ if (draft.choice !== choice) {
136
+ setDraft(id, { ...draft, choice });
137
+ renderNeeds(state.board);
138
+ return;
139
+ }
140
+ sendAnswer((state.board.owner || []).find((q) => q.id === id), choice, opt);
124
141
  });
125
142
 
143
+ // The marked option's tap sends it with the question's drafted note and files, as the questions page's Send does, and
144
+ // spins until the board shows the question answered, which takes it off the deck. Files still uploading hold it.
145
+ async function sendAnswer(q, choice, button) {
146
+ const composer = 'q:' + q.id;
147
+ if (BoardAttach.busy(composer, B.site.root)) { toast('Wait for the upload to finish'); return; }
148
+ const files = BoardAttach.files(composer, B.site.root);
149
+ const answer = { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, getDraft(q.id)).trim(), ...(files.length ? { files } : {}) };
150
+ const sent = () => { setDraft(q.id, {}); BoardAttach.clear(composer, B.site.root); };
151
+ try {
152
+ await waitOn(button, BoardUI.pending.within(B.send('owner-answers', { answers: [answer] }, sent), B.stillWaiting));
153
+ } catch (e) { B.failed(e); }
154
+ }
155
+
126
156
  // From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
127
157
  let questions = null;
158
+ // Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
159
+ // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
160
+ function openOnBoard(id) {
161
+ if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
162
+ else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
163
+ }
128
164
  function fitPane() {
129
165
  if (wide.matches === Boolean(questions)) return;
130
166
  if (state.board) renderNeeds(state.board);
131
- if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
167
+ if (questions) { closeViewer(); questions.unmount(); questions.slot.remove(); questions = null; return; }
132
168
  $('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
133
169
  const slot = $('#needsPane .needs-questions');
134
- questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
170
+ questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true, openOnBoard, onTopic: (id) => B.saveFragment({ openItemsCard: id }) }), { slot });
135
171
  questions.mount();
136
172
  }
137
173
  // 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
174
  // or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
139
175
  // is no pane or it hasn't loaded, so the shell asks again.
140
176
  function focusQuestion(id) {
177
+ closeViewer();
141
178
  const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
142
179
  if (!questions?.B.openTopic(topic?.id ?? id)) return false;
143
180
  const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
@@ -145,18 +182,62 @@ BoardView.part((B) => {
145
182
  pill?.scrollIntoView({ block: 'nearest' });
146
183
  return true;
147
184
  }
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>),
149
- // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
150
- B.on(B.root, 'dblclick', (ev) => {
151
- const pill = questions && ev.target.closest?.('button[data-topic]');
152
- if (!pill || !questions.slot.contains(pill)) return;
153
- const id = pill.dataset.topic;
154
- if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
155
- else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
185
+
186
+ // From 900px a prototype opens in the pane (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md,
187
+ // shell.js go): a top bar of ‹ Open questions, ⤢ and the card's Round pill over the prototype page framed at the
188
+ // pane's width, which shows its footer at the bottom of the pane (prototype-switcher.js .ps-pane). ⤢ widens the pane
189
+ // over the lanes and back. ‹ returns to Waiting on you with the round's question focused, as Choose this does through
190
+ // the shell. viewer: {url, q, widened, sheet} while one is open. → false while there is no pane, so the shell asks again.
191
+ let viewer = null;
192
+ function openViewer(url) {
193
+ if (!questions) return false;
194
+ viewer = { url, q: new URL(url, location.href).searchParams.get('q'), widened: viewer?.widened ?? false, sheet: false };
195
+ paintViewer();
196
+ return true;
197
+ }
198
+ // board: the board download it shows, which renderNeeds passes before state.board holds it.
199
+ function paintViewer(board = state.board) {
200
+ const pane = $('#needsPane');
201
+ pane.classList.toggle('viewing', Boolean(viewer));
202
+ pane.classList.toggle('widened', Boolean(viewer?.widened));
203
+ let box = pane.querySelector('.pane-viewer');
204
+ if (!viewer) { box?.remove(); return; }
205
+ if (!box) {
206
+ pane.insertAdjacentHTML('beforeend', '<div class="pane-viewer"><div class="pv-top"></div><iframe class="pv-frame" title="Prototype"></iframe></div>');
207
+ box = pane.querySelector('.pane-viewer');
208
+ }
209
+ const card = (board?.items || []).find((it) => it.prototypes?.rounds?.some((r) => r.q === viewer.q));
210
+ const rounds = card?.prototypes.rounds;
211
+ box.querySelector('.pv-top').innerHTML = `<button type="button" class="pv-back" data-pv="back">‹ Open questions</button><span class="pv-grow"></span>
212
+ <button type="button" class="pv-widen" data-pv="widen" aria-pressed="${viewer.widened}" aria-label="Widen over the lanes" title="Widen over the lanes">⤢</button>${BoardUI.roundPill(rounds, viewer.q)}`;
213
+ box.querySelector('.ps-sheet')?.remove();
214
+ if (viewer.sheet && rounds) box.insertAdjacentHTML('beforeend', BoardUI.roundSheet({ ref: card.ref, rounds, qid: viewer.q }));
215
+ const frame = box.querySelector('iframe.pv-frame');
216
+ if (frame.getAttribute('src') !== viewer.url) frame.setAttribute('src', viewer.url);
217
+ }
218
+ function closeViewer() {
219
+ if (!viewer) return;
220
+ viewer = null;
221
+ paintViewer();
222
+ }
223
+ B.on(B.root, 'click', (ev) => {
224
+ if (!viewer || !ev.target.closest?.('#needsPane .pane-viewer')) return;
225
+ const round = ev.target.closest('[data-ps-round]')?.dataset.psRound;
226
+ if (round) { openViewer(round); return; }
227
+ const act = ev.target.closest('[data-pv], [data-ps-top]');
228
+ if (!act) return;
229
+ if (act.dataset.pv === 'back') { focusQuestion(viewer.q); return; }
230
+ if (act.dataset.pv === 'widen') viewer.widened = !viewer.widened;
231
+ else viewer.sheet = !viewer.sheet;
232
+ paintViewer();
156
233
  });
234
+ // A push to a card's thread (#item=<key>&reply, board/store.mjs pushUrl), the switcher's for a question already picked
235
+ // among them, closes the viewer, so the thread it sent the owner to shows.
236
+ B.on(window, 'hashchange', () => { if (/^#item=[^&]+&reply$/.test(location.hash)) closeViewer(); });
237
+
157
238
  fitPane();
158
239
  B.on(wide, 'change', fitPane);
159
240
  B.atUnmount(() => { questions?.unmount(); questions = null; });
160
241
 
161
- Object.assign(B, { renderNeeds, focusQuestion });
242
+ Object.assign(B, { renderNeeds, focusQuestion, openViewer });
162
243
  });