@lovek4d/kickflip 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -1,40 +1,52 @@
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
- const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
3
+ const { $, store, state, quieted, sendDraft, links } = B;
4
+ const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
5
5
 
6
- const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
7
- // A /prototype or /grill word in a message, the last one winning, and the message without it.
8
- const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
6
+ const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
7
+ // A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
8
+ const SLASH = /(^|\s)\/(grill|prototype|visual)(?=\s|$)/g;
9
9
  const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
10
10
 
11
11
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
12
12
 
13
- // The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
14
- function syncDone(el, key) {
15
- const send = el.querySelector('form button[type=submit]');
16
- if (!send) return;
17
- const html = doneButton(state.board?.items.find((i) => i.key === key));
18
- const current = el.querySelector('form [data-confirm="finish"]');
19
- if (current?.outerHTML === html) return;
20
- current?.remove();
21
- if (html) send.insertAdjacentHTML('beforebegin', html);
13
+ // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
14
+ // docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
15
+ // docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
16
+ // A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
17
+ // action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
18
+ // (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
19
+ // onto its own line under the row.
20
+ const REVIEW = new Set(['review', 'stale']);
21
+ const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
22
+ : REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
23
+ const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
24
+ const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
25
+ : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
26
+ : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
27
+ // The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
28
+ function syncSends(el, key) {
29
+ const at = el.querySelector('form [data-sends]');
30
+ if (!at) return;
31
+ const item = state.board?.items.find((i) => i.key === key);
32
+ const kind = sendsOf(item);
33
+ if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind, item)); at.remove(); }
22
34
  }
23
35
 
24
36
  // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
25
- // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed. A
26
- // board left and opened again in this page keeps them, so its open threads show what they showed.
37
+ // thread draws, so its unread messages start from its read mark from before it opened (B.seenBefore). A board left
38
+ // and opened again in this page keeps them, so its open threads show what they showed.
27
39
  const kept = B.place?.threads;
28
- const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
29
40
  const moreTaps = new Map(kept?.moreTaps);
30
41
  const firstDrawn = new Map(kept?.firstDrawn);
31
- B.keep(() => ({ threads: { seenBefore: [...seenBefore], moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
42
+ B.keep(() => ({ threads: { moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
43
+ const seenAt = (key) => (B.seenBefore.has(key) ? B.seenBefore.get(key) : B.seen[key] || null);
32
44
  const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
33
- const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
45
+ const viewOf = (key) => ({ seenAt: seenAt(key), more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
34
46
  B.on(B.root, 'toggle', (ev) => {
35
47
  const el = ev.target;
36
48
  if (!el.matches?.('details.item') || el.open) return;
37
- seenBefore.set(el.dataset.key, B.seen[el.dataset.key]);
49
+ B.seenBefore.delete(el.dataset.key);
38
50
  moreTaps.delete(el.dataset.key);
39
51
  firstDrawn.delete(el.dataset.key);
40
52
  }, true);
@@ -49,11 +61,11 @@ BoardView.part((B) => {
49
61
  function renderThread(key, msgs) {
50
62
  const el = $(`[data-thread="${CSS.escape(key)}"]`);
51
63
  if (!el) return;
52
- syncDone(el, key);
64
+ syncSends(el, key);
53
65
  const edits = editsIn(key);
54
66
  if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
55
67
  const view = viewOf(key);
56
- const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
68
+ const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenAt(key));
57
69
  const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
58
70
  const sig = JSON.stringify([lines, [...edits.keys()], view]);
59
71
  if (el.dataset.sig === sig) return;
@@ -61,36 +73,24 @@ BoardView.part((B) => {
61
73
  // The reply form is built once per card, so a refresh never drops what is being typed.
62
74
  if (!el.querySelector('.msgs')) {
63
75
  const draft = store.get('draft:' + key, '');
64
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
65
- <div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
76
+ // The row: What's left? at the left, the eye, fire, image and attach at its right, and the send buttons
77
+ // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
78
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`, { attach: false })}
66
79
  <div class="pending-files" data-files></div>
67
- <div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
80
+ <div class="row"><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><span class="icons">${protoButton(key)}${grillButton(key)}${visualButton(key)}${attachButton()}</span>${sends('one')}</div></form>`;
68
81
  BoardAttach.paint('item:' + key, B.site.root);
69
- syncFlag(el.querySelector('form'));
70
82
  }
71
- syncDone(el, key);
83
+ syncSends(el, key);
72
84
  B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
73
85
  for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
74
86
  B.flashChanged?.(el);
75
87
  }
76
88
 
77
- // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
78
- // The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
79
- // with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
80
- const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
81
- const HINT = 'Tap the eye or the fire to request a prototype or a grill';
82
- const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
83
- function syncFlag(f) {
84
- const chosen = f.dataset.flag || null;
85
- f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
86
- f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
87
- const hint = f.querySelector('[data-hint]');
88
- if (!hint) return;
89
- const flag = flagOf(f);
90
- const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
91
- hint.classList.toggle('on', Boolean(flag));
92
- hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
93
- }
89
+ // docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
90
+ // The composer's eye asks for a prototype, its fire for a grill and its image for a visual: a tap sends the request at
91
+ // once, with whatever is typed, as /prototype, /grill or /visual in the text does on Reply. Nothing typed sends the
92
+ // bare request (docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
93
+ const PLACEHOLDER = 'Reply in this thread… (/prototype, /grill or /visual asks for one)';
94
94
 
95
95
  // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
96
96
  // starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
@@ -111,7 +111,7 @@ BoardView.part((B) => {
111
111
  loadedAt.set(key, Date.now());
112
112
  state.threadCache.set(key, msgs);
113
113
  renderThread(key, msgs);
114
- B.saveSnapshotSoon?.();
114
+ B.saveCopySoon?.();
115
115
  })
116
116
  .catch(() => {})
117
117
  .finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
@@ -122,7 +122,6 @@ BoardView.part((B) => {
122
122
  B.on(B.root, 'input', (ev) => {
123
123
  const f = ev.target.closest('form[data-reply]');
124
124
  if (!f || ev.target.tagName !== 'TEXTAREA') return;
125
- syncFlag(f);
126
125
  if (quieted('draft:' + f.dataset.reply, ev.target)) return;
127
126
  store.set('draft:' + f.dataset.reply, ev.target.value);
128
127
  });
@@ -130,49 +129,53 @@ BoardView.part((B) => {
130
129
  const b = ev.target.closest('form[data-reply] button[data-quick]');
131
130
  if (!b) return;
132
131
  const key = b.closest('form').dataset.reply;
133
- try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
134
- catch { toast('Could not send'); }
135
- });
136
- B.on(B.root, 'click', (ev) => {
137
- const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
138
- if (!b) return;
139
- const f = b.closest('form');
140
- const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
141
- if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
142
- syncFlag(f);
132
+ // Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
133
+ try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
134
+ catch (e) { B.failed(e); }
143
135
  });
144
- B.on(B.root, 'submit', async (ev) => {
145
- const f = ev.target.closest('form[data-reply]');
146
- if (!f) return;
147
- ev.preventDefault();
136
+ // Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
137
+ async function post(f, flag, button) {
148
138
  const ta = f.querySelector('textarea');
149
139
  ta.blur();
150
- const flag = flagOf(f);
151
140
  const text = flag ? slashOf(ta.value).text : ta.value.trim();
152
141
  const id = f.dataset.composer;
153
142
  const files = BoardAttach.files(id, B.site.root);
154
- if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
143
+ // An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
144
+ // starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
145
+ const intent = flag ? null : button.dataset.intent || null;
146
+ if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
155
147
  const key = f.dataset.reply;
156
- const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
157
- try {
158
- await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
159
- () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
148
+ const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}), ...(intent ? { intent } : {}) };
149
+ // Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
150
+ const sending = (async () => {
151
+ await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
152
+ () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data)));
160
153
  BoardAttach.clear(id, B.site.root);
161
- delete f.dataset.flag;
162
- syncFlag(f);
163
- loadThread(f.dataset.reply, { force: true });
164
- } catch { B.notice('Could not send. Is the host awake?'); }
154
+ await loadThread(key, { force: true });
155
+ })();
156
+ try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
157
+ }
158
+ B.on(B.root, 'click', (ev) => {
159
+ const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill], form[data-reply] button[data-visual]');
160
+ if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : b.hasAttribute('data-grill') ? 'grill' : 'visual', b);
161
+ });
162
+ B.on(B.root, 'submit', (ev) => {
163
+ const f = ev.target.closest('form[data-reply]');
164
+ if (!f) return;
165
+ ev.preventDefault();
166
+ // Done on ⌥↵ (app-asks.js) confirms and closes the card as its tap does (app-cards.js).
167
+ if (ev.submitter?.dataset.confirm) { B.confirmTap(ev.submitter); return; }
168
+ post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
165
169
  });
166
- // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
170
+ // A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
167
171
  B.on(B.root, 'attach-change', (ev) => {
168
- const b = ev.target.querySelector('button[type=submit]');
169
- if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
170
- if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
172
+ for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
173
+ if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill], button[data-visual]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
171
174
  });
172
175
 
173
176
  Object.assign(B, {
174
177
  thread, renderThread, loadThread,
175
178
  repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
176
- syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
179
+ syncSends: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncSends(el, key); },
177
180
  });
178
181
  });
@@ -6,7 +6,8 @@
6
6
  <title>Claude</title>
7
7
  <script>
8
8
  // The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
9
- // below; app.js loadBoard takes the loads over when it mounts that board. A board's other views load their own, and a
9
+ // below; app.js loadBoard takes the loads over when it mounts that board. With a copy of the board saved under this
10
+ // build (app-snapshot.js copyVersion), it asks only for the changes since the copy's number. A board's other views load their own, and a
10
11
  // site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
11
12
  // after one waits for it to load.
12
13
  window.BoardBoot = (function () {
@@ -16,12 +17,16 @@ window.BoardBoot = (function () {
16
17
  if (!route || route === '_hub') return null;
17
18
  try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
18
19
  const base = '/' + encodeURIComponent(route) + '/';
19
- const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))); p.catch(() => {}); return p; };
20
+ // A failed answer rides on the error, for the board view's BoardUI.downOf.
21
+ const unanswered = (r) => Object.assign(new Error(String(r.status)), { answer: r });
22
+ const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
20
23
  let open = [];
21
24
  try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
22
25
  const focus = /^#item=([^&]+)/.exec(location.hash);
23
26
  if (focus) open.push(decodeURIComponent(focus[1]));
24
- return { route, board: get('api/board'), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
27
+ let since = null;
28
+ try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
29
+ return { route, board: get('api/board' + (since === null ? '' : '?since=' + since)), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
25
30
  })();
26
31
  </script>
27
32
  <script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
@@ -41,7 +46,7 @@ window.BoardBoot = (function () {
41
46
  <script src="/_hub/app/ui.js"></script>
42
47
  <script src="/_hub/app/ui-links.js"></script>
43
48
  <script src="/_hub/app/ui-enums.js"></script>
44
- <script src="/_hub/app/ui-phase-bars.js"></script>
49
+ <script src="/_hub/app/ui-progress-bar.js"></script>
45
50
  <script src="/_hub/app/ui-order.js"></script>
46
51
  <script src="/_hub/app/ui-patch.js"></script>
47
52
  <script src="/_hub/app/ui-threads.js"></script>
@@ -6,11 +6,8 @@
6
6
  // them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
7
7
  // BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
8
8
  // (view-*.js) the same site, $ and recorders.
9
- // opts, both from the app shell (shell.js):
9
+ // opts, from the app shell (shell.js):
10
10
  // signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
11
- // live {fresh, generatedAt}
12
- // this board was left fresh and hasn't changed since, so a stored last visit whose board has
13
- // that generatedAt paints live, with the header time `fresh`, rather than dimmed
14
11
  // Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
15
12
  window.BoardView = (function () {
16
13
  const parts = [];
@@ -27,7 +24,11 @@ window.BoardView = (function () {
27
24
  try {
28
25
  const old = [];
29
26
  for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
30
- for (const k of old.filter((k) => /^(openItems|wave|lanes|seenThreads|dismissedAnswers|draft:ask|foldOpen:.+)$/.test(k))) {
27
+ // Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
28
+ for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
29
+ // The saved copy moved to IndexedDB (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
30
+ for (const k of old.filter((k) => k === 'boardSnapshots' || k.startsWith('boardSnapshot:'))) localStorage.removeItem(k);
31
+ for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
31
32
  if (localStorage.getItem(keyOf(k)) != null) continue;
32
33
  localStorage.setItem(keyOf(k), localStorage.getItem(k));
33
34
  localStorage.removeItem(k);
@@ -127,11 +128,13 @@ window.BoardView = (function () {
127
128
  board: null,
128
129
  fresh: null,
129
130
  openItems: new Set(store.get('openItems', [])),
131
+ // The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
132
+ restored: new Set(store.get('openItems', [])),
130
133
  threadCache: new Map(),
131
134
  edits: new Map(),
132
135
  githubPending: null,
133
136
  // Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
134
- mainPull: { sending: false, open: false },
137
+ mainPull: { open: false },
135
138
  // The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
136
139
  // the archive holds past them, null before the first page.
137
140
  archived: { cards: [], left: null },
@@ -142,7 +145,8 @@ window.BoardView = (function () {
142
145
  const keep = (fn) => { if (B.alive()) keepers.push(fn); };
143
146
  const place = places.get(B.route) || null;
144
147
  places.delete(B.route);
145
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
148
+ Object.assign(B, { store, state, signal, pause: opts.pause, away: opts.away, place, keep });
149
+ BoardUI.usagePause = opts.pause;
146
150
 
147
151
  function mount() {
148
152
  if (B.alive()) return;
@@ -187,7 +191,8 @@ window.BoardView = (function () {
187
191
  BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
188
192
  B.renderMainLog(d.main, load);
189
193
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
190
- for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
194
+ B.takeMarks(d.items);
195
+ for (const it of d.items) if (state.openItems.has(it.key)) markSeen(it);
191
196
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
192
197
  const strips = B.captureStrips();
193
198
  setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
@@ -217,6 +222,7 @@ window.BoardView = (function () {
217
222
  focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
218
223
  replyKey = m[2] ? focusKey : null;
219
224
  state.openItems.add(focusKey);
225
+ state.restored.delete(focusKey);
220
226
  const it = state.board?.items.find((i) => i.key === focusKey);
221
227
  if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
222
228
  if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
@@ -229,6 +235,15 @@ window.BoardView = (function () {
229
235
  ev.preventDefault();
230
236
  readHash(); render(); scrollToFocus();
231
237
  });
238
+ // Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
239
+ function focusOnBoard({ key, slug }) {
240
+ const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
241
+ if (!band && !state.board?.items.some((i) => i.key === key)) return false;
242
+ if (key) { location.hash = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
243
+ render();
244
+ if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
245
+ return true;
246
+ }
232
247
  // A card missing from the last visit's board waits for the fresh one, which is final.
233
248
  function scrollToFocus(final = true) {
234
249
  if (!focusKey) return;
@@ -251,24 +266,24 @@ window.BoardView = (function () {
251
266
  ta.focus({ preventScroll: true });
252
267
  }
253
268
 
254
- // The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
255
- // spinning, and the header's time is the snapshot's until a load lands (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
256
- // → true when it painted live (B.live), so the controls turn on at once.
257
- function paintSnapshot() {
258
- const snap = B.readSnapshot();
259
- if (!snap) return false;
260
- const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
261
- state.board = snap.board;
262
- for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
263
- state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
264
- B.setView(live ? 'fresh' : 'snapshot');
269
+ // The saved copy paints live before the first load, which asks only for the changes since its number; the lines
270
+ // that catch-up changes flash (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). A board that landed
271
+ // meanwhile stays.
272
+ // old: a copy another build saved, whose board the next load asks for whole.
273
+ function paintCopy(copy, { old = false } = {}) {
274
+ if (!copy || !B.alive() || state.view === 'fresh') return;
275
+ state.oldCopy = old;
276
+ state.board = copy.board;
277
+ for (const [key, msgs] of Object.entries(copy.threads)) state.threadCache.set(key, msgs);
278
+ state.fresh = { at: copy.savedAt, sig: null };
279
+ B.setView('fresh');
265
280
  readHash();
266
281
  hashRead = true;
267
282
  render();
268
283
  scrollToFocus(false);
269
284
  returnToPlace();
270
- if (!live) B.holdForFlash();
271
- return live;
285
+ B.holdForFlash();
286
+ B.linkUp();
272
287
  }
273
288
 
274
289
  // The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
@@ -279,6 +294,10 @@ window.BoardView = (function () {
279
294
  globalThis.BoardBoot = null;
280
295
  if (boot?.route && boot.route !== B.route) boot = null;
281
296
  let preloaded = false;
297
+ // A load nothing answers gives up after this, so an asleep host reads as out of reach.
298
+ const LOAD_TIMEOUT = 20000;
299
+ // A failed answer, carried to showDown for BoardUI.downOf.
300
+ const unanswered = (r) => Object.assign(new Error(`board ${r.status}`), { answer: r });
282
301
  async function loadBoard({ via = 'poll' } = {}) {
283
302
  const load = B.boardLoad = (B.boardLoad || 0) + 1;
284
303
  const started = boot;
@@ -286,12 +305,15 @@ window.BoardView = (function () {
286
305
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
287
306
  let landed = false;
288
307
  try {
289
- // A reload names the version of the board on screen: an unchanged board answers 304 with no body
290
- // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch from it
291
- // (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md).
292
- const tag = state.view === 'fresh' ? state.board.version : null;
293
- const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
294
- .then((r) => (r.status === 304 ? null : r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
308
+ // A reload names the change number of the board on screen: an unchanged board answers 304 with no body
309
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch of what
310
+ // changed after it (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
311
+ const tag = state.view === 'fresh' && !state.oldCopy ? state.board.version : null;
312
+ const timeout = new AbortController();
313
+ const stopTimer = B.later(LOAD_TIMEOUT, () => timeout.abort());
314
+ const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', signal: timeout.signal, ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
315
+ .then((r) => (r.status === 304 ? null : r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r))))
316
+ .finally(stopTimer));
295
317
  if (board) land(board, load); else B.linkUp();
296
318
  landed = true;
297
319
  if (!preloaded) {
@@ -300,12 +322,36 @@ window.BoardView = (function () {
300
322
  }
301
323
  } catch (e) {
302
324
  B.linkDown();
325
+ if (state.view !== 'fresh' && B.alive()) await showDown(BoardUI.downOf(e.answer || null));
303
326
  }
304
327
  await Promise.all(threads);
305
328
  if (landed) B.landed(via);
306
329
  if (state.view === 'fresh') B.endFlash();
307
330
  }
308
331
 
332
+ // A board out of reach with nothing on screen paints the copy another build saved, which the first paint skipped,
333
+ // guarded: a paint that throws leaves the landing, the kickflip mark and down's two lines in the board's place, and
334
+ // the landing retries every 5 s while the board can come back by itself
335
+ // (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
336
+ let triedOldCopy = false;
337
+ async function showDown(down) {
338
+ if (!triedOldCopy) {
339
+ triedOldCopy = true;
340
+ const old = await B.readCopy({ anyBuild: true });
341
+ try { paintCopy(old, { old: true }); } catch {
342
+ state.board = null;
343
+ B.setView(null);
344
+ }
345
+ if (!B.alive()) return;
346
+ if (state.view === 'fresh') { B.linkDown(); return; }
347
+ }
348
+ state.down = down;
349
+ $('#downLanding').innerHTML = BoardUI.downLanding(down);
350
+ $('#downLanding').hidden = false;
351
+ B.root.classList.add('down');
352
+ }
353
+ B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
354
+
309
355
  // A board from the server, a load's or an events POST's, or a patch from the board on screen (ui-patch.js). One built
310
356
  // before the board on screen leaves it as it is, so a slow load can't undo a newer one; a newer patch from a board
311
357
  // no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
@@ -315,6 +361,12 @@ window.BoardView = (function () {
315
361
  if (!board) { signalled(); return false; }
316
362
  const first = state.view !== 'fresh';
317
363
  state.board = board;
364
+ state.oldCopy = false;
365
+ if (state.down) {
366
+ state.down = null;
367
+ $('#downLanding').hidden = true;
368
+ B.root.classList.remove('down');
369
+ }
318
370
  B.setView('fresh');
319
371
  if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
320
372
  B.linkUp();
@@ -322,7 +374,7 @@ window.BoardView = (function () {
322
374
  scrollToFocus();
323
375
  returnToPlace();
324
376
  B.site.syncBadge();
325
- B.saveSnapshotSoon();
377
+ B.saveCopySoon();
326
378
  return true;
327
379
  }
328
380
 
@@ -336,10 +388,16 @@ window.BoardView = (function () {
336
388
  const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
337
389
  if (!B.alive()) return out;
338
390
  answered?.();
339
- land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
391
+ if (!land(out.board, B.boardLoad = (B.boardLoad || 0) + 1)) await boardAfter(out.board.generatedAt);
340
392
  return out;
341
393
  }
342
394
 
395
+ // A board that reload raced the POST left the tap's own board unlanded (land): the tap is not drawn until a board
396
+ // made after it is, so send waits for one, and a tap's spinner with it (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
397
+ async function boardAfter(generatedAt) {
398
+ for (let tries = 0; tries < 3 && B.alive() && !(state.view === 'fresh' && state.board.generatedAt >= generatedAt); tries++) await (reloading || loadBoard({ via: 'signal' }));
399
+ }
400
+
343
401
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
344
402
  // 30 s poll stays as the fallback and the offline check (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
345
403
  let reloading = null;
@@ -356,30 +414,48 @@ window.BoardView = (function () {
356
414
 
357
415
  // The toast for a tap the server refused, with its reason, or that never reached it.
358
416
  const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
359
- Object.assign(B, { render, loadBoard, send, failed, reload: signalled });
360
- const live = paintSnapshot();
417
+ // A tap's change no board has shown in BoardUI's PENDING_LATE_MS (ui.js pending): the board reloads once and the button rests.
418
+ function stillWaiting() {
419
+ if (!B.alive()) return;
420
+ B.toast('Still waiting on the board');
421
+ signalled();
422
+ render();
423
+ }
424
+ Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
361
425
  B.headerWired();
362
- if (live) B.linkUp();
363
426
  const sent = BoardUI.sentNotice.take(B.route);
364
427
  if (sent) B.toast(sent);
365
428
  B.on(document, 'visibilitychange', () => {
366
429
  if (document.hidden) return;
367
430
  if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
368
431
  });
369
- loadBoard();
432
+ B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
370
433
  B.signal?.(signalled);
371
434
  B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
372
435
  }
373
436
 
374
- function markup({ title = '' } = {}) {
375
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
437
+ // shared: {by, full} for someone the board is shared with, whose board opens with a strip saying what they can do there
438
+ // (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md). → the strip's inside
439
+ function sharedStrip({ by, full }) {
440
+ const from = `Shared with you${by ? ` by ${BoardUI.esc(by)}` : ''}.`;
441
+ return full ? `<b>Full.</b> ${from} You can answer and act. Merge and Board settings are ${by ? `${BoardUI.esc(by)}’s` : 'the owner’s'}.`
442
+ : `<b>View only.</b> ${from} You can look and open Visit links.`;
443
+ }
444
+ function markup({ title = '', shared = null } = {}) {
445
+ const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
446
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
447
+ <div class="split">
376
448
  <main>
377
- <section id="setup" class="setup" aria-live="polite" hidden></section>
449
+ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
378
450
  <section id="answers" class="answer-banner" hidden></section>
379
- <section id="waiting" class="waiting" hidden></section>
451
+ <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
380
452
  <div id="tryMain"></div>
381
453
  <div id="lanes"></div>
454
+ <div id="downLanding" hidden></div>
455
+ ${BoardUI.boardFoot()}
382
456
  </main>
457
+ <aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
458
+ </div>
383
459
  ${BoardNewTask.markup()}
384
460
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
385
461
  <div class="sheet-body">
@@ -416,5 +492,5 @@ ${BoardNewTask.markup()}
416
492
  </dialog>`;
417
493
  }
418
494
 
419
- return { part, create, page, markup };
495
+ return { part, create, page, markup, sharedStrip };
420
496
  })();
@@ -1,6 +1,6 @@
1
1
  // docs/adr/2026-11-03-1722-every-text-box-attaches-through-attach-js.md
2
- // Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton(), its text box in
3
- // BoardUI.dropZone(), and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
2
+ // Files attached to a composer: any element with data-composer="<id>" holding its text box in BoardUI.dropZone(), which
3
+ // puts BoardUI.attachButton() inside the box, and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
4
4
  // uploads them at once to the api/upload of the site named by the nearest
5
5
  // [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
6
6
  // files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them