@lovek4d/kickflip 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- 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-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- 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 +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- 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 +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.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 +7 -7
- 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/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/public/app-arch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The bulb at the foot of the board and its review sheet: the board's last architecture review and Run, which confirms and asks the
|
|
2
2
|
// board server to start one (docs/adr/2026-11-02-1813-the-bulb-runs-an-architecture-review-of-the-boards-repo.md). The bulb glows
|
|
3
3
|
// while a review runs. Needs app-core.js.
|
|
4
4
|
BoardView.part((B) => {
|
package/board/public/app-asks.js
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
5
|
const { $, state, toast } = B;
|
|
6
|
-
const { waitOn } = BoardUI;
|
|
6
|
+
const { waitOn, keyedSend } = BoardUI;
|
|
7
7
|
|
|
8
|
-
// ⌘↵
|
|
8
|
+
// ⌘↵ and ⌥↵ press a thread reply's or an edit's send buttons from its box (ui.js keyedSend); the ask's own are new-task.js's.
|
|
9
9
|
B.on(B.root, 'keydown', (ev) => {
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
if (!
|
|
10
|
+
const form = ev.target.tagName === 'TEXTAREA' ? ev.target.closest('form[data-reply], form.edit-form') : null;
|
|
11
|
+
const button = form && keyedSend(ev, form);
|
|
12
|
+
if (!button) return;
|
|
13
13
|
ev.preventDefault();
|
|
14
|
-
form.requestSubmit();
|
|
14
|
+
form.requestSubmit(button);
|
|
15
15
|
});
|
|
16
16
|
BoardNewTask.wire(B);
|
|
17
17
|
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
// The answer banner at the top of the board view: asks Claude answered without making work, whose answer
|
|
2
|
-
//
|
|
1
|
+
// The answer banner at the top of the board view: asks Claude answered without making work, whose answer came after the
|
|
2
|
+
// card's read mark (domain.mjs answers; docs/adr/2026-10-29-1639-every-ask-is-a-card.md,
|
|
3
|
+
// docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md). Needs app-core.js.
|
|
3
4
|
BoardView.part((B) => {
|
|
4
|
-
const { $,
|
|
5
|
+
const { $, state, seen, markSeen, markRead, plural, svg } = B;
|
|
5
6
|
const { esc } = BoardUI;
|
|
6
|
-
const DISMISSED = 'dismissedAnswers';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
|
|
10
|
-
return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at));
|
|
11
|
-
}
|
|
8
|
+
const unseenAnswers = (d) => (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key]);
|
|
12
9
|
|
|
13
10
|
function renderBanner(d) {
|
|
14
|
-
// A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
|
|
15
|
-
for (const it of d.items) if (it.isNew) markSeen(it, true);
|
|
16
11
|
const shown = unseenAnswers(d);
|
|
17
12
|
const el = $('#answers');
|
|
18
13
|
el.hidden = !shown.length;
|
|
@@ -28,10 +23,9 @@ BoardView.part((B) => {
|
|
|
28
23
|
B.on($('#answers'), 'click', (ev) => {
|
|
29
24
|
const d = state.board;
|
|
30
25
|
if (!d) return;
|
|
26
|
+
// ✕ marks each answer shown read, on every device.
|
|
31
27
|
if (ev.target.closest('.answer-dismiss')) {
|
|
32
|
-
|
|
33
|
-
for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
|
|
34
|
-
store.set(DISMISSED, dismissed);
|
|
28
|
+
markRead(unseenAnswers(d).map((a) => [a.key, a.at]));
|
|
35
29
|
renderBanner(d);
|
|
36
30
|
return;
|
|
37
31
|
}
|
|
@@ -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, 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) {
|
|
@@ -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
|
|
|
@@ -206,7 +215,7 @@ BoardView.part((B) => {
|
|
|
206
215
|
const pr = it.pr;
|
|
207
216
|
const ctx = links(it.key);
|
|
208
217
|
const rows = [];
|
|
209
|
-
rows.push(['Wave',
|
|
218
|
+
rows.push(['Wave', escText(it.waveTitle)]);
|
|
210
219
|
if (pr) {
|
|
211
220
|
const ci = pr.checks ? `${pr.checks.state}${pr.checks.fail ? ` (${pr.checks.fail} failing)` : pr.checks.pending ? ` (${pr.checks.pending} running)` : ''}` : 'unknown';
|
|
212
221
|
if (pr.state === 'OPEN') {
|
|
@@ -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>` : '',
|
|
@@ -268,8 +275,8 @@ BoardView.part((B) => {
|
|
|
268
275
|
: openPRs(it).length > 1
|
|
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
|
-
'card-build': (it, ref) => (it.
|
|
272
|
-
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It
|
|
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.`
|
|
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,20 +289,33 @@ 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 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') : '';
|
|
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).
|
|
304
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
|
|
291
305
|
}
|
|
292
306
|
|
|
293
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).
|
|
294
308
|
const ACTION_BUTTONS = {
|
|
295
|
-
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">
|
|
296
|
-
[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>`,
|
|
297
310
|
};
|
|
298
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
|
+
};
|
|
299
319
|
|
|
300
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).
|
|
301
321
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
@@ -347,7 +367,7 @@ BoardView.part((B) => {
|
|
|
347
367
|
// After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
|
|
348
368
|
// entering strip grows in one bar at a time, then sweeps each reached bar.
|
|
349
369
|
function moveStrips(items, before) {
|
|
350
|
-
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]));
|
|
351
371
|
const still = reduceMotion.matches || !drawn;
|
|
352
372
|
for (const [key, reached] of next) {
|
|
353
373
|
const kind = still ? null : phaseMotion(phases.get(key), reached);
|
|
@@ -381,8 +401,10 @@ BoardView.part((B) => {
|
|
|
381
401
|
const key = el.dataset.key;
|
|
382
402
|
if (el.open) {
|
|
383
403
|
state.openItems.add(key);
|
|
404
|
+
state.restored.delete(key);
|
|
384
405
|
const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
|
|
385
406
|
markSeen(it);
|
|
407
|
+
B.renderBanner(state.board);
|
|
386
408
|
const unread = el.querySelector('summary .card-unread');
|
|
387
409
|
const line = unread?.parentNode;
|
|
388
410
|
unread?.remove();
|
|
@@ -396,12 +418,6 @@ BoardView.part((B) => {
|
|
|
396
418
|
store.set('openItems', [...state.openItems]);
|
|
397
419
|
}, true);
|
|
398
420
|
|
|
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
421
|
B.on(B.root, 'click', async (ev) => {
|
|
406
422
|
const log = ev.target.closest('[data-try-log]');
|
|
407
423
|
if (log && state.openItems.has(log.dataset.tryLog)) {
|
|
@@ -410,6 +426,8 @@ BoardView.part((B) => {
|
|
|
410
426
|
card?.querySelector('.thread')?.scrollIntoView({ block: 'start' });
|
|
411
427
|
return;
|
|
412
428
|
}
|
|
429
|
+
const off = ev.target.closest('a[data-tip]');
|
|
430
|
+
if (off) { ev.preventDefault(); toast(off.dataset.tip); return; }
|
|
413
431
|
const button = ev.target.closest('button[data-try]');
|
|
414
432
|
if (!button) return;
|
|
415
433
|
ev.preventDefault();
|
|
@@ -417,13 +435,22 @@ BoardView.part((B) => {
|
|
|
417
435
|
const key = button.dataset.try;
|
|
418
436
|
const it = state.board.items.find((i) => i.key === key);
|
|
419
437
|
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
|
-
|
|
438
|
+
// Serve / Build spins until a board shows the card's build changed: running, or already done
|
|
439
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
440
|
+
const before = JSON.stringify(it.try);
|
|
441
|
+
const id = `${key}:try`;
|
|
442
|
+
BoardUI.pending.start(id, (board) => JSON.stringify(board.items.find((i) => i.key === key)?.try) !== before, B.stillWaiting);
|
|
443
|
+
B.render();
|
|
444
|
+
try {
|
|
422
445
|
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
423
446
|
const out = await r.json();
|
|
424
447
|
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
425
|
-
|
|
426
|
-
|
|
448
|
+
BoardUI.pending.answered(id);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
BoardUI.pending.drop(id);
|
|
451
|
+
B.render();
|
|
452
|
+
toast(error.message || 'Could not start. Is the host awake?');
|
|
453
|
+
}
|
|
427
454
|
B.reload();
|
|
428
455
|
});
|
|
429
456
|
|
|
@@ -440,11 +467,16 @@ BoardView.part((B) => {
|
|
|
440
467
|
catch (e) { B.failed(e); }
|
|
441
468
|
});
|
|
442
469
|
|
|
443
|
-
B.on(B.root, 'click',
|
|
470
|
+
B.on(B.root, 'click', (ev) => {
|
|
444
471
|
const b = ev.target.closest('button[data-confirm]');
|
|
445
472
|
if (!b) return;
|
|
446
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) {
|
|
447
478
|
const { key, confirm: act } = b.dataset;
|
|
479
|
+
if (b.classList.contains('nudging')) return;
|
|
448
480
|
const it = state.board.items.find((i) => i.key === key);
|
|
449
481
|
if (it && act === 'hold') { openHold(it); return; }
|
|
450
482
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
@@ -472,15 +504,21 @@ BoardView.part((B) => {
|
|
|
472
504
|
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
473
505
|
B.failed(e);
|
|
474
506
|
}
|
|
475
|
-
if (act === 'finish') B.
|
|
507
|
+
if (act === 'finish') B.syncSends(key);
|
|
476
508
|
return;
|
|
477
509
|
}
|
|
478
|
-
//
|
|
479
|
-
|
|
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
|
|
511
|
+
// changed, the builder no longer stalled or the card gone (style.css .nudging).
|
|
512
|
+
const id = `${key}:nudge`;
|
|
513
|
+
const { progress, at } = it.builder;
|
|
514
|
+
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);
|
|
515
|
+
B.render();
|
|
480
516
|
try {
|
|
481
|
-
await
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
|
|
518
|
+
BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
|
|
519
|
+
} catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
|
|
520
|
+
B.render();
|
|
521
|
+
}
|
|
484
522
|
|
|
485
|
-
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
|
523
|
+
Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
|
|
486
524
|
});
|
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,34 +77,34 @@ 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
|
|
|
84
85
|
state.link = 'online';
|
|
85
86
|
const controls = { wired: false, boardSettled: false, reloading: false };
|
|
86
87
|
function applyControls() {
|
|
87
|
-
const on = headerControls(
|
|
88
|
+
const on = headerControls(controls);
|
|
88
89
|
$('#refresh').disabled = !on.refresh;
|
|
89
90
|
$('#refresh').classList.toggle('spinning', on.spin);
|
|
90
91
|
const gear = $('#settingsLink');
|
|
91
92
|
if (on.settings) { gear.removeAttribute('aria-disabled'); gear.removeAttribute('tabindex'); } else { gear.setAttribute('aria-disabled', 'true'); gear.tabIndex = -1; }
|
|
92
93
|
}
|
|
93
94
|
const headerWired = () => { controls.wired = true; applyControls(); };
|
|
94
|
-
//
|
|
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).
|
|
95
97
|
function setLink(link) {
|
|
96
|
-
const was = state.link;
|
|
97
98
|
state.link = link;
|
|
98
99
|
if (link !== 'reconnecting') controls.boardSettled = true;
|
|
99
|
-
|
|
100
|
-
B.root.classList.toggle('offline', off);
|
|
101
|
-
if (link === 'offline' && was === 'online') toast('Offline. Use refresh in the header to retry.');
|
|
100
|
+
B.root.classList.toggle('offline', link !== 'online');
|
|
102
101
|
applyControls();
|
|
103
102
|
renderStatus();
|
|
104
103
|
}
|
|
104
|
+
BoardUI.holdSends(B.on, B.root, () => state.link !== 'online', () => B.toast(BoardUI.WAITING));
|
|
105
105
|
const linkUp = () => setLink('online');
|
|
106
106
|
const linkDown = () => { if (state.link !== 'reconnecting') setLink('offline'); };
|
|
107
|
-
B.on($('
|
|
107
|
+
B.on($('#settingsLink'), 'click', (ev) => {
|
|
108
108
|
if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
109
109
|
}, true);
|
|
110
110
|
// A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
|