@lovek4d/kickflip 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +151 -115
- package/README.md +23 -14
- package/SKILL.md +37 -37
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +186 -69
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +57 -38
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +156 -72
- package/board/public/app-core.js +2 -4
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +11 -14
- package/board/public/app.html +2 -1
- package/board/public/app.js +53 -83
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +5 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +20 -6
- package/board/public/sidebar.css +21 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +155 -62
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +176 -71
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +26 -23
- package/board/public/ui-sidebar.js +46 -22
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +17 -11
- package/board/public/ui.js +80 -12
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +101 -50
- package/board/public/view-settings.js +167 -49
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -12
- package/board/store.mjs +13 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -14
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -12
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/repos.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Log</title>
|
|
7
|
+
<link rel="stylesheet" href="colors.css">
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<!--header {"lead":"back","title":"Log","titleId":"ref","backHref":"./","controls":false}-->
|
|
12
|
+
<main>
|
|
13
|
+
<p class="muted" id="note"></p>
|
|
14
|
+
<pre class="log" id="log">Loading the log…</pre>
|
|
15
|
+
</main>
|
|
16
|
+
<style>
|
|
17
|
+
/* The tail of a builder's log (GET /api/log), newest at the bottom. */
|
|
18
|
+
pre.log { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
19
|
+
</style>
|
|
20
|
+
<script>
|
|
21
|
+
// Shows the tail of the log named by ?file=, last lines in view, and re-reads it every 3 seconds while it grows;
|
|
22
|
+
// the page stays where the reader scrolled to when they've left the bottom.
|
|
23
|
+
(function () {
|
|
24
|
+
const name = new URLSearchParams(location.search).get('file') || '';
|
|
25
|
+
const log = document.getElementById('log');
|
|
26
|
+
const note = document.getElementById('note');
|
|
27
|
+
document.getElementById('ref').textContent = name || 'Log';
|
|
28
|
+
document.title = name || 'Log';
|
|
29
|
+
let size = -1;
|
|
30
|
+
async function read() {
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch('api/log?file=' + encodeURIComponent(name), { cache: 'no-store' });
|
|
33
|
+
const body = await r.json();
|
|
34
|
+
if (!r.ok) { log.textContent = body.error || 'Could not load this log.'; return; }
|
|
35
|
+
if (body.size === size) return;
|
|
36
|
+
const atEnd = size === -1 || innerHeight + scrollY >= document.documentElement.scrollHeight - 40;
|
|
37
|
+
size = body.size;
|
|
38
|
+
log.textContent = body.text || 'The log is empty so far.';
|
|
39
|
+
note.textContent = body.truncated ? 'Showing the last 256 KiB.' : '';
|
|
40
|
+
if (atEnd) scrollTo(0, document.documentElement.scrollHeight);
|
|
41
|
+
} catch { /* the next read tries again */ }
|
|
42
|
+
}
|
|
43
|
+
read();
|
|
44
|
+
setInterval(read, 3000);
|
|
45
|
+
})();
|
|
46
|
+
</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
package/board/public/prompts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
|
|
2
|
-
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/
|
|
2
|
+
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
|
|
3
3
|
// Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
|
|
4
4
|
// since it covers every later call like it until the session ends. Several questions, or one taking several picks,
|
|
5
5
|
// send with Send once each has a pick.
|
|
@@ -8,13 +8,14 @@ globalThis.PromptAnswers = (function () {
|
|
|
8
8
|
// short message; after() runs once an answer has gone. → {state(p) for promptItem, shows(p)}
|
|
9
9
|
function wire(B, { list, paint, notify, after = () => {} }) {
|
|
10
10
|
const picks = new Map();
|
|
11
|
-
|
|
11
|
+
// Each prompt whose answer is out, to the button tapped (ui-prompts.js promptItem's sending).
|
|
12
|
+
const sending = new Map();
|
|
12
13
|
// Answered here, hidden until the view's next load drops them.
|
|
13
14
|
const answered = new Set();
|
|
14
15
|
const picksOf = (id) => { if (!picks.has(id)) picks.set(id, {}); return picks.get(id); };
|
|
15
16
|
|
|
16
|
-
async function send(p, body) {
|
|
17
|
-
sending.
|
|
17
|
+
async function send(p, body, tapped) {
|
|
18
|
+
sending.set(p.id, tapped);
|
|
18
19
|
paint();
|
|
19
20
|
try {
|
|
20
21
|
const r = await fetch(`/_hub/prompts/${encodeURIComponent(p.id)}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
@@ -40,23 +41,23 @@ globalThis.PromptAnswers = (function () {
|
|
|
40
41
|
const got = mine[i] || [];
|
|
41
42
|
const label = opt.dataset.promptOpt;
|
|
42
43
|
mine[i] = q.multiSelect ? (got.includes(label) ? got.filter((l) => l !== label) : [...got, label]) : [label];
|
|
43
|
-
if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine));
|
|
44
|
+
if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine), `${i}:${label}`);
|
|
44
45
|
else paint();
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
47
48
|
if (ev.target.closest('button[data-prompt-send]')) {
|
|
48
49
|
const body = BoardUI.promptAnswer(p, picksOf(p.id));
|
|
49
|
-
if (body) send(p, body);
|
|
50
|
+
if (body) send(p, body, 'send');
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
const choice = ev.target.closest('button[data-prompt-choice]')?.dataset.promptChoice;
|
|
53
54
|
if (!choice) return;
|
|
54
55
|
if (choice === 'always' && !(await BoardDialogs.confirm(`Always allow ${p.rule} for the rest of this session?`))) return;
|
|
55
|
-
send(p, { choice });
|
|
56
|
+
send(p, { choice }, choice);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
return {
|
|
59
|
-
state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.
|
|
60
|
+
state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.get(p.id) ?? null }),
|
|
60
61
|
shows: (p) => !answered.has(p.id),
|
|
61
62
|
};
|
|
62
63
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
|
|
2
|
-
// with board.mjs prototype add (docs/adr/
|
|
2
|
+
// with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
|
|
3
3
|
// public/prototypes/<topic>/<KEY>.js, is a module:
|
|
4
4
|
// export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
|
|
5
5
|
// render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
...(claude === null ? {} : { claude: reading(...[claude, 61].flat().slice(0, 2), 2 * 60_000, 3.4, 2.6) }),
|
|
40
40
|
...(codex === null ? {} : { codex: reading(...[codex, 40].flat().slice(0, 2), 6 * 60_000, 1.2, 4.1) }),
|
|
41
41
|
}),
|
|
42
|
-
sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: '
|
|
42
|
+
sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
|
|
43
43
|
hub: () => ({
|
|
44
44
|
usage: fake.usage(),
|
|
45
|
-
hub: { tone: 'ok', word: '
|
|
45
|
+
hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
|
|
46
46
|
sites: fake.sites(),
|
|
47
|
-
polling: { title: 'Checks every
|
|
48
|
-
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['
|
|
47
|
+
polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
|
|
48
|
+
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
|
|
49
49
|
['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
|
|
50
50
|
}),
|
|
51
51
|
items: () => [
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
function hub(data = {}) {
|
|
64
64
|
const d = { ...fake.hub(), header: true, ...data };
|
|
65
65
|
const { row, tile, nowRow, CHEVRON } = HubUI;
|
|
66
|
-
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">
|
|
66
|
+
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
|
|
67
67
|
const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
|
|
68
68
|
const usage = d.usageCard ?? HubUI.usageCard(d.usage);
|
|
69
69
|
const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The floating ‹ take › pill every prototype page switches its takes with (docs/adr/
|
|
1
|
+
// The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
|
|
2
2
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
3
3
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
4
4
|
// render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
|
|
9
9
|
// to switch its full-width frame without a reload (view-questions.js inPlace).
|
|
10
10
|
// With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
|
|
11
|
-
// (docs/adr/
|
|
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
12
|
// bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
|
|
13
13
|
// to the page's height.
|
|
14
14
|
(function () {
|
|
@@ -120,6 +120,8 @@
|
|
|
120
120
|
let take = startTake(keys, params.get('variant'), initial);
|
|
121
121
|
let question = null;
|
|
122
122
|
let topics = [];
|
|
123
|
+
// The board's cards, which name their own question topics (ui-questions.js cardTopics).
|
|
124
|
+
let cards = [];
|
|
123
125
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
|
|
124
126
|
// reload stays on the page; only the first load sends an already-picked question on to its card's thread.
|
|
125
127
|
let fromPush = params.get('from') === 'push';
|
|
@@ -154,8 +156,14 @@
|
|
|
154
156
|
let board = null;
|
|
155
157
|
try {
|
|
156
158
|
board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
topics = board.ownerTopics;
|
|
160
|
+
cards = board.items || [];
|
|
161
|
+
// A resolved question is left out of /api/board, and its topic's resolved questions hold it
|
|
162
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
163
|
+
const topic = topics.find((t) => t.questions.includes(qid));
|
|
164
|
+
question = board.owner.find((q) => q.id === qid)
|
|
165
|
+
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
166
|
+
|| null;
|
|
159
167
|
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
160
168
|
} catch { /* keep the last question shown */ }
|
|
161
169
|
if (fromPush && pickedChoice(question)) {
|
|
@@ -179,7 +187,7 @@
|
|
|
179
187
|
ui = await sharedQuestions(site);
|
|
180
188
|
pills = ui.topicRow(topics, answered.topic).pills;
|
|
181
189
|
const topic = topics.find((t) => t.id === answered.topic);
|
|
182
|
-
name = topic ? ui.topicName(topic) : '';
|
|
190
|
+
name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
|
|
183
191
|
await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
|
|
184
192
|
} catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
|
|
185
193
|
try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
<script src="ui.js"></script>
|
|
45
45
|
<script src="ui-links.js"></script>
|
|
46
46
|
<script src="ui-enums.js"></script>
|
|
47
|
+
<script src="ui-phase-bars.js"></script>
|
|
47
48
|
<script src="ui-cards.js"></script>
|
|
48
49
|
<script src="ui-page-header.js"></script>
|
|
49
50
|
<script src="ui-sidebar.js"></script>
|
package/board/public/router.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The app shell's router (docs/adr/
|
|
1
|
+
// The app shell's router (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): every URL on the hub names a view, and a view
|
|
2
2
|
// names its URL, so a push link, a reload and a shared link all land on the same view. viewOf and urlOf are pure;
|
|
3
3
|
// ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
|
|
4
4
|
// A view is {kind, …}:
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// questions {route, id} /<route>/questions.html#<id>
|
|
9
9
|
// notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
|
|
10
10
|
// redirects there
|
|
11
|
-
// hub {hash
|
|
12
|
-
//
|
|
11
|
+
// hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
|
|
12
|
+
// ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
|
|
13
|
+
// and the old Polling page, /_hub/settings/polling, opens Quiet hours
|
|
13
14
|
// frame {route, url} any other page under a route, a folder or an .html file: other sites, prototypes and
|
|
14
15
|
// every other board page, shown in a frame
|
|
15
16
|
// Any other URL, such as an image or another origin, is no view (null) and loads as itself.
|
|
@@ -29,8 +30,8 @@ globalThis.AppRouter = (function () {
|
|
|
29
30
|
const p = u.pathname;
|
|
30
31
|
if (p === '/') return { kind: 'start' };
|
|
31
32
|
if (['/_hub/', '/_hub/settings', '/_hub/settings/polling'].includes(p)) {
|
|
32
|
-
const hash = p === '/_hub/settings/polling' ? '#
|
|
33
|
-
return { kind: 'hub', ...(hash ? { hash } : {})
|
|
33
|
+
const hash = p === '/_hub/settings/polling' ? '#quiet' : u.hash || (p === '/_hub/settings' ? '#notifications' : '');
|
|
34
|
+
return { kind: 'hub', ...(hash ? { hash } : {}) };
|
|
34
35
|
}
|
|
35
36
|
const m = /^\/([^/]+)\/(.*)$/.exec(p);
|
|
36
37
|
let route = null;
|
|
@@ -60,7 +61,7 @@ globalThis.AppRouter = (function () {
|
|
|
60
61
|
case 'settings': return `${r}settings.html`;
|
|
61
62
|
case 'questions': return `${r}questions.html${view.id ? `#${encodeURIComponent(view.id)}` : ''}`;
|
|
62
63
|
case 'notes': return `${r}notes.html${view.wave ? `?wave=${encodeURIComponent(view.wave)}` : view.item ? `?item=${encodeURIComponent(view.item)}` : ''}`;
|
|
63
|
-
case 'hub': return `/_hub/${view.
|
|
64
|
+
case 'hub': return `/_hub/${view.hash || ''}`;
|
|
64
65
|
default: return view.url;
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -77,7 +78,7 @@ globalThis.AppRouter = (function () {
|
|
|
77
78
|
return { href: u.href, pushed };
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
// The page tree (docs/adr/
|
|
81
|
+
// The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
|
|
81
82
|
// to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
|
|
82
83
|
function questionOf(view, boards) {
|
|
83
84
|
if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The rules editor on Board Settings and on the Hub page
|
|
2
|
-
// (docs/adr/
|
|
2
|
+
// (docs/adr/2026-10-29-1639-rules-live-on-settings-at-hub-and-board-level.md). Edits stay on the page until Save, as the
|
|
3
3
|
// reviewers list does; Save hands {rules: [{id?, text}], base: [the ids it loaded]} to the page's save(), whose
|
|
4
4
|
// server merges it (hub/rules.mjs applyRules). It brings its own CSS, colours from colors.css only, and takes the page's
|
|
5
5
|
// button classes. rows, payload, dirty and render are pure, for ui.test.mjs; mount touches the DOM.
|
|
@@ -110,10 +110,9 @@
|
|
|
110
110
|
addBox.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); add(); } });
|
|
111
111
|
addBox.addEventListener('input', mark);
|
|
112
112
|
saveBtn.addEventListener('click', async () => {
|
|
113
|
-
|
|
114
|
-
status.textContent = 'Saving…';
|
|
113
|
+
status.textContent = '';
|
|
115
114
|
try {
|
|
116
|
-
const r = await opts.save(payload(list));
|
|
115
|
+
const r = await BoardUI.waitOn(saveBtn, opts.save(payload(list)));
|
|
117
116
|
list = rows(r.rules);
|
|
118
117
|
editing = null;
|
|
119
118
|
status.textContent = r.message || 'Saved.';
|
package/board/public/shell.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The app shell (app.html; docs/adr/
|
|
1
|
+
// The app shell (app.html; docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md): the sidebar, mounted once, beside one view at a
|
|
2
2
|
// time. A board's view is BoardView (app.js); Waiting on you and Notes are AppViews.questions and .notes,
|
|
3
3
|
// the Hub page AppViews.hub with its settings modals, and every other page AppViews.frame. Board Settings mounts over the board
|
|
4
4
|
// as a modal. Each page is {className, title(view, {siteTitle}), markup(view, {siteTitle}), create(root,
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
|
|
8
8
|
// (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
|
|
9
9
|
// leaves is unmounted; one left fresh whose stream sent no change since paints live when it comes back, and any other
|
|
10
|
-
// paints dimmed from its last visit (docs/adr/
|
|
10
|
+
// paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
|
|
11
11
|
// A push tap reaches it from hub/sw.js as {open: url}, and a page framed inside it hands it a view's URL through
|
|
12
12
|
// AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
|
|
13
|
+
// AppShell.reload() reloads the app past the shell the service worker keeps.
|
|
13
14
|
window.AppShell = (function () {
|
|
14
15
|
const LAST_KEY = 'hub:lastBoard';
|
|
15
16
|
const DATA_KEY = 'hub:sidebar';
|
|
@@ -111,7 +112,20 @@ window.AppShell = (function () {
|
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
// A
|
|
115
|
+
// A tap, key or scroll on any page tells the hub the owner is here (hub/presence.mjs ownerTracker), at most once a
|
|
116
|
+
// minute while present. While away the first one goes at once and the moon goes from this page at the same moment
|
|
117
|
+
// (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
118
|
+
let beatAt = 0;
|
|
119
|
+
const beat = () => {
|
|
120
|
+
const away = Boolean(data?.away);
|
|
121
|
+
if (!away && Date.now() - beatAt < 60_000) return;
|
|
122
|
+
beatAt = Date.now();
|
|
123
|
+
if (away) { data = { ...data, away: false }; current?.v?.B?.renderStatus?.(); }
|
|
124
|
+
fetch('/_hub/presence', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => { beatAt = 0; });
|
|
125
|
+
};
|
|
126
|
+
for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
|
|
127
|
+
|
|
128
|
+
// A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
|
|
115
129
|
// rather than waiting for the stream's next one.
|
|
116
130
|
async function refreshSidebar() {
|
|
117
131
|
sidebar.checkDevice();
|
|
@@ -175,7 +189,7 @@ window.AppShell = (function () {
|
|
|
175
189
|
if (board) {
|
|
176
190
|
const live = streamUp ? left.get(route) || null : null;
|
|
177
191
|
left.delete(route);
|
|
178
|
-
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause });
|
|
192
|
+
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
|
|
179
193
|
} else {
|
|
180
194
|
v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
|
|
181
195
|
reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
|
|
@@ -254,7 +268,7 @@ window.AppShell = (function () {
|
|
|
254
268
|
}
|
|
255
269
|
|
|
256
270
|
// `/` opens this device's last board, else the board the hub picks (sidebar data's start), else the hub's page
|
|
257
|
-
// (docs/adr/
|
|
271
|
+
// (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
258
272
|
let first = AppRouter.viewOf(location.href, { boards: boards() });
|
|
259
273
|
if (first?.kind === 'start') {
|
|
260
274
|
let last = null;
|
|
@@ -294,5 +308,5 @@ window.AppShell = (function () {
|
|
|
294
308
|
});
|
|
295
309
|
}
|
|
296
310
|
|
|
297
|
-
return { start, open, codeSeen };
|
|
311
|
+
return { start, open, codeSeen, reload };
|
|
298
312
|
})();
|
package/board/public/sidebar.css
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
.sidebar .sb-update-row:disabled { opacity: 1; cursor: default; }
|
|
37
37
|
.sidebar .sb-go { flex: none; padding: 5px 10px; border-radius: 7px; background: var(--accent); color: var(--on-accent); font-size: 13px; font-weight: 600; }
|
|
38
38
|
.sidebar .sb-update-row:disabled .sb-go { background: transparent; color: var(--muted); padding-right: 0; font-weight: 400; }
|
|
39
|
+
/* While Reload or Upgrade runs, its pill keeps its blue with a spinner in place of its word (style.css, ui.js BUSY). */
|
|
40
|
+
.sidebar .sb-update-row[aria-busy="true"] .sb-go { background: var(--accent); color: var(--on-accent); padding-right: 10px; font-weight: 600; }
|
|
39
41
|
html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
40
42
|
html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
41
43
|
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
|
@@ -48,6 +50,21 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
48
50
|
.sidebar .sb-new { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; }
|
|
49
51
|
.sidebar .sb-starting { color: var(--muted); }
|
|
50
52
|
.sidebar .sb-starting:hover, .sidebar .sb-starting:active { background: transparent; }
|
|
53
|
+
/* A starting board's steps on the board's phase bars, spread evenly across the row, its step and seconds below
|
|
54
|
+
(docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). */
|
|
55
|
+
.sidebar .sb-steps { display: flex; margin: 6px 0 4px; }
|
|
56
|
+
.sidebar .sb-steps .phase-bars { flex: 1 1 auto; min-width: 0; display: flex; gap: 3px; }
|
|
57
|
+
.sidebar .sb-steps .phase-bars i { flex: 1 1 0; width: auto; min-width: 0; }
|
|
58
|
+
.sidebar .sb-step { font-variant-numeric: tabular-nums; }
|
|
59
|
+
/* At join the board's row fades in, ringed for a moment (sidebar.js). */
|
|
60
|
+
.sidebar .sb-joined { animation: sb-join 2.5s ease-out both; }
|
|
61
|
+
@keyframes sb-join {
|
|
62
|
+
0% { opacity: 0; transform: translateY(4px); box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
63
|
+
20% { opacity: 1; transform: none; box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
64
|
+
80% { box-shadow: 0 0 0 1px var(--accent) inset; }
|
|
65
|
+
100% { box-shadow: none; }
|
|
66
|
+
}
|
|
67
|
+
@media (prefers-reduced-motion: reduce) { .sidebar .sb-joined { animation: none; } }
|
|
51
68
|
.sidebar .sb-failed { width: 100%; text-align: left; border: 0; background: transparent; font: inherit; color: var(--text); }
|
|
52
69
|
.sidebar .sb-failed .sb-title { color: var(--color-danger); }
|
|
53
70
|
.sidebar .sb-failed:active { background: var(--chip); }
|
|
@@ -143,7 +160,10 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
143
160
|
|
|
144
161
|
/* A site's status (hub/home.mjs statusLine): the lane chips wrap on their own line, and the status word goes below them. */
|
|
145
162
|
.sidebar .sb-status { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
|
|
146
|
-
.sidebar .sb-
|
|
163
|
+
.sidebar .sb-head { display: flex; align-items: baseline; gap: 8px; }
|
|
164
|
+
.sidebar .sb-head .sb-title { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
165
|
+
.sidebar .sb-ctx { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
166
|
+
.sidebar .sb-ctx.full { color: var(--color-danger); font-weight: 600; }
|
|
147
167
|
.sidebar .sb-ctx.stale { opacity: .6; }
|
|
148
168
|
.sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
149
169
|
.sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
|
package/board/public/sidebar.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The sidebar's behaviour on a page with <aside id="sidebar" class="sidebar"> and <div id="sidebarScrim"
|
|
2
|
-
// class="sidebar-scrim">, after ui-page-header.js and ui-sidebar.js (docs/adr/
|
|
2
|
+
// class="sidebar-scrim">, after ui-phase-bars.js, ui-page-header.js and ui-sidebar.js (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
3
3
|
// Sidebar.mount({here, data}) paints `data` or the last copy this device stored; the app shell hands it each new copy
|
|
4
4
|
// through setData from its one stream (shell.js). The page header's logo (a.hub) toggles the sidebar at every
|
|
5
5
|
// width, with what waits on the other sites in its accessible label. Below 900px the sidebar is a drawer: the logo or a swipe in from the
|
|
6
6
|
// left edge opens it, and the scrim, Escape, ✕, the drawer's own logo or a swipe back closes it. From 900px it is a
|
|
7
7
|
// rail, which its own logo collapses and the page's logo brings back; this device remembers that, and <html> carries
|
|
8
|
-
// sb-collapsed meanwhile (docs/adr/
|
|
8
|
+
// sb-collapsed meanwhile (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md).
|
|
9
9
|
// Switch mode: s pressed twice within a second, outside a text field, a contenteditable or an open dialog, shows each
|
|
10
10
|
// row's key (ui-sidebar.js switchKeys), opening the drawer or showing a collapsed rail until it ends; the next key opens
|
|
11
11
|
// that row through open(href). Escape, a click, a held modifier, typing in a field or 3 seconds with no key end it
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// → {setHere(route), setData(data), setUpdate(state), checkDevice(), close(), toggle(), keydown(e), pointerdown()}, toggle doing what the
|
|
17
17
|
// logo does, checkDevice reading this device's push subscription again, and keydown and pointerdown taking a framed
|
|
18
18
|
// page's events (view-frame.js), as the document's own
|
|
19
|
+
// A starting board's row (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md): its seconds
|
|
20
|
+
// tick in place each second, and a repaint sets its running bar's pulse from the clock, so a new copy never restarts
|
|
21
|
+
// it. When the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
|
|
19
22
|
(function () {
|
|
20
23
|
const DATA_KEY = 'hub:sidebar';
|
|
21
24
|
// app.html reads it before the first paint, so a collapsed rail never shows for a moment.
|
|
@@ -24,6 +27,9 @@
|
|
|
24
27
|
// A phone has no keys to press, so it never shows the shortcuts sheet.
|
|
25
28
|
const DESKTOP = '(hover: hover) and (pointer: fine)';
|
|
26
29
|
const TWICE_MS = 1000;
|
|
30
|
+
// style.css pulse-fill's period, and sidebar.css sb-join's length.
|
|
31
|
+
const PULSE_MS = 1400;
|
|
32
|
+
const JOIN_MS = 2500;
|
|
27
33
|
// paint() sets the bar's duration from this and its delay from switching.started.
|
|
28
34
|
const IDLE_MS = 3000;
|
|
29
35
|
const FIELD = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
|
|
@@ -46,7 +52,7 @@
|
|
|
46
52
|
const aside = document.getElementById('sidebar');
|
|
47
53
|
const scrim = document.getElementById('sidebarScrim');
|
|
48
54
|
// ui.js replaces the BoardUI global when it loads after this, so the renderers are held here.
|
|
49
|
-
const { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
|
|
55
|
+
const { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
|
|
50
56
|
const none = { setHere() {}, setData() {}, setUpdate() {}, checkDevice() {}, close() {}, toggle() {}, keydown() {}, pointerdown() {} };
|
|
51
57
|
if (!aside || !sidebar) return none;
|
|
52
58
|
if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
|
|
@@ -69,6 +75,30 @@
|
|
|
69
75
|
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
70
76
|
let switching = null;
|
|
71
77
|
let painted = false;
|
|
78
|
+
// joined: when each board that was starting joined, by route, through its fade. ticker: the seconds' interval while
|
|
79
|
+
// a start runs.
|
|
80
|
+
const joined = new Map();
|
|
81
|
+
let ticker = null;
|
|
82
|
+
const startsRunning = () => (data?.starting || []).filter((s) => !s.failed);
|
|
83
|
+
function tickSteps() {
|
|
84
|
+
for (const s of startsRunning()) {
|
|
85
|
+
const caption = aside.querySelector(`[data-starting="${s.route}"] .sb-step`);
|
|
86
|
+
if (caption) caption.textContent = startCaption(s);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function paintStarts() {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
for (const bar of aside.querySelectorAll('.sb-starting .phase-bars i.now')) bar.style.animationDelay = `-${now % PULSE_MS}ms`;
|
|
92
|
+
for (const [route, at] of joined) {
|
|
93
|
+
const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
|
|
94
|
+
if (!row) { joined.delete(route); continue; }
|
|
95
|
+
row.classList.add('sb-joined');
|
|
96
|
+
row.style.animationDelay = `-${now - at}ms`;
|
|
97
|
+
}
|
|
98
|
+
const running = startsRunning().length > 0;
|
|
99
|
+
if (running && !ticker) ticker = setInterval(tickSteps, 1000);
|
|
100
|
+
if (!running && ticker) { clearInterval(ticker); ticker = null; }
|
|
101
|
+
}
|
|
72
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
73
103
|
function paint() {
|
|
74
104
|
html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
|
|
@@ -90,11 +120,14 @@
|
|
|
90
120
|
updateHTML = row;
|
|
91
121
|
const list = aside.querySelector('.sb-scroll');
|
|
92
122
|
if (list) list.scrollTop = top ?? list.scrollHeight;
|
|
123
|
+
paintStarts();
|
|
93
124
|
painted = true;
|
|
94
125
|
logoCount();
|
|
95
126
|
}
|
|
96
127
|
function setData(next) {
|
|
97
128
|
if (!next) return;
|
|
129
|
+
const starting = new Set(startsRunning().map((s) => s.route));
|
|
130
|
+
for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
|
|
98
131
|
data = next;
|
|
99
132
|
write(DATA_KEY, JSON.stringify(data));
|
|
100
133
|
paint();
|
|
@@ -145,7 +178,7 @@
|
|
|
145
178
|
const response = await fetch('/_hub/new-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
|
|
146
179
|
if (!response.ok) throw new Error(await response.text());
|
|
147
180
|
const { route } = await response.json();
|
|
148
|
-
setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone,
|
|
181
|
+
setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone, step: selected.cloned ? 'starting' : 'cloning', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false }] });
|
|
149
182
|
if (rail.matches) collapse(false); else open();
|
|
150
183
|
aside.querySelector(`[data-starting="${route}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
151
184
|
refresh().catch(() => {});
|
|
@@ -165,25 +198,24 @@
|
|
|
165
198
|
const failed = e.target.closest?.('[data-failed-start]');
|
|
166
199
|
if (failed) openFailed(failed.dataset.failedStart);
|
|
167
200
|
});
|
|
168
|
-
// Upgrade asks the hub to run kickflip update (docs/adr/
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
setData({ ...data, upgrade: next });
|
|
201
|
+
// Upgrade asks the hub to run kickflip update (docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). The row
|
|
202
|
+
// waits (ui.js waitOn) until the hub answers with what it did, then while that update waits or runs (ui-sidebar.js
|
|
203
|
+
// updateRow), and a refusal shows as a failed update.
|
|
204
|
+
function upgrade(button) {
|
|
205
|
+
BoardUI.waitOn(button, (async () => {
|
|
206
|
+
let next;
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
209
|
+
const body = await response.json().catch(() => ({}));
|
|
210
|
+
next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
|
|
211
|
+
} catch {
|
|
212
|
+
next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
|
|
213
|
+
}
|
|
214
|
+
setData({ ...data, upgrade: next });
|
|
215
|
+
})());
|
|
184
216
|
}
|
|
185
|
-
// A failed start's sheet (docs/adr/
|
|
186
|
-
// record; Retry starts it again, and its row reads
|
|
217
|
+
// A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
|
|
218
|
+
// record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
|
|
187
219
|
function openFailed(route) {
|
|
188
220
|
const s = (data?.starting || []).find((x) => x.route === route && x.failed);
|
|
189
221
|
if (!s || document.getElementById('failedStartSheet')) return;
|
|
@@ -195,13 +227,13 @@
|
|
|
195
227
|
el.innerHTML = failedStart(s, { home: data.home });
|
|
196
228
|
let sending = false;
|
|
197
229
|
const done = () => { if (!sending) el.close(); };
|
|
198
|
-
|
|
230
|
+
// The tapped button waits (ui.js waitOn), the other disabled, until the hub answers.
|
|
231
|
+
async function act(button, request, after) {
|
|
199
232
|
sending = true;
|
|
200
|
-
el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = true; });
|
|
201
233
|
const refused = el.querySelector('.fs-refused');
|
|
202
234
|
refused.hidden = true;
|
|
203
235
|
try {
|
|
204
|
-
const response = await fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options);
|
|
236
|
+
const response = await BoardUI.waitOn(button, fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options), { group: el.querySelectorAll('.fs-actions button') });
|
|
205
237
|
if (!response.ok) throw new Error(await response.text());
|
|
206
238
|
after();
|
|
207
239
|
sending = false;
|
|
@@ -209,7 +241,6 @@
|
|
|
209
241
|
refresh().catch(() => {});
|
|
210
242
|
} catch (e) {
|
|
211
243
|
sending = false;
|
|
212
|
-
el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = false; });
|
|
213
244
|
refused.textContent = e.message;
|
|
214
245
|
refused.hidden = false;
|
|
215
246
|
}
|
|
@@ -217,12 +248,14 @@
|
|
|
217
248
|
el.addEventListener('click', (e) => {
|
|
218
249
|
if (sending) return;
|
|
219
250
|
if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
act({
|
|
251
|
+
const dismiss = e.target.closest('[data-failed-dismiss]');
|
|
252
|
+
const retry = e.target.closest('[data-failed-retry]');
|
|
253
|
+
if (dismiss) {
|
|
254
|
+
act(dismiss, { options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
|
|
255
|
+
} else if (retry) {
|
|
256
|
+
act(retry, { path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
|
|
224
257
|
const { error, ...rest } = s;
|
|
225
|
-
const again = { ...rest,
|
|
258
|
+
const again = { ...rest, step: 'starting', stepAt: new Date().toISOString(), askedAt: new Date().toISOString(), failed: false };
|
|
226
259
|
setData({ ...data, starting: data.starting.map((x) => (x.route === route ? again : x)) });
|
|
227
260
|
});
|
|
228
261
|
}
|