@lovek4d/kickflip 0.5.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 +47 -42
- package/README.md +1 -1
- package/SKILL.md +16 -16
- package/bin/kickflip.mjs +26 -17
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +65 -18
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +5 -4
- package/board/on-host.mjs +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +51 -27
- package/board/public/app-core.js +26 -8
- 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 +1 -1
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +70 -59
- package/board/public/app.js +38 -8
- 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/linear-key.js +46 -35
- package/board/public/new-task.js +17 -38
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -5
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +108 -37
- package/board/public/ui-cards.js +41 -30
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-questions.js +25 -38
- package/board/public/ui-sidebar.js +18 -12
- package/board/public/ui-threads.js +3 -3
- package/board/public/ui.js +40 -8
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +142 -199
- package/board/public/view-settings.js +191 -72
- package/board/server.mjs +19 -20
- package/board/store.mjs +20 -15
- 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 +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +6 -6
- 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/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, time, ago, replyBody, attachments,
|
|
4
|
-
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
3
|
+
const { esc, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
5
5
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
6
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
7
|
// suggested: the line under a suggested option saying where the owner chose it before, as HTML.
|
|
8
|
-
const option = (o, { type = 'radio', name, checked = false, numbered = true,
|
|
8
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
9
|
<span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
|
|
10
10
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
11
|
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
12
|
// suggested, pre-checked so the topic's Send sends it.
|
|
13
13
|
const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
|
+
// A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
|
|
15
|
+
// phone's deck (app-needs.js) and the prototype viewer's Pick (prototype-switcher.js) write it.
|
|
16
|
+
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
17
|
+
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
14
18
|
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
15
19
|
|
|
16
20
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
@@ -18,7 +22,7 @@
|
|
|
18
22
|
// requested until its prototype round arrives (view-questions.js).
|
|
19
23
|
const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
|
|
20
24
|
const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
|
|
21
|
-
<div class="note-tools">${
|
|
25
|
+
<div class="note-tools">${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
|
|
22
26
|
|
|
23
27
|
const QUESTION_STATE = {
|
|
24
28
|
open: { label: 'open', tone: 'normal' },
|
|
@@ -40,7 +44,9 @@
|
|
|
40
44
|
const last = q.answers[q.answers.length - 1];
|
|
41
45
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
42
46
|
}
|
|
43
|
-
// A prototype page in the site's public/ opens in place,
|
|
47
|
+
// A prototype page in the site's public/ is the question's full-page viewer: it opens in place, shows one take at a
|
|
48
|
+
// time, and its switcher drafts a pick (board/public/prototype-switcher.js,
|
|
49
|
+
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
|
|
44
50
|
const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
|
|
45
51
|
const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
|
|
46
52
|
const withParam = (url, key, value) => {
|
|
@@ -48,13 +54,11 @@
|
|
|
48
54
|
return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
|
|
49
55
|
};
|
|
50
56
|
const questionLink = (url) => !url ? ''
|
|
51
|
-
: isPrototype(url) ? `<a class="
|
|
57
|
+
: isPrototype(url) ? `<a class="button proto-view" href="${esc(url)}">View prototypes</a>`
|
|
52
58
|
: `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
|
|
53
59
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
57
|
-
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
60
|
+
// An option with `diagram` draws it with its `practice` in the question (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
61
|
+
const drawn = (q) => q.options.some((o) => o.diagram);
|
|
58
62
|
|
|
59
63
|
// rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
|
|
60
64
|
// of them; tone is a status colour's name.
|
|
@@ -72,28 +76,12 @@
|
|
|
72
76
|
}
|
|
73
77
|
const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
&& (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
|
|
77
|
-
// Grid heights decide the layout; fullH measures the chosen take at the full content width.
|
|
78
|
-
function takeBody(q, o, links, fit, full = false) {
|
|
79
|
-
if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
|
|
80
|
-
if (!isPrototype(q.url)) return '';
|
|
81
|
-
const size = fit(o.id);
|
|
82
|
-
const h = full ? size?.fullH : size?.h;
|
|
83
|
-
// Every take loads so one outside the viewport can still decide the question's layout.
|
|
84
|
-
return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
|
|
85
|
-
}
|
|
86
|
-
function takes(q, chosen, links, fit, locked = false) {
|
|
79
|
+
function takes(q, chosen, links, locked = false) {
|
|
87
80
|
const name = 'c-' + q.id;
|
|
88
|
-
|
|
89
|
-
const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
|
|
90
|
-
return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, locked, suggested: suggestedLine(q, o, links) })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
|
|
91
|
-
}
|
|
92
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
|
|
81
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
93
82
|
}
|
|
94
|
-
// A confirm
|
|
95
|
-
const
|
|
96
|
-
: `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
|
|
83
|
+
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
84
|
+
const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
|
|
97
85
|
// docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
|
|
98
86
|
// A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
|
|
99
87
|
// `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
|
|
@@ -115,11 +103,10 @@
|
|
|
115
103
|
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
116
104
|
</section>`;
|
|
117
105
|
}
|
|
118
|
-
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext
|
|
119
|
-
// fit(option id) supplies the measured grid and full-width heights.
|
|
106
|
+
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
|
|
120
107
|
// A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
|
|
121
108
|
// round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
122
|
-
function questionCard(q, { n = 0, draft = {}, links = {}
|
|
109
|
+
function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
|
|
123
110
|
if (q.state === 'resolved') return resolvedCard(q, links);
|
|
124
111
|
const locked = q.state === 'requested';
|
|
125
112
|
const chosen = locked ? q.request.choice : choiceOf(q, draft);
|
|
@@ -127,9 +114,9 @@
|
|
|
127
114
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
128
115
|
<h3>${linkText(q.question, links)}</h3>
|
|
129
116
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
130
|
-
${
|
|
131
|
-
${
|
|
132
|
-
${
|
|
117
|
+
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
118
|
+
${confirmLine(q)}
|
|
119
|
+
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
133
120
|
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
|
|
134
121
|
${answerHistory(q, links)}
|
|
135
122
|
</section>`;
|
|
@@ -142,7 +129,7 @@
|
|
|
142
129
|
const byKey = new Map(cards.map((c) => [c.key, c]));
|
|
143
130
|
return topics.map((t) => {
|
|
144
131
|
const c = t.title === undefined && byKey.get(t.card);
|
|
145
|
-
return c ? { ...t, title: `${c.ref}: ${c.title}`,
|
|
132
|
+
return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
|
|
146
133
|
});
|
|
147
134
|
}
|
|
148
135
|
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
@@ -179,5 +166,5 @@
|
|
|
179
166
|
},
|
|
180
167
|
};
|
|
181
168
|
|
|
182
|
-
Object.assign(globalThis.BoardUI, { choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip,
|
|
169
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
183
170
|
})();
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
const sites = data?.sites || [];
|
|
70
70
|
const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
|
|
71
71
|
const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
|
|
72
|
-
...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
72
|
+
...(data?.shared ? [] : UTILITY).map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
73
73
|
rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
|
|
74
74
|
const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
|
|
75
75
|
const taken = new Set();
|
|
@@ -176,25 +176,28 @@
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
|
|
179
|
-
// /_hub/sidebar.json's npm update, null | {state: available |
|
|
180
|
-
// (hub/upgrade.mjs; docs/adr/2026-11-
|
|
181
|
-
//
|
|
179
|
+
// /_hub/sidebar.json's npm update, null | {state: available | updating | updated | restarting | failed, version, error}
|
|
180
|
+
// (hub/upgrade.mjs; docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The hub pulls a new
|
|
181
|
+
// version itself, so the row reads Updated to v<version> with Restart; restarting is the sidebar's own state while the hub
|
|
182
|
+
// restarts. A reload, a pull and a restart wait (ui.js BUSY): the pill keeps its blue with a spinner in place of its word.
|
|
182
183
|
function updateRow(update, upgrade) {
|
|
183
184
|
const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
|
|
184
185
|
+ `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
|
|
185
186
|
if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
|
|
186
187
|
if (!upgrade) return '';
|
|
187
|
-
if (upgrade.state === '
|
|
188
|
-
if (upgrade.state === '
|
|
189
|
-
if (upgrade.state === '
|
|
190
|
-
return button('data-app-upgrade', false, 'Update
|
|
188
|
+
if (upgrade.state === 'updated') return button('data-app-restart', false, `Updated to v${esc(upgrade.version)}`, upgrade.error, 'Restart');
|
|
189
|
+
if (upgrade.state === 'restarting') return button('data-app-restart', true, `Restarting to v${esc(upgrade.version)}`, '', 'Restart');
|
|
190
|
+
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Updating', '', 'Update');
|
|
191
|
+
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Update');
|
|
192
|
+
return button('data-app-upgrade', false, 'Update available', '', 'Update');
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
194
196
|
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
195
197
|
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
196
198
|
// update: null | 'ready' | 'reloading'.
|
|
197
|
-
// now: the time a starting board's seconds count to.
|
|
199
|
+
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
200
|
+
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
198
201
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
199
202
|
const sites = data?.sites || [];
|
|
200
203
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
@@ -231,13 +234,16 @@
|
|
|
231
234
|
const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
|
|
232
235
|
const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
|
|
233
236
|
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
234
|
-
|
|
235
|
-
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
237
|
+
const top = header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
238
|
+
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } });
|
|
239
|
+
const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
|
|
240
|
+
if (data?.shared) return `${top}<div class="sb-scroll"><section class="sb-boards" aria-label="Boards">${boardsLabel}<div class="sb-group">${boards.map(siteRow).join('')}</div></section></div>`;
|
|
241
|
+
return top
|
|
236
242
|
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
|
|
237
243
|
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
238
244
|
+ `</div>${turnOn}</section>`
|
|
239
245
|
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
240
|
-
+ `<section class="sb-boards" aria-label="Boards"
|
|
246
|
+
+ `<section class="sb-boards" aria-label="Boards">${boardsLabel}`
|
|
241
247
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
242
248
|
}
|
|
243
249
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
2
|
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const { esc, time, etaLine, replyBody, attachments,
|
|
4
|
+
const { esc, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// files it adds to the message, under composer id edit:<event id>.
|
|
33
33
|
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
|
|
34
34
|
<div class="pending-files" data-files></div>
|
|
35
|
-
<div class="
|
|
35
|
+
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
36
36
|
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
|
|
37
37
|
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
38
38
|
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
46
46
|
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
47
47
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
48
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log
|
|
48
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
|
|
49
49
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
50
50
|
${m.eventId && !batched ? msgFooter(m) : ''}
|
|
51
51
|
</div>`);
|
package/board/public/ui.js
CHANGED
|
@@ -52,19 +52,36 @@
|
|
|
52
52
|
}
|
|
53
53
|
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
54
54
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
55
|
-
// A composer's text box, which attach.js outlines with Drop to
|
|
56
|
-
|
|
55
|
+
// A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
|
|
56
|
+
// attach while files are dragged over the composer.
|
|
57
|
+
const dropZone = (textarea) => `<div class="dz">${textarea}${attachButton()}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
58
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
|
|
59
|
+
// A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
|
|
60
|
+
// wherever each sits in the row (Ctrl+↵ and
|
|
61
|
+
// Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
|
|
62
|
+
const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
|
|
63
|
+
const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
|
|
64
|
+
const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
|
|
65
|
+
// The send button an Enter keydown in form's box presses, or null.
|
|
66
|
+
const keyedSend = (ev, form) => {
|
|
67
|
+
if (ev.key !== 'Enter' || ev.isComposing) return null;
|
|
68
|
+
const nth = (ev.metaKey || ev.ctrlKey) && !ev.altKey ? 0 : ev.altKey && !ev.metaKey && !ev.ctrlKey ? 1 : -1;
|
|
69
|
+
return nth < 0 ? null : form.querySelector(`button[type="submit"][data-nth="${nth}"]`);
|
|
70
|
+
};
|
|
57
71
|
const icon = (body, size = 18, attrs = '') => `<svg${attrs} 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>`;
|
|
58
72
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
59
73
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
60
74
|
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
61
75
|
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
62
76
|
const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
|
|
77
|
+
const BOLT = icon('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>');
|
|
63
78
|
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
64
79
|
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
65
80
|
const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
66
81
|
// The thread's fire, the eye's twin for a grill.
|
|
67
82
|
const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
|
|
83
|
+
// A Todo card's thread footer lightning: starts a quick build of the card at once (app-threads.js).
|
|
84
|
+
const boltButton = (id) => `<button type="button" class="attach proto-ask" data-bolt="${esc(id)}" aria-label="Quick build" title="Quick build">${BOLT}</button>`;
|
|
68
85
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
69
86
|
// A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
|
|
70
87
|
// same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
|
|
@@ -72,7 +89,7 @@
|
|
|
72
89
|
const MARK_ICONS = {
|
|
73
90
|
'prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
74
91
|
'grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
|
|
75
|
-
'quick build: no grill
|
|
92
|
+
'quick build: no grill': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
|
|
76
93
|
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
77
94
|
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
78
95
|
questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
|
|
@@ -299,14 +316,29 @@
|
|
|
299
316
|
// until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
|
|
300
317
|
// drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
|
|
301
318
|
// keeps the spinner.
|
|
319
|
+
// A tap whose answer carries no board (a bare fetch) calls answered(id) with no time: any board then ends it, so its
|
|
320
|
+
// until() must hold only for a change the tap caused. start(id, until, onLate): if no board ends it within
|
|
321
|
+
// PENDING_LATE_MS the entry drops and onLate() runs (the page reloads once and says so).
|
|
322
|
+
const PENDING_LATE_MS = 60000;
|
|
302
323
|
const pendings = new Map();
|
|
303
324
|
const pending = {
|
|
304
|
-
start(id, until
|
|
305
|
-
|
|
306
|
-
|
|
325
|
+
start(id, until, onLate = null) {
|
|
326
|
+
pending.drop(id);
|
|
327
|
+
const late = onLate && setTimeout(() => { pendings.delete(id); onLate(); }, PENDING_LATE_MS);
|
|
328
|
+
pendings.set(id, { until, after: null, late });
|
|
329
|
+
},
|
|
330
|
+
answered(id, after = '') { const p = pendings.get(id); if (p) p.after = after; },
|
|
331
|
+
drop(id) { clearTimeout(pendings.get(id)?.late); pendings.delete(id); },
|
|
307
332
|
has: (id) => pendings.has(id),
|
|
333
|
+
// A tap whose change is drawn by the page's own load, not by a board (a thread, a page's redraw): resolves when work
|
|
334
|
+
// settles, or after PENDING_LATE_MS calls onLate() and resolves, so waitOn's spinner rests either way.
|
|
335
|
+
within(work, onLate) {
|
|
336
|
+
let timer;
|
|
337
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => { onLate(); resolve(); }, PENDING_LATE_MS); });
|
|
338
|
+
return Promise.race([work, late]).finally(() => clearTimeout(timer));
|
|
339
|
+
},
|
|
308
340
|
settle(board) {
|
|
309
|
-
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board))
|
|
341
|
+
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
|
|
310
342
|
},
|
|
311
343
|
};
|
|
312
344
|
|
|
@@ -340,6 +372,6 @@
|
|
|
340
372
|
globalThis.BoardUI = {
|
|
341
373
|
LINK_ARROW,
|
|
342
374
|
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
|
|
343
|
-
attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
375
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, boltButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
344
376
|
};
|
|
345
377
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
2
|
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
|
|
3
|
-
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key
|
|
3
|
+
// hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
|
|
4
4
|
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
5
5
|
// and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
|
|
6
6
|
(function () {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
61
|
+
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
62
62
|
const sectionOf = (hash) => {
|
|
63
63
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
64
64
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
let flash = null;
|
|
80
80
|
let rules = null;
|
|
81
81
|
let rulesJSON = '';
|
|
82
|
-
let
|
|
82
|
+
let tokenSlot = null;
|
|
83
83
|
|
|
84
84
|
function paint() {
|
|
85
85
|
if (!last) return;
|
|
@@ -87,16 +87,16 @@
|
|
|
87
87
|
top.innerHTML = HubUI.flash(flash);
|
|
88
88
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
89
89
|
if (active === 'notifications') {
|
|
90
|
-
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}
|
|
90
|
+
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
91
91
|
} else if (active === 'quiet') {
|
|
92
92
|
content.innerHTML = quietRows(last.quiet);
|
|
93
93
|
} else if (active === 'keep-awake') {
|
|
94
94
|
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
95
95
|
: `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
|
|
96
|
-
} else if (active === 'linear') {
|
|
97
|
-
if (
|
|
96
|
+
} else if (active === 'linear' || active === 'slack') {
|
|
97
|
+
if (tokenSlot) tokenSlot.set(last[active]);
|
|
98
98
|
else {
|
|
99
|
-
|
|
99
|
+
tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
|
|
100
100
|
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
101
101
|
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
102
102
|
}
|
|
@@ -112,6 +112,7 @@
|
|
|
112
112
|
quiet: last.quiet.summary,
|
|
113
113
|
'keep-awake': last.awake.summary,
|
|
114
114
|
linear: last.linear?.summary || 'No key',
|
|
115
|
+
slack: last.slack?.summary || 'No token',
|
|
115
116
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
116
117
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
117
118
|
};
|
|
@@ -152,7 +153,7 @@
|
|
|
152
153
|
function closeModal(restore = true) {
|
|
153
154
|
unfitModal?.(); unfitModal = null;
|
|
154
155
|
modal.close();
|
|
155
|
-
|
|
156
|
+
tokenSlot?.destroy(); tokenSlot = null;
|
|
156
157
|
content.innerHTML = '';
|
|
157
158
|
rules = null;
|
|
158
159
|
active = null;
|
|
@@ -186,8 +187,8 @@
|
|
|
186
187
|
return true;
|
|
187
188
|
};
|
|
188
189
|
const dismiss = () => {
|
|
189
|
-
if (!
|
|
190
|
-
|
|
190
|
+
if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
191
|
+
tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
191
192
|
};
|
|
192
193
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
193
194
|
B.on(root, 'click', (e) => {
|
package/board/public/view-hub.js
CHANGED
|
@@ -39,8 +39,12 @@
|
|
|
39
39
|
const { row, tile } = HubUI;
|
|
40
40
|
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
41
41
|
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
42
|
+
// One line per device with notifications on: kind, browser, last seen, and a Remove that forgets it.
|
|
43
|
+
const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
|
|
44
|
+
const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
|
|
45
|
+
const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
|
|
42
46
|
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
|
|
43
|
-
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
|
|
47
|
+
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
|
|
44
48
|
: `<span class="st wait">not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span>${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` })).join('');
|
|
45
49
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
46
50
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
@@ -73,7 +77,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
73
77
|
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
|
|
80
|
+
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
|
|
77
81
|
|
|
78
82
|
// iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
|
|
79
83
|
function badge(n) {
|
|
@@ -109,8 +113,11 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
109
113
|
if (!r.ok || !out) throw new Error();
|
|
110
114
|
if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
|
|
111
115
|
} catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
|
|
116
|
+
// The row keeps its Restart busy until the hub's data shows the board's new word, and only a refusal loads after
|
|
117
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
118
|
+
if (!flash && B.alive()) await BoardUI.pending.within(load(), () => { B.toast('Still waiting on the board'); load(); });
|
|
112
119
|
restarting.delete(route);
|
|
113
|
-
if (B.alive()) { paint(); load(); }
|
|
120
|
+
if (B.alive()) { paint(); if (flash) load(); }
|
|
114
121
|
}
|
|
115
122
|
// Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
|
|
116
123
|
// with other code than it ran (its x-hub-code) and reloads the app; past HUB_WAIT tries it says how to restart by hand.
|
|
@@ -145,7 +152,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
145
152
|
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
146
153
|
if (B.alive()) paint();
|
|
147
154
|
}
|
|
155
|
+
async function forgetDevice(id) {
|
|
156
|
+
const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
|
|
157
|
+
if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
|
|
158
|
+
load();
|
|
159
|
+
}
|
|
148
160
|
B.on(main, 'click', (e) => {
|
|
161
|
+
const forgetter = e.target.closest?.('[data-forget-device]');
|
|
162
|
+
if (forgetter) forgetDevice(forgetter.dataset.forgetDevice);
|
|
149
163
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
150
164
|
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
151
165
|
if (e.target.closest?.('[data-restart-hub]')) restartHub();
|