@lovek4d/kickflip 0.4.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
// The floating ‹ take › pill
|
|
1
|
+
// The floating ‹ take › pill of a question's full-page prototype viewer (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
|
|
2
|
+
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
|
|
2
3
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
3
4
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
4
5
|
// render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
|
|
5
|
-
// take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
|
|
9
|
-
// to switch its full-width frame without a reload (view-questions.js inPlace).
|
|
10
|
-
// With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
|
|
11
|
-
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
|
|
12
|
-
// bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
|
|
13
|
-
// to the page's height.
|
|
6
|
+
// take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's pick: a take is
|
|
7
|
+
// pickable when an option's id equals its key. Pick drafts it, with the note typed above the pill, in the {choice, note}
|
|
8
|
+
// questions.html keeps for that question, and goes back to the question, whose Send sends it.
|
|
14
9
|
(function () {
|
|
15
10
|
const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
16
11
|
|
|
@@ -20,53 +15,42 @@
|
|
|
20
15
|
};
|
|
21
16
|
const startTake = (keys, wanted, initial) => (keys.includes(wanted) ? wanted : keys.includes(initial) ? initial : keys[0]);
|
|
22
17
|
|
|
23
|
-
// The owner's
|
|
18
|
+
// The owner's last sent pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
|
|
24
19
|
const pickedChoice = (question) => [...(question?.answers || [])].reverse().find((a) => a.choice)?.choice ?? null;
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
20
|
+
// The pick a question shows on Waiting on you (ui-questions.js choiceOf): drafted, this device's unsent pick, which Send
|
|
21
|
+
// would send, else the last answer's, else the option the overnight triage suggested. A resolved question is locked at
|
|
22
|
+
// its last answer, and one waiting on the prototype the owner asked for at the request's pick.
|
|
23
|
+
const locked = (question) => question.state === 'resolved' || question.state === 'requested';
|
|
24
|
+
const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
|
|
25
|
+
: question.state === 'resolved' ? pickedChoice(question)
|
|
26
|
+
: drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
|
|
27
|
+
// none: no question, the take is not one of its options, or the question is locked and the take isn't its pick.
|
|
28
|
+
// pick: nothing picked yet. picked: this take is the pick. change: another take is.
|
|
29
|
+
function pickState(question, take, drafted = null) {
|
|
28
30
|
if (!question) return { kind: 'none' };
|
|
29
31
|
const option = (question.options || []).find((o) => o.id === take);
|
|
30
|
-
const picked =
|
|
32
|
+
const picked = shownPick(question, drafted);
|
|
31
33
|
if (!option) return { kind: 'none', picked };
|
|
32
|
-
if (
|
|
33
|
-
return { kind:
|
|
34
|
+
if (picked === take) return { kind: 'picked', option, picked };
|
|
35
|
+
if (locked(question)) return { kind: 'none', picked };
|
|
36
|
+
return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
|
|
34
37
|
}
|
|
35
|
-
//
|
|
36
|
-
const answerLabel = (option) => `${/^[A-Z][0-9]?$/i.test(option.id) ? `${option.id}. ` : ''}${option.label}`;
|
|
37
|
-
const questionUrl = (q) => (/^prototype-[\w-]+\.html(?:[?#]|$)/.test(q.url || '') ? q.url : `questions.html#${encodeURIComponent(q.id)}`);
|
|
38
|
-
// questions.html keeps each question's unsent {choice, note} under this key; a pick sent here takes its note and clears it.
|
|
38
|
+
// questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
|
|
39
39
|
const draftKey = (qid) => 'qdraft:' + qid;
|
|
40
40
|
const readDraft = (qid) => { try { return JSON.parse(localStorage.getItem(draftKey(qid)) || '{}'); } catch { return {}; } };
|
|
41
|
-
|
|
42
|
-
//
|
|
41
|
+
const writeDraft = (qid, draft) => { try { localStorage.setItem(draftKey(qid), JSON.stringify(draft)); } catch { /* nothing kept */ } };
|
|
42
|
+
// In place of this page: framed in the app shell, the shell shows the URL's view in place, going up to the question
|
|
43
|
+
// the viewer sits under (board/public/shell.js AppShell.open, router.js); alone, the page loads it.
|
|
43
44
|
function replaceWith(url) {
|
|
44
45
|
let app = null;
|
|
45
46
|
try { app = window.top !== window ? window.top.AppShell : null; } catch { app = null; }
|
|
46
47
|
if (!app?.open?.(String(url), { replace: true })) location.replace(url);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
let questionsUI = null;
|
|
51
|
-
const sharedQuestions = (site) => (questionsUI ||= (async () => {
|
|
52
|
-
try { if (window.top !== window && window.top.BoardUI?.nextQuestion) return window.top.BoardUI; } catch {}
|
|
53
|
-
for (const [file, key] of [['ui.js', 'esc'], ['ui-links.js', 'linkText'], ['ui-questions.js', 'nextQuestion']]) {
|
|
54
|
-
if (globalThis.BoardUI?.[key]) continue;
|
|
55
|
-
await new Promise((resolve, reject) => {
|
|
56
|
-
const script = document.createElement('script');
|
|
57
|
-
script.src = site.url(file);
|
|
58
|
-
script.onload = resolve;
|
|
59
|
-
script.onerror = reject;
|
|
60
|
-
document.head.append(script);
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
return globalThis.BoardUI;
|
|
64
|
-
})().catch((e) => { questionsUI = null; throw e; }));
|
|
65
|
-
|
|
66
|
-
function pillHtml({ name, pick = { kind: 'none' }, busy = false }) {
|
|
50
|
+
function pillHtml({ name, pick = { kind: 'none' } }) {
|
|
67
51
|
const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
|
|
68
52
|
: pick.kind === 'pick' || pick.kind === 'change'
|
|
69
|
-
? `<button type="button" class="ps-pick" data-ps="pick"
|
|
53
|
+
? `<button type="button" class="ps-pick" data-ps="pick">${pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
|
|
70
54
|
: '';
|
|
71
55
|
return `<button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button>${control}`;
|
|
72
56
|
}
|
|
@@ -82,16 +66,13 @@
|
|
|
82
66
|
.ps-switch .pill button.on { background:#fff; color:#000; }
|
|
83
67
|
.ps-name { flex:0 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
|
|
84
68
|
.ps-pill .ps-pick { background:#fff; color:#000; margin-left:2px; }
|
|
85
|
-
.ps-pill .ps-pick:disabled { opacity:.6; cursor:default; }
|
|
86
69
|
.ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
|
|
87
70
|
padding:7px 10px; font:400 14px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
|
|
88
71
|
.ps-switch .ps-text::placeholder { color:#fffa; }
|
|
89
72
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
90
73
|
.ps-picked { margin-left:2px; padding:7px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
91
74
|
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
92
|
-
.ps-note[hidden] { display:none; }
|
|
93
|
-
.ps-embed header.top { display:none; }
|
|
94
|
-
.ps-embed main { padding-bottom:12px !important; }`;
|
|
75
|
+
.ps-note[hidden] { display:none; }`;
|
|
95
76
|
|
|
96
77
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
97
78
|
|
|
@@ -101,29 +82,10 @@
|
|
|
101
82
|
const qid = params.get('q');
|
|
102
83
|
const site = globalThis.ClaudeSite;
|
|
103
84
|
if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
|
|
104
|
-
if (params.has('take')) {
|
|
105
|
-
let only = startTake(keys, params.get('take'), initial);
|
|
106
|
-
document.documentElement.classList.add('ps-embed');
|
|
107
|
-
render(only);
|
|
108
|
-
const embedded = {
|
|
109
|
-
takes: keys,
|
|
110
|
-
get take() { return only; },
|
|
111
|
-
go(next) {
|
|
112
|
-
if (!keys.includes(next) || next === only) return;
|
|
113
|
-
only = next;
|
|
114
|
-
const u = new URL(location.href); u.searchParams.set('take', only); history.replaceState(history.state, '', u);
|
|
115
|
-
render(only);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
return (globalThis.PrototypeSwitcher.current = embedded);
|
|
119
|
-
}
|
|
120
85
|
let take = startTake(keys, params.get('variant'), initial);
|
|
121
86
|
let question = null;
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
let cards = [];
|
|
125
|
-
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
|
|
126
|
-
// reload stays on the page; only the first load sends an already-picked question on to its card's thread.
|
|
87
|
+
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
|
|
88
|
+
// page; only the first load sends an already-picked question on to its card's thread.
|
|
127
89
|
let fromPush = params.get('from') === 'push';
|
|
128
90
|
if (fromPush) { const u = new URL(location.href); u.searchParams.delete('from'); history.replaceState(null, '', u); }
|
|
129
91
|
|
|
@@ -135,13 +97,13 @@
|
|
|
135
97
|
const pill = box.querySelector('.ps-pill');
|
|
136
98
|
const noteEl = box.querySelector('.ps-note');
|
|
137
99
|
const text = box.querySelector('.ps-text');
|
|
138
|
-
let busy = false;
|
|
139
100
|
|
|
140
101
|
const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
|
|
102
|
+
const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
|
|
141
103
|
function paint() {
|
|
142
|
-
const
|
|
143
|
-
pill.innerHTML = pillHtml({ name: takes[take], pick:
|
|
144
|
-
text.hidden = !(
|
|
104
|
+
const now = state();
|
|
105
|
+
pill.innerHTML = pillHtml({ name: takes[take], pick: now });
|
|
106
|
+
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
145
107
|
}
|
|
146
108
|
function go(next) {
|
|
147
109
|
take = next;
|
|
@@ -153,14 +115,11 @@
|
|
|
153
115
|
|
|
154
116
|
async function load() {
|
|
155
117
|
if (!qid || !site) return;
|
|
156
|
-
let board = null;
|
|
157
118
|
try {
|
|
158
|
-
board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
159
|
-
topics = board.ownerTopics;
|
|
160
|
-
cards = board.items || [];
|
|
119
|
+
const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
161
120
|
// A resolved question is left out of /api/board, and its topic's resolved questions hold it
|
|
162
121
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
163
|
-
const topic =
|
|
122
|
+
const topic = board.ownerTopics.find((t) => t.questions.includes(qid));
|
|
164
123
|
question = board.owner.find((q) => q.id === qid)
|
|
165
124
|
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
166
125
|
|| null;
|
|
@@ -174,37 +133,13 @@
|
|
|
174
133
|
if (question && !text.value) text.value = readDraft(qid).note || '';
|
|
175
134
|
fromPush = false;
|
|
176
135
|
paint();
|
|
177
|
-
return board;
|
|
178
136
|
}
|
|
179
137
|
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
let ui, pills, name;
|
|
186
|
-
try {
|
|
187
|
-
ui = await sharedQuestions(site);
|
|
188
|
-
pills = ui.topicRow(topics, answered.topic).pills;
|
|
189
|
-
const topic = topics.find((t) => t.id === answered.topic);
|
|
190
|
-
name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
|
|
191
|
-
await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
|
|
192
|
-
} catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
|
|
193
|
-
try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
|
|
194
|
-
text.value = '';
|
|
195
|
-
busy = false;
|
|
196
|
-
note('Sent. Claude replies on the card.');
|
|
197
|
-
const board = await load();
|
|
198
|
-
const next = ui.nextQuestion(board?.owner || [], pills, answered);
|
|
199
|
-
if (next) {
|
|
200
|
-
const u = new URL(site.url(questionUrl(next)), location.href);
|
|
201
|
-
if (globalThis.PageBack?.pushed) u.searchParams.set('from', 'push');
|
|
202
|
-
replaceWith(u);
|
|
203
|
-
} else {
|
|
204
|
-
const route = new URL(site.url(''), location.href).pathname.replace(/^\/+|\/+$/g, '');
|
|
205
|
-
ui.sentNotice.put(route, `Sent 1 answer${name ? ` to ${name}` : ''}.`);
|
|
206
|
-
replaceWith(site.url('./'));
|
|
207
|
-
}
|
|
138
|
+
function pick() {
|
|
139
|
+
const now = state();
|
|
140
|
+
if (!(now.kind === 'pick' || now.kind === 'change')) return;
|
|
141
|
+
writeDraft(qid, { ...readDraft(qid), choice: now.option.id, note: text.value.trim() });
|
|
142
|
+
replaceWith(site.url(`questions.html#${encodeURIComponent(qid)}`));
|
|
208
143
|
}
|
|
209
144
|
text.addEventListener('keydown', (e) => {
|
|
210
145
|
if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || e.isComposing) return;
|
|
@@ -227,8 +162,8 @@
|
|
|
227
162
|
|
|
228
163
|
go(take);
|
|
229
164
|
load();
|
|
230
|
-
return
|
|
165
|
+
return { takes: keys, get take() { return take; }, go };
|
|
231
166
|
}
|
|
232
167
|
|
|
233
|
-
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState,
|
|
168
|
+
globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, mount };
|
|
234
169
|
})();
|
package/board/public/shell.js
CHANGED
|
@@ -57,6 +57,10 @@ window.AppShell = (function () {
|
|
|
57
57
|
// A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
|
|
58
58
|
bootCode = data?.code || null;
|
|
59
59
|
data ||= read(DATA_KEY);
|
|
60
|
+
// A board shared with someone else, view only (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
|
|
61
|
+
// the hub sends them data.shared, and style.css hides what writes under .shared-view.
|
|
62
|
+
const shared = data?.shared || null;
|
|
63
|
+
document.documentElement.classList.toggle('shared-view', Boolean(shared));
|
|
60
64
|
sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
|
|
61
65
|
const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
|
|
62
66
|
const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
|
|
@@ -116,7 +120,9 @@ window.AppShell = (function () {
|
|
|
116
120
|
// minute while present. While away the first one goes at once and the moon goes from this page at the same moment
|
|
117
121
|
// (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
118
122
|
let beatAt = 0;
|
|
123
|
+
// A shared person's taps are never the owner's.
|
|
119
124
|
const beat = () => {
|
|
125
|
+
if (shared) return;
|
|
120
126
|
const away = Boolean(data?.away);
|
|
121
127
|
if (!away && Date.now() - beatAt < 60_000) return;
|
|
122
128
|
beatAt = Date.now();
|
|
@@ -182,7 +188,7 @@ window.AppShell = (function () {
|
|
|
182
188
|
const page = board ? null : AppViews[view.kind];
|
|
183
189
|
const root = document.createElement('div');
|
|
184
190
|
root.className = board ? 'view-board' : page.className;
|
|
185
|
-
root.innerHTML = board ? BoardView.markup({ title: siteTitle }) : page.markup(view, { siteTitle });
|
|
191
|
+
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
|
|
186
192
|
if (current) current.root.replaceWith(root); else scrim.after(root);
|
|
187
193
|
const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
|
|
188
194
|
let v;
|
package/board/public/sidebar.css
CHANGED
|
@@ -84,6 +84,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
84
84
|
#failedStartSheet { inset: 0; margin: auto; height: fit-content; max-height: calc(100dvh - 48px); }
|
|
85
85
|
}
|
|
86
86
|
.sidebar .sb-row .icon.sb-plus { color: var(--muted); background: var(--chip); }
|
|
87
|
+
.sidebar .sb-row .icon.sb-start-icon { color: var(--accent); }
|
|
87
88
|
|
|
88
89
|
#newBoardSheet .nb-form { min-width: 0; }
|
|
89
90
|
#newBoardSheet .sheet-head { margin-bottom: 8px; }
|
package/board/public/sidebar.js
CHANGED
|
@@ -58,6 +58,9 @@
|
|
|
58
58
|
if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
|
|
59
59
|
let device = null;
|
|
60
60
|
let update = null;
|
|
61
|
+
// While Restart waits for the hub: null | {version} | {version, error}, which replaces the hub's upgrade field in the row.
|
|
62
|
+
let restart = null;
|
|
63
|
+
const upgradeShown = () => (restart ? { state: restart.error ? 'updated' : 'restarting', ...restart } : data?.upgrade);
|
|
61
64
|
let updateHTML = '';
|
|
62
65
|
const rail = matchMedia(RAIL);
|
|
63
66
|
const html = document.documentElement;
|
|
@@ -70,7 +73,7 @@
|
|
|
70
73
|
const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
|
|
71
74
|
if (!logo) return;
|
|
72
75
|
const n = othersWaiting(data, here);
|
|
73
|
-
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' :
|
|
76
|
+
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : upgradeShown() ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
74
77
|
}
|
|
75
78
|
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
76
79
|
let switching = null;
|
|
@@ -101,7 +104,7 @@
|
|
|
101
104
|
}
|
|
102
105
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
103
106
|
function paint() {
|
|
104
|
-
html.classList.toggle('sb-update', Boolean(update ||
|
|
107
|
+
html.classList.toggle('sb-update', Boolean(update || upgradeShown()));
|
|
105
108
|
if (!data) return;
|
|
106
109
|
const scroll = aside.querySelector('.sb-scroll');
|
|
107
110
|
const top = painted ? scroll?.scrollTop : undefined;
|
|
@@ -115,7 +118,7 @@
|
|
|
115
118
|
}
|
|
116
119
|
const fresh = aside.querySelector('.sb-update');
|
|
117
120
|
if (held) fresh.replaceWith(held);
|
|
118
|
-
const row = updateRow(update,
|
|
121
|
+
const row = updateRow(update, upgradeShown());
|
|
119
122
|
if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
|
|
120
123
|
updateHTML = row;
|
|
121
124
|
const list = aside.querySelector('.sb-scroll');
|
|
@@ -192,14 +195,50 @@
|
|
|
192
195
|
if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
|
|
193
196
|
return;
|
|
194
197
|
}
|
|
198
|
+
const restartApp = e.target.closest?.('[data-app-restart]');
|
|
199
|
+
if (restartApp) { if (!restartApp.disabled) restartHub(); return; }
|
|
195
200
|
const upgradeApp = e.target.closest?.('[data-app-upgrade]');
|
|
196
201
|
if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
|
|
197
202
|
if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
|
|
198
203
|
const failed = e.target.closest?.('[data-failed-start]');
|
|
199
204
|
if (failed) openFailed(failed.dataset.failedStart);
|
|
200
205
|
});
|
|
201
|
-
//
|
|
202
|
-
//
|
|
206
|
+
// Restart asks the hub to restart (POST /_hub/restart-hub), then polls until it answers with other code than it ran (its
|
|
207
|
+
// x-hub-code) and reloads the page (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md).
|
|
208
|
+
// Past RESTART_TRIES polls the row goes back to Restart with how to restart by hand.
|
|
209
|
+
const RESTART_TRIES = 120;
|
|
210
|
+
async function restartHub() {
|
|
211
|
+
if (restart) return;
|
|
212
|
+
const version = data.upgrade.version;
|
|
213
|
+
restart = { version };
|
|
214
|
+
paint();
|
|
215
|
+
let before, command;
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
218
|
+
const body = await response.json().catch(() => ({}));
|
|
219
|
+
if (!response.ok) throw new Error(body.error || 'The hub didn’t restart.');
|
|
220
|
+
before = response.headers.get('x-hub-code');
|
|
221
|
+
command = body.command;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
restart = { version, error: e.message || 'The hub didn’t answer.' };
|
|
224
|
+
paint();
|
|
225
|
+
restart = null;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (let i = 0; i < RESTART_TRIES; i++) {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
|
|
232
|
+
const code = response.headers.get('x-hub-code');
|
|
233
|
+
if (response.ok && code && code !== before) { reload(); return; }
|
|
234
|
+
} catch { /* the hub is down while it restarts */ }
|
|
235
|
+
}
|
|
236
|
+
restart = { version, error: `The hub hasn’t come back. On the host, run ${command}` };
|
|
237
|
+
paint();
|
|
238
|
+
restart = null;
|
|
239
|
+
}
|
|
240
|
+
// Update pulls again after a failed pull (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The row
|
|
241
|
+
// waits (ui.js waitOn) until the hub answers with what it did, then while that pull runs (ui-sidebar.js
|
|
203
242
|
// updateRow), and a refusal shows as a failed update.
|
|
204
243
|
function upgrade(button) {
|
|
205
244
|
BoardUI.waitOn(button, (async () => {
|