@lovek4d/kickflip 0.3.1 → 0.4.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 +151 -115
- package/README.md +23 -14
- package/SKILL.md +37 -37
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +186 -69
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +57 -38
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +156 -72
- package/board/public/app-core.js +2 -4
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +11 -14
- package/board/public/app.html +2 -1
- package/board/public/app.js +53 -83
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +5 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +20 -6
- package/board/public/sidebar.css +21 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +155 -62
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +176 -71
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +26 -23
- package/board/public/ui-sidebar.js +46 -22
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +17 -11
- package/board/public/ui.js +80 -12
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +101 -50
- package/board/public/view-settings.js +167 -49
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -12
- package/board/store.mjs +13 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -14
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -12
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/repos.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -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, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, BUTTON, LINK_ARROW } = BoardUI;
|
|
6
|
+
const { esc, ago, REVIEW, reviewAge, unreadCount, workLines, BUILD_PHASES, phaseStrip, phaseMotion, heldUntilWords, holdPicker, linkText, mergeButton, buildButton, installButton, tryButton, tryWords, waitOn, prName, BUTTON, LINK_ARROW } = BoardUI;
|
|
7
7
|
|
|
8
8
|
const mainSheet = $('#mainLogSheet');
|
|
9
9
|
const mainRetry = $('#mainLogRetry');
|
|
@@ -57,11 +57,10 @@ BoardView.part((B) => {
|
|
|
57
57
|
}
|
|
58
58
|
mainState = main.state;
|
|
59
59
|
}
|
|
60
|
+
// Retry waits (ui.js BUSY's look) from the tap until main is up again or its build fails.
|
|
60
61
|
const busy = postingMain || followMainAfter !== null || main.state === 'running';
|
|
61
62
|
mainRetry.disabled = busy;
|
|
62
|
-
mainRetry.setAttribute('aria-
|
|
63
|
-
mainRetry.classList.toggle('main-log-going', busy);
|
|
64
|
-
mainRetry.innerHTML = busy ? `<span class="try-spin" aria-hidden="true"></span>${esc(words.going)}` : 'Retry';
|
|
63
|
+
if (busy) mainRetry.setAttribute('aria-busy', 'true'); else mainRetry.removeAttribute('aria-busy');
|
|
65
64
|
}
|
|
66
65
|
B.renderMainLog = renderMainLog;
|
|
67
66
|
B.on(B.root, 'click', (ev) => {
|
|
@@ -99,8 +98,43 @@ BoardView.part((B) => {
|
|
|
99
98
|
}
|
|
100
99
|
});
|
|
101
100
|
|
|
101
|
+
// Main's refresh icon pulls now, spinning until the server answers. After a failed pull, a hover pointer's click
|
|
102
|
+
// pulls again while its reason shows on hover; a touch tap opens the reason instead, and a tap anywhere closes it.
|
|
103
|
+
// An open reason shifts left as far as it would run past the screen's right edge, less the 16px gutter.
|
|
104
|
+
function paintMain() {
|
|
105
|
+
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(state.board?.main, state.mainPull));
|
|
106
|
+
const why = $('#tryMain .main-pull-why.open');
|
|
107
|
+
if (!why) return;
|
|
108
|
+
const over = why.getBoundingClientRect().right - (innerWidth - 16);
|
|
109
|
+
if (over > 0) why.style.left = `${-over}px`;
|
|
110
|
+
}
|
|
111
|
+
B.on(B.root, 'click', async (ev) => {
|
|
112
|
+
const button = ev.target.closest('button[data-main-pull]');
|
|
113
|
+
if (!button) {
|
|
114
|
+
if (state.mainPull.open) { state.mainPull.open = false; paintMain(); }
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
ev.preventDefault();
|
|
118
|
+
const pull = state.board?.main?.pull;
|
|
119
|
+
if (!pull || pull.running || state.mainPull.sending) return;
|
|
120
|
+
if (pull.error && !matchMedia('(hover: hover)').matches) {
|
|
121
|
+
state.mainPull.open = !state.mainPull.open;
|
|
122
|
+
paintMain();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
state.mainPull = { sending: true, open: false };
|
|
126
|
+
paintMain();
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(B.site.url('api/try/main/pull'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
129
|
+
const out = await response.json();
|
|
130
|
+
if (!response.ok) throw new Error(out.error || 'The hub refused the pull.');
|
|
131
|
+
} catch (error) { if (B.alive()) toast(error.message || 'Could not pull. Is the host awake?'); }
|
|
132
|
+
state.mainPull.sending = false;
|
|
133
|
+
if (B.alive()) { paintMain(); B.reload(); }
|
|
134
|
+
});
|
|
135
|
+
|
|
102
136
|
// Hold's sheet: the shared duration picker in place of a confirm, since a chip is already a deliberate tap
|
|
103
|
-
// (docs/adr/
|
|
137
|
+
// (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md). A chip or Custom sends card-hold {key, hold: true, for}.
|
|
104
138
|
const holdSheet = $('#holdSheet');
|
|
105
139
|
let holdPicking = null;
|
|
106
140
|
const localInput = (d) => {
|
|
@@ -117,9 +151,9 @@ BoardView.part((B) => {
|
|
|
117
151
|
function openHold(it) {
|
|
118
152
|
$('#holdTitle').textContent = `Hold ${it.ref}`;
|
|
119
153
|
$('#holdLede').textContent = `${it.lane === 'todo' ? 'It won’t start' : 'Claude stops work on it'} until then. Forever holds it until you unhold it.`;
|
|
120
|
-
$('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices || []);
|
|
121
|
-
const input = $('#holdPicker [data-hold-at]');
|
|
122
154
|
const now = Date.now();
|
|
155
|
+
$('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices, new Date(now));
|
|
156
|
+
const input = $('#holdPicker [data-hold-at]');
|
|
123
157
|
input.min = localInput(new Date(now + 60_000));
|
|
124
158
|
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
125
159
|
input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
@@ -127,10 +161,12 @@ BoardView.part((B) => {
|
|
|
127
161
|
holdSheet.showModal();
|
|
128
162
|
$('#holdTitle').focus({ preventScroll: true });
|
|
129
163
|
}
|
|
164
|
+
// The card's Hold waits while the server records the hold (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
130
165
|
async function hold(key, choice) {
|
|
131
166
|
closeHold();
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
const button = $(`button[data-confirm="hold"][data-key="${CSS.escape(key)}"]`);
|
|
168
|
+
try { await waitOn(button, B.sentHere(key, () => B.send('card-hold', { key, hold: true, for: choice }))); }
|
|
169
|
+
catch (e) { B.failed(e); }
|
|
134
170
|
}
|
|
135
171
|
B.on($('#holdClose'), 'click', closeHold);
|
|
136
172
|
B.on(holdSheet, 'click', (ev) => {
|
|
@@ -180,15 +216,15 @@ BoardView.part((B) => {
|
|
|
180
216
|
}
|
|
181
217
|
}
|
|
182
218
|
if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
|
|
183
|
-
// building is an implementer at work, since Build means the package (docs/adr/
|
|
219
|
+
// building is an implementer at work, since Build means the package (docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
|
|
184
220
|
if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
|
|
185
|
-
// docs/adr/
|
|
221
|
+
// 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.
|
|
186
222
|
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]]) {
|
|
187
223
|
if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
|
|
188
224
|
}
|
|
189
225
|
if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
|
|
190
226
|
if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
|
|
191
|
-
// docs/adr/
|
|
227
|
+
// docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
|
|
192
228
|
const external = [
|
|
193
229
|
it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
|
|
194
230
|
// A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
|
|
@@ -208,7 +244,7 @@ BoardView.part((B) => {
|
|
|
208
244
|
|
|
209
245
|
const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
|
|
210
246
|
// A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
|
|
211
|
-
// (docs/adr/
|
|
247
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
212
248
|
const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
|
|
213
249
|
const pushedWords = (it) => [`Pushed ${it.ref}${it.pushed.host ? ` to ${it.pushed.host}` : ''}`, it.pushed.at && ago(it.pushed.at)].filter(Boolean).join(' · ');
|
|
214
250
|
B.on(B.root, 'click', (ev) => {
|
|
@@ -223,18 +259,21 @@ BoardView.part((B) => {
|
|
|
223
259
|
});
|
|
224
260
|
});
|
|
225
261
|
|
|
226
|
-
const
|
|
227
|
-
const sendingBuild = new Set();
|
|
262
|
+
const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
|
|
228
263
|
const CONFIRM = {
|
|
229
264
|
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
230
265
|
nudge: () => 'Nudge Claude to check this implementer?',
|
|
231
266
|
grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
|
|
232
|
-
|
|
267
|
+
prototype: (it, ref) => `Ask Claude for prototypes of ${ref}?`,
|
|
268
|
+
merge: (it, ref) => (openPRs(it).length > 1
|
|
269
|
+
? `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join(', ')} in turn if they are still approved, green and clean.`
|
|
270
|
+
: `Merge ${ref}? Claude squash-merges ${openPRs(it).map(prName).join('')} if it is still approved, green and clean.`),
|
|
233
271
|
'card-build': (it, ref) => (it.blockedBy?.length
|
|
234
272
|
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It waits in In Progress until then.`
|
|
235
273
|
: `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
|
|
236
274
|
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
237
|
-
: `
|
|
275
|
+
: it.ownerStep ? `Done with "${it.ownerStep}"? ${ref} moves to Done and its ticket closes.`
|
|
276
|
+
: `Close ${ref}? It moves to Done and Claude stops work on it.${openPRs(it).length ? ` Its PR${openPRs(it).length > 1 ? 's stay' : ' stays'} open on GitHub.` : it.pushed ? ` Its branch stays on ${it.pushed.host || 'the remote'}.` : ''}`),
|
|
238
277
|
};
|
|
239
278
|
|
|
240
279
|
const classes = (it, button, base = '') => {
|
|
@@ -248,36 +287,101 @@ BoardView.part((B) => {
|
|
|
248
287
|
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
249
288
|
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
250
289
|
: it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
|
|
251
|
-
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it
|
|
290
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it)}`)}</div>`;
|
|
252
291
|
}
|
|
253
292
|
|
|
254
|
-
// board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/
|
|
293
|
+
// 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).
|
|
255
294
|
const ACTION_BUTTONS = {
|
|
256
295
|
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
|
|
257
296
|
[BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
|
|
258
|
-
|
|
297
|
+
// Grill and Prototype each ask for a planner; both wait while either is under way.
|
|
298
|
+
[BUTTON.grill]: (it) => ['grill', 'prototype'].map((act) => `<button data-confirm="${act}" data-key="${esc(it.key)}"${it.grilling || it.prototyping ? ' disabled' : ''}>${act === 'grill' ? 'Grill' : 'Prototype'}</button>`).join(''),
|
|
259
299
|
};
|
|
260
300
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
261
301
|
|
|
262
|
-
// The latest bump among New cards takes no second bump (docs/adr/
|
|
302
|
+
// 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).
|
|
263
303
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
264
|
-
|
|
304
|
+
// band {step, actions} draws the card in a wave band (app-lanes.js).
|
|
305
|
+
// Its open questions asked since this browser last opened it count as new, as its messages do.
|
|
306
|
+
function card(it, band = {}) {
|
|
307
|
+
const unseen = (times) => (state.openItems.has(it.key) ? 0 : unreadCount(times, seen[it.key]));
|
|
265
308
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
266
309
|
<summary class="work">
|
|
267
310
|
<span class="status ${it.lane}"></span>
|
|
268
|
-
<div class="title-wrap">${workLines(it, { unread:
|
|
311
|
+
<div class="title-wrap">${workLines(it, { unread: unseen(it.othersAt), newQuestions: unseen(it.questions?.askedAt), top: latestBump(it), ...band })}</div>
|
|
269
312
|
</summary>
|
|
270
313
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
271
314
|
</details>`;
|
|
272
315
|
}
|
|
273
316
|
|
|
317
|
+
// The phase bars move as a card's reached count changes across redraws
|
|
318
|
+
// (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md,
|
|
319
|
+
// docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md). phases is each card's last drawn count by
|
|
320
|
+
// key, null for no strip; the first draw only records it, so a loaded board stays still.
|
|
321
|
+
const phases = new Map();
|
|
322
|
+
let drawn = false;
|
|
323
|
+
const PHASE_STEP = 40;
|
|
324
|
+
const play = (el, frames, opts, hold = 'backwards') => el.animate(frames, { fill: hold, easing: 'ease-out', ...opts });
|
|
325
|
+
const GROW = [{ opacity: 0, transform: 'scaleX(0)', transformOrigin: 'left' }, { opacity: 1, transform: 'none', transformOrigin: 'left' }];
|
|
326
|
+
const PHASE_SWEEP = 120;
|
|
327
|
+
// --bar-fill is the strip's fill, the muted one on a dimmed strip (style.css).
|
|
328
|
+
const SWEEP = 'linear-gradient(var(--bar-fill), var(--bar-fill))';
|
|
329
|
+
const sweepFrame = (size) => ({ backgroundColor: 'var(--line)', backgroundImage: SWEEP, backgroundRepeat: 'no-repeat', backgroundPosition: 'left', backgroundSize: size });
|
|
330
|
+
const stripOf = (card) => {
|
|
331
|
+
const bars = card?.querySelector('.card-time .phase-bars');
|
|
332
|
+
return bars && { sep: bars.previousElementSibling?.matches('.sep') ? bars.previousElementSibling : null, bars };
|
|
333
|
+
};
|
|
334
|
+
const cardEl = (key) => $(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
335
|
+
// Before lanes redraw: each drawn strip, kept so a build that ends can still leave.
|
|
336
|
+
function captureStrips() {
|
|
337
|
+
const strips = new Map();
|
|
338
|
+
for (const [key, reached] of phases) {
|
|
339
|
+
const strip = typeof reached === 'number' && stripOf(cardEl(key));
|
|
340
|
+
if (strip) strips.set(key, { sep: strip.sep?.cloneNode(true), bars: strip.bars.cloneNode(true) });
|
|
341
|
+
}
|
|
342
|
+
return strips;
|
|
343
|
+
}
|
|
344
|
+
// Sweeps bars from to before to full, one after another, starting after delay.
|
|
345
|
+
const sweep = (bars, from, to, delay) => [...bars.querySelectorAll('i')].slice(from, to)
|
|
346
|
+
.forEach((bar, k) => play(bar, [sweepFrame('0% 100%'), sweepFrame('100% 100%')], { duration: 220, delay: delay + k * PHASE_SWEEP, easing: 'ease-in-out' }));
|
|
347
|
+
// After lanes redraw: compares each card's reached count to its last, then enters, fills or leaves its strip. An
|
|
348
|
+
// entering strip grows in one bar at a time, then sweeps each reached bar.
|
|
349
|
+
function moveStrips(items, before) {
|
|
350
|
+
const next = new Map(items.map((it) => [it.key, phaseStrip(it)?.reached ?? null]));
|
|
351
|
+
const still = reduceMotion.matches || !drawn;
|
|
352
|
+
for (const [key, reached] of next) {
|
|
353
|
+
const kind = still ? null : phaseMotion(phases.get(key), reached);
|
|
354
|
+
if (!kind) continue;
|
|
355
|
+
const strip = stripOf(cardEl(key));
|
|
356
|
+
if (kind === 'leave') {
|
|
357
|
+
const gone = before.get(key);
|
|
358
|
+
const row = cardEl(key)?.querySelector('.card-time');
|
|
359
|
+
if (!gone || !row) continue;
|
|
360
|
+
const bars = [...gone.bars.querySelectorAll('i')].reverse();
|
|
361
|
+
row.append(...[gone.sep, gone.bars].filter(Boolean));
|
|
362
|
+
Promise.all(bars.map((bar, k) => play(bar, [...GROW].reverse(), { duration: 120, delay: k * PHASE_STEP, easing: 'ease-in' }, 'forwards').finished))
|
|
363
|
+
.then(() => play(gone.sep ?? gone.bars, [{ opacity: 1 }, { opacity: 0 }], { duration: 100 }, 'forwards').finished)
|
|
364
|
+
.finally(() => { gone.sep?.remove(); gone.bars.remove(); });
|
|
365
|
+
} else if (strip && kind === 'enter') {
|
|
366
|
+
if (strip.sep) play(strip.sep, [{ opacity: 0 }, { opacity: 1 }], { duration: 120 });
|
|
367
|
+
[...strip.bars.querySelectorAll('i')].forEach((bar, k) => play(bar, GROW, { duration: 140, delay: 60 + k * PHASE_STEP }));
|
|
368
|
+
sweep(strip.bars, 0, reached, 60 + BUILD_PHASES.length * PHASE_STEP + 140);
|
|
369
|
+
} else if (strip && kind === 'fill') {
|
|
370
|
+
sweep(strip.bars, phases.get(key), reached, 0);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
phases.clear();
|
|
374
|
+
for (const [key, reached] of next) phases.set(key, reached);
|
|
375
|
+
drawn = true;
|
|
376
|
+
}
|
|
377
|
+
|
|
274
378
|
B.on(B.root, 'toggle', (ev) => {
|
|
275
379
|
const el = ev.target;
|
|
276
380
|
if (!el.matches?.('details.item')) return;
|
|
277
381
|
const key = el.dataset.key;
|
|
278
382
|
if (el.open) {
|
|
279
383
|
state.openItems.add(key);
|
|
280
|
-
const it = state.board.items.find((i) => i.key === key);
|
|
384
|
+
const it = state.board.items.find((i) => i.key === key) || state.archived.cards.find((i) => i.key === key);
|
|
281
385
|
markSeen(it);
|
|
282
386
|
const unread = el.querySelector('summary .card-unread');
|
|
283
387
|
const line = unread?.parentNode;
|
|
@@ -313,29 +417,27 @@ BoardView.part((B) => {
|
|
|
313
417
|
const key = button.dataset.try;
|
|
314
418
|
const it = state.board.items.find((i) => i.key === key);
|
|
315
419
|
if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
|
|
316
|
-
|
|
317
|
-
|
|
420
|
+
// Serve / Build waits until the hub has started the build; the card's chip then says it runs.
|
|
421
|
+
const post = (async () => {
|
|
318
422
|
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
319
423
|
const out = await r.json();
|
|
320
424
|
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
321
|
-
}
|
|
425
|
+
})();
|
|
426
|
+
try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
|
|
322
427
|
B.reload();
|
|
323
428
|
});
|
|
324
429
|
|
|
325
430
|
// A work card's bump icon bumps it, or unbumps a bumped one, with no confirm
|
|
326
|
-
// (docs/adr/
|
|
327
|
-
// (docs/adr/
|
|
431
|
+
// (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md). The icon pulses until the server has recorded it, and the card
|
|
432
|
+
// moves as its answer lands (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
328
433
|
B.on(B.root, 'click', async (ev) => {
|
|
329
434
|
const b = ev.target.closest('button[data-card-bump]');
|
|
330
435
|
if (!b) return;
|
|
331
436
|
ev.preventDefault();
|
|
332
437
|
const key = b.dataset.cardBump;
|
|
333
438
|
const unbump = b.hasAttribute('data-bumped');
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
B.fadeBump(icon, !unbump);
|
|
337
|
-
try { await sending; toast(unbump ? 'Unbumped' : 'Moved to the top'); }
|
|
338
|
-
catch { B.fadeBump(icon, unbump); toast(B.FAILED); }
|
|
439
|
+
try { await waitOn(b, B.sentHere(key, () => B.send(unbump ? 'card-unbump' : 'card-bump', { key }))); toast(unbump ? 'Unbumped' : 'Moved to the top'); }
|
|
440
|
+
catch (e) { B.failed(e); }
|
|
339
441
|
});
|
|
340
442
|
|
|
341
443
|
B.on(B.root, 'click', async (ev) => {
|
|
@@ -346,48 +448,30 @@ BoardView.part((B) => {
|
|
|
346
448
|
const it = state.board.items.find((i) => i.key === key);
|
|
347
449
|
if (it && act === 'hold') { openHold(it); return; }
|
|
348
450
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
451
|
+
// Unhold, Merge (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md), Implement and Done wait while the
|
|
452
|
+
// server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
453
|
+
const sent = { grill: ['grill-more', { key }], prototype: ['prototype-request', { key }], resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
|
|
454
|
+
finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
|
|
455
|
+
if (sent) {
|
|
456
|
+
// Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
|
|
457
|
+
// 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).
|
|
458
|
+
const close = act === 'finish' ? () => {
|
|
459
|
+
const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
460
|
+
if (card) BoardUI.closeDetails(card);
|
|
461
|
+
state.openItems.delete(key);
|
|
462
|
+
store.set('openItems', [...state.openItems]);
|
|
463
|
+
} : null;
|
|
464
|
+
try { await waitOn(b, B.sentHere(key, () => B.send(...sent, close))); if (act === 'grill' || act === 'prototype') B.loadThread(key, { force: true }); }
|
|
465
|
+
catch (e) { B.failed(e); }
|
|
466
|
+
if (act === 'finish') B.syncDone(key);
|
|
352
467
|
return;
|
|
353
468
|
}
|
|
354
|
-
//
|
|
355
|
-
|
|
356
|
-
sendingMerge.add(key);
|
|
357
|
-
redrawActions(key);
|
|
358
|
-
try { await B.sentHere(key, () => B.send('card-merge', { key })); }
|
|
359
|
-
catch { toast('Could not send. Is the host awake?'); }
|
|
360
|
-
sendingMerge.delete(key);
|
|
361
|
-
redrawActions(key);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
if (act === 'card-build') {
|
|
365
|
-
sendingBuild.add(key);
|
|
366
|
-
redrawActions(key);
|
|
367
|
-
try { await B.sentHere(key, () => B.send('card-build', { key })); }
|
|
368
|
-
catch { toast('Could not send. Is the host awake?'); }
|
|
369
|
-
sendingBuild.delete(key);
|
|
370
|
-
redrawActions(key);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
// docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
|
|
374
|
-
// The card closes first, or app-core.js steady would anchor it and scroll the page after it into Done.
|
|
375
|
-
if (act === 'finish') {
|
|
376
|
-
const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
377
|
-
if (card) card.open = false;
|
|
378
|
-
try { await B.sentHere(key, () => (it.kind === 'ask' ? B.send('ask-done', { id: it.id }) : B.send('card-done', { key }))); }
|
|
379
|
-
catch { toast('Could not close. Is the host awake?'); }
|
|
380
|
-
B.syncDone(key);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
384
|
-
row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
|
|
385
|
-
b.disabled = true;
|
|
469
|
+
// The tapped button waits and the rest of its action row is disabled until the server answers.
|
|
470
|
+
const group = { group: $(`.actions[data-actions="${CSS.escape(key)}"]`)?.querySelectorAll('button') ?? [] };
|
|
386
471
|
try {
|
|
387
|
-
|
|
388
|
-
else { await B.sentHere(key, () => B.send('grill-more', { key })); B.loadThread(key, { force: true }); }
|
|
472
|
+
await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
|
|
389
473
|
} catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
|
|
390
474
|
});
|
|
391
475
|
|
|
392
|
-
Object.assign(B, { detail, card });
|
|
476
|
+
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
|
393
477
|
});
|
package/board/public/app-core.js
CHANGED
|
@@ -36,7 +36,7 @@ BoardView.part((B) => {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
|
|
39
|
-
// moved, in the same task so nothing visibly jumps (docs/adr/
|
|
39
|
+
// moved, in the same task so nothing visibly jumps (docs/adr/2026-10-30-1637-an-open-card-moves-at-once-and-the-page-follows-it.md).
|
|
40
40
|
// The anchor is the field being typed in, else the title row of the open card on screen, else there is none. A field
|
|
41
41
|
// in a dialog doesn't move with the page, so the card anchors then.
|
|
42
42
|
function anchorOf() {
|
|
@@ -114,8 +114,6 @@ BoardView.part((B) => {
|
|
|
114
114
|
quiet.set(draftKey, Date.now() + 1000);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
// A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
|
|
118
|
-
const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
|
|
119
117
|
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
120
118
|
const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
|
|
121
119
|
const svg = (body, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
@@ -126,5 +124,5 @@ BoardView.part((B) => {
|
|
|
126
124
|
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
127
125
|
const notice = dialogs.notice;
|
|
128
126
|
|
|
129
|
-
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft,
|
|
127
|
+
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, links, svg, confirm, notice });
|
|
130
128
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The board view's header and GitHub status: the time since the last change signal or refresh, refresh, the In Review
|
|
2
|
-
// header's GitHub line (docs/adr/
|
|
3
|
-
// the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/
|
|
2
|
+
// header's GitHub line (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md);
|
|
3
|
+
// the GitHub mark's Hub links sheet and a new board's setup checklist (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md);
|
|
4
4
|
// and the wake: any tap on an asleep board's page, refresh included, asks the hub to resume its session
|
|
5
|
-
// (docs/adr/
|
|
5
|
+
// (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). Needs app-core.js.
|
|
6
6
|
BoardView.part((B) => {
|
|
7
7
|
const { $, state, toast } = B;
|
|
8
8
|
const { statusLine, freshSig, nextFresh, githubLine, remoteLine, linksList, setupCard, checkLanded, githubRefresh, headerControls } = BoardUI;
|
|
@@ -16,7 +16,14 @@ BoardView.part((B) => {
|
|
|
16
16
|
$('#setup').innerHTML = setupCard(d.setup);
|
|
17
17
|
if ($('#linksSheet').open) $('#linksList').innerHTML = linksList(d.links);
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
// The status line, and the moon right of the title while the owner reads away (hub/presence.mjs ownerTracker).
|
|
20
|
+
const renderStatus = () => {
|
|
21
|
+
$('#status').innerHTML = statusLine(state.fresh?.at, { link: state.link, watcher: state.board?.checks?.watcher, pause: B.pause?.(), waking: state.waking });
|
|
22
|
+
const moon = $('.away-moon');
|
|
23
|
+
const away = Boolean(B.away?.());
|
|
24
|
+
moon.classList.toggle('on', away);
|
|
25
|
+
moon.setAttribute('aria-hidden', String(!away));
|
|
26
|
+
};
|
|
20
27
|
// A board load and its open threads landed; via is ui-header.js nextFresh's.
|
|
21
28
|
function landed(via) {
|
|
22
29
|
state.fresh = nextFresh(state.fresh, { via, at: new Date().toISOString(), sig: freshSig(state.board, state.threadCache, state.openItems) });
|
|
@@ -100,7 +107,7 @@ BoardView.part((B) => {
|
|
|
100
107
|
B.on($('.nav'), 'click', (ev) => {
|
|
101
108
|
if (ev.target.closest('a[aria-disabled="true"]')) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
102
109
|
}, true);
|
|
103
|
-
// A load can return in milliseconds, so the spin and "
|
|
110
|
+
// A load can return in milliseconds, so the spin and "reconnecting…" hold long enough to read.
|
|
104
111
|
const RELOAD_MIN = 700;
|
|
105
112
|
async function reloading(load) {
|
|
106
113
|
controls.reloading = true;
|