@lovek4d/kickflip 0.1.0 → 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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- 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 +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- 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 +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- 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 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- 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 +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
|
@@ -1,8 +1,202 @@
|
|
|
1
|
-
// Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation
|
|
1
|
+
// Work cards on the board view: a card in a lane, its expanded detail, its action row with each button's confirmation or
|
|
2
|
+
// Hold's sheet, and opening it. Needs
|
|
2
3
|
// app-core.js and app-threads.js.
|
|
3
4
|
BoardView.part((B) => {
|
|
4
5
|
const { $, store, state, markSeen, seen, toast, links } = B;
|
|
5
|
-
const { esc, REVIEW, reviewAge, unreadCount, workLines, linkText, mergeButton, 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
|
+
|
|
8
|
+
const mainSheet = $('#mainLogSheet');
|
|
9
|
+
const mainRetry = $('#mainLogRetry');
|
|
10
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
11
|
+
let postingMain = false;
|
|
12
|
+
let followMainAfter = null;
|
|
13
|
+
let mainState = null;
|
|
14
|
+
let logRequest = 0;
|
|
15
|
+
function mainReason(text = '') {
|
|
16
|
+
$('#mainLogReason').textContent = text;
|
|
17
|
+
$('#mainLogReason').hidden = !text;
|
|
18
|
+
}
|
|
19
|
+
function closeMainLog() {
|
|
20
|
+
if (!mainSheet.open || mainSheet.classList.contains('closing')) return;
|
|
21
|
+
logRequest++;
|
|
22
|
+
if (reduceMotion.matches) { mainSheet.close(); return; }
|
|
23
|
+
mainSheet.classList.add('closing');
|
|
24
|
+
B.later(180, () => { mainSheet.classList.remove('closing'); mainSheet.close(); });
|
|
25
|
+
}
|
|
26
|
+
async function loadMainLog() {
|
|
27
|
+
const request = ++logRequest;
|
|
28
|
+
const pre = $('#mainLogLines');
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(B.site.url('api/try/main/log'), { cache: 'no-store' });
|
|
31
|
+
const out = await response.json();
|
|
32
|
+
if (!response.ok) throw new Error(out.error || 'Could not read main’s log.');
|
|
33
|
+
if (!B.alive() || request !== logRequest || !mainSheet.open) return;
|
|
34
|
+
pre.innerHTML = out.lines.length ? out.lines.map((line) => esc(line)).join('\n') : 'No log was written.';
|
|
35
|
+
pre.scrollTop = pre.scrollHeight;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (B.alive() && request === logRequest && mainSheet.open) mainReason(error.message || 'Could not read main’s log.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function renderMainLog(main, load = null) {
|
|
41
|
+
if (!mainSheet.open || mainSheet.classList.contains('closing') || !main) return;
|
|
42
|
+
const words = tryWords(main.kind);
|
|
43
|
+
$('#mainLogTitle').textContent = `Main’s ${words.failed.toLowerCase()}`;
|
|
44
|
+
$('#mainLogSource').innerHTML = `<code>.kickflip/build</code> stopped at main@<code>${esc(main.sha)}</code> · last lines of <code>data/try/main.log</code>`;
|
|
45
|
+
// Only a load begun after the POST resolved can settle this retry.
|
|
46
|
+
const fresh = !postingMain && load !== null && (followMainAfter === null || load >= followMainAfter);
|
|
47
|
+
if (fresh) {
|
|
48
|
+
const followed = followMainAfter !== null || mainState === 'running';
|
|
49
|
+
if (main.state === 'ready' && followed) {
|
|
50
|
+
followMainAfter = null;
|
|
51
|
+
closeMainLog();
|
|
52
|
+
toast(`Main is up again at ${main.sha}.`);
|
|
53
|
+
} else if (main.state === 'failed' && followed) {
|
|
54
|
+
followMainAfter = null;
|
|
55
|
+
mainReason();
|
|
56
|
+
loadMainLog();
|
|
57
|
+
}
|
|
58
|
+
mainState = main.state;
|
|
59
|
+
}
|
|
60
|
+
// Retry waits (ui.js BUSY's look) from the tap until main is up again or its build fails.
|
|
61
|
+
const busy = postingMain || followMainAfter !== null || main.state === 'running';
|
|
62
|
+
mainRetry.disabled = busy;
|
|
63
|
+
if (busy) mainRetry.setAttribute('aria-busy', 'true'); else mainRetry.removeAttribute('aria-busy');
|
|
64
|
+
}
|
|
65
|
+
B.renderMainLog = renderMainLog;
|
|
66
|
+
B.on(B.root, 'click', (ev) => {
|
|
67
|
+
if (!ev.target.closest('[data-main-log]') || !state.board?.main || mainSheet.open) return;
|
|
68
|
+
ev.preventDefault();
|
|
69
|
+
mainReason();
|
|
70
|
+
if (!postingMain) followMainAfter = null;
|
|
71
|
+
mainState = state.board.main.state;
|
|
72
|
+
mainSheet.classList.remove('closing');
|
|
73
|
+
mainSheet.showModal();
|
|
74
|
+
$('#mainLogLines').textContent = 'Loading…';
|
|
75
|
+
renderMainLog(state.board.main);
|
|
76
|
+
loadMainLog();
|
|
77
|
+
});
|
|
78
|
+
B.on($('#mainLogClose'), 'click', closeMainLog);
|
|
79
|
+
B.on(mainSheet, 'click', (ev) => { if (ev.target === mainSheet) closeMainLog(); });
|
|
80
|
+
B.on(mainSheet, 'cancel', (ev) => { ev.preventDefault(); closeMainLog(); });
|
|
81
|
+
B.on(mainRetry, 'click', async () => {
|
|
82
|
+
if (mainRetry.disabled) return;
|
|
83
|
+
postingMain = true;
|
|
84
|
+
mainReason();
|
|
85
|
+
renderMainLog(state.board.main);
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'main' }) });
|
|
88
|
+
const out = await response.json();
|
|
89
|
+
if (!response.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
90
|
+
if (!B.alive()) return;
|
|
91
|
+
followMainAfter = B.boardLoad + 1;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (B.alive()) mainReason(error.message || 'Could not start. Is the host awake?');
|
|
94
|
+
} finally { postingMain = false; }
|
|
95
|
+
if (B.alive()) {
|
|
96
|
+
renderMainLog(state.board.main);
|
|
97
|
+
B.reload();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
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
|
+
|
|
136
|
+
// Hold's sheet: the shared duration picker in place of a confirm, since a chip is already a deliberate tap
|
|
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}.
|
|
138
|
+
const holdSheet = $('#holdSheet');
|
|
139
|
+
let holdPicking = null;
|
|
140
|
+
const localInput = (d) => {
|
|
141
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
142
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
143
|
+
};
|
|
144
|
+
function closeHold() {
|
|
145
|
+
if (!holdSheet.open || holdSheet.classList.contains('closing')) return;
|
|
146
|
+
holdPicking = null;
|
|
147
|
+
if (reduceMotion.matches) { holdSheet.close(); return; }
|
|
148
|
+
holdSheet.classList.add('closing');
|
|
149
|
+
B.later(180, () => { holdSheet.classList.remove('closing'); holdSheet.close(); });
|
|
150
|
+
}
|
|
151
|
+
function openHold(it) {
|
|
152
|
+
$('#holdTitle').textContent = `Hold ${it.ref}`;
|
|
153
|
+
$('#holdLede').textContent = `${it.lane === 'todo' ? 'It won’t start' : 'Claude stops work on it'} until then. Forever holds it until you unhold it.`;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
$('#holdPicker').innerHTML = holdPicker(it.key, state.board.holdChoices, new Date(now));
|
|
156
|
+
const input = $('#holdPicker [data-hold-at]');
|
|
157
|
+
input.min = localInput(new Date(now + 60_000));
|
|
158
|
+
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
159
|
+
input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
160
|
+
holdSheet.classList.remove('closing');
|
|
161
|
+
holdSheet.showModal();
|
|
162
|
+
$('#holdTitle').focus({ preventScroll: true });
|
|
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).
|
|
165
|
+
async function hold(key, choice) {
|
|
166
|
+
closeHold();
|
|
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); }
|
|
170
|
+
}
|
|
171
|
+
B.on($('#holdClose'), 'click', closeHold);
|
|
172
|
+
B.on(holdSheet, 'click', (ev) => {
|
|
173
|
+
if (ev.target === holdSheet) { closeHold(); return; }
|
|
174
|
+
const chip = ev.target.closest('[data-hold-for]');
|
|
175
|
+
if (chip) { hold(chip.dataset.holdFor, chip.value); return; }
|
|
176
|
+
const input = ev.target.closest('[data-hold-at]');
|
|
177
|
+
if (!input) return;
|
|
178
|
+
holdPicking = input;
|
|
179
|
+
try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
|
|
180
|
+
});
|
|
181
|
+
B.on(holdSheet, 'cancel', (ev) => { ev.preventDefault(); closeHold(); });
|
|
182
|
+
// A chip's pointerdown comes before Custom's focusout; without this reset Custom sends first and the chip's is lost.
|
|
183
|
+
B.on(holdSheet, 'pointerdown', (ev) => {
|
|
184
|
+
if (!ev.target.closest('[data-hold-for]')) return;
|
|
185
|
+
const input = $('#holdPicker [data-hold-at]');
|
|
186
|
+
holdPicking = null;
|
|
187
|
+
if (input) input.value = input.defaultValue;
|
|
188
|
+
});
|
|
189
|
+
// Closing an opened picker accepts its suggested time; an unchanged field reached by Tab sends nothing.
|
|
190
|
+
function holdCustom(input) {
|
|
191
|
+
if (!input.value || (input !== holdPicking && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
192
|
+
const at = new Date(input.value);
|
|
193
|
+
if (Number.isNaN(at.getTime())) return;
|
|
194
|
+
holdPicking = null;
|
|
195
|
+
input.defaultValue = input.value;
|
|
196
|
+
hold(input.dataset.holdAt, at.toISOString());
|
|
197
|
+
}
|
|
198
|
+
B.on(holdSheet, 'change', (ev) => { if (ev.target.matches?.('[data-hold-at]') && document.activeElement !== ev.target) holdCustom(ev.target); });
|
|
199
|
+
B.on(holdSheet, 'focusout', (ev) => { if (ev.target.matches?.('[data-hold-at]')) holdCustom(ev.target); });
|
|
6
200
|
|
|
7
201
|
function detail(it) {
|
|
8
202
|
if (it.kind === 'ask') {
|
|
@@ -22,17 +216,22 @@ BoardView.part((B) => {
|
|
|
22
216
|
}
|
|
23
217
|
}
|
|
24
218
|
if (it.branch) rows.push(['Branch', `<code>${esc(it.branch)}</code>`]);
|
|
25
|
-
|
|
26
|
-
if (it.
|
|
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).
|
|
220
|
+
if (it.status) rows.push(['Status', esc(it.status === 'building' ? 'implementing' : it.status)]);
|
|
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.
|
|
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]]) {
|
|
223
|
+
if (list?.length) rows.push([label, list.map(cardLink).join(' ')]);
|
|
224
|
+
}
|
|
27
225
|
if (it.manualSteps.length) rows.push(['Manual steps', linkText(it.manualSteps.join('; '), ctx)]);
|
|
28
|
-
if (it.slack) rows.push(['Slack', `<a href="${esc(it.slack)}" target="_blank" rel="noopener">thread</a>`]);
|
|
29
|
-
if (it.from) rows.push(['Filed from', esc(it.from)]);
|
|
30
226
|
if (it.next) rows.push(['Next', linkText(it.next, ctx)]);
|
|
31
|
-
// docs/adr/
|
|
227
|
+
// docs/adr/2026-10-30-1626-notes-is-a-link-in-the-details-list.md
|
|
32
228
|
const external = [
|
|
33
229
|
it.hasNotes ? `<a href="notes.html?item=${encodeURIComponent(it.key)}">Notes ${LINK_ARROW}</a>` : '',
|
|
230
|
+
// A vscode: link opens a folder on the machine whose browser follows it, so only the host's browser gets one.
|
|
231
|
+
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>` : '',
|
|
34
232
|
installButton(it),
|
|
35
233
|
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>` : '',
|
|
234
|
+
it.pushed ? pushedLink(it) : '',
|
|
36
235
|
].filter(Boolean);
|
|
37
236
|
const externalRow = external.length ? `<dt class="external-links-label">Links</dt><dd class="external-links">${external.map((html, i) => `<span class="external-link">${i ? '<span class="external-separator" aria-hidden="true"> · </span>' : ''}${html}</span>`).join('')}</dd>` : '';
|
|
38
237
|
|
|
@@ -43,20 +242,38 @@ BoardView.part((B) => {
|
|
|
43
242
|
</div>`;
|
|
44
243
|
}
|
|
45
244
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
245
|
+
const cardLink = (l) => `<a class="card-link" href="#item=${encodeURIComponent(l.key)}">${esc(l.ref)}</a>`;
|
|
246
|
+
// A card whose branch is pushed to a plain git remote has no PR to link, so its line copies the branch name
|
|
247
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
248
|
+
const pushedLink = (it) => `<a href="#" data-copy-branch="${esc(it.pushed.branch)}" title="Copy the branch name">${esc(pushedWords(it))}</a>`;
|
|
249
|
+
const pushedWords = (it) => [`Pushed ${it.ref}${it.pushed.host ? ` to ${it.pushed.host}` : ''}`, it.pushed.at && ago(it.pushed.at)].filter(Boolean).join(' · ');
|
|
250
|
+
B.on(B.root, 'click', (ev) => {
|
|
251
|
+
const link = ev.target.closest('[data-copy-branch]');
|
|
252
|
+
if (!link) return;
|
|
253
|
+
ev.preventDefault();
|
|
254
|
+
navigator.clipboard?.writeText(link.dataset.copyBranch).catch(() => {});
|
|
255
|
+
link.textContent = 'Copied the branch name';
|
|
256
|
+
B.later(1600, () => {
|
|
257
|
+
const it = state.board?.items.find((i) => i.pushed?.branch === link.dataset.copyBranch);
|
|
258
|
+
if (link.isConnected && it) link.textContent = pushedWords(it);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
|
|
50
263
|
const CONFIRM = {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
bump: (it, ref) => `Move ${ref} to the top of its lane?`,
|
|
54
|
-
nudge: () => 'Nudge Claude to check this builder?',
|
|
264
|
+
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
265
|
+
nudge: () => 'Nudge Claude to check this implementer?',
|
|
55
266
|
grill: (it, ref) => `Ask Claude for questions about ${ref}?`,
|
|
56
|
-
|
|
57
|
-
|
|
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.`),
|
|
271
|
+
'card-build': (it, ref) => (it.blockedBy?.length
|
|
272
|
+
? `${it.held ? 'Unhold and implement' : 'Implement'} ${ref} once ${it.blockedBy.map((d) => d.ref).join(', ')} ${it.blockedBy.length === 1 ? 'is' : 'are'} done? It waits in In Progress until then.`
|
|
273
|
+
: `${it.held ? `Unhold and implement ${ref}?` : `Implement ${ref}?`} Claude starts its implementer now with the notes as they stand.`),
|
|
58
274
|
finish: (it, ref) => (it.kind === 'ask' ? `Mark this task done? "${it.title}" moves to Done.`
|
|
59
|
-
: `
|
|
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'}.` : ''}`),
|
|
60
277
|
};
|
|
61
278
|
|
|
62
279
|
const classes = (it, button, base = '') => {
|
|
@@ -67,48 +284,109 @@ BoardView.part((B) => {
|
|
|
67
284
|
|
|
68
285
|
function actions(it) {
|
|
69
286
|
const key = esc(it.key);
|
|
70
|
-
const ask = (act, label) => `<button data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
71
|
-
const holding = sendingHold.has(it.key) ? sendingHold.get(it.key) : it.holdPending;
|
|
287
|
+
const ask = (act, label, cls = '') => `<button${cls ? ` class="${cls}"` : ''} data-confirm="${act}" data-key="${key}">${label}</button>`;
|
|
72
288
|
const hold = it.kind !== 'work' || !it.holdable ? ''
|
|
73
|
-
:
|
|
74
|
-
|
|
75
|
-
const build = it.kind === 'followup' && it.lane !== 'done' ? `<button class="build" data-confirm="build" data-key="${key}">Build</button>` : '';
|
|
76
|
-
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${build}${mergeButton(it, sendingMerge.has(it.key))}${hold}${actionButton(it)}${tryButton(it)}`)}</div>`;
|
|
289
|
+
: it.held ? (it.buildable ? '' : ask('resume', 'Unhold', 'unhold')) : ask('hold', 'Hold');
|
|
290
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${mergeButton(it)}${hold}${actionButton(it)}${tryButton(it)}${buildButton(it)}`)}</div>`;
|
|
77
291
|
}
|
|
78
292
|
|
|
79
|
-
// 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).
|
|
80
294
|
const ACTION_BUTTONS = {
|
|
81
295
|
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Open questions</a>`,
|
|
82
296
|
[BUTTON.markDone]: (it) => `<button${classes(it, BUTTON.markDone)} data-confirm="finish" data-key="${esc(it.key)}">Mark as done</button>`,
|
|
83
|
-
|
|
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(''),
|
|
84
299
|
};
|
|
85
300
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
86
301
|
|
|
87
|
-
// The latest bump
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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).
|
|
303
|
+
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
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]));
|
|
91
308
|
return `<details class="item" data-key="${esc(it.key)}" ${state.openItems.has(it.key) ? 'open' : ''}>
|
|
92
309
|
<summary class="work">
|
|
93
310
|
<span class="status ${it.lane}"></span>
|
|
94
|
-
<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>
|
|
95
312
|
</summary>
|
|
96
313
|
${state.openItems.has(it.key) ? detail(it) : ''}
|
|
97
314
|
</details>`;
|
|
98
315
|
}
|
|
99
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
|
+
|
|
100
378
|
B.on(B.root, 'toggle', (ev) => {
|
|
101
379
|
const el = ev.target;
|
|
102
380
|
if (!el.matches?.('details.item')) return;
|
|
103
381
|
const key = el.dataset.key;
|
|
104
382
|
if (el.open) {
|
|
105
383
|
state.openItems.add(key);
|
|
106
|
-
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);
|
|
107
385
|
markSeen(it);
|
|
108
|
-
const unread = el.querySelector('summary .
|
|
109
|
-
const
|
|
386
|
+
const unread = el.querySelector('summary .card-unread');
|
|
387
|
+
const line = unread?.parentNode;
|
|
110
388
|
unread?.remove();
|
|
111
|
-
if (
|
|
389
|
+
if (line && !line.children.length) line.remove();
|
|
112
390
|
if (it && !el.querySelector('.detail')) { el.insertAdjacentHTML('beforeend', detail(it)); }
|
|
113
391
|
if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
114
392
|
B.loadThread(key, { maxAge: 10000 });
|
|
@@ -139,13 +417,27 @@ BoardView.part((B) => {
|
|
|
139
417
|
const key = button.dataset.try;
|
|
140
418
|
const it = state.board.items.find((i) => i.key === key);
|
|
141
419
|
if (!it || !(await B.confirm(`${tryWords(it.try?.kind).go} ${it.ref}? The hub runs its build script.`)) || !B.alive()) return;
|
|
142
|
-
|
|
143
|
-
|
|
420
|
+
// Serve / Build waits until the hub has started the build; the card's chip then says it runs.
|
|
421
|
+
const post = (async () => {
|
|
144
422
|
const r = await fetch(B.site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key }) });
|
|
145
423
|
const out = await r.json();
|
|
146
424
|
if (!r.ok) throw new Error(out.error || 'The hub refused the build.');
|
|
147
|
-
}
|
|
148
|
-
|
|
425
|
+
})();
|
|
426
|
+
try { await waitOn(button, post); } catch (error) { toast(error.message || 'Could not start. Is the host awake?'); }
|
|
427
|
+
B.reload();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// A work card's bump icon bumps it, or unbumps a bumped one, with no confirm
|
|
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).
|
|
433
|
+
B.on(B.root, 'click', async (ev) => {
|
|
434
|
+
const b = ev.target.closest('button[data-card-bump]');
|
|
435
|
+
if (!b) return;
|
|
436
|
+
ev.preventDefault();
|
|
437
|
+
const key = b.dataset.cardBump;
|
|
438
|
+
const unbump = b.hasAttribute('data-bumped');
|
|
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); }
|
|
149
441
|
});
|
|
150
442
|
|
|
151
443
|
B.on(B.root, 'click', async (ev) => {
|
|
@@ -154,47 +446,32 @@ BoardView.part((B) => {
|
|
|
154
446
|
ev.preventDefault();
|
|
155
447
|
const { key, confirm: act } = b.dataset;
|
|
156
448
|
const it = state.board.items.find((i) => i.key === key);
|
|
157
|
-
if (
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
B.
|
|
449
|
+
if (it && act === 'hold') { openHold(it); return; }
|
|
450
|
+
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
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);
|
|
175
467
|
return;
|
|
176
468
|
}
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
B.sendingDone.add(key);
|
|
180
|
-
B.syncDone(key);
|
|
181
|
-
try { await (it.kind === 'ask' ? B.site.send('ask-done', { id: it.id }) : B.site.send('card-done', { key })); }
|
|
182
|
-
catch { toast('Could not close. Is the host awake?'); }
|
|
183
|
-
B.sendingDone.delete(key);
|
|
184
|
-
await B.loadBoard();
|
|
185
|
-
B.syncDone(key);
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const row = $(`.actions[data-actions="${CSS.escape(key)}"]`);
|
|
189
|
-
row?.querySelectorAll('button').forEach((x) => { x.disabled = true; });
|
|
190
|
-
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') ?? [] };
|
|
191
471
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
else { await B.site.send(act === 'grill' ? 'grill-more' : 'build-followup', { key }); B.loadThread(key, { force: true }); }
|
|
195
|
-
} catch { toast('Could not send. Is the host awake?'); }
|
|
196
|
-
B.loadBoard();
|
|
472
|
+
await waitOn(b, B.sentHere(key, () => B.send('builder-nudge', { key })), group); b.disabled = true; toast('Nudge sent');
|
|
473
|
+
} catch { redrawActions(key); toast('Could not send. Is the host awake?'); }
|
|
197
474
|
});
|
|
198
475
|
|
|
199
|
-
Object.assign(B, { detail, card });
|
|
476
|
+
Object.assign(B, { captureStrips, moveStrips, detail, card });
|
|
200
477
|
});
|
package/board/public/app-core.js
CHANGED
|
@@ -6,12 +6,16 @@ BoardView.part((B) => {
|
|
|
6
6
|
const a = document.activeElement;
|
|
7
7
|
return a && B.root.contains(a) && a.matches('textarea, input') ? a : null;
|
|
8
8
|
};
|
|
9
|
-
// →
|
|
10
|
-
//
|
|
11
|
-
function
|
|
9
|
+
// → the selector of field's box as drawn anew (an edit box from state.edits, a reply box from its draft), by its form;
|
|
10
|
+
// null for any other field.
|
|
11
|
+
function boxOf(field) {
|
|
12
12
|
const form = field.closest('form[data-reply], form[data-edit-form]');
|
|
13
|
-
|
|
13
|
+
return !form ? null : form.dataset.reply != null ? `form[data-reply="${CSS.escape(form.dataset.reply)}"] textarea`
|
|
14
14
|
: `form[data-edit-form="${CSS.escape(form.dataset.editForm)}"] textarea`;
|
|
15
|
+
}
|
|
16
|
+
// → a function finding field after a repaint: the same node, else the box drawn anew in its place.
|
|
17
|
+
function finder(field) {
|
|
18
|
+
const again = boxOf(field);
|
|
15
19
|
return () => (field.isConnected ? field : again && $(again));
|
|
16
20
|
}
|
|
17
21
|
|
|
@@ -32,7 +36,7 @@ BoardView.part((B) => {
|
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// Runs paint through keepFocus and keeps the anchor where it was on screen, scrolling the page by however far it
|
|
35
|
-
// 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).
|
|
36
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
|
|
37
41
|
// in a dialog doesn't move with the page, so the card anchors then.
|
|
38
42
|
function anchorOf() {
|
|
@@ -50,6 +54,36 @@ BoardView.part((B) => {
|
|
|
50
54
|
const after = before == null ? null : find()?.getBoundingClientRect().top;
|
|
51
55
|
if (after != null && after !== before) scrollBy(0, after - before);
|
|
52
56
|
}
|
|
57
|
+
// Where the owner left the board, kept for its next mount in this page: the page's scroll and the box they were typing
|
|
58
|
+
// in, as it unmounts or at their last press outside the board, such as on a sidebar row, which takes the focus first.
|
|
59
|
+
// The header's logo, which opens the sidebar, counts as outside.
|
|
60
|
+
let held = null;
|
|
61
|
+
B.on(document, 'pointerdown', (e) => {
|
|
62
|
+
const outside = !B.root.contains(e.target) || e.target.closest?.('.top a.hub');
|
|
63
|
+
held = outside ? focusedField() || held : null;
|
|
64
|
+
}, true);
|
|
65
|
+
// A board left before its first render keeps the place it hadn't returned to yet.
|
|
66
|
+
let place = B.place;
|
|
67
|
+
B.keep(() => {
|
|
68
|
+
if (place) return { y: place.y, box: place.box };
|
|
69
|
+
const field = focusedField() || (held?.isConnected ? held : null);
|
|
70
|
+
const selector = field && boxOf(field);
|
|
71
|
+
return { y: scrollY, box: selector ? { selector, selection: [field.selectionStart, field.selectionEnd, field.selectionDirection], top: field.scrollTop } : null };
|
|
72
|
+
});
|
|
73
|
+
// Only the first call restores, and none when the URL names a card (linked). A phone's keyboard would open for the
|
|
74
|
+
// focus, so only a mouse or trackpad gets the box back (sidebar.js DESKTOP).
|
|
75
|
+
function restorePlace({ linked = false } = {}) {
|
|
76
|
+
const p = place;
|
|
77
|
+
place = null;
|
|
78
|
+
if (!p || linked) return;
|
|
79
|
+
scrollTo(0, p.y);
|
|
80
|
+
const box = p.box && $(p.box.selector);
|
|
81
|
+
if (!box || box.closest('details:not([open])') || !matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
|
82
|
+
box.focus({ preventScroll: true });
|
|
83
|
+
box.setSelectionRange(...p.box.selection);
|
|
84
|
+
box.scrollTop = p.box.top;
|
|
85
|
+
}
|
|
86
|
+
|
|
53
87
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
54
88
|
|
|
55
89
|
// A card this browser has never seen starts from now, so a first load badges nothing.
|
|
@@ -80,8 +114,6 @@ BoardView.part((B) => {
|
|
|
80
114
|
quiet.set(draftKey, Date.now() + 1000);
|
|
81
115
|
}
|
|
82
116
|
|
|
83
|
-
// A deleted ask is hidden on the server, so it leaves every device (docs/adr/0008-dismissals-hide-on-the-server.md).
|
|
84
|
-
const hide = (id, hidden) => fetch(B.site.url('api/hide'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, hidden }) });
|
|
85
117
|
// The ui-links.js linkContext for text shown on item `key`'s card or thread.
|
|
86
118
|
const links = (key) => BoardUI.linkContext(state.board?.items || [], key);
|
|
87
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>`;
|
|
@@ -92,5 +124,5 @@ BoardView.part((B) => {
|
|
|
92
124
|
const confirm = (message, labels) => dialogs.confirm(message, { ...labels, signal: controller.signal });
|
|
93
125
|
const notice = dialogs.notice;
|
|
94
126
|
|
|
95
|
-
Object.assign(B, { steady, plural, seen, markSeen, quieted, sendDraft,
|
|
127
|
+
Object.assign(B, { steady, restorePlace, plural, seen, markSeen, quieted, sendDraft, links, svg, confirm, notice });
|
|
96
128
|
});
|