@lovek4d/kickflip 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +179 -92
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +2 -2
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -11,17 +11,24 @@
|
|
|
11
11
|
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
12
12
|
// recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
|
|
13
13
|
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
14
|
-
// A
|
|
15
|
-
//
|
|
14
|
+
// A question the owner has sent an answer to is resolved: read-only, with a Resolved badge. One that resolves
|
|
15
|
+
// while its topic shows stays in place until you leave that topic, by a pill, a link or the move on after the last
|
|
16
|
+
// Send; a topic's sent questions otherwise fold under "resolved (N)", which names only the count, above its open ones. A sent question's badge, once confirmed, sends `question-reopen {key?, question}`, and the question
|
|
17
|
+
// opens again with its last pick and note for a new Send. Each question is numbered by its place on its card
|
|
18
|
+
// (docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md,
|
|
19
|
+
// docs/adr/2026-11-06-1100-resolved-questions-fold-away-once-you-leave-their-topic.md).
|
|
16
20
|
// A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
|
|
17
21
|
// update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
|
|
18
22
|
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md).
|
|
23
|
+
// The page shows open questions only: it ends at the Send bar, opens by itself only a topic with an open question, and
|
|
24
|
+
// with none shows the hub's moon and "no open questions". A link to a question still opens its topic, as a work item's
|
|
25
|
+
// Questions button does (docs/adr/2026-11-06-0037-the-open-questions-page-shows-open-questions-only.md).
|
|
19
26
|
// markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
|
|
20
27
|
// and the board's URL and scroll are left alone.
|
|
21
28
|
(globalThis.AppViews ||= {}).questions = {
|
|
22
29
|
className: 'view-questions',
|
|
23
30
|
title: () => 'Waiting On You',
|
|
24
|
-
markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: '
|
|
31
|
+
markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'logo', title: 'Waiting on you', logoSrc: '/_hub/kickflip.png', controls: false, trail: '<span class="muted small" id="progress"></span>', pages: 'board' })}
|
|
25
32
|
<main>
|
|
26
33
|
${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
|
|
27
34
|
</main>`,
|
|
@@ -30,7 +37,7 @@
|
|
|
30
37
|
const { $, site } = B;
|
|
31
38
|
const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
|
|
32
39
|
const setHash = (id) => { if (!ctx.embedded) history.replaceState(history.state, '', '#' + encodeURIComponent(id)); };
|
|
33
|
-
const { esc, getDraft, setDraft, choiceLabel, choiceOf, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON,
|
|
40
|
+
const { esc, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, downLanding } = BoardUI;
|
|
34
41
|
const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
35
42
|
|
|
36
43
|
let all = [];
|
|
@@ -46,6 +53,9 @@
|
|
|
46
53
|
let soloId = null;
|
|
47
54
|
let shown = '';
|
|
48
55
|
let sentLine = '';
|
|
56
|
+
// The shown topic's questions this page has drawn unsent, which stay in place once sent or resolved until you leave
|
|
57
|
+
// the topic (setTopic).
|
|
58
|
+
const inPlace = new Set();
|
|
49
59
|
// The footer after a prototype request: the blue spinner, then these words.
|
|
50
60
|
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
51
61
|
// The bar's line while a suggested option is still pre-checked.
|
|
@@ -57,7 +67,16 @@
|
|
|
57
67
|
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
58
68
|
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
59
69
|
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
60
|
-
|
|
70
|
+
// Shows a topic, or none; the topic left folds its sent questions.
|
|
71
|
+
const setTopic = (id) => {
|
|
72
|
+
if (id !== topicId) for (const qid of topics.find((t) => t.id === topicId)?.questions || []) inPlace.delete(qid);
|
|
73
|
+
topicId = id;
|
|
74
|
+
};
|
|
75
|
+
const urgentOpen =() => all.find((q) => q.urgent && q.state === 'open');
|
|
76
|
+
// A question's number on its card: its place in its topic, which keeps every question it has had.
|
|
77
|
+
const numberOf = (q) => (topicOf(q.id)?.questions.indexOf(q.id) ?? -1) + 1;
|
|
78
|
+
// The questions the form shows, in order; the rest of qs fold under resolved (N).
|
|
79
|
+
const inForm = (qs) => qs.filter((q) => !isSent(q) || inPlace.has(q.id));
|
|
61
80
|
|
|
62
81
|
// docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
63
82
|
function topicBar() {
|
|
@@ -66,19 +85,20 @@
|
|
|
66
85
|
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
|
|
67
86
|
}
|
|
68
87
|
// An arrival (arrive) slides in with a glow and settles (style.css .arrived).
|
|
69
|
-
const pillHtml = (t, arrived = false) => `<button${arrived ? '
|
|
88
|
+
const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
|
|
70
89
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
71
90
|
|
|
72
|
-
// Only open questions changed here since their last answer, with files attached,
|
|
73
|
-
// pre-checked and never answered, are sent; a
|
|
91
|
+
// Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
|
|
92
|
+
// suggested option pre-checked and never answered, are sent; a sent one is read-only.
|
|
74
93
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
75
94
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
76
95
|
function pending(qs) {
|
|
77
|
-
return qs.filter((q) => q.state
|
|
96
|
+
return qs.filter((q) => q.state === 'open').filter((q) => {
|
|
78
97
|
const d = getDraft(q.id);
|
|
79
98
|
if (filesOf(q).length) return true;
|
|
80
99
|
const last = q.answers[q.answers.length - 1];
|
|
81
100
|
if (!last && q.suggested) return true;
|
|
101
|
+
if (q.reopened && choiceOf(q, d) !== null) return true;
|
|
82
102
|
if (!('choice' in d) && !d.note) return false;
|
|
83
103
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
84
104
|
});
|
|
@@ -110,37 +130,26 @@
|
|
|
110
130
|
const el = $('#page');
|
|
111
131
|
const qs = visible();
|
|
112
132
|
if (!qs.length) {
|
|
113
|
-
el.innerHTML =
|
|
133
|
+
el.innerHTML = `<div class="q-empty"><svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${HubUI.TILE_PATHS.moon}</svg><p class="muted">no open questions</p></div>`;
|
|
114
134
|
$('#progress').textContent = '';
|
|
115
135
|
return;
|
|
116
136
|
}
|
|
117
137
|
const t = topics.find((x) => x.id === topicId);
|
|
118
|
-
const live = qs
|
|
119
|
-
const done = qs.filter((q) => q
|
|
138
|
+
const live = inForm(qs);
|
|
139
|
+
const done = qs.filter((q) => !live.includes(q));
|
|
140
|
+
for (const q of live) inPlace.add(q.id);
|
|
120
141
|
const head = soloId
|
|
121
142
|
? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
|
|
122
143
|
: `${topicBar()}${topicHead(t)}`;
|
|
123
144
|
el.innerHTML = `${head}<form id="qform">
|
|
124
|
-
${
|
|
125
|
-
${
|
|
145
|
+
${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span></summary>${done.map((q) => questionCard(q, { n: numberOf(q), links: links(q) })).join('')}</details>` : ''}
|
|
146
|
+
${live.map((q) => questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) })).join('')}
|
|
126
147
|
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
|
|
127
|
-
</form
|
|
148
|
+
</form>`;
|
|
128
149
|
BoardAttach.paintAll();
|
|
129
150
|
updateBar();
|
|
130
151
|
}
|
|
131
152
|
|
|
132
|
-
function waitingTopics() {
|
|
133
|
-
const list = topicRow(topics, topicId).waiting;
|
|
134
|
-
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
135
|
-
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function resolvedTopics() {
|
|
139
|
-
const done = topics.filter((t) => t.state === 'resolved' && t.id !== topicId);
|
|
140
|
-
return done.length ? `<details class="done"><summary>Resolved topics · ${done.length}</summary>${done.map((t) =>
|
|
141
|
-
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
153
|
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
145
154
|
function leaving() {
|
|
146
155
|
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
@@ -175,23 +184,24 @@
|
|
|
175
184
|
const d = getDraft(q.id);
|
|
176
185
|
return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
177
186
|
let out;
|
|
178
|
-
try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d
|
|
187
|
+
try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: noteOf(q, d).trim(), choice: choiceOf(q, d) }); } catch (e) { refused(e); return; }
|
|
179
188
|
setDraft(q.id, {});
|
|
180
189
|
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
181
190
|
})(), stillWaiting));
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
// A
|
|
185
|
-
//
|
|
186
|
-
async function
|
|
187
|
-
if (!(await BoardDialogs.confirm('
|
|
193
|
+
// A sent question's Resolved badge reopens it once the owner confirms: the question opens in place with its last
|
|
194
|
+
// pick and note, and stays in the form until you leave the topic.
|
|
195
|
+
async function reopen(q, button) {
|
|
196
|
+
if (!(await BoardDialogs.confirm('Reopen this question?')) || !B.alive()) return;
|
|
188
197
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
189
198
|
await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
190
199
|
let out;
|
|
191
|
-
try { out = await send('
|
|
200
|
+
try { out = await send('question-reopen', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
|
|
192
201
|
if (!B.alive()) return;
|
|
202
|
+
inPlace.add(q.id);
|
|
203
|
+
sentLine = '';
|
|
193
204
|
if (!land(out.board, false)) await load(false);
|
|
194
|
-
await loadResolved(true);
|
|
195
205
|
repaint(false);
|
|
196
206
|
})(), stillWaiting));
|
|
197
207
|
}
|
|
@@ -201,7 +211,7 @@
|
|
|
201
211
|
const n = pending(qs).length;
|
|
202
212
|
const b = $('#send');
|
|
203
213
|
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
204
|
-
const live = qs
|
|
214
|
+
const live = inForm(qs);
|
|
205
215
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
206
216
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
207
217
|
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
@@ -212,10 +222,10 @@
|
|
|
212
222
|
|
|
213
223
|
function pick(hash) {
|
|
214
224
|
const u = urgentOpen();
|
|
215
|
-
if (u && (!hash || hash === u.id)) { soloId = u.id;
|
|
225
|
+
if (u && (!hash || hash === u.id)) { soloId = u.id; setTopic(topicOf(u.id)?.id ?? null); return; }
|
|
216
226
|
soloId = null;
|
|
217
|
-
const t = (hash && topicOf(hash)) ||
|
|
218
|
-
|
|
227
|
+
const t = (hash && topicOf(hash)) || topics.find((x) => x.open);
|
|
228
|
+
setTopic(t ? t.id : null);
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])]);
|
|
@@ -224,16 +234,30 @@
|
|
|
224
234
|
let loading = null;
|
|
225
235
|
let loaded = false;
|
|
226
236
|
const load = (first) => (loading ||= fetchBoard(first).finally(() => { loading = null; }));
|
|
237
|
+
// While the board can't be reached, the page holds its sending buttons and shows the board's copy here, painted live,
|
|
238
|
+
// or with none and nothing landed the landing for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
239
|
+
let copy = null;
|
|
240
|
+
BoardUI.holdSends(B.on, root, () => root.classList.contains('offline'), () => B.toast(BoardUI.WAITING));
|
|
241
|
+
const out = (down) => {
|
|
242
|
+
root.classList.add('offline');
|
|
243
|
+
copy ||= BoardUI.boardCopy(site.url(''), document.documentElement.dataset.build);
|
|
244
|
+
copy.then((c) => {
|
|
245
|
+
if (!B.alive() || loaded) return;
|
|
246
|
+
if (!c) { $('#page').innerHTML = downLanding(down); return; }
|
|
247
|
+
land(c.board, true);
|
|
248
|
+
repaint(true);
|
|
249
|
+
});
|
|
250
|
+
};
|
|
227
251
|
async function fetchBoard(first) {
|
|
228
|
-
const page = $('#page');
|
|
229
252
|
try {
|
|
230
|
-
const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: () =>
|
|
253
|
+
const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: (ms, down) => out(down), sleep: B.sleep });
|
|
231
254
|
if (!B.alive()) return false;
|
|
255
|
+
root.classList.remove('offline');
|
|
232
256
|
if (d) land(d, first);
|
|
233
257
|
repaint(first);
|
|
234
258
|
return true;
|
|
235
259
|
} catch (e) {
|
|
236
|
-
|
|
260
|
+
out(e.down);
|
|
237
261
|
return false;
|
|
238
262
|
}
|
|
239
263
|
}
|
|
@@ -274,7 +298,7 @@
|
|
|
274
298
|
let drawn = null;
|
|
275
299
|
// A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
|
|
276
300
|
// pill keeps its node and a new one lands at its place in the row, a new question's card goes after the last open
|
|
277
|
-
// one, and the topic line
|
|
301
|
+
// one, and the topic line and the bar update. → false when anything shown changed.
|
|
278
302
|
function arrive() {
|
|
279
303
|
const form = $('#qform');
|
|
280
304
|
const now = drawnOf();
|
|
@@ -300,13 +324,11 @@
|
|
|
300
324
|
const head = page.querySelector('.topichead');
|
|
301
325
|
const headHtml = topicHead(topics.find((x) => x.id === topicId));
|
|
302
326
|
if (head && head.outerHTML !== headHtml) head.outerHTML = headHtml;
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
page.insertAdjacentHTML('beforeend', waitingTopics() + resolvedTopics());
|
|
309
|
-
for (const d of page.children) if (d.matches('details.done') && opened.has(d.querySelector('summary').textContent.split(' ·')[0])) d.open = true;
|
|
327
|
+
const sendBar = form.querySelector('.bar');
|
|
328
|
+
for (const q of added) {
|
|
329
|
+
inPlace.add(q.id);
|
|
330
|
+
sendBar.insertAdjacentHTML('beforebegin', questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) }));
|
|
331
|
+
}
|
|
310
332
|
BoardAttach.paintAll();
|
|
311
333
|
drawn = now;
|
|
312
334
|
updateBar();
|
|
@@ -319,7 +341,6 @@
|
|
|
319
341
|
const next = nextQuestion(all, pills, { topic: from });
|
|
320
342
|
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
321
343
|
if (next?.topic === from) {
|
|
322
|
-
topicId = from;
|
|
323
344
|
sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
|
|
324
345
|
shown = sigNow();
|
|
325
346
|
const y = scrollY;
|
|
@@ -332,9 +353,10 @@
|
|
|
332
353
|
sentNotice.put(B.route, what);
|
|
333
354
|
if (ctx.up?.()) return;
|
|
334
355
|
sentNotice.take(B.route);
|
|
335
|
-
|
|
356
|
+
setTopic(null);
|
|
357
|
+
B.toast(what);
|
|
336
358
|
} else {
|
|
337
|
-
|
|
359
|
+
setTopic(next.topic);
|
|
338
360
|
setHash(next.id);
|
|
339
361
|
}
|
|
340
362
|
shown = sigNow();
|
|
@@ -346,11 +368,11 @@
|
|
|
346
368
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
347
369
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
348
370
|
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
371
|
+
const reopenBadge = ev.target.closest && ev.target.closest('[data-reopen]');
|
|
372
|
+
if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
|
|
351
373
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
352
374
|
if (b) {
|
|
353
|
-
soloId = null;
|
|
375
|
+
soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
|
|
354
376
|
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
355
377
|
if (first) setHash(first);
|
|
356
378
|
render(); scroll(0);
|
|
@@ -385,7 +407,7 @@
|
|
|
385
407
|
const d = getDraft(q.id);
|
|
386
408
|
const choice = choiceOf(q, d);
|
|
387
409
|
const files = filesOf(q);
|
|
388
|
-
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d
|
|
410
|
+
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, d).trim(), ...(files.length ? { files } : {}) };
|
|
389
411
|
});
|
|
390
412
|
// Send waits until the page has moved on (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
391
413
|
await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
|
|
@@ -403,18 +425,6 @@
|
|
|
403
425
|
});
|
|
404
426
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
405
427
|
B.on(root, 'attach-change', updateBar);
|
|
406
|
-
// While a text field is focused and the keyboard is up, the bar docks on the keyboard (style.css .kb-up) from
|
|
407
|
-
// visualViewport, which iOS reports apart from the layout viewport the sticky bar follows.
|
|
408
|
-
const vv = window.visualViewport;
|
|
409
|
-
let typing = false;
|
|
410
|
-
const dock = () => {
|
|
411
|
-
const kb = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
|
|
412
|
-
root.style.setProperty('--kb', kb + 'px');
|
|
413
|
-
root.classList.toggle('kb-up', typing && kb > 0);
|
|
414
|
-
};
|
|
415
|
-
B.on(root, 'focusin', (ev) => { typing = ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false; dock(); });
|
|
416
|
-
B.on(root, 'focusout', () => { typing = false; dock(); });
|
|
417
|
-
if (vv) { B.on(vv, 'resize', dock); B.on(vv, 'scroll', dock); }
|
|
418
428
|
// A link to another question here only moves the hash.
|
|
419
429
|
if (!ctx.embedded) B.on(window, 'hashchange', () => {
|
|
420
430
|
if (!loaded) return;
|
|
@@ -425,6 +435,18 @@
|
|
|
425
435
|
scroll(0);
|
|
426
436
|
});
|
|
427
437
|
|
|
438
|
+
// In the board's pane, the shell opens the topic of a question or topic id there, a resolved one included, and with
|
|
439
|
+
// none or one that's gone the topic the page would open (app-needs.js focusQuestion). → false before the first load.
|
|
440
|
+
if (ctx.embedded) B.openTopic = (id) => {
|
|
441
|
+
if (!loaded) return false;
|
|
442
|
+
const topic = topics.find((t) => t.id === id);
|
|
443
|
+
if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
|
|
444
|
+
sentLine = '';
|
|
445
|
+
shown = '';
|
|
446
|
+
render();
|
|
447
|
+
return true;
|
|
448
|
+
};
|
|
449
|
+
|
|
428
450
|
load(true);
|
|
429
451
|
B.every(15000, () => { if (!document.hidden) load(false); });
|
|
430
452
|
B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
|
|
@@ -4,16 +4,24 @@
|
|
|
4
4
|
// (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
|
|
5
5
|
// Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
|
|
6
6
|
// asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
|
|
7
|
-
// lists the hub's GET /_hub/share/<route>, who
|
|
8
|
-
// through POST api/share.
|
|
7
|
+
// lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
|
|
8
|
+
// each Add, level and ✕ at once through POST api/share.
|
|
9
9
|
(globalThis.AppViews ||= {}).settings = {
|
|
10
10
|
className: 'view-settings',
|
|
11
11
|
title: () => 'Board Settings',
|
|
12
12
|
markup: (view, { siteTitle = '' } = {}) => `<dialog class="settings-modal" id="boardSettingsModal" aria-labelledby="boardSettingsTitle"><div class="settings-head"><div><h2 id="boardSettingsTitle" tabindex="-1">Board settings</h2><p class="muted small">${BoardUI.esc(siteTitle)}</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close board settings">✕</button></div>
|
|
13
13
|
<div class="set settings-body">
|
|
14
|
+
<h2>Pause board</h2>
|
|
15
|
+
<div class="hub-view"><div class="group" id="pauseBoard"><p class="muted small block">Loading…</p></div></div>
|
|
16
|
+
<p class="muted small">Running steps finish. No new builders, planners or pulls start on this board until then, as when usage reaches the pause line.</p>
|
|
17
|
+
<p class="muted small" id="pauseBoardStatus" role="status"></p>
|
|
14
18
|
<h2>Repo</h2>
|
|
15
19
|
<button type="button" class="who repo-row" id="boardRepo" aria-haspopup="dialog" disabled><span id="boardRepoValue">Loading…</span><span class="muted small" id="boardRepoChange">Change ›</span></button>
|
|
16
20
|
<p class="muted small" id="boardRepoStatus"></p>
|
|
21
|
+
<label class="who" for="buildTarget"><span><span>Build target</span>
|
|
22
|
+
<span class="muted small sub" id="buildTargetSub">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</span></span>
|
|
23
|
+
<select id="buildTarget" disabled><option value="">Default</option></select></label>
|
|
24
|
+
<p class="muted small" id="buildTargetStatus" role="status"></p>
|
|
17
25
|
<h2>Tickets</h2>
|
|
18
26
|
<p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
|
|
19
27
|
<form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
|
|
@@ -34,7 +42,7 @@
|
|
|
34
42
|
<input type="checkbox" class="toggle" id="autoPull" disabled></label>
|
|
35
43
|
<p class="muted small" id="autoPullStatus" role="status"></p>
|
|
36
44
|
<label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
|
|
37
|
-
<span class="muted small sub">
|
|
45
|
+
<span class="muted small sub">Each agent that builds or runs tests counts, a card’s helpers included. Grills, read-only helpers and cards waiting in Orchestrator review or on you don’t.</span></span>
|
|
38
46
|
<input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
|
|
39
47
|
<p class="muted small" id="maxImplementersStatus" role="status"></p>
|
|
40
48
|
<label class="who" for="maxPlanners"><span><span>Maximum parallel planners</span>
|
|
@@ -61,7 +69,6 @@
|
|
|
61
69
|
<span class="muted small sub">Pushes once when the board has been idle for 15 minutes, with what closed since the last one.</span></span>
|
|
62
70
|
<input type="checkbox" class="toggle" id="idleNotification" disabled></label>
|
|
63
71
|
<p class="muted small" id="idleNotificationStatus" role="status"></p>
|
|
64
|
-
<p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
|
|
65
72
|
<h2>Permissions</h2>
|
|
66
73
|
<div id="permissions"><p class="muted small">Loading…</p></div>
|
|
67
74
|
<div id="reviewersSection" hidden>
|
|
@@ -77,11 +84,11 @@
|
|
|
77
84
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
78
85
|
</div>
|
|
79
86
|
<h2>Sharing</h2>
|
|
80
|
-
<p class="muted small">
|
|
87
|
+
<p class="muted small">View, or Full: everything but Merge and Board settings. Nothing else on the host opens for them.</p>
|
|
81
88
|
<div id="shareList"><p class="muted small">Loading…</p></div>
|
|
82
89
|
<div id="shareRefused"></div>
|
|
83
90
|
<form class="add" id="shareAdd"><input id="shareLogin" placeholder="Add by login, like name@github" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit">Add</button></form>
|
|
84
|
-
<div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page
|
|
91
|
+
<div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ${BoardUI.LINK_ARROW}</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
|
|
85
92
|
<p class="muted small" id="shareStatus" role="status"></p>
|
|
86
93
|
<h2>Rules</h2>
|
|
87
94
|
<p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
|
|
@@ -107,8 +114,31 @@
|
|
|
107
114
|
|
|
108
115
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
109
116
|
const { $, site } = B;
|
|
110
|
-
const { esc, fetchJSON, restartNotice } = BoardUI;
|
|
111
|
-
|
|
117
|
+
const { esc, fetchJSON, restartNotice, pageCopy, copyNote, downLanding } = BoardUI;
|
|
118
|
+
// While the board can't be reached, each part reads its last answer here and the whole body turns read only under
|
|
119
|
+
// the copy line, reopening live once the board answers; a part with no copy waits out a restart, then the body shows
|
|
120
|
+
// the landing (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
121
|
+
const settingsBody = $('.settings-body');
|
|
122
|
+
let readOnly = false;
|
|
123
|
+
function fromCopy(savedAt) {
|
|
124
|
+
if (readOnly) return;
|
|
125
|
+
readOnly = true;
|
|
126
|
+
settingsBody.inert = true;
|
|
127
|
+
settingsBody.insertAdjacentHTML('afterbegin', copyNote('Settings', savedAt));
|
|
128
|
+
B.every(5000, () => { fetchJSON(site.url('api/settings'), { limit: 0 }).then(() => { if (B.alive()) ctx.reload(); }, () => {}); });
|
|
129
|
+
}
|
|
130
|
+
function down(e) {
|
|
131
|
+
if (readOnly || root.classList.contains('down')) return;
|
|
132
|
+
root.classList.add('down');
|
|
133
|
+
settingsBody.insertAdjacentHTML('afterbegin', downLanding(e.down));
|
|
134
|
+
}
|
|
135
|
+
const saved = (url, r) => { if (r.ok) pageCopy.save(url, r.body); return r; };
|
|
136
|
+
async function get(url, onWait) {
|
|
137
|
+
try { return saved(url, await fetchJSON(url, { limit: 0, sleep: B.sleep })); } catch {}
|
|
138
|
+
const c = await pageCopy.read(url);
|
|
139
|
+
if (c) { fromCopy(c.savedAt); return { ok: true, status: 200, body: c.body }; }
|
|
140
|
+
try { return saved(url, await fetchJSON(url, { onWait, sleep: B.sleep })); } catch (e) { down(e); throw e; }
|
|
141
|
+
}
|
|
112
142
|
const waitIn = (sel) => () => { $(sel).innerHTML = restartNotice(); };
|
|
113
143
|
const modal = $('#boardSettingsModal');
|
|
114
144
|
const previous = document.activeElement;
|
|
@@ -200,7 +230,8 @@
|
|
|
200
230
|
const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
|
|
201
231
|
let repo = {}, closeRepo = () => {};
|
|
202
232
|
let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
|
|
203
|
-
// Landing shows on a board on GitHub that builds from its shared clone
|
|
233
|
+
// Landing shows on a board on GitHub that builds from its shared clone and on the hub board, which builds from its
|
|
234
|
+
// folder, starts on Merge and opens PRs on Draft PR or Ready PR; any other local board never opens PRs. Its choice is
|
|
204
235
|
// the board's prs and skip_draft settings read together: Draft PR, prs on and skip_draft off; Ready PR, both on;
|
|
205
236
|
// Merge, prs off. Reviewers show only while cards open PRs
|
|
206
237
|
// (docs/adr/2026-11-05-0200-landing-is-one-choice-draft-pr-ready-pr-or-merge.md).
|
|
@@ -212,9 +243,9 @@
|
|
|
212
243
|
let landing = 'draft';
|
|
213
244
|
const landingButtons = () => Object.keys(LANDING).map((value) => $(`[data-landing="${value}"]`));
|
|
214
245
|
const landingOf = (settings) => (settings?.prs === false ? 'merge' : settings?.skip_draft === true ? 'ready' : 'draft');
|
|
215
|
-
const opensPRs = () => Boolean(repo.github) && !repo.local && landing !== 'merge';
|
|
246
|
+
const opensPRs = () => Boolean(repo.github) && (!repo.local || Boolean(repo.hub)) && landing !== 'merge';
|
|
216
247
|
const syncPRs = () => {
|
|
217
|
-
$('#landingSection').hidden = !repo.github || Boolean(repo.local);
|
|
248
|
+
$('#landingSection').hidden = !repo.github || (Boolean(repo.local) && !repo.hub);
|
|
218
249
|
$('#reviewersSection').hidden = !opensPRs();
|
|
219
250
|
$('#landingSub').textContent = LANDING[landing].sub;
|
|
220
251
|
for (const button of landingButtons()) button.setAttribute('aria-pressed', String(button === $(`[data-landing="${landing}"]`)));
|
|
@@ -241,6 +272,7 @@
|
|
|
241
272
|
tickets?.render();
|
|
242
273
|
if (tickets) syncTickets();
|
|
243
274
|
showRepo();
|
|
275
|
+
loadBuildTargets();
|
|
244
276
|
$('#boardRepoStatus').textContent = 'Saved. The board’s session has been told.';
|
|
245
277
|
} });
|
|
246
278
|
});
|
|
@@ -383,7 +415,44 @@
|
|
|
383
415
|
const autoMerge = $('#autoMerge');
|
|
384
416
|
const autoMergeStatus = $('#autoMergeStatus');
|
|
385
417
|
const savedCaps = {};
|
|
418
|
+
let buildTarget = null, buildTargets = null;
|
|
419
|
+
// Pause board (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): the hub's duration
|
|
420
|
+
// picker (hub-kit.js) with the Hold sheet's chips, Forever included, posting {pause} to api/pause at once. A Forever
|
|
421
|
+
// pause lasts until Resume.
|
|
422
|
+
const pauseBox = $('#pauseBoard');
|
|
423
|
+
const pickers = HubKit.durationPickers(B);
|
|
424
|
+
let pauseChoices = [];
|
|
425
|
+
const pausedWords = (end, now) => (end === 'forever' ? 'until you resume' : `until ${BoardUI.heldUntilWords(end, now)}`);
|
|
426
|
+
function paintPause(pausedUntil) {
|
|
427
|
+
const now = new Date();
|
|
428
|
+
const choices = pauseChoices.map((c) => ({ value: c.value, label: c.label, until: pausedWords(c.ms === null ? 'forever' : new Date(now.getTime() + c.ms), now) }));
|
|
429
|
+
pauseBox.innerHTML = HubKit.durationPicker({ id: 'pauseBoard', title: 'Pause board', icon: '', action: site.url('api/pause'), name: 'pause', button: 'Resume',
|
|
430
|
+
choices: pausedUntil ? null : choices, on: pausedUntil ? { title: `Paused ${pausedWords(pausedUntil === 'forever' ? 'forever' : new Date(pausedUntil), now)}` } : null });
|
|
431
|
+
pickers.painted();
|
|
432
|
+
}
|
|
433
|
+
B.on(pauseBox, 'submit', async (e) => {
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
const form = e.target;
|
|
436
|
+
const pause = e.submitter?.value ?? form.querySelector('input[type="hidden"]')?.value;
|
|
437
|
+
const status = $('#pauseBoardStatus');
|
|
438
|
+
try {
|
|
439
|
+
const out = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"])'), (async () => {
|
|
440
|
+
const r = await fetch(site.url('api/pause'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pause }) });
|
|
441
|
+
const body = await r.json().catch(() => ({}));
|
|
442
|
+
if (!r.ok) throw new Error(body.error);
|
|
443
|
+
return body;
|
|
444
|
+
})(), { group: pauseBox.querySelectorAll('button') });
|
|
445
|
+
if (!B.alive()) return;
|
|
446
|
+
paintPause(out.paused_until);
|
|
447
|
+
status.textContent = out.paused_until ? 'Paused. New work on this board waits.' : 'Resumed. New work starts again.';
|
|
448
|
+
ctx.sidebarChanged?.();
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (B.alive()) status.textContent = error.message || 'Could not save. Is the host awake?';
|
|
451
|
+
}
|
|
452
|
+
});
|
|
386
453
|
get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
|
|
454
|
+
pauseChoices = d.pause_choices || [];
|
|
455
|
+
paintPause(d.paused_until);
|
|
387
456
|
if (!B.alive()) return;
|
|
388
457
|
savedTickets = d.settings?.tickets || null;
|
|
389
458
|
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
|
|
@@ -403,6 +472,8 @@
|
|
|
403
472
|
autoMerge.disabled = false;
|
|
404
473
|
$('#autoBuild').checked = d.settings?.auto_build === true;
|
|
405
474
|
$('#autoBuild').disabled = false;
|
|
475
|
+
buildTarget = d.settings?.build_target || '';
|
|
476
|
+
showBuildTargets();
|
|
406
477
|
$('#autoPull').checked = d.settings?.auto_pull === true;
|
|
407
478
|
$('#autoPull').disabled = false;
|
|
408
479
|
for (const [id, key] of [['maxImplementers', 'max_implementers'], ['maxPlanners', 'max_planners']]) {
|
|
@@ -446,6 +517,43 @@
|
|
|
446
517
|
}
|
|
447
518
|
});
|
|
448
519
|
|
|
520
|
+
// Build target lists the repo's .kickflip/build-* scripts at main's tip and saves a pick at once, null for the
|
|
521
|
+
// default .kickflip/build (docs/adr/2026-11-05-1823-board-settings-picks-which-build-script-a-board-runs.md).
|
|
522
|
+
// A saved target the tip no longer has stays listed, marked, so the picker shows what the hub runs.
|
|
523
|
+
const buildScriptOf = (target) => (target ? `.kickflip/build-${target}` : '.kickflip/build');
|
|
524
|
+
const buildTargetSub = (target) => `The hub runs <code>${esc(buildScriptOf(target))}</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.`;
|
|
525
|
+
const showBuildTargets = () => {
|
|
526
|
+
if (buildTarget === null || buildTargets === null) return;
|
|
527
|
+
const select = $('#buildTarget');
|
|
528
|
+
const names = buildTargets.includes(buildTarget) || !buildTarget ? buildTargets : [...buildTargets, buildTarget];
|
|
529
|
+
select.innerHTML = [['', 'Default'], ...names.map((t) => [t, buildTargets.includes(t) ? t : `${t} (not on main)`])]
|
|
530
|
+
.map(([value, label]) => `<option value="${esc(value)}"${value && value === buildTarget ? ' selected' : ''}>${esc(label)}</option>`).join('');
|
|
531
|
+
select.value = buildTarget;
|
|
532
|
+
select.disabled = false;
|
|
533
|
+
$('#buildTargetSub').innerHTML = buildTargetSub(buildTarget);
|
|
534
|
+
};
|
|
535
|
+
// A new repo brings its own scripts, so a repo save lists them again.
|
|
536
|
+
const loadBuildTargets = () => get(site.url('api/build-targets')).then(({ body }) => {
|
|
537
|
+
if (!B.alive()) return;
|
|
538
|
+
buildTargets = body.targets;
|
|
539
|
+
showBuildTargets();
|
|
540
|
+
}).catch(() => { $('#buildTargetStatus').textContent = 'Could not list the repo’s build scripts. Is the host awake?'; });
|
|
541
|
+
loadBuildTargets();
|
|
542
|
+
B.on($('#buildTarget'), 'change', async () => {
|
|
543
|
+
const select = $('#buildTarget');
|
|
544
|
+
try {
|
|
545
|
+
const settings = await saveToggle(select, { build_target: select.value || null });
|
|
546
|
+
if (!B.alive()) return;
|
|
547
|
+
buildTarget = settings.build_target || '';
|
|
548
|
+
showBuildTargets();
|
|
549
|
+
$('#buildTargetStatus').textContent = `Saved. The hub runs ${buildScriptOf(buildTarget)} for each card and main.`;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
if (!B.alive()) return;
|
|
552
|
+
select.value = buildTarget;
|
|
553
|
+
$('#buildTargetStatus').textContent = e.message || 'Could not save. Is the host awake?';
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
449
557
|
B.on($('#autoBuild'), 'change', async () => {
|
|
450
558
|
const toggle = $('#autoBuild');
|
|
451
559
|
const want = toggle.checked;
|
|
@@ -500,7 +608,7 @@
|
|
|
500
608
|
status.textContent = e.message || 'Could not save. Is the host awake?';
|
|
501
609
|
}
|
|
502
610
|
});
|
|
503
|
-
capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1
|
|
611
|
+
capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 agent builds' : `${n} agents build`} at once.`);
|
|
504
612
|
capInput('maxPlanners', 'max_planners', (n) => `Saved. Up to ${n === 1 ? '1 grill or prototype runs' : `${n} grills or prototypes run`} at once.`);
|
|
505
613
|
autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
|
|
506
614
|
autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
|
|
@@ -527,13 +635,18 @@
|
|
|
527
635
|
// the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
|
|
528
636
|
// switch as it was and a spinner beside it, then draws the server's answer.
|
|
529
637
|
// Sharing: each row is the person's name, then their login and when; a login Tailscale sent no name with is its own.
|
|
638
|
+
// A person's row also sets them View or Full with one tap, saying what that level can do
|
|
639
|
+
// (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md).
|
|
530
640
|
let sharing = null;
|
|
531
|
-
const
|
|
641
|
+
const CAN = { view: 'Looks only. Visit links open.', full: 'Answers, taps card buttons and adds New tasks. Merge and Board settings stay yours.' };
|
|
642
|
+
const shareRow = (title, sub, button, more = '') => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span>${more}</span>${button}</div>`;
|
|
643
|
+
const shareLevel = (p) => `<span role="group" class="share-level" aria-label="${esc(p.name || p.login)}’s access">${[['view', 'View'], ['full', 'Full']].map(([level, label]) =>
|
|
644
|
+
`<button type="button" data-share-level="${level}" data-login="${esc(p.login)}" aria-pressed="${p.level === level}">${label}</button>`).join('')}</span><span class="muted small sub">${CAN[p.level]}</span>`;
|
|
532
645
|
const shareWhen = (p) => (p.now ? '<span class="share-now">viewing now</span>' : esc(p.at ? BoardUI.since(p.at) : 'not seen yet'));
|
|
533
646
|
const shareSub = (p, when) => (p.name ? `${esc(p.login)} · ${when}` : when);
|
|
534
647
|
function drawSharing() {
|
|
535
648
|
$('#shareList').innerHTML = sharing.people.length ? sharing.people.map((p) => shareRow(p.name || p.login, shareSub(p, shareWhen(p)),
|
|
536
|
-
`<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button
|
|
649
|
+
`<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`, shareLevel(p))).join('') : '<p class="muted small">Nobody yet.</p>';
|
|
537
650
|
$('#shareRefused').innerHTML = sharing.refused.length ? `<p class="muted small">Just refused. Tap to share with them:</p>${sharing.refused.map((r) => shareRow(r.name || r.login, shareSub(r, esc(BoardUI.since(r.at))),
|
|
538
651
|
`<button type="button" data-share-add="${esc(r.login)}">Add</button>`)).join('')}` : '';
|
|
539
652
|
}
|
|
@@ -557,7 +670,12 @@
|
|
|
557
670
|
}
|
|
558
671
|
}
|
|
559
672
|
loadSharing();
|
|
560
|
-
B.on($('#shareList'), 'click', (e) => {
|
|
673
|
+
B.on($('#shareList'), 'click', (e) => {
|
|
674
|
+
const b = e.target.closest?.('[data-share-remove]');
|
|
675
|
+
if (b) { saveShare({ remove: b.dataset.shareRemove }); return; }
|
|
676
|
+
const level = e.target.closest?.('[data-share-level]');
|
|
677
|
+
if (level) saveShare({ login: level.dataset.login, level: level.dataset.shareLevel });
|
|
678
|
+
});
|
|
561
679
|
B.on($('#shareRefused'), 'click', (e) => { const b = e.target.closest?.('[data-share-add]'); if (b) saveShare({ add: b.dataset.shareAdd }); });
|
|
562
680
|
B.on($('#shareAdd'), 'submit', async (e) => {
|
|
563
681
|
e.preventDefault?.();
|