@lovek4d/kickflip 0.5.0 → 0.5.3
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.
- package/CONTEXT.md +47 -42
- package/README.md +1 -1
- package/SKILL.md +16 -16
- package/bin/kickflip.mjs +26 -17
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +65 -18
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +5 -4
- package/board/on-host.mjs +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +51 -27
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +70 -59
- package/board/public/app.js +38 -8
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +17 -38
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -5
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +108 -37
- package/board/public/ui-cards.js +41 -30
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-questions.js +25 -38
- package/board/public/ui-sidebar.js +18 -12
- package/board/public/ui-threads.js +3 -3
- package/board/public/ui.js +40 -8
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +142 -199
- package/board/public/view-settings.js +191 -72
- package/board/server.mjs +19 -20
- package/board/store.mjs +20 -15
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// app-core.js and app-threads.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
5
|
const { $, store, state, markSeen, seen, toast, links } = B;
|
|
6
|
-
const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton,
|
|
6
|
+
const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
|
|
7
7
|
|
|
8
8
|
const mainSheet = $('#mainLogSheet');
|
|
9
9
|
const mainRetry = $('#mainLogRetry');
|
|
@@ -116,20 +116,29 @@ BoardView.part((B) => {
|
|
|
116
116
|
}
|
|
117
117
|
ev.preventDefault();
|
|
118
118
|
const pull = state.board?.main?.pull;
|
|
119
|
-
if (!pull || pull.running ||
|
|
119
|
+
if (!pull || pull.running || BoardUI.pending.has('main:pull')) return;
|
|
120
120
|
if (pull.error && !matchMedia('(hover: hover)').matches) {
|
|
121
121
|
state.mainPull.open = !state.mainPull.open;
|
|
122
122
|
paintMain();
|
|
123
123
|
return;
|
|
124
124
|
}
|
|
125
|
-
state.mainPull = {
|
|
125
|
+
state.mainPull = { open: false };
|
|
126
|
+
// Spins until a board shows the pull running or changed (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
127
|
+
// A pull that brought no commits changes nothing a board shows, so its answer ends the spinner; one that did waits
|
|
128
|
+
// for Main to rebuild.
|
|
129
|
+
const mainFace = (main) => JSON.stringify([main?.pull, main?.sha, main?.state, main?.at]);
|
|
130
|
+
const before = mainFace(state.board.main);
|
|
131
|
+
BoardUI.pending.start('main:pull', (board) => mainFace(board.main) !== before, B.stillWaiting);
|
|
126
132
|
paintMain();
|
|
127
133
|
try {
|
|
128
134
|
const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
129
135
|
const out = await response.json();
|
|
130
136
|
if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
if (out.commits) BoardUI.pending.answered('main:pull'); else BoardUI.pending.drop('main:pull');
|
|
138
|
+
} catch (error) {
|
|
139
|
+
BoardUI.pending.drop('main:pull');
|
|
140
|
+
if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?');
|
|
141
|
+
}
|
|
133
142
|
if (B.alive()) { paintMain(); B.reload(); }
|
|
134
143
|
});
|
|
135
144
|
|
|
@@ -216,18 +225,16 @@ BoardView.part((B) => {
|
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
|
|
219
|
-
|
|
220
|
-
if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
|
|
228
|
+
rows.push(['Status', esc(BoardUI.statusWord(it))]);
|
|
221
229
|
// docs/adr/2026-11-03-0526-a-follow-up-is-a-work-card-with-a-parent.md: each link row is a row of labels that open their cards.
|
|
222
230
|
for (const [label, list] of [['Parent', it.parent ? [it.parent] : []], ['Children', it.children], ['Related to', it.related], ['Blocked by', it.blockedBy], ['Waits on it', it.waitsOnIt]]) {
|
|
223
231
|
if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
|
|
224
232
|
}
|
|
225
233
|
if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
|
|
226
234
|
if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
|
|
227
|
-
// docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
|
|
228
235
|
const external = [
|
|
229
|
-
it
|
|
230
|
-
|
|
236
|
+
// A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets a live one; elsewhere it is muted, with a tooltip and a tap toast.
|
|
237
|
+
it.worktree && !state.board.onHost ? `<a aria-disabled="true" title="not on kickflip host" data-tip="not on kickflip host">Open in VS Code</a>` : '',
|
|
231
238
|
it.worktree && state.board.onHost ? `<a href="vscode://file${esc(encodeURI(it.worktree))}" title="Open the worktree in VS Code">Open in VS Code ${LINK_ARROW}</a>` : '',
|
|
232
239
|
installButton(it),
|
|
233
240
|
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>` : '',
|
|
@@ -269,7 +276,7 @@ BoardView.part((B) => {
|
|
|
269
276
|
? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
|
|
270
277
|
: `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
|
|
271
278
|
'card-build': (it, ref) => (it.blockedBy?.length
|
|
272
|
-
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It
|
|
279
|
+
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It is bumped to the top of Todo until then.`
|
|
273
280
|
: `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
|
|
274
281
|
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
275
282
|
: it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
|
|
@@ -282,12 +289,16 @@ BoardView.part((B) => {
|
|
|
282
289
|
};
|
|
283
290
|
const actionRow = (kind, html) => (html.trim() ? `<div class="action-row ${kind}">${html}</div>` : '');
|
|
284
291
|
|
|
292
|
+
// The reader sheet opens it (app-reader.js); docs/adr/2026-11-05-0830-notes-is-a-button-on-the-hold-row.md.
|
|
293
|
+
const notesButton = (it) => (it.hasNotes ? `<a class="button" href="notes.html?item=${encodeURIComponent(it.key)}">Notes</a>` : '');
|
|
294
|
+
|
|
285
295
|
function actions(it) {
|
|
286
296
|
const key = esc(it.key);
|
|
287
297
|
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
288
298
|
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
289
|
-
: it.held ?
|
|
290
|
-
|
|
299
|
+
: it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
|
|
300
|
+
// A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
|
|
301
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${notesButton(it)}`)}</div>`;
|
|
291
302
|
}
|
|
292
303
|
|
|
293
304
|
// 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).
|
|
@@ -383,6 +394,7 @@ BoardView.part((B) => {
|
|
|
383
394
|
state.openItems.add(key);
|
|
384
395
|
const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
|
|
385
396
|
markSeen(it);
|
|
397
|
+
B.renderBanner(state.board);
|
|
386
398
|
const unread = el.querySelector('summary .card-unread');
|
|
387
399
|
const line = unread?.parentNode;
|
|
388
400
|
unread?.remove();
|
|
@@ -396,12 +408,6 @@ BoardView.part((B) => {
|
|
|
396
408
|
store.set('openItems', [...state.openItems]);
|
|
397
409
|
}, true);
|
|
398
410
|
|
|
399
|
-
function redrawActions(key) {
|
|
400
|
-
const it = state.board.items.find((i) => i.key === key);
|
|
401
|
-
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
402
|
-
if (it && row) row.outerHTML = actions(it);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
411
|
B.on(B.root, 'click', async (ev) => {
|
|
406
412
|
const log = ev.target.closest('[data-try-log]');
|
|
407
413
|
if (log && state.openItems.has(log.dataset.tryLog)) {
|
|
@@ -410,6 +416,8 @@ BoardView.part((B) => {
|
|
|
410
416
|
card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
|
|
411
417
|
return;
|
|
412
418
|
}
|
|
419
|
+
const off = ev.target.closest('a[data-tip]');
|
|
420
|
+
if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
|
|
413
421
|
const button = ev.target.closest('button[data-try]');
|
|
414
422
|
if (!button) return;
|
|
415
423
|
ev.preventDefault();
|
|
@@ -417,13 +425,22 @@ BoardView.part((B) => {
|
|
|
417
425
|
const key = button.dataset.try;
|
|
418
426
|
const it = state.board.items.find((i) => i.key === key);
|
|
419
427
|
if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
|
|
420
|
-
// Serve / Build
|
|
421
|
-
|
|
428
|
+
// Serve / Build spins until a board shows the card's build changed: running, or already done
|
|
429
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
430
|
+
const before = JSON.stringify(it.try);
|
|
431
|
+
const id = `${key}:try`;
|
|
432
|
+
BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
|
|
433
|
+
B.render();
|
|
434
|
+
try {
|
|
422
435
|
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
423
436
|
const out = await r.json();
|
|
424
437
|
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
425
|
-
|
|
426
|
-
|
|
438
|
+
BoardUI.pending.answered(id);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
BoardUI.pending.drop(id);
|
|
441
|
+
B.render();
|
|
442
|
+
toast(error.message || 'Could not start. Is the host awake?');
|
|
443
|
+
}
|
|
427
444
|
B.reload();
|
|
428
445
|
});
|
|
429
446
|
|
|
@@ -445,6 +462,7 @@ BoardView.part((B) => {
|
|
|
445
462
|
if (!b) return;
|
|
446
463
|
ev.preventDefault();
|
|
447
464
|
const { key, confirm: act } = b.dataset;
|
|
465
|
+
if (b.classList.contains('nudging')) return;
|
|
448
466
|
const it = state.board.items.find((i) => i.key === key);
|
|
449
467
|
if (it && act === 'hold') { openHold(it); return; }
|
|
450
468
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
@@ -475,11 +493,17 @@ BoardView.part((B) => {
|
|
|
475
493
|
if (act === 'finish') B.syncDone(key);
|
|
476
494
|
return;
|
|
477
495
|
}
|
|
478
|
-
//
|
|
479
|
-
|
|
496
|
+
// A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
|
|
497
|
+
// changed, the builder no longer stalled or the card gone (style.css .nudging).
|
|
498
|
+
const id = `${key}:nudge`;
|
|
499
|
+
const { progress, at } = it.builder;
|
|
500
|
+
BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
|
|
501
|
+
B.render();
|
|
480
502
|
try {
|
|
481
|
-
await
|
|
482
|
-
|
|
503
|
+
const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
|
|
504
|
+
BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
|
|
505
|
+
} catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
|
|
506
|
+
B.render();
|
|
483
507
|
});
|
|
484
508
|
|
|
485
509
|
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
package/board/public/app-core.js
CHANGED
|
@@ -86,13 +86,31 @@ BoardView.part((B) => {
|
|
|
86
86
|
|
|
87
87
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
// Read marks live on the board, one set for every device: each card's readAt, the newest message the owner has seen
|
|
90
|
+
// on it (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). seen is card key → the later of the board's mark
|
|
91
|
+
// and this page's own, so a card read here reads at once, before the board comes back with it. seenBefore is an open
|
|
92
|
+
// card's mark from before it opened, where its thread's unread lines start (app-threads.js); a card closing drops it,
|
|
93
|
+
// and a board left and opened again in this page keeps it.
|
|
94
|
+
const seen = Object.create(null);
|
|
95
|
+
const seenBefore = new Map(B.place?.seenBefore);
|
|
96
|
+
B.keep(() => ({ seenBefore: [...seenBefore] }));
|
|
97
|
+
function takeMarks(items) {
|
|
98
|
+
for (const it of items) if (String(it.readAt || '') > (seen[it.key] || '')) seen[it.key] = it.readAt;
|
|
99
|
+
}
|
|
100
|
+
// [[card key, time]] → each card read up to its time, here at once and on the board for every device.
|
|
101
|
+
function markRead(pairs) {
|
|
102
|
+
const marks = Object.fromEntries(pairs.filter(([key, at]) => at && String(at) > (seen[key] || '')));
|
|
103
|
+
if (!Object.keys(marks).length) return;
|
|
104
|
+
Object.assign(seen, marks);
|
|
105
|
+
fetch(B.site.url('api/read'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ marks }) }).catch(() => {});
|
|
106
|
+
}
|
|
107
|
+
// The newest message card it shows: its counted messages (domain.mjs othersAt), its questions' asks and its answer.
|
|
108
|
+
const newestOf = (it) => [...(it.othersAt || []), ...(it.questions?.askedAt || []), it.ask?.answerAt].filter(Boolean).map(String).sort().pop();
|
|
109
|
+
// Card it, open, reads up to the newest message it shows.
|
|
110
|
+
function markSeen(it) {
|
|
111
|
+
if (!it) return;
|
|
112
|
+
if (!seenBefore.has(it.key)) seenBefore.set(it.key, seen[it.key] || null);
|
|
113
|
+
markRead([[it.key, newestOf(it)]]);
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
@@ -105,5 +123,5 @@ BoardView.part((B) => {
|
|
|
105
123
|
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
106
124
|
const notice = dialogs.notice;
|
|
107
125
|
|
|
108
|
-
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, links, svg, confirm, notice });
|
|
126
|
+
Object.assign(B, { steady, restorePlace, plural, seen, seenBefore, takeMarks, markRead, markSeen, links, svg, confirm, notice });
|
|
109
127
|
});
|
|
@@ -77,7 +77,8 @@ BoardView.part((B) => {
|
|
|
77
77
|
} catch (e) { toast(`Couldn't wake this board's session: ${e.message}`); }
|
|
78
78
|
finally { state.waking = false; if (B.alive()) renderStatus(); }
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// A shared person's tap never wakes the owner's session (shell.js .shared-view).
|
|
81
|
+
const wakeOnTap = () => { if (state.board?.checks?.watcher === 'asleep' && !state.waking && state.link === 'online' && !document.documentElement.classList.contains('shared-view')) wake(); };
|
|
81
82
|
B.on(B.root, 'click', wakeOnTap, true);
|
|
82
83
|
B.on(B.root, 'submit', wakeOnTap, true);
|
|
83
84
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards,
|
|
1
|
+
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Done's and Asks' Show more,
|
|
2
2
|
// Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
|
|
3
3
|
// Needs app-core.js and app-cards.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
@@ -25,7 +25,7 @@ BoardView.part((B) => {
|
|
|
25
25
|
const n = left + (older || 0);
|
|
26
26
|
const words = `${n} older card${n === 1 ? '' : 's'}`;
|
|
27
27
|
const fetches = older !== 0 && shown + STEP > cards.length;
|
|
28
|
-
return cards.slice(0, shown)
|
|
28
|
+
return waveGroups(laneId, cards.slice(0, shown))
|
|
29
29
|
+ (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -97,7 +97,7 @@ BoardView.part((B) => {
|
|
|
97
97
|
if (lane.hideEmpty && !items.length) return '';
|
|
98
98
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
99
99
|
const open = items.filter((i) => !i.fold);
|
|
100
|
-
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) :
|
|
100
|
+
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);
|
|
101
101
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
102
102
|
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
103
103
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
@@ -106,8 +106,36 @@ BoardView.part((B) => {
|
|
|
106
106
|
}).join('');
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
|
|
110
|
+
// Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
|
|
111
|
+
// band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
|
|
112
|
+
// counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
|
|
113
|
+
// the lane's own number bold in its colour and a tooltip that spells the counts out.
|
|
114
|
+
// A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
|
|
115
|
+
let countsShown = null;
|
|
116
|
+
const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
|
|
117
|
+
function waveCounts(band, laneId) {
|
|
118
|
+
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
|
|
119
|
+
const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
|
|
120
|
+
const here = laneId === 'stale' ? 'progress' : laneId;
|
|
121
|
+
const id = `${laneId}/${band.slug}`;
|
|
122
|
+
const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
|
|
123
|
+
const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
|
|
124
|
+
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
|
|
125
|
+
}
|
|
126
|
+
function waveGroups(laneId, cards) {
|
|
127
|
+
const bands = state.board.waveBands || [];
|
|
128
|
+
const inBand = (b) => cards.filter((i) => b.keys.includes(i.key));
|
|
129
|
+
const grouped = bands.filter((b) => inBand(b).length);
|
|
130
|
+
if (!grouped.length) return cards.map((it) => B.card(it)).join('');
|
|
131
|
+
const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
|
|
132
|
+
const group = (cls, data, name, n, counts, list) => `<section class="${cls}"${data}><div class="wb-head wb-loosehead">${counts ? BoardUI.WAVE_ICON : ''}<span class="wb-label"><span class="lane-name">${name}</span> <span class="count">${n}</span></span>${counts}</div>${list.map((it) => B.card(it)).join('')}</section>`;
|
|
133
|
+
return grouped.map((b) => group('wb-lane', ` data-wave="${esc(b.slug)}"`, esc(b.title), inBand(b).length, waveCounts(b, laneId), inBand(b))).join('')
|
|
134
|
+
+ (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
// docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
|
|
110
|
-
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under
|
|
138
|
+
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under No wave. A band's open state lives
|
|
111
139
|
// in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
|
|
112
140
|
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
|
|
113
141
|
// card whose ⋯ menu is open, goalShown the band whose goal popover is open.
|
|
@@ -125,33 +153,25 @@ BoardView.part((B) => {
|
|
|
125
153
|
const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
|
|
126
154
|
const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
|
|
127
155
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
|
|
134
|
-
return head + bandRows(d, shown, all.filter((i) => !i.queued));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function bandRows(d, shown, todo) {
|
|
138
|
-
const bands = d.waveBands || [];
|
|
139
|
-
const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
|
|
140
|
-
const banded = new Set(bands.flatMap((b) => b.keys));
|
|
156
|
+
// Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
|
|
157
|
+
function todoRows(d, shown, todo) {
|
|
158
|
+
const byKey = new Map(shown.map((i) => [i.key, i]));
|
|
159
|
+
const bands = (d.waveBands || []).filter((b) => b.keys.some((k) => byKey.get(k)?.lane === 'todo'));
|
|
160
|
+
const banded = new Set((d.waveBands || []).flatMap((b) => b.keys));
|
|
141
161
|
const loose = todo.filter((i) => !banded.has(i.key));
|
|
142
162
|
const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
|
|
143
163
|
if (!bands.length) return loose.map((it) => B.card(it)).join('');
|
|
144
164
|
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
|
|
145
|
-
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">
|
|
165
|
+
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">No wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
|
|
146
166
|
}
|
|
147
167
|
|
|
148
168
|
function bandHtml(band, cards, open) {
|
|
149
169
|
const todo = cards.filter((i) => i.lane === 'todo');
|
|
150
170
|
const done = cards.filter((i) => i.lane === 'done');
|
|
151
|
-
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${
|
|
171
|
+
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${LANE_WORDS[i.lane]}`);
|
|
152
172
|
const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
|
|
153
173
|
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
|
|
154
|
-
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes
|
|
174
|
+
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
|
|
155
175
|
const next = band.next;
|
|
156
176
|
const go = !next ? '' : next.ready
|
|
157
177
|
? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
|
|
@@ -159,7 +179,7 @@ BoardView.part((B) => {
|
|
|
159
179
|
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
160
180
|
const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
|
|
161
181
|
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
|
|
162
|
-
+ `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
182
|
+
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
163
183
|
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
164
184
|
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
165
185
|
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
@@ -220,8 +240,14 @@ BoardView.part((B) => {
|
|
|
220
240
|
const pb = ev.target.closest('button[data-plan]');
|
|
221
241
|
if (pb) {
|
|
222
242
|
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
|
|
223
|
-
|
|
224
|
-
|
|
243
|
+
// Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
244
|
+
const keys = pb.dataset.plan.split(' ');
|
|
245
|
+
BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
|
|
246
|
+
try {
|
|
247
|
+
const out = await waitOn(pb, B.send('plan-waves', { keys }));
|
|
248
|
+
BoardUI.pending.answered('todo:plan', out.board.generatedAt);
|
|
249
|
+
B.render();
|
|
250
|
+
} catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
|
|
225
251
|
return;
|
|
226
252
|
}
|
|
227
253
|
const tb = ev.target.closest('button[data-triage]');
|
|
@@ -264,6 +290,12 @@ BoardView.part((B) => {
|
|
|
264
290
|
for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
|
|
265
291
|
B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
|
|
266
292
|
B.on(B.root, 'click', async (ev) => {
|
|
293
|
+
const counts = ev.target.closest?.('[data-counts]');
|
|
294
|
+
if (counts || countsShown) {
|
|
295
|
+
countsShown = counts && counts.dataset.counts !== countsShown ? counts.dataset.counts : null;
|
|
296
|
+
B.render();
|
|
297
|
+
if (counts) return;
|
|
298
|
+
}
|
|
267
299
|
if (pressed) { pressed = false; ev.preventDefault(); return; }
|
|
268
300
|
if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
|
|
269
301
|
const dots = ev.target.closest('button[data-band-menu]');
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
// The board view's Needs you
|
|
2
|
-
//
|
|
1
|
+
// The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
|
|
2
|
+
// (prompts.js). It draws twice, and the stylesheet shows one by width
|
|
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;
|
|
6
|
+
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
7
|
+
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
8
|
+
// a line that only asks questions, and with nothing else left the orange section is gone. A link into those
|
|
9
|
+
// questions, wherever it sits on the board, picks its topic's pill there instead of opening the page.
|
|
10
|
+
// Needs app-core.js.
|
|
3
11
|
BoardView.part((B) => {
|
|
4
12
|
const { $, plural, state, toast } = B;
|
|
5
|
-
const { esc, promptItem } = BoardUI;
|
|
13
|
+
const { esc, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
|
|
6
14
|
|
|
7
15
|
const prompts = PromptAnswers.wire(B, {
|
|
8
16
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -11,13 +19,21 @@ BoardView.part((B) => {
|
|
|
11
19
|
after: () => B.loadBoard(),
|
|
12
20
|
});
|
|
13
21
|
|
|
22
|
+
// From 900px, the pane beside the lanes, the same width the stylesheet splits the board at.
|
|
23
|
+
const wide = matchMedia('(min-width: 900px)');
|
|
24
|
+
|
|
14
25
|
function renderNeeds(d) {
|
|
26
|
+
const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
|
|
27
|
+
renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
|
|
28
|
+
renderDeck(d, needs);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderList(needs) {
|
|
15
32
|
const parts = (line) => [
|
|
16
33
|
line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
|
|
17
34
|
line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
|
|
18
35
|
line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
|
|
19
36
|
].filter(Boolean).join(' · ');
|
|
20
|
-
const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
|
|
21
37
|
const w = $('#waiting');
|
|
22
38
|
w.hidden = needs.length === 0;
|
|
23
39
|
w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
|
|
@@ -28,5 +44,114 @@ BoardView.part((B) => {
|
|
|
28
44
|
: `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
|
|
29
45
|
}
|
|
30
46
|
|
|
47
|
+
// The deck's things, in Needs you order: a prompt; each open question of a line that asks some, in its topic's
|
|
48
|
+
// order; any other line as itself. → [{id, what, html}]
|
|
49
|
+
function deckOf(d, needs) {
|
|
50
|
+
const items = d.items || [];
|
|
51
|
+
const owner = (d.owner || []).filter((q) => q.state === 'open');
|
|
52
|
+
const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
|
|
53
|
+
return needs.flatMap((line) => {
|
|
54
|
+
if (line.prompt) return [{ id: `p:${line.prompt.id}`, what: 'Claude asks', html: `<ul class="deck-prompt">${promptItem(line.prompt, prompts.state(line.prompt))}</ul>` }];
|
|
55
|
+
const what = line.key ? line.title.split(':')[0] : line.title;
|
|
56
|
+
const topic = line.questions && topicOf(line);
|
|
57
|
+
const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
|
|
58
|
+
const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
|
|
59
|
+
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
|
|
60
|
+
const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
|
|
61
|
+
: line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
|
|
62
|
+
: line.action ? [line.action.label, line.action.url, '']
|
|
63
|
+
: line.review ? ['Open the review', line.url, ` data-review-open="${line.review}"`]
|
|
64
|
+
: ['Open', line.url, ''];
|
|
65
|
+
const title = line.key ? line.title.slice(what.length + 1).trim() : line.title;
|
|
66
|
+
return [{ id: `l:${line.key || line.review || line.band || line.url}`, what,
|
|
67
|
+
html: `<h3>${line.key ? `<a href="${esc(line.url)}">${esc(title)}</a>` : esc(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const questionHtml = (q) => {
|
|
71
|
+
const picked = getDraft(q.id).choice;
|
|
72
|
+
return `<h3>${esc(q.question)}</h3>${q.context ? `<p class="ctx">${esc(q.context)}</p>` : ''}
|
|
73
|
+
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
74
|
+
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${esc(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// 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.
|
|
78
|
+
let showing = null;
|
|
79
|
+
let deck = [];
|
|
80
|
+
function renderDeck(d, needs) {
|
|
81
|
+
const el = $('#needsDeck');
|
|
82
|
+
const before = deck.findIndex((x) => x.id === showing);
|
|
83
|
+
deck = deckOf(d, needs);
|
|
84
|
+
el.hidden = deck.length === 0;
|
|
85
|
+
if (!deck.length) { el.innerHTML = ''; showing = null; return; }
|
|
86
|
+
const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
|
|
87
|
+
showing = now.id;
|
|
88
|
+
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
89
|
+
const drafted = (d.owner || []).filter((q) => q.state === 'open' && 'choice' in getDraft(q.id)).length;
|
|
90
|
+
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
91
|
+
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${esc(now.what)}</p>${now.html}</div>
|
|
92
|
+
${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-skip>Skip →</button></div>` : ''}
|
|
93
|
+
${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
B.on(B.root, 'click', (ev) => {
|
|
97
|
+
if (!ev.target.closest?.('#needsDeck')) return;
|
|
98
|
+
if (ev.target.closest('[data-deck-skip]')) {
|
|
99
|
+
const at = deck.findIndex((x) => x.id === showing);
|
|
100
|
+
showing = deck[(at + 1) % deck.length].id;
|
|
101
|
+
renderNeeds(state.board);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const opt = ev.target.closest('button[data-deck-pick]');
|
|
105
|
+
if (!opt) return;
|
|
106
|
+
const id = opt.closest('[data-deck]').dataset.deck.slice(2);
|
|
107
|
+
setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
|
|
108
|
+
const at = deck.findIndex((x) => x.id === showing);
|
|
109
|
+
showing = deck[(at + 1) % deck.length].id;
|
|
110
|
+
renderNeeds(state.board);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
114
|
+
let questions = null;
|
|
115
|
+
function fitPane() {
|
|
116
|
+
if (wide.matches === Boolean(questions)) return;
|
|
117
|
+
if (state.board) renderNeeds(state.board);
|
|
118
|
+
if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
119
|
+
$('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
|
|
120
|
+
const slot = $('#needsPane .needs-questions');
|
|
121
|
+
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
|
|
122
|
+
questions.mount();
|
|
123
|
+
}
|
|
124
|
+
// From 900px, a link into open questions for a topic the pane holds picks that topic's pill there, rather than
|
|
125
|
+
// navigating to the page. It runs before the router's listener, which skips a prevented click.
|
|
126
|
+
B.on(B.root, 'click', (ev) => {
|
|
127
|
+
if (ev.defaultPrevented || ev.button || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey || !questions) return;
|
|
128
|
+
const a = ev.target.closest?.('a[href]');
|
|
129
|
+
if (!a || (a.target && a.target !== '_self')) return;
|
|
130
|
+
const home = new URL(`${B.site.root}/`, location.href);
|
|
131
|
+
const url = new URL(a.getAttribute('href'), home);
|
|
132
|
+
if (url.pathname !== new URL('questions.html', home).pathname || url.hash.length < 2) return;
|
|
133
|
+
const id = decodeURIComponent(url.hash.slice(1));
|
|
134
|
+
const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
|
|
135
|
+
const pill = topic && questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
|
|
136
|
+
if (!pill) return;
|
|
137
|
+
ev.preventDefault();
|
|
138
|
+
pill.click();
|
|
139
|
+
const picked = questions.slot.querySelector(`button[data-topic="${CSS.escape(topic.id)}"]`);
|
|
140
|
+
picked.focus();
|
|
141
|
+
picked.scrollIntoView({ block: 'nearest' });
|
|
142
|
+
});
|
|
143
|
+
// A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
144
|
+
// wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
145
|
+
B.on(B.root, 'dblclick', (ev) => {
|
|
146
|
+
const pill = questions && ev.target.closest?.('button[data-topic]');
|
|
147
|
+
if (!pill || !questions.slot.contains(pill)) return;
|
|
148
|
+
const id = pill.dataset.topic;
|
|
149
|
+
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
150
|
+
else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
151
|
+
});
|
|
152
|
+
fitPane();
|
|
153
|
+
B.on(wide, 'change', fitPane);
|
|
154
|
+
B.atUnmount(() => { questions?.unmount(); questions = null; });
|
|
155
|
+
|
|
31
156
|
Object.assign(B, { renderNeeds });
|
|
32
157
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log"
|
|
2
2
|
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
4
|
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
@@ -8,8 +8,8 @@ BoardView.part((B) => {
|
|
|
8
8
|
// The hub serves every site from one origin, so each board keeps its own key.
|
|
9
9
|
const KEY = 'boardSnapshot:' + B.site.url('');
|
|
10
10
|
// Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
|
|
11
|
-
// dropping first, so they leave room in the browser's 5 MB of storage for the drafts
|
|
12
|
-
//
|
|
11
|
+
// dropping first, so they leave room in the browser's 5 MB of storage for the drafts, which would otherwise fail to
|
|
12
|
+
// save.
|
|
13
13
|
const BUDGET = 3 * SNAPSHOT_MAX;
|
|
14
14
|
const ORDER = 'boardSnapshots';
|
|
15
15
|
const BUILD = document.documentElement.dataset.build;
|
|
@@ -91,8 +91,8 @@ BoardView.part((B) => {
|
|
|
91
91
|
}
|
|
92
92
|
const endFlash = () => { before = null; };
|
|
93
93
|
|
|
94
|
-
// Since the page last drew it, a last visit's board included, a card that changed lanes,
|
|
95
|
-
// the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
|
|
94
|
+
// Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
|
|
95
|
+
// the place of a drawn New task (its fromAsk), or a work card or New task that joins Todo at the bottom, flashes once; any other card whose updateSign changed takes a brief
|
|
96
96
|
// tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
|
|
97
97
|
// card's next change unsigned until the first board load started after send settles.
|
|
98
98
|
let drawn = new Map();
|
|
@@ -109,7 +109,8 @@ BoardView.part((B) => {
|
|
|
109
109
|
const now = Date.now();
|
|
110
110
|
for (const it of state.board.items) {
|
|
111
111
|
const was = drawn.get(it.key);
|
|
112
|
-
const
|
|
112
|
+
const arrived = !was && drawn.size > 0 && it.lane === 'todo' && (it.isNew || (it.kind === 'work' && !it.fromAsk));
|
|
113
|
+
const moved = was ? was.lane !== it.lane : arrived || (Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`));
|
|
113
114
|
const updated = Boolean(was) && was.sign !== updateSign(it);
|
|
114
115
|
const own = ownChanges.get(it.key);
|
|
115
116
|
if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
|