@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.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 +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/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/public/shell.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// view in place through router.js; a link to anything that isn't a page loads it. Leaving a view whose root holds
|
|
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
|
-
// leaves is unmounted
|
|
10
|
-
//
|
|
9
|
+
// leaves is unmounted, and paints live from its saved copy when it comes back
|
|
10
|
+
// (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.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
13
|
// AppShell.reload() reloads the app past the shell the service worker keeps.
|
|
@@ -44,6 +44,9 @@ window.AppShell = (function () {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
async function start() {
|
|
47
|
+
// The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
|
|
48
|
+
// offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
|
|
49
|
+
history.scrollRestoration = 'manual';
|
|
47
50
|
// A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
|
|
48
51
|
if (outer()?.open(location.href, { frames: false })) return;
|
|
49
52
|
// The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
|
|
@@ -57,6 +60,20 @@ window.AppShell = (function () {
|
|
|
57
60
|
// A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
|
|
58
61
|
bootCode = data?.code || null;
|
|
59
62
|
data ||= read(DATA_KEY);
|
|
63
|
+
// A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
|
|
64
|
+
// the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
|
|
65
|
+
// (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
|
|
66
|
+
// which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
|
|
67
|
+
const shared = data?.shared || null;
|
|
68
|
+
document.documentElement.classList.toggle('shared-view', Boolean(shared));
|
|
69
|
+
const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
|
|
70
|
+
function levelHere() {
|
|
71
|
+
if (!shared || !current) return;
|
|
72
|
+
const here = sharedHere(current.route);
|
|
73
|
+
document.documentElement.classList.toggle('shared-full', here.full);
|
|
74
|
+
const strip = current.root.querySelector('.view-only-strip');
|
|
75
|
+
if (strip) strip.innerHTML = BoardView.sharedStrip(here);
|
|
76
|
+
}
|
|
60
77
|
sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
|
|
61
78
|
const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
|
|
62
79
|
const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
|
|
@@ -68,13 +85,8 @@ window.AppShell = (function () {
|
|
|
68
85
|
listeners.get(route).add(fn);
|
|
69
86
|
return () => listeners.get(route)?.delete(fn);
|
|
70
87
|
};
|
|
71
|
-
// route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
|
|
72
|
-
// a dropped stream may have missed a change, so its return forgets them all.
|
|
73
|
-
const left = new Map();
|
|
74
|
-
let streamUp = false;
|
|
75
88
|
let current = null;
|
|
76
89
|
function changed(route) {
|
|
77
|
-
left.delete(route);
|
|
78
90
|
for (const fn of listeners.get(route) || []) fn();
|
|
79
91
|
}
|
|
80
92
|
|
|
@@ -90,8 +102,7 @@ window.AppShell = (function () {
|
|
|
90
102
|
const cur = es = new EventSource('/_hub/stream');
|
|
91
103
|
cur.addEventListener('open', () => {
|
|
92
104
|
tries = 0;
|
|
93
|
-
|
|
94
|
-
if (opened) { left.clear(); if (current) changed(current.route); }
|
|
105
|
+
if (opened && current) changed(current.route);
|
|
95
106
|
opened = true;
|
|
96
107
|
});
|
|
97
108
|
cur.addEventListener('change', (e) => {
|
|
@@ -103,10 +114,10 @@ window.AppShell = (function () {
|
|
|
103
114
|
try { data = JSON.parse(e.data); } catch { return; }
|
|
104
115
|
codeSeen(data.code);
|
|
105
116
|
sidebar.setData(data);
|
|
117
|
+
levelHere();
|
|
106
118
|
current?.v?.B?.renderStatus?.();
|
|
107
119
|
});
|
|
108
120
|
cur.addEventListener('error', () => {
|
|
109
|
-
streamUp = false;
|
|
110
121
|
if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
|
|
111
122
|
retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
|
|
112
123
|
});
|
|
@@ -116,7 +127,9 @@ window.AppShell = (function () {
|
|
|
116
127
|
// minute while present. While away the first one goes at once and the moon goes from this page at the same moment
|
|
117
128
|
// (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
118
129
|
let beatAt = 0;
|
|
130
|
+
// A shared person's taps are never the owner's.
|
|
119
131
|
const beat = () => {
|
|
132
|
+
if (shared) return;
|
|
120
133
|
const away = Boolean(data?.away);
|
|
121
134
|
if (!away && Date.now() - beatAt < 60_000) return;
|
|
122
135
|
beatAt = Date.now();
|
|
@@ -125,6 +138,14 @@ window.AppShell = (function () {
|
|
|
125
138
|
};
|
|
126
139
|
for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
|
|
127
140
|
|
|
141
|
+
// docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
|
|
142
|
+
// While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
|
|
143
|
+
// on the page moves.
|
|
144
|
+
document.addEventListener('focusin', (ev) => {
|
|
145
|
+
document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
|
|
146
|
+
});
|
|
147
|
+
document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
|
|
148
|
+
|
|
128
149
|
// A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
|
|
129
150
|
// rather than waiting for the stream's next one.
|
|
130
151
|
async function refreshSidebar() {
|
|
@@ -173,38 +194,28 @@ window.AppShell = (function () {
|
|
|
173
194
|
const board = view.kind === 'board';
|
|
174
195
|
const hub = route === undefined;
|
|
175
196
|
const siteTitle = hub ? 'Hub' : titleOf(route);
|
|
176
|
-
if (current)
|
|
177
|
-
const st = current.v.B.state;
|
|
178
|
-
const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
|
|
179
|
-
current.v.unmount();
|
|
180
|
-
if (keep) left.set(current.route, keep);
|
|
181
|
-
}
|
|
197
|
+
if (current) current.v.unmount();
|
|
182
198
|
const page = board ? null : AppViews[view.kind];
|
|
183
199
|
const root = document.createElement('div');
|
|
184
200
|
root.className = board ? 'view-board' : page.className;
|
|
185
|
-
root.innerHTML = board ? BoardView.markup({ title: siteTitle }) : page.markup(view, { siteTitle });
|
|
201
|
+
root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
|
|
186
202
|
if (current) current.root.replaceWith(root); else scrim.after(root);
|
|
187
203
|
const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
|
|
188
204
|
let v;
|
|
189
205
|
if (board) {
|
|
190
|
-
|
|
191
|
-
left.delete(route);
|
|
192
|
-
v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
|
|
206
|
+
v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
|
|
193
207
|
} else {
|
|
194
208
|
v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
|
|
195
209
|
reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
|
|
196
210
|
loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
|
|
197
211
|
}
|
|
198
212
|
current = { view, route, root, v };
|
|
213
|
+
levelHere();
|
|
199
214
|
document.title = board ? siteTitle : page.title(view, { siteTitle });
|
|
200
215
|
if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
|
|
201
216
|
sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
|
|
202
217
|
scrollTo(0, 0);
|
|
203
|
-
// Every other page of a board has the board's + and its sheet too; the hub's own pages aren't a board's.
|
|
204
|
-
const plus = !board && boards().has(route);
|
|
205
|
-
if (plus) root.insertAdjacentHTML('beforeend', BoardNewTask.markup());
|
|
206
218
|
v.mount();
|
|
207
|
-
if (plus) BoardNewTask.wire(v.B);
|
|
208
219
|
if (modal) showSettings();
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -230,8 +241,29 @@ window.AppShell = (function () {
|
|
|
230
241
|
// go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
|
|
231
242
|
// own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
|
|
232
243
|
let router = null;
|
|
244
|
+
const wide = matchMedia('(min-width: 900px)');
|
|
245
|
+
// Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
|
|
246
|
+
// from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
|
|
247
|
+
// hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
|
|
248
|
+
// or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
|
|
249
|
+
function focusPane(id, tries = 50) {
|
|
250
|
+
if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
|
|
251
|
+
setTimeout(() => focusPane(id, tries - 1), 100);
|
|
252
|
+
}
|
|
233
253
|
function go(view, href, how, from) {
|
|
234
254
|
if (!AppRouter.mounted(view)) return false;
|
|
255
|
+
if (view.kind === 'questions' && wide.matches) {
|
|
256
|
+
const board = { kind: 'board', route: view.route, hash: '' };
|
|
257
|
+
const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
|
|
258
|
+
if (how === 'pop') history.replaceState(history.state, '', boardHref);
|
|
259
|
+
// On its own board it only opens the topic, so the URL, history and an open card stay as they are.
|
|
260
|
+
else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
|
|
261
|
+
focusPane(view.id);
|
|
262
|
+
sidebar.close();
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
|
|
266
|
+
}
|
|
235
267
|
if (view.kind === 'settings') {
|
|
236
268
|
if (current?.view.kind === 'board' && current.route === view.route) {
|
|
237
269
|
const here = AppRouter.viewOf(location.href, { boards: boards() });
|
|
@@ -283,7 +315,17 @@ window.AppShell = (function () {
|
|
|
283
315
|
}
|
|
284
316
|
if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
|
|
285
317
|
|
|
286
|
-
|
|
318
|
+
let pane = null;
|
|
319
|
+
if (first.kind === 'questions' && wide.matches) {
|
|
320
|
+
pane = { id: first.id };
|
|
321
|
+
first = { kind: 'board', route: first.route, hash: '' };
|
|
322
|
+
history.replaceState(history.state, '', AppRouter.urlOf(first));
|
|
323
|
+
}
|
|
324
|
+
router = AppRouter.listen({ boards, go, wide: () => wide.matches });
|
|
325
|
+
// Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
|
|
326
|
+
wide.addEventListener('change', () => {
|
|
327
|
+
if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
|
|
328
|
+
});
|
|
287
329
|
opener = (url, { replace = false, frames = true } = {}) => {
|
|
288
330
|
const view = AppRouter.viewOf(url, { boards: boards() });
|
|
289
331
|
if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
|
|
@@ -299,6 +341,7 @@ window.AppShell = (function () {
|
|
|
299
341
|
navigator.serviceWorker?.startMessages?.();
|
|
300
342
|
|
|
301
343
|
show(first);
|
|
344
|
+
if (pane) focusPane(pane.id);
|
|
302
345
|
openStream();
|
|
303
346
|
addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
|
|
304
347
|
addEventListener('pageshow', openStream);
|
|
@@ -307,7 +350,6 @@ window.AppShell = (function () {
|
|
|
307
350
|
addEventListener('pagehide', (e) => {
|
|
308
351
|
if (!e.persisted || !es) return;
|
|
309
352
|
clearTimeout(retry);
|
|
310
|
-
streamUp = false;
|
|
311
353
|
es.close();
|
|
312
354
|
});
|
|
313
355
|
}
|
package/board/public/sidebar.css
CHANGED
|
@@ -180,6 +180,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
|
|
|
180
180
|
.sidebar .t-building { --tone: var(--color-notice); }
|
|
181
181
|
.sidebar .t-ok { --tone: var(--color-success); }
|
|
182
182
|
.sidebar .t-wait { --tone: var(--color-notice); }
|
|
183
|
+
.sidebar .t-paused { --tone: var(--muted); }
|
|
183
184
|
|
|
184
185
|
@media (min-width: 900px) {
|
|
185
186
|
body.has-sidebar { padding-left: 272px; }
|
package/board/public/sidebar.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
2
|
+
// class="sidebar-scrim">, after ui-progress-bar.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
|
|
@@ -17,8 +17,8 @@
|
|
|
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
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
|
|
21
|
-
//
|
|
20
|
+
// tick in place each second, and its running bar beats on every pulse's one clock (ui-progress-bar.js onePulse). When
|
|
21
|
+
// the board joins, its row fades in, and a repaint during the fade carries it on from where it was.
|
|
22
22
|
(function () {
|
|
23
23
|
const DATA_KEY = 'hub:sidebar';
|
|
24
24
|
// app.html reads it before the first paint, so a collapsed rail never shows for a moment.
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
// A phone has no keys to press, so it never shows the shortcuts sheet.
|
|
28
28
|
const DESKTOP = '(hover: hover) and (pointer: fine)';
|
|
29
29
|
const TWICE_MS = 1000;
|
|
30
|
-
//
|
|
31
|
-
const PULSE_MS = 1400;
|
|
30
|
+
// sidebar.css sb-join's length.
|
|
32
31
|
const JOIN_MS = 2500;
|
|
33
32
|
// paint() sets the bar's duration from this and its delay from switching.started.
|
|
34
33
|
const IDLE_MS = 3000;
|
|
@@ -58,6 +57,8 @@
|
|
|
58
57
|
if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
|
|
59
58
|
let device = null;
|
|
60
59
|
let update = null;
|
|
60
|
+
// The hub's version when this page booted; the row names the version only once it differs.
|
|
61
|
+
let bootVersion = data?.version ?? null;
|
|
61
62
|
let updateHTML = '';
|
|
62
63
|
const rail = matchMedia(RAIL);
|
|
63
64
|
const html = document.documentElement;
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
|
|
71
72
|
if (!logo) return;
|
|
72
73
|
const n = othersWaiting(data, here);
|
|
73
|
-
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' :
|
|
74
|
+
logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
74
75
|
}
|
|
75
76
|
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
76
77
|
let switching = null;
|
|
@@ -88,7 +89,6 @@
|
|
|
88
89
|
}
|
|
89
90
|
function paintStarts() {
|
|
90
91
|
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
92
|
for (const [route, at] of joined) {
|
|
93
93
|
const row = now - at < JOIN_MS && aside.querySelector(`a.sb-row[href="/${encodeURIComponent(route)}/"]`);
|
|
94
94
|
if (!row) { joined.delete(route); continue; }
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
}
|
|
102
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
103
103
|
function paint() {
|
|
104
|
-
html.classList.toggle('sb-update', Boolean(update
|
|
104
|
+
html.classList.toggle('sb-update', Boolean(update));
|
|
105
105
|
if (!data) return;
|
|
106
106
|
const scroll = aside.querySelector('.sb-scroll');
|
|
107
107
|
const top = painted ? scroll?.scrollTop : undefined;
|
|
@@ -113,11 +113,14 @@
|
|
|
113
113
|
bar.style.animationDelay = `${switching.started - performance.now()}ms`;
|
|
114
114
|
bar.style.animationDuration = `${IDLE_MS}ms`;
|
|
115
115
|
}
|
|
116
|
+
// A shared person's sidebar has no update row (ui-sidebar.js).
|
|
116
117
|
const fresh = aside.querySelector('.sb-update');
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
if (fresh) {
|
|
119
|
+
if (held) fresh.replaceWith(held);
|
|
120
|
+
const row = updateRow(update, data.version && data.version !== bootVersion ? data.version : null);
|
|
121
|
+
if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
|
|
122
|
+
updateHTML = row;
|
|
123
|
+
}
|
|
121
124
|
const list = aside.querySelector('.sb-scroll');
|
|
122
125
|
if (list) list.scrollTop = top ?? list.scrollHeight;
|
|
123
126
|
paintStarts();
|
|
@@ -128,6 +131,7 @@
|
|
|
128
131
|
if (!next) return;
|
|
129
132
|
const starting = new Set(startsRunning().map((s) => s.route));
|
|
130
133
|
for (const s of next.sites || []) if (starting.has(s.route)) joined.set(s.route, Date.now());
|
|
134
|
+
bootVersion ??= next.version ?? null;
|
|
131
135
|
data = next;
|
|
132
136
|
write(DATA_KEY, JSON.stringify(data));
|
|
133
137
|
paint();
|
|
@@ -192,28 +196,10 @@
|
|
|
192
196
|
if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
|
|
193
197
|
return;
|
|
194
198
|
}
|
|
195
|
-
const upgradeApp = e.target.closest?.('[data-app-upgrade]');
|
|
196
|
-
if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
|
|
197
199
|
if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
|
|
198
200
|
const failed = e.target.closest?.('[data-failed-start]');
|
|
199
201
|
if (failed) openFailed(failed.dataset.failedStart);
|
|
200
202
|
});
|
|
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
|
-
})());
|
|
216
|
-
}
|
|
217
203
|
// A failed start's sheet (docs/adr/2026-11-02-2006-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
|
|
218
204
|
// record; Retry starts it again, and its row reads starting… until the hub's next copy says otherwise.
|
|
219
205
|
function openFailed(route) {
|