@lovek4d/kickflip 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +69 -37
- package/SKILL.md +19 -13
- 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 +156 -83
- 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 +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -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 +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- 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 +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- 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 +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- 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 +1 -1
- 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 +7 -7
- 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/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') {
|
|
@@ -275,8 +275,8 @@ BoardView.part((B) => {
|
|
|
275
275
|
: openPRs(it).length > 1
|
|
276
276
|
? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
|
|
277
277
|
: `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.`
|
|
278
|
+
'card-build': (it, ref) => (it.blocked
|
|
279
|
+
? `${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
280
|
: `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
|
|
281
281
|
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
282
282
|
: it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
|
|
@@ -297,16 +297,25 @@ BoardView.part((B) => {
|
|
|
297
297
|
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
298
298
|
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
299
299
|
: it.held ? ask('resume', 'Unhold', 'unhold') : ask('hold', 'Hold');
|
|
300
|
+
// A Todo card's Close, right of Hold; an In Review card's Done is in its thread box
|
|
301
|
+
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
302
|
+
const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
|
|
300
303
|
// 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', `${
|
|
304
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
|
|
302
305
|
}
|
|
303
306
|
|
|
304
307
|
// 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).
|
|
305
308
|
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>`,
|
|
309
|
+
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
|
|
308
310
|
};
|
|
309
311
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
312
|
+
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
|
|
313
|
+
// On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
|
|
314
|
+
// the viewer on the latest round, solid orange while a round waits on the owner's pick.
|
|
315
|
+
const prototypesButton = (it) => {
|
|
316
|
+
const latest = it.prototypes?.rounds.at(-1);
|
|
317
|
+
return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
|
|
318
|
+
};
|
|
310
319
|
|
|
311
320
|
// The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
|
|
312
321
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
@@ -358,7 +367,7 @@ BoardView.part((B) => {
|
|
|
358
367
|
// After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
|
|
359
368
|
// entering strip grows in one bar at a time, then sweeps each reached bar.
|
|
360
369
|
function moveStrips(items, before) {
|
|
361
|
-
const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.
|
|
370
|
+
const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.done ?? null]));
|
|
362
371
|
const still = reduceMotion.matches || !drawn;
|
|
363
372
|
for (const [key, reached] of next) {
|
|
364
373
|
const kind = still ? null : phaseMotion(phases.get(key), reached);
|
|
@@ -392,6 +401,7 @@ BoardView.part((B) => {
|
|
|
392
401
|
const key = el.dataset.key;
|
|
393
402
|
if (el.open) {
|
|
394
403
|
state.openItems.add(key);
|
|
404
|
+
state.restored.delete(key);
|
|
395
405
|
const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
|
|
396
406
|
markSeen(it);
|
|
397
407
|
B.renderBanner(state.board);
|
|
@@ -457,10 +467,14 @@ BoardView.part((B) => {
|
|
|
457
467
|
catch (e) { B.failed(e); }
|
|
458
468
|
});
|
|
459
469
|
|
|
460
|
-
B.on(B.root, 'click',
|
|
470
|
+
B.on(B.root, 'click', (ev) => {
|
|
461
471
|
const b = ev.target.closest('button[data-confirm]');
|
|
462
472
|
if (!b) return;
|
|
463
473
|
ev.preventDefault();
|
|
474
|
+
confirmTap(b);
|
|
475
|
+
});
|
|
476
|
+
// A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
|
|
477
|
+
async function confirmTap(b) {
|
|
464
478
|
const { key, confirm: act } = b.dataset;
|
|
465
479
|
if (b.classList.contains('nudging')) return;
|
|
466
480
|
const it = state.board.items.find((i) => i.key === key);
|
|
@@ -490,7 +504,7 @@ BoardView.part((B) => {
|
|
|
490
504
|
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
491
505
|
B.failed(e);
|
|
492
506
|
}
|
|
493
|
-
if (act === 'finish') B.
|
|
507
|
+
if (act === 'finish') B.syncSends(key);
|
|
494
508
|
return;
|
|
495
509
|
}
|
|
496
510
|
// A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
|
|
@@ -504,7 +518,7 @@ BoardView.part((B) => {
|
|
|
504
518
|
BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
|
|
505
519
|
} catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
|
|
506
520
|
B.render();
|
|
507
|
-
}
|
|
521
|
+
}
|
|
508
522
|
|
|
509
|
-
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
|
523
|
+
Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
|
|
510
524
|
});
|
|
@@ -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,9 +1,9 @@
|
|
|
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
9
|
{ id: 'stale', name: 'Stale', open: true, hideEmpty: true },
|
|
@@ -13,13 +13,13 @@ BoardView.part((B) => {
|
|
|
13
13
|
{ id: 'asks', name: 'Asks', open: false, capped: true },
|
|
14
14
|
];
|
|
15
15
|
// 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
|
-
//
|
|
16
|
+
// A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner opened
|
|
17
|
+
// 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
18
|
// older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
|
|
19
19
|
const STEP = 10;
|
|
20
20
|
const moreTaps = new Map();
|
|
21
21
|
function cappedRows(laneId, cards, older = 0) {
|
|
22
|
-
const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
|
|
22
|
+
const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key) && !state.restored.has(it.key));
|
|
23
23
|
const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
|
|
24
24
|
const left = Math.max(0, cards.length - shown);
|
|
25
25
|
const n = left + (older || 0);
|
|
@@ -55,11 +55,11 @@ BoardView.part((B) => {
|
|
|
55
55
|
// A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
|
|
56
56
|
// Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
|
|
57
57
|
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
|
|
58
|
+
// A fold's header reads like a lane's: its name, then its count.
|
|
59
|
+
function fold(id, list, name) {
|
|
60
60
|
if (!list.length) return '';
|
|
61
61
|
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
|
|
62
|
+
<summary><span class="lane-name">${name}</span><span class="count">${list.length}</span></summary>
|
|
63
63
|
<div class="fold-list">${list.map((it) => B.card(it)).join('')}</div>
|
|
64
64
|
</details>`;
|
|
65
65
|
}
|
|
@@ -69,8 +69,7 @@ BoardView.part((B) => {
|
|
|
69
69
|
const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
|
|
70
70
|
return slugs.map((slug) => {
|
|
71
71
|
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` : '');
|
|
72
|
+
return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), escText(w.title));
|
|
74
73
|
}).join('');
|
|
75
74
|
}
|
|
76
75
|
|
|
@@ -110,7 +109,7 @@ BoardView.part((B) => {
|
|
|
110
109
|
// Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
|
|
111
110
|
// 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
111
|
// counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
|
|
113
|
-
// the lane's own number bold in
|
|
112
|
+
// 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
113
|
// A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
|
|
115
114
|
let countsShown = null;
|
|
116
115
|
const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
|
|
@@ -130,7 +129,7 @@ BoardView.part((B) => {
|
|
|
130
129
|
if (!grouped.length) return cards.map((it) => B.card(it)).join('');
|
|
131
130
|
const loose = cards.filter((i) => !bands.some((b) => b.keys.includes(i.key)));
|
|
132
131
|
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)}"`,
|
|
132
|
+
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
133
|
+ (loose.length ? group('wb-loose', '', 'No wave', loose.length, '', loose) : '');
|
|
135
134
|
}
|
|
136
135
|
|
|
@@ -141,13 +140,6 @@ BoardView.part((B) => {
|
|
|
141
140
|
// card whose ⋯ menu is open, goalShown the band whose goal popover is open.
|
|
142
141
|
let menu = null;
|
|
143
142
|
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
143
|
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
152
144
|
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
153
145
|
const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
|
|
@@ -169,8 +161,8 @@ BoardView.part((B) => {
|
|
|
169
161
|
const todo = cards.filter((i) => i.lane === 'todo');
|
|
170
162
|
const done = cards.filter((i) => i.lane === 'done');
|
|
171
163
|
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)}"` : ''}>${
|
|
164
|
+
const progress = now.length ? ` <span class="count">· ${now.join(' · ')}</span>` : '';
|
|
165
|
+
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${escText(band.title)}</span>`;
|
|
174
166
|
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
167
|
const next = band.next;
|
|
176
168
|
const go = !next ? '' : next.ready
|
|
@@ -178,8 +170,8 @@ BoardView.part((B) => {
|
|
|
178
170
|
: `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
|
|
179
171
|
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
180
172
|
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}
|
|
173
|
+
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span>${progress}</span>`
|
|
174
|
+
+ waveCounts(band, 'todo') + `<span class="wb-end"><span class="wb-links">${notes}</span>${go}</span>${pop}</div>`;
|
|
183
175
|
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
184
176
|
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
185
177
|
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
@@ -250,21 +242,6 @@ BoardView.part((B) => {
|
|
|
250
242
|
} catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
|
|
251
243
|
return;
|
|
252
244
|
}
|
|
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
245
|
const show = ev.target.closest('a[data-band-show]');
|
|
269
246
|
if (show) {
|
|
270
247
|
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
|
});
|
|
@@ -1,72 +1,41 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The board view's saved copy: reading it from IndexedDB and saving it there with its change number
|
|
2
|
+
// (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md), flashing the lines the catch-up changed, and the sign
|
|
3
|
+
// on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
|
|
4
4
|
// Needs app-core.js and ui-snapshot.js; app.js paints it.
|
|
5
5
|
BoardView.part((B) => {
|
|
6
6
|
const { $$, state } = B;
|
|
7
|
-
const {
|
|
8
|
-
// The hub serves every site from one origin, so each board keeps its own key.
|
|
9
|
-
const KEY =
|
|
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, which would otherwise fail to
|
|
12
|
-
// save.
|
|
13
|
-
const BUDGET = 3 * SNAPSHOT_MAX;
|
|
14
|
-
const ORDER = 'boardSnapshots';
|
|
7
|
+
const { copyOf, boardCopy, copyStore, preloadKeys, changedLines, updateSign } = BoardUI;
|
|
8
|
+
// The hub serves every site from one origin, so each board keeps its own key in the one store.
|
|
9
|
+
const KEY = B.site.url('');
|
|
15
10
|
const BUILD = document.documentElement.dataset.build;
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try { order = JSON.parse(localStorage.getItem(ORDER) || '[]'); } catch {}
|
|
26
|
-
const stored = [];
|
|
27
|
-
for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith('boardSnapshot:') && k !== KEY) stored.push(k); }
|
|
28
|
-
const recent = [...order.filter((k) => stored.includes(k)), ...stored.filter((k) => !order.includes(k))];
|
|
29
|
-
let used = raw.length;
|
|
30
|
-
const kept = [KEY];
|
|
31
|
-
for (const k of recent) {
|
|
32
|
-
const size = localStorage.getItem(k)?.length || 0;
|
|
33
|
-
if (used + size <= BUDGET) { used += size; kept.push(k); } else localStorage.removeItem(k);
|
|
34
|
-
}
|
|
35
|
-
localStorage.setItem(ORDER, JSON.stringify(kept));
|
|
36
|
-
}
|
|
12
|
+
const copies = (mode, fn) => copyStore('boards', mode, fn);
|
|
13
|
+
|
|
14
|
+
// → {savedAt, board, threads} or null. anyBuild reads a copy another build saved too, which only a board out of reach
|
|
15
|
+
// paints, guarded (app.js showDown).
|
|
16
|
+
const readCopy = ({ anyBuild = false } = {}) => boardCopy(KEY, anyBuild ? null : BUILD);
|
|
17
|
+
|
|
18
|
+
// Saves the board on screen. app.html's boot reads copyVersion before any script loads, so its first board request
|
|
19
|
+
// asks since the copy's number; it names no number when the save failed.
|
|
37
20
|
let saveTimer = null;
|
|
38
|
-
function
|
|
21
|
+
async function saveCopy() {
|
|
39
22
|
saveTimer?.();
|
|
40
23
|
saveTimer = null;
|
|
41
24
|
if (state.view !== 'fresh' || !state.board) return;
|
|
42
|
-
const
|
|
43
|
-
build: BUILD,
|
|
44
|
-
savedAt: state.board.generatedAt || new Date().toISOString(),
|
|
45
|
-
keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
|
|
46
|
-
});
|
|
25
|
+
const copy = copyOf(state.board, state.threadCache, { build: BUILD, savedAt: state.board.generatedAt || new Date().toISOString() });
|
|
47
26
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} catch { try { localStorage.removeItem(KEY); } catch {} }
|
|
27
|
+
await copies('readwrite', (store) => store.put(copy, KEY));
|
|
28
|
+
B.store.set('copyVersion', { build: BUILD, version: copy.board.version });
|
|
29
|
+
} catch { B.store.set('copyVersion', null); }
|
|
52
30
|
}
|
|
53
|
-
const
|
|
54
|
-
B.on(window, 'pagehide', () => { if (saveTimer)
|
|
55
|
-
|
|
56
|
-
B.atUnmount(saveSnapshot);
|
|
31
|
+
const saveCopySoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveCopy); };
|
|
32
|
+
B.on(window, 'pagehide', () => { if (saveTimer) saveCopy(); });
|
|
33
|
+
B.atUnmount(saveCopy);
|
|
57
34
|
|
|
58
|
-
// view is
|
|
35
|
+
// view is null until a board paints, then 'fresh'; a guarded paint that failed sets it back to null.
|
|
59
36
|
function setView(view) {
|
|
60
37
|
state.view = view;
|
|
61
|
-
B.root.dataset.board = view;
|
|
62
|
-
}
|
|
63
|
-
for (const type of ['click', 'submit']) {
|
|
64
|
-
B.on(B.root, type, (ev) => {
|
|
65
|
-
const { blocked } = liveControls(state.view);
|
|
66
|
-
if (!blocked) return;
|
|
67
|
-
const control = type === 'submit' ? ev.target.querySelector?.('button[type=submit]') : ev.target.closest?.(blocked);
|
|
68
|
-
if (control?.matches(blocked)) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
69
|
-
}, true);
|
|
38
|
+
if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
|
|
70
39
|
}
|
|
71
40
|
|
|
72
41
|
// The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
|
|
@@ -79,31 +48,39 @@ BoardView.part((B) => {
|
|
|
79
48
|
}
|
|
80
49
|
const texts = (els) => Object.fromEntries([...els].map(([id, el]) => [id, el.textContent.replace(/\s+/g, ' ').trim()]));
|
|
81
50
|
let before = null;
|
|
51
|
+
// The one highlight: every changed line, moved card and updated card takes the full tint of style.css .tint. Re-adding
|
|
52
|
+
// the class after a layout read restarts the animation, from the delay's point.
|
|
53
|
+
function highlight(el, elapsed = 0) {
|
|
54
|
+
el.classList.remove('tint');
|
|
55
|
+
el.style.setProperty('--sign-delay', `-${elapsed}ms`);
|
|
56
|
+
void el.offsetWidth;
|
|
57
|
+
el.classList.add('tint');
|
|
58
|
+
}
|
|
82
59
|
const holdForFlash = () => { before = texts(lines()); };
|
|
83
60
|
function flashChanged() {
|
|
84
61
|
if (!before || state.view !== 'fresh') return;
|
|
85
62
|
const els = lines();
|
|
86
63
|
const after = texts(els);
|
|
87
64
|
for (const id of changedLines(before, after)) {
|
|
88
|
-
els.get(id)
|
|
65
|
+
highlight(els.get(id));
|
|
89
66
|
before[id] = after[id];
|
|
90
67
|
}
|
|
91
68
|
}
|
|
92
69
|
const endFlash = () => { before = null; };
|
|
93
70
|
|
|
94
71
|
// 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),
|
|
96
|
-
//
|
|
97
|
-
// card's next change unsigned until the first board load started after send settles.
|
|
72
|
+
// the place of a drawn New task (its fromAsk), a work card or New task that joins Todo at the bottom, or any other
|
|
73
|
+
// card whose updateSign changed takes the same tint. It does not show for a change the owner sent from this page:
|
|
74
|
+
// sentHere(key, send) holds the card's next change unsigned until the first board load started after send settles.
|
|
98
75
|
let drawn = new Map();
|
|
99
76
|
const ownChanges = new Map();
|
|
100
77
|
async function sentHere(key, send) {
|
|
101
78
|
ownChanges.set(key, Infinity);
|
|
102
79
|
try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
|
|
103
80
|
}
|
|
104
|
-
//
|
|
81
|
+
// The tint's style.css animation length. A lane repaint resets a card's classes, so a tint still running is put back
|
|
105
82
|
// as far into its animation as it was.
|
|
106
|
-
const
|
|
83
|
+
const TINT_MS = 2400;
|
|
107
84
|
const running = new Map();
|
|
108
85
|
function signCards(load = null) {
|
|
109
86
|
const now = Date.now();
|
|
@@ -114,19 +91,15 @@ BoardView.part((B) => {
|
|
|
114
91
|
const updated = Boolean(was) && was.sign !== updateSign(it);
|
|
115
92
|
const own = ownChanges.get(it.key);
|
|
116
93
|
if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
|
|
117
|
-
if ((moved || updated) && state.view === 'fresh') running.set(it.key,
|
|
94
|
+
if ((moved || updated) && state.view === 'fresh') running.set(it.key, now);
|
|
118
95
|
}
|
|
119
96
|
drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
|
|
120
|
-
for (const [key,
|
|
121
|
-
const card = now - at <
|
|
97
|
+
for (const [key, at] of running) {
|
|
98
|
+
const card = now - at < TINT_MS && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
122
99
|
if (!card) { running.delete(key); continue; }
|
|
123
|
-
|
|
124
|
-
card.classList.remove('flash', 'tint');
|
|
125
|
-
card.style.setProperty('--sign-delay', `-${now - at}ms`);
|
|
126
|
-
void card.offsetWidth;
|
|
127
|
-
card.classList.add(sign);
|
|
100
|
+
highlight(card, now - at);
|
|
128
101
|
}
|
|
129
102
|
}
|
|
130
103
|
|
|
131
|
-
Object.assign(B, {
|
|
104
|
+
Object.assign(B, { readCopy, saveCopySoon, setView, holdForFlash, flashChanged, endFlash, highlight, sentHere, signCards });
|
|
132
105
|
});
|