@lovek4d/kickflip 0.0.0-stage → 0.1.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 (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,200 @@
1
+ // Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation, and opening it. Needs
2
+ // app-core.js and app-threads.js.
3
+ BoardView.part((B) => {
4
+ const { $, store, state, markSeen, seen, toast, links } = B;
5
+ const { esc, REVIEW, reviewAge, unreadCount, workLines, linkText, mergeButton, installButton, tryButton, tryWords, BUTTON, LINK_ARROW } = BoardUI;
6
+
7
+ function detail(it) {
8
+ if (it.kind === 'ask') {
9
+ const button = actionButton(it);
10
+ return `<div class="detail">${button ? `<div class="actions" data-actions="${esc(it.key)}">${actionRow('workflow', button)}</div>` : ''}${B.thread(it.key)}</div>`;
11
+ }
12
+ const pr = it.pr;
13
+ const ctx = links(it.key);
14
+ const rows = [];
15
+ rows.push(['Wave', esc(it.waveTitle)]);
16
+ if (pr) {
17
+ const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
18
+ if (pr.state === 'OPEN') {
19
+ rows.push(['CI', esc(ci)]);
20
+ rows.push(['Merge', esc([pr.mergeStateStatus, pr.reviewDecision].filter(Boolean).join(' · ').toLowerCase() || 'unknown')]);
21
+ rows.push(['Review', (it.reviewers || []).map((r) => `${esc(r)} <span class="muted">${REVIEW[BoardUI.record(it.review, r)?.state || 'unsent'].label}${reviewAge(BoardUI.record(it.review, r)) ? `,${reviewAge(BoardUI.record(it.review, r))}` : ''}</span>`).join(' · ') || '—']);
22
+ }
23
+ }
24
+ if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
25
+ if (it.status) rows.push(['Status', esc(it.status)]);
26
+ if (it.deps.length) rows.push(['Waits on', esc(it.deps.join(', '))]);
27
+ if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
28
+ if (it.slack) rows.push(['Slack', `<a href="${esc(it.slack)}" target="_blank" rel="noopener">thread</a>`]);
29
+ if (it.from) rows.push(['Filed from', esc(it.from)]);
30
+ if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
31
+ // docs/adr/0062-notes-is-a-link-in-the-details-list.md
32
+ const external = [
33
+ it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
34
+ installButton(it),
35
+ pr?.state === 'OPEN' ? `<a${classes(it, BUTTON.github)} href="${esc(pr.url)}" target="_blank" rel="noopener">${pr.isDraft ? 'Review draft on GitHub' : 'Review on GitHub'} ${LINK_ARROW}</a>` : '',
36
+ ].filter(Boolean);
37
+ const externalRow = external.length ? `<dt class="external-links-label">Links</dt><dd class="external-links">${external.map((html, i) => `<span class="external-link">${i ? '<span class="external-separator" aria-hidden="true"> · </span>' : ''}${html}</span>`).join('')}</dd>` : '';
38
+
39
+ return `<div class="detail">
40
+ <dl class="grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}${externalRow}</dl>
41
+ ${actions(it)}
42
+ ${B.thread(it.key)}
43
+ </div>`;
44
+ }
45
+
46
+ // docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md
47
+ const sendingHold = new Map();
48
+ const sendingMerge = new Set();
49
+ const refFor = (it) => (it.kind !== 'work' || it.internal ? it.ref : it.linear?.[0]?.id || (it.pr ? `#${it.pr.number}` : it.ref));
50
+ const CONFIRM = {
51
+ hold: (it, ref) => `Hold ${ref}? ${it.lane === 'todo' ? "It won't start" : 'Claude stops work on it'} until you resume it.`,
52
+ resume: (it, ref) => `Resume ${ref}? It can start again once nothing blocks it.`,
53
+ bump: (it, ref) => `Move ${ref} to the top of its lane?`,
54
+ nudge: () => 'Nudge Claude to check this builder?',
55
+ grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
56
+ merge: (it, ref) => `Merge ${ref}? Claude squash-merges #${it.pr?.number} if it is still approved, green and clean.`,
57
+ build: (it, ref) => `Build ${ref}? Claude replies with a short plan, then builds it as a draft.`,
58
+ finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
59
+ : `Close ${ref}? It moves to Done and Claude stops work on it.${it.pr?.state === 'OPEN' ? ' Its PR stays open on GitHub.' : ''}`),
60
+ };
61
+
62
+ const classes = (it, button, base = '') => {
63
+ const names = [base, it.resolvedBy === button && 'resolves'].filter(Boolean).join(' ');
64
+ return names ? ` class="${names}"` : '';
65
+ };
66
+ const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
67
+
68
+ function actions(it) {
69
+ const key = esc(it.key);
70
+ const ask = (act, label) => `<button data-confirm="${act}" data-key="${key}">${label}</button>`;
71
+ const holding = sendingHold.has(it.key) ? sendingHold.get(it.key) : it.holdPending;
72
+ const hold = it.kind !== 'work' || !it.holdable ? ''
73
+ : holding != null ? `<button disabled>${holding ? 'Holding…' : 'Resuming…'}</button>`
74
+ : it.held ? ask('resume', 'Resume') : ask('hold', 'Hold');
75
+ const build = it.kind === 'followup' && it.lane !== 'done' ? `<button class="build" data-confirm="build" data-key="${key}">Build</button>` : '';
76
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${build}${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}`)}</div>`;
77
+ }
78
+
79
+ // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md).
80
+ const ACTION_BUTTONS = {
81
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
82
+ [BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
83
+ [BUTTON.grill]: (it) => (it.grilling ? '<button disabled>Writing questions…</button>' : `<button data-confirm="grill" data-key="${esc(it.key)}">Grill</button>`),
84
+ };
85
+ const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
86
+
87
+ // The latest bump takes no second bump: among New cards for a New card (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md),
88
+ // in its lane for any other card (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md).
89
+ const latestBump = (it) => Boolean(it.bumpedAt) && !state.board.items.some((i) => (it.isNew ? i.isNew : i.lane === it.lane) && (i.bumpedAt || '') > it.bumpedAt);
90
+ function card(it) {
91
+ return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
92
+ <summary class="work">
93
+ <span class="status ${it.lane}"></span>
94
+ <div class="title-wrap">${workLines(it, { unread: state.openItems.has(it.key) ? 0 : unreadCount(it.othersAt, seen[it.key]), top: latestBump(it) })}</div>
95
+ </summary>
96
+ ${state.openItems.has(it.key) ? detail(it) : ''}
97
+ </details>`;
98
+ }
99
+
100
+ B.on(B.root, 'toggle', (ev) => {
101
+ const el = ev.target;
102
+ if (!el.matches?.('details.item')) return;
103
+ const key = el.dataset.key;
104
+ if (el.open) {
105
+ state.openItems.add(key);
106
+ const it = state.board.items.find((i) => i.key === key);
107
+ markSeen(it);
108
+ const unread = el.querySelector('summary .chip.unread');
109
+ const row = unread?.parentNode;
110
+ unread?.remove();
111
+ if (row && !row.children.length) row.remove();
112
+ if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
113
+ if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
114
+ B.loadThread(key, { maxAge: 10000 });
115
+ } else {
116
+ state.openItems.delete(key);
117
+ }
118
+ store.set('openItems', [...state.openItems]);
119
+ }, true);
120
+
121
+ function redrawActions(key) {
122
+ const it = state.board.items.find((i) => i.key === key);
123
+ const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
124
+ if (it && row) row.outerHTML = actions(it);
125
+ }
126
+
127
+ B.on(B.root, 'click', async (ev) => {
128
+ const log = ev.target.closest('[data-try-log]');
129
+ if (log && state.openItems.has(log.dataset.tryLog)) {
130
+ ev.preventDefault();
131
+ const card = log.closest('details.item');
132
+ card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
133
+ return;
134
+ }
135
+ const button = ev.target.closest('button[data-try]');
136
+ if (!button) return;
137
+ ev.preventDefault();
138
+ if (button.dataset.tip) { toast(button.dataset.tip); return; }
139
+ const key = button.dataset.try;
140
+ const it = state.board.items.find((i) => i.key === key);
141
+ if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
142
+ button.disabled = true;
143
+ try {
144
+ const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
145
+ const out = await r.json();
146
+ if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
147
+ } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
148
+ if (B.alive()) B.loadBoard();
149
+ });
150
+
151
+ B.on(B.root, 'click', async (ev) => {
152
+ const b = ev.target.closest('button[data-confirm]');
153
+ if (!b) return;
154
+ ev.preventDefault();
155
+ const { key, confirm: act } = b.dataset;
156
+ const it = state.board.items.find((i) => i.key === key);
157
+ if (!it || !(await B.confirm(CONFIRM[act](it, refFor(it))))) return;
158
+ if (act === 'hold' || act === 'resume') {
159
+ sendingHold.set(key, act === 'hold');
160
+ redrawActions(key);
161
+ try { await B.site.send('card-hold', { key, hold: act === 'hold' }); }
162
+ catch { toast(`Could not ${act}. Is the host awake?`); }
163
+ sendingHold.delete(key);
164
+ B.loadBoard();
165
+ return;
166
+ }
167
+ // docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md
168
+ if (act === 'merge') {
169
+ sendingMerge.add(key);
170
+ redrawActions(key);
171
+ try { await B.site.send('card-merge', { key }); }
172
+ catch { toast('Could not send. Is the host awake?'); }
173
+ sendingMerge.delete(key);
174
+ B.loadBoard();
175
+ return;
176
+ }
177
+ // docs/adr/0035-an-ask-can-wait-in-progress-until-the-owner-marks-it-done.md
178
+ if (act === 'finish') {
179
+ B.sendingDone.add(key);
180
+ B.syncDone(key);
181
+ try { await (it.kind === 'ask' ? B.site.send('ask-done', { id: it.id }) : B.site.send('card-done', { key })); }
182
+ catch { toast('Could not close. Is the host awake?'); }
183
+ B.sendingDone.delete(key);
184
+ await B.loadBoard();
185
+ B.syncDone(key);
186
+ return;
187
+ }
188
+ const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
189
+ row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
190
+ b.disabled = true;
191
+ try {
192
+ if (act === 'bump') { await B.site.send('card-bump', { key }); toast('Moved to the top'); }
193
+ else if (act === 'nudge') { await B.site.send('builder-nudge', { key }); b.textContent = 'Sent'; toast('Nudge sent'); }
194
+ else { await B.site.send(act === 'grill' ? 'grill-more' : 'build-followup', { key }); B.loadThread(key, { force: true }); }
195
+ } catch { toast('Could not send. Is the host awake?'); }
196
+ B.loadBoard();
197
+ });
198
+
199
+ Object.assign(B, { detail, card });
200
+ });
@@ -0,0 +1,96 @@
1
+ // The board view's shared helpers, which the other parts take from B. Register it first, after app.js.
2
+ BoardView.part((B) => {
3
+ const { $, store, state } = B;
4
+
5
+ const focusedField = () => {
6
+ const a = document.activeElement;
7
+ return a && B.root.contains(a) && a.matches('textarea, input') ? a : null;
8
+ };
9
+ // → a function finding field after a repaint: the same node, else the box drawn anew in its place (an edit box from
10
+ // state.edits, a reply box from its draft), found by its form.
11
+ function finder(field) {
12
+ const form = field.closest('form[data-reply], form[data-edit-form]');
13
+ const again = !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
14
+ : `form[data-edit-form="${CSS.escape(form.dataset.editForm)}"] textarea`;
15
+ return () => (field.isConnected ? field : again && $(again));
16
+ }
17
+
18
+ // Runs paint, a repaint through ui.js setHTML, then gives the field being typed in its focus and cursor back when it
19
+ // lost them, which setHTML avoids for a node it moves. A card that moved into a closed lane or fold opens it, since a
20
+ // field there can't keep focus. Refocusing doesn't scroll: steady does.
21
+ function keepFocus(paint) {
22
+ const field = focusedField();
23
+ const find = field && finder(field);
24
+ const sel = field ? [field.selectionStart, field.selectionEnd, field.selectionDirection] : null;
25
+ paint();
26
+ const el = find?.();
27
+ if (!el) return;
28
+ for (let d = el.closest('details:not([open])'); d; d = d.closest('details:not([open])')) d.open = true;
29
+ if (document.activeElement === el) return;
30
+ el.focus({ preventScroll: true });
31
+ if (sel[0] != null) el.setSelectionRange(...sel);
32
+ }
33
+
34
+ // Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
35
+ // moved, in the same task so nothing visibly jumps (docs/adr/0063-an-open-card-moves-at-once-and-the-page-follows-it.md).
36
+ // The anchor is the field being typed in, else the title row of the open card on screen, else there is none. A field
37
+ // in a dialog doesn't move with the page, so the card anchors then.
38
+ function anchorOf() {
39
+ const field = focusedField();
40
+ if (field && !field.closest('dialog')) return finder(field);
41
+ const card = [...B.$$('details.item[open]')].find((d) => { const r = d.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight; });
42
+ if (!card) return null;
43
+ const title = `details.item[data-key="${CSS.escape(card.dataset.key)}"] > summary`;
44
+ return () => $(title);
45
+ }
46
+ function steady(paint) {
47
+ const find = anchorOf();
48
+ const before = find?.()?.getBoundingClientRect().top;
49
+ keepFocus(paint);
50
+ const after = before == null ? null : find()?.getBoundingClientRect().top;
51
+ if (after != null && after !== before) scrollBy(0, after - before);
52
+ }
53
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
54
+
55
+ // A card this browser has never seen starts from now, so a first load badges nothing.
56
+ const seen = Object.assign(Object.create(null), store.get('seenThreads', {}));
57
+ const latestOf = (it) => [new Date().toISOString(), ...(it?.othersAt || [])].sort().pop();
58
+ function markSeen(it, onlyNew = false) {
59
+ if (!it || (onlyNew && seen[it.key])) return;
60
+ seen[it.key] = latestOf(it);
61
+ store.set('seenThreads', seen);
62
+ }
63
+
64
+ // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
65
+ // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
66
+ const quiet = new Map();
67
+ function quieted(draftKey, ta) {
68
+ const until = quiet.get(draftKey) || 0;
69
+ if (until <= Date.now()) return false;
70
+ if (until !== Infinity) ta.value = '';
71
+ return true;
72
+ }
73
+ async function sendDraft(draftKey, ta, current, send) {
74
+ ta.blur();
75
+ quiet.set(draftKey, Infinity);
76
+ try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
77
+ store.set(draftKey, '');
78
+ const now = current();
79
+ if (now) now.value = '';
80
+ quiet.set(draftKey, Date.now() + 1000);
81
+ }
82
+
83
+ // A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
84
+ const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
85
+ // The ui-links.js linkContext for text shown on item `key`'s card or thread.
86
+ const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
87
+ const svg = (body, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
88
+
89
+ const dialogs = globalThis.BoardDialogs;
90
+ const controller = new AbortController();
91
+ B.atUnmount(() => controller.abort());
92
+ const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
93
+ const notice = dialogs.notice;
94
+
95
+ Object.assign(B, { steady, plural, seen, markSeen, quieted, sendDraft, hide, links, svg, confirm, notice });
96
+ });
@@ -0,0 +1,108 @@
1
+ // The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
2
+ // header's GitHub line (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md);
3
+ // the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
4
+ // Needs app-core.js.
5
+ BoardView.part((B) => {
6
+ const { $, state, toast } = B;
7
+ const { statusLine, freshSig, nextFresh, githubLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
8
+ // A request no check answers stops showing after this.
9
+ const GITHUB_GIVE_UP = 5 * 60000;
10
+
11
+ function renderHeader(d) {
12
+ if (state.githubPending && (checkLanded(d.checks, state.githubPending) || Date.now() - state.githubPending > GITHUB_GIVE_UP)) state.githubPending = null;
13
+ renderStatus();
14
+ $('#setup').hidden = !d.setup;
15
+ $('#setup').innerHTML = setupCard(d.setup);
16
+ if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
17
+ }
18
+ const renderStatus = () => { $('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.() }); };
19
+ // A board load and its open threads landed; via is ui-header.js nextFresh's.
20
+ function landed(via) {
21
+ state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
22
+ renderStatus();
23
+ }
24
+ B.every(15000, () => { if (!document.hidden) renderStatus(); });
25
+ const githubHeader = (d) => `<span class="lane-github">${githubLine(d.checks, { pending: Boolean(state.githubPending), links: d.links })}${githubRefresh(d.checks, state.githubPending)}</span>`;
26
+
27
+ // The Hub links sheet: the GitHub mark opens it, from inside the lane's summary, so the tap must not fold the lane.
28
+ const linksSheet = $('#linksSheet');
29
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
30
+ function closeLinks() {
31
+ if (!linksSheet.open || linksSheet.classList.contains('closing')) return;
32
+ if (reduceMotion.matches) { linksSheet.close(); return; }
33
+ linksSheet.classList.add('closing');
34
+ B.later(180, () => { linksSheet.classList.remove('closing'); linksSheet.close(); });
35
+ }
36
+ B.on(B.root, 'click', (ev) => {
37
+ if (!ev.target.closest('[data-hub-links]')) return;
38
+ ev.preventDefault();
39
+ if (!state.board || linksSheet.open) return;
40
+ $('#linksList').innerHTML = linksList(state.board.links);
41
+ linksSheet.classList.remove('closing');
42
+ linksSheet.showModal();
43
+ });
44
+ B.on($('#linksClose'), 'click', closeLinks);
45
+ B.on(linksSheet, 'click', (ev) => { if (ev.target === linksSheet) closeLinks(); });
46
+ B.on(linksSheet, 'cancel', (ev) => { ev.preventDefault(); closeLinks(); });
47
+ function askGitHub() {
48
+ state.githubPending = Date.now();
49
+ B.render();
50
+ B.site.send('check-github', {}).catch(() => { state.githubPending = null; B.render(); toast('Could not ask Claude to check GitHub'); });
51
+ }
52
+ B.on(B.root, 'click', (ev) => {
53
+ const b = ev.target.closest('[data-check-github]');
54
+ if (!b) return;
55
+ ev.preventDefault();
56
+ if (state.link === 'online' && state.board && !state.githubPending) askGitHub();
57
+ });
58
+
59
+ state.link = 'online';
60
+ const controls = { wired: false, boardSettled: false, reloading: false };
61
+ function applyControls() {
62
+ const on = headerControls({ link: state.link, ...controls });
63
+ $('#refresh').disabled = !on.refresh;
64
+ $('#refresh').classList.toggle('spinning', on.spin);
65
+ const gear = $('#settingsLink');
66
+ if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
67
+ }
68
+ const headerWired = () => { controls.wired = true; applyControls(); };
69
+ // style.css dims the offline view from .view-board.offline.
70
+ function setLink(link) {
71
+ const was = state.link;
72
+ state.link = link;
73
+ if (link !== 'reconnecting') controls.boardSettled = true;
74
+ const off = link !== 'online';
75
+ B.root.classList.toggle('offline', off);
76
+ if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
77
+ applyControls();
78
+ renderStatus();
79
+ }
80
+ const linkUp = () => setLink('online');
81
+ const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
82
+ B.on($('.nav'), 'click', (ev) => {
83
+ if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
84
+ }, true);
85
+ // A load can return in milliseconds, so the spin and "Reconnecting…" hold long enough to read.
86
+ const RELOAD_MIN = 700;
87
+ async function reloading(load) {
88
+ controls.reloading = true;
89
+ applyControls();
90
+ try { await Promise.all([load(), new Promise((r) => setTimeout(r, RELOAD_MIN))]); }
91
+ finally { controls.reloading = false; applyControls(); }
92
+ }
93
+ async function reconnect() {
94
+ setLink('reconnecting');
95
+ await reloading(() => B.loadBoard({ via: 'refresh' }));
96
+ if (state.link === 'reconnecting') setLink('offline');
97
+ }
98
+
99
+ // The header refresh reloads the board and its open threads; only the GitHub ↻ asks Claude to check GitHub.
100
+ // Offline, it retries the board load instead.
101
+ B.on($('#refresh'), 'click', () => {
102
+ if (state.link === 'offline') { reconnect(); return; }
103
+ reloading(() => B.loadBoard({ via: 'refresh' }));
104
+ });
105
+ B.every(5000, () => { if (state.githubPending && !document.hidden) B.loadBoard(); });
106
+
107
+ Object.assign(B, { renderHeader, renderStatus, landed, githubHeader, linkUp, linkDown, headerWired });
108
+ });
@@ -0,0 +1,92 @@
1
+ // The board view's lanes and wave filter: each lane's header and cards, the "N older" and finished-wave folds, and the wave bars.
2
+ // Needs app-core.js and app-cards.js.
3
+ BoardView.part((B) => {
4
+ const { $, store, state } = B;
5
+ const { esc } = BoardUI;
6
+ const LANES = [
7
+ { id: 'review', name: 'In Review', open: true },
8
+ { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
9
+ { id: 'progress', name: 'In Progress', open: true },
10
+ { id: 'todo', name: 'Todo', open: true },
11
+ { id: 'done', name: 'Done', open: false },
12
+ ];
13
+
14
+ // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
15
+ // Fold ids are 'older', 'asks' or wave:<slug> (board/domain.mjs), so any id maps to its own storage key.
16
+ const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
17
+ // A fold's header reads like a lane's: its name, then its count, then an optional note.
18
+ function fold(id, list, name, note = '') {
19
+ if (!list.length) return '';
20
+ return `<details class="fold" data-fold="${esc(id)}" ${store.get(FOLDS[id].store, false) ? 'open' : ''}>
21
+ <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span>${note ? `<span class="count">· ${note}</span>` : ''}</summary>
22
+ <div class="fold-list">${list.map((it) => B.card(it)).join('')}</div>
23
+ </details>`;
24
+ }
25
+
26
+ // One row per finished wave, newest card first, as Done's items arrive sorted.
27
+ function waveFolds(d, items) {
28
+ const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
29
+ return slugs.map((slug) => {
30
+ const w = d.waves.find((x) => x.slug === slug);
31
+ const { dropped } = w.finished;
32
+ return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), esc(w.title), dropped ? `${dropped} dropped` : '');
33
+ }).join('');
34
+ }
35
+
36
+ // shown is the board's items after the wave filter and pending deletes.
37
+ function lanesHtml(d, shown) {
38
+ const laneState = store.get('lanes', {});
39
+ return LANES.map((lane) => {
40
+ const items = shown.filter((i) => i.lane === lane.id);
41
+ if (lane.hideEmpty && !items.length) return '';
42
+ const open = laneState[lane.id] ?? lane.open;
43
+ const older = items.filter((i) => i.fold === 'older');
44
+ const asks = items.filter((i) => i.fold === 'asks');
45
+ const rows = items.filter((i) => !i.fold).map((it) => B.card(it)).join('') + fold('asks', asks, 'Answered')
46
+ + waveFolds(d, items) + fold('older', older, 'Older');
47
+ return `<details class="lane" data-lane="${lane.id}" ${open ? 'open' : ''}>
48
+ <summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
49
+ <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
50
+ ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
51
+ </details>`;
52
+ }).join('');
53
+ }
54
+
55
+ function renderWaves(d) {
56
+ if (d.waves.some((w) => w.finished && w.slug === state.waveFilter)) { state.waveFilter = null; store.set('wave', null); }
57
+ const rows = d.waves.map((w) => {
58
+ const items = d.items.filter((i) => i.wave === w.slug);
59
+ const merged = items.filter((i) => i.pr?.state === 'MERGED' || (!i.pr && i.lane === 'done')).length;
60
+ const drafts = items.filter((i) => i.pr?.state === 'OPEN' && i.pr.isDraft).length;
61
+ const review = items.filter((i) => i.pr?.state === 'OPEN' && !i.pr.isDraft).length;
62
+ return { w, total: items.length, merged, drafts, review };
63
+ }).filter((r) => r.total && !r.w.finished);
64
+ $('#waves').innerHTML = rows.map(({ w, total, merged, drafts, review }) => `<button class="wave ${w.slug === state.waveFilter ? 'on' : ''}" data-wave="${esc(w.slug)}">
65
+ <span class="wave-name">${esc(w.title)}</span>
66
+ <span class="muted small">${merged} of ${total} ${w.internal ? 'done' : 'merged'}${review ? ` · ${review} in review` : ''}${drafts ? ` · ${drafts} draft${drafts === 1 ? '' : 's'}` : ''}</span>
67
+ <span class="bar"><span style="width:${Math.round((merged / total) * 100)}%"></span></span>
68
+ </button>`).join('');
69
+ }
70
+
71
+ B.on(B.root, 'toggle', (ev) => {
72
+ const el = ev.target;
73
+ if (el.matches?.('details.lane')) {
74
+ const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
75
+ } else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
76
+ }, true);
77
+
78
+ B.on(B.root, 'click', async (ev) => {
79
+ const pb = ev.target.closest('button[data-plan]');
80
+ if (pb) {
81
+ if (!(await B.confirm('Claude groups these follow-ups into a small wave and asks you about the plan in Needs you. Nothing builds until you answer. Start?'))) return;
82
+ pb.disabled = true;
83
+ try { await B.site.send('plan-followups', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
84
+ catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
85
+ return;
86
+ }
87
+ const wb = ev.target.closest('button[data-wave]');
88
+ if (wb) { state.waveFilter = wb.dataset.wave !== state.waveFilter ? wb.dataset.wave : null; store.set('wave', state.waveFilter); B.render(); }
89
+ });
90
+
91
+ Object.assign(B, { FOLDS, lanesHtml, renderWaves });
92
+ });
@@ -0,0 +1,30 @@
1
+ // The board view's Needs you box: one line per thing waiting on the owner (domain.mjs needsOf), led by the board
2
+ // session's pending prompts, answered in place (prompts.js). Needs app-core.js.
3
+ BoardView.part((B) => {
4
+ const { $, plural, state, toast } = B;
5
+ const { esc, promptItem } = BoardUI;
6
+
7
+ const prompts = PromptAnswers.wire(B, {
8
+ list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
9
+ paint: () => { if (state.board) renderNeeds(state.board); },
10
+ notify: (text) => toast(text),
11
+ after: () => B.loadBoard(),
12
+ });
13
+
14
+ function renderNeeds(d) {
15
+ const parts = (line) => [
16
+ line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
17
+ line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
18
+ line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
19
+ ].filter(Boolean).join(' · ');
20
+ const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
21
+ const w = $('#waiting');
22
+ w.hidden = needs.length === 0;
23
+ w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
+ <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
25
+ : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
+ : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
27
+ }
28
+
29
+ Object.assign(B, { renderNeeds });
30
+ });
@@ -0,0 +1,97 @@
1
+ // The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
2
+ // board lands, and flashing the lines the fresh board changed (docs/adr/0028-the-board-opens-on-the-last-visit.md).
3
+ // Needs app-core.js and ui-snapshot.js; app.js paints it.
4
+ BoardView.part((B) => {
5
+ const { $$, state } = B;
6
+ const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX } = BoardUI;
7
+ // The hub serves every site from one origin, so each board keeps its own key.
8
+ const KEY = 'boardSnapshot:' + B.site.url('');
9
+ // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
10
+ // dropping first, so they leave room in the browser's 5 MB of storage for the drafts and seen marks, which would
11
+ // otherwise fail to save.
12
+ const BUDGET = 3 * SNAPSHOT_MAX;
13
+ const ORDER = 'boardSnapshots';
14
+
15
+ function readSnapshot() {
16
+ let raw = null;
17
+ try { raw = localStorage.getItem(KEY); } catch {}
18
+ return raw ? unpackSnapshot(raw) : null;
19
+ }
20
+ // Makes room for this board's raw snapshot.
21
+ function keepRecent(raw) {
22
+ let order = [];
23
+ try { order = JSON.parse(localStorage.getItem(ORDER) || '[]'); } catch {}
24
+ const stored = [];
25
+ for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith('boardSnapshot:') && k !== KEY) stored.push(k); }
26
+ const recent = [...order.filter((k) => stored.includes(k)), ...stored.filter((k) => !order.includes(k))];
27
+ let used = raw.length;
28
+ const kept = [KEY];
29
+ for (const k of recent) {
30
+ const size = localStorage.getItem(k)?.length || 0;
31
+ if (used + size <= BUDGET) { used += size; kept.push(k); } else localStorage.removeItem(k);
32
+ }
33
+ localStorage.setItem(ORDER, JSON.stringify(kept));
34
+ }
35
+ let saveTimer = null;
36
+ function saveSnapshot() {
37
+ saveTimer?.();
38
+ saveTimer = null;
39
+ if (state.view !== 'fresh' || !state.board) return;
40
+ const raw = packSnapshot(state.board, state.threadCache, {
41
+ savedAt: state.board.generatedAt || new Date().toISOString(),
42
+ keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
43
+ });
44
+ try {
45
+ if (!raw) { localStorage.removeItem(KEY); return; }
46
+ keepRecent(raw);
47
+ localStorage.setItem(KEY, raw);
48
+ } catch { try { localStorage.removeItem(KEY); } catch {} }
49
+ }
50
+ const saveSnapshotSoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveSnapshot); };
51
+ B.on(window, 'pagehide', () => { if (saveTimer) saveSnapshot(); });
52
+ // The app shell paints a board it comes back to from this, live when nothing changed since (shell.js).
53
+ B.atUnmount(saveSnapshot);
54
+
55
+ // view is ui-snapshot.js liveControls' view; style.css dims the snapshot and its held controls from the view root's data-board.
56
+ function setView(view) {
57
+ state.view = view;
58
+ B.root.dataset.board = view;
59
+ }
60
+ for (const type of ['click', 'submit']) {
61
+ B.on(B.root, type, (ev) => {
62
+ const { blocked } = liveControls(state.view);
63
+ if (!blocked) return;
64
+ const control = type === 'submit' ? ev.target.querySelector?.('button[type=submit]') : ev.target.closest?.(blocked);
65
+ if (control?.matches(blocked)) { ev.preventDefault(); ev.stopImmediatePropagation(); }
66
+ }, true);
67
+ }
68
+
69
+ // The lines a fresh board can change: each card line, lane count, Needs you line and thread message.
70
+ function lines() {
71
+ const out = new Map();
72
+ for (const wrap of $$('details.item[data-key] > summary .title-wrap')) {
73
+ const key = wrap.closest('[data-key]').dataset.key;
74
+ [...wrap.children].forEach((el, i) => out.set(`${key}|${i}`, el));
75
+ }
76
+ for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
77
+ $$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
78
+ for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
79
+ return out;
80
+ }
81
+ const texts = (els) => Object.fromEntries([...els].map(([id, el]) => [id, el.textContent.replace(/\s+/g, ' ').trim()]));
82
+ let before = null;
83
+ const holdForFlash = () => { before = texts(lines()); };
84
+ function flashChanged() {
85
+ if (!before || state.view !== 'fresh') return;
86
+ const els = lines();
87
+ const after = texts(els);
88
+ for (const id of changedLines(before, after)) {
89
+ const el = els.get(id);
90
+ (el.closest('details.item') || el).classList.add('flash');
91
+ before[id] = after[id];
92
+ }
93
+ }
94
+ const endFlash = () => { before = null; };
95
+
96
+ Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash });
97
+ });