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