@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
package/board/public/ui.js
CHANGED
|
@@ -49,16 +49,18 @@
|
|
|
49
49
|
}
|
|
50
50
|
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>';
|
|
51
51
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
52
|
+
// A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
|
|
53
|
+
const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
52
54
|
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>`;
|
|
53
55
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
54
56
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
55
57
|
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"/>');
|
|
56
58
|
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"/>');
|
|
57
59
|
const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
|
|
58
|
-
// Asks Claude for throwaway prototypes of one item (docs/adr/
|
|
59
|
-
function protoButton(id, state = null, { label = 'Ask for prototypes'
|
|
60
|
-
if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made"
|
|
61
|
-
return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}"
|
|
60
|
+
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
|
|
61
|
+
function protoButton(id, state = null, { label = 'Ask for prototypes' } = {}) {
|
|
62
|
+
if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
|
|
63
|
+
return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
62
64
|
}
|
|
63
65
|
// latestBump is the data.id of the newest ask-bump event.
|
|
64
66
|
const protoNotice = (eventId, latestBump) => (latestBump === eventId
|
|
@@ -71,15 +73,15 @@
|
|
|
71
73
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
72
74
|
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
|
|
73
75
|
// bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
|
|
74
|
-
// it is filled orange, card-unbump {key} (docs/adr/
|
|
75
|
-
// docs/adr/
|
|
76
|
+
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
77
|
+
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
76
78
|
const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
77
79
|
const cardBumpButton = (key, bumped) => (bumped
|
|
78
80
|
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
79
81
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
80
|
-
const workingLine = '<span class="working-dot" aria-hidden="true"></span>
|
|
82
|
+
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
81
83
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
82
|
-
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
|
|
84
|
+
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
83
85
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
84
86
|
if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
|
|
85
87
|
const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
@@ -212,21 +214,87 @@
|
|
|
212
214
|
}
|
|
213
215
|
lastHtml.set(el, html);
|
|
214
216
|
if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
|
|
217
|
+
for (const w of waits) { const b = waitingButton(w); if (b && !b.classList.contains(w.mark)) markWaiting(b, w); }
|
|
215
218
|
return true;
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
// Closes <details> d at once: its toggle event comes a task later, and a setHTML before it would reopen it.
|
|
222
|
+
function closeDetails(d) {
|
|
223
|
+
openDetails.delete(detailsKey(d));
|
|
224
|
+
d.open = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A tap that saves something waits for the server (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md,
|
|
228
|
+
// docs/adr/2026-11-03-1915-every-pending-control-waits-in-one-style.md): waitOn(control, promise, {group}) disables
|
|
229
|
+
// control until promise settles and marks it tap-waiting and aria-busy. style.css button[aria-busy="true"] keeps a
|
|
230
|
+
// button's colours and width and shows a spinner in its text colour in place of its content, or pulses a bump icon
|
|
231
|
+
// orange. A toggle, select or field keeps its face, with a spinner beside it: after its caption in a .tk-field, else
|
|
232
|
+
// just before it. group: the other choices of the same setting, disabled (tap-blocked) with no spinner meanwhile.
|
|
233
|
+
// A repaint meanwhile (setHTML) patches each back to its markup, so setHTML marks it again, found by its id and data
|
|
234
|
+
// attributes when the repaint replaced it. Settling restores the disabled and aria-busy its latest markup had. → promise
|
|
235
|
+
// Work that outlives its request (a restart, an update, a merge) draws its button with BUSY in its markup until the
|
|
236
|
+
// state the page draws from says the work is done.
|
|
237
|
+
const BUSY = ' disabled aria-busy="true"';
|
|
238
|
+
const waits = new Set();
|
|
239
|
+
function markWaiting(b, w) {
|
|
240
|
+
w.el = b;
|
|
241
|
+
w.disabled = b.disabled;
|
|
242
|
+
w.busy = b.getAttribute('aria-busy');
|
|
243
|
+
b.disabled = true;
|
|
244
|
+
b.classList.add(w.mark);
|
|
245
|
+
if (w.mark !== 'tap-waiting') return;
|
|
246
|
+
b.setAttribute('aria-busy', 'true');
|
|
247
|
+
if (b.tagName === 'BUTTON') return;
|
|
248
|
+
w.spin?.remove();
|
|
249
|
+
w.spin = document.createElement('span');
|
|
250
|
+
w.spin.setAttribute('class', 'try-spin tap-beside');
|
|
251
|
+
w.spin.setAttribute('aria-hidden', 'true');
|
|
252
|
+
const caption = b.closest('.tk-field')?.firstElementChild;
|
|
253
|
+
if (caption && caption !== b) caption.appendChild(w.spin);
|
|
254
|
+
else b.before(w.spin);
|
|
255
|
+
}
|
|
256
|
+
const waitingButton = (w) => (w.el.isConnected && w.el.matches(w.selector) ? w.el
|
|
257
|
+
: w.named ? document.body.querySelector(w.selector) : null);
|
|
258
|
+
function waitFor(el, mark) {
|
|
259
|
+
const named = [...el.attributes].filter(({ name }) => name === 'id' || name.startsWith('data-'));
|
|
260
|
+
const w = { mark, selector: el.tagName.toLowerCase() + named.map(({ name, value }) => (value ? `[${name}="${CSS.escape(value)}"]` : `[${name}]`)).join(''), named: named.length > 0 };
|
|
261
|
+
markWaiting(el, w);
|
|
262
|
+
waits.add(w);
|
|
263
|
+
return w;
|
|
264
|
+
}
|
|
265
|
+
function waitOn(control, promise, { group = [] } = {}) {
|
|
266
|
+
const mine = [waitFor(control, 'tap-waiting'), ...[...group].filter((el) => el !== control).map((el) => waitFor(el, 'tap-blocked'))];
|
|
267
|
+
const done = () => {
|
|
268
|
+
for (const w of mine) {
|
|
269
|
+
waits.delete(w);
|
|
270
|
+
w.spin?.remove();
|
|
271
|
+
// setHTML keeps w.el the node it last marked; one a plain innerHTML replaced is gone with its mark.
|
|
272
|
+
const b = w.el;
|
|
273
|
+
if (!b.classList.contains(w.mark)) continue;
|
|
274
|
+
b.classList.remove(w.mark);
|
|
275
|
+
if (w.mark === 'tap-waiting') { if (w.busy === null) b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', w.busy); }
|
|
276
|
+
b.disabled = w.disabled;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
promise.then(done, done);
|
|
280
|
+
return promise;
|
|
281
|
+
}
|
|
282
|
+
|
|
218
283
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
219
284
|
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
220
285
|
// then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
|
|
286
|
+
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
287
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
221
288
|
const RESTARTING = 'Board restarting…';
|
|
222
289
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
223
|
-
async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
290
|
+
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
224
291
|
let waited = 0;
|
|
225
292
|
let delay = 500;
|
|
226
293
|
for (;;) {
|
|
227
294
|
let failure;
|
|
228
295
|
try {
|
|
229
|
-
const r = await get(url, { cache: 'no-store' });
|
|
296
|
+
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
297
|
+
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
230
298
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
231
299
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
232
300
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
@@ -241,7 +309,7 @@
|
|
|
241
309
|
|
|
242
310
|
globalThis.BoardUI = {
|
|
243
311
|
LINK_ARROW,
|
|
244
|
-
esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
|
|
245
|
-
attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
312
|
+
esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
|
|
313
|
+
attachments, attachButton, dropZone, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
246
314
|
};
|
|
247
315
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Any other page on the hub as a view of the app shell (shell.js): another site, a prototype, or a board page that isn't
|
|
2
|
-
// a view of its own, in a same-origin frame beside the board header (docs/adr/
|
|
2
|
+
// a view of its own, in a same-origin frame beside the board header (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md). The
|
|
3
3
|
// header's title follows the page's, and Open alone ↗ loads the page by itself (?alone=1, hub/shell.mjs pageOf). A
|
|
4
4
|
// page that draws the board header itself, such as a prototype or another site's page, hides this one; its logo then
|
|
5
5
|
// toggles the sidebar as this one's would, and hides while the rail shows. The page's own navigations stay in the frame
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// HubSettings: summary rows below Hub’s health and status open centred settings modals (
|
|
2
|
-
// settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications,
|
|
3
|
-
// Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
|
|
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
|
+
// 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 does 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 () {
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
|
|
34
34
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
35
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
|
-
// Grey while off and orange while on, as the machine working (docs/adr/
|
|
36
|
+
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
37
37
|
const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
|
|
38
38
|
action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
|
|
39
39
|
|
|
40
|
-
// The on/off, snooze and weekend rows (docs/adr/
|
|
40
|
+
// The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
|
|
41
41
|
function notificationRows(d) {
|
|
42
42
|
const { tile } = HubUI;
|
|
43
43
|
const toggle = ({ icon, set, title, checked, attrs = '' }) => `<form class="row field" method="post" action="${ACTION}"><input type="hidden" name="set" value="${set}">${icon}<label class="body"><span class="text"><span class="title">${title}</span></span><input type="checkbox" name="value" value="on" class="toggle"${attrs}${checked ? ' checked' : ''}></label></form>`;
|
|
@@ -47,9 +47,20 @@
|
|
|
47
47
|
return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Quiet hours (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md): start and
|
|
51
|
+
// end, each saved once its picker closes, the overnight hold, the muted cadence hint and what the snoozes and wake read.
|
|
52
|
+
function quietRows(q) {
|
|
53
|
+
const { row, tile } = HubUI;
|
|
54
|
+
const time = (name, title, value) => `<form class="row field" method="post" action="/_hub/settings/quiet"><label class="body"><span class="text title">${title}</span><input type="time" name="${name}" value="${esc(value)}" required data-autosave></label></form>`;
|
|
55
|
+
const holdRow = `<form class="row field" method="post" action="/_hub/settings/quiet"><input type="hidden" name="set" value="hold">${tile('moon', 'var(--accent)')}<label class="body"><span class="text"><span class="title">Hold pushes</span><span class="sub wrap">${esc(q.hold.sub)}</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${q.hold.on ? ' checked' : ''}></label></form>`;
|
|
56
|
+
const reads = q.reads.map((r) => row({ title: esc(r.title), sub: `<span class="sub wrap">${esc(r.sub)}</span>` })).join('');
|
|
57
|
+
return `<div class="group">${time('start', 'Starts', q.start)}${time('end', 'Ends', q.end)}${holdRow}</div><p class="quiet-hint">${esc(q.hint)}</p>`
|
|
58
|
+
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
51
62
|
const sectionOf = (hash) => {
|
|
52
|
-
const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
|
|
63
|
+
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
53
64
|
return sections.some(([section]) => section === id) ? id : null;
|
|
54
65
|
};
|
|
55
66
|
const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
|
|
@@ -65,7 +76,6 @@
|
|
|
65
76
|
let settingsURL = '/_hub/';
|
|
66
77
|
let returnFocus = null;
|
|
67
78
|
let unfitModal = null;
|
|
68
|
-
let polling = null;
|
|
69
79
|
let flash = null;
|
|
70
80
|
let rules = null;
|
|
71
81
|
let rulesJSON = '';
|
|
@@ -78,6 +88,8 @@
|
|
|
78
88
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
79
89
|
if (active === 'notifications') {
|
|
80
90
|
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
|
|
91
|
+
} else if (active === 'quiet') {
|
|
92
|
+
content.innerHTML = quietRows(last.quiet);
|
|
81
93
|
} else if (active === 'keep-awake') {
|
|
82
94
|
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
83
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>`;
|
|
@@ -89,7 +101,7 @@
|
|
|
89
101
|
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
90
102
|
}
|
|
91
103
|
} else if (active === 'closed') {
|
|
92
|
-
content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening ?
|
|
104
|
+
content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening === b.route ? BoardUI.BUSY : reopening ? ' disabled' : ''}>Reopen</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
|
|
93
105
|
}
|
|
94
106
|
const input = B.$('[data-dur-at]');
|
|
95
107
|
if (input) customBounds(input, true);
|
|
@@ -97,7 +109,7 @@
|
|
|
97
109
|
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
98
110
|
const summaries = {
|
|
99
111
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
100
|
-
|
|
112
|
+
quiet: last.quiet.summary,
|
|
101
113
|
'keep-awake': last.awake.summary,
|
|
102
114
|
linear: last.linear?.summary || 'No key',
|
|
103
115
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
@@ -138,7 +150,6 @@
|
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
function closeModal(restore = true) {
|
|
141
|
-
polling?.unmount(); polling = null;
|
|
142
153
|
unfitModal?.(); unfitModal = null;
|
|
143
154
|
modal.close();
|
|
144
155
|
linearKey?.destroy(); linearKey = null;
|
|
@@ -168,13 +179,9 @@
|
|
|
168
179
|
modal.showModal();
|
|
169
180
|
B.$('.settings-body').scrollTop = 0;
|
|
170
181
|
B.$('#hubSettingsTitle').focus({ preventScroll: true });
|
|
171
|
-
if (id === 'polling') {
|
|
172
|
-
polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
|
|
173
|
-
polling.mount();
|
|
174
|
-
}
|
|
175
182
|
}
|
|
176
|
-
}
|
|
177
|
-
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {})
|
|
183
|
+
}
|
|
184
|
+
settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}) });
|
|
178
185
|
history.replaceState(history.state, '', settingsURL);
|
|
179
186
|
return true;
|
|
180
187
|
};
|
|
@@ -254,7 +261,7 @@
|
|
|
254
261
|
else on.checked = true;
|
|
255
262
|
});
|
|
256
263
|
const forms = HubKit.forms(B, {
|
|
257
|
-
accepts: (action) => action !== '/_hub/push/flush'
|
|
264
|
+
accepts: (action) => action !== '/_hub/push/flush',
|
|
258
265
|
answer(out) {
|
|
259
266
|
last = out;
|
|
260
267
|
flash = null;
|
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
|
|
2
2
|
// prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
|
|
3
|
-
// polling status, then hub settings summary rows (
|
|
3
|
+
// polling status, then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). A board's row can carry Restart (hub/restart-board.mjs), and
|
|
4
|
+
// the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
|
|
4
5
|
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
5
6
|
(function () {
|
|
6
7
|
// The last data, which a return to the view paints at once.
|
|
@@ -11,14 +12,14 @@
|
|
|
11
12
|
return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const RESTARTING = '<span class="st wait">
|
|
15
|
+
const RESTARTING = '<span class="st wait">restarting</span> · stopping its session, then starting it again';
|
|
15
16
|
|
|
16
17
|
// A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
|
|
17
|
-
// button can't sit inside a link.
|
|
18
|
+
// button can't sit inside a link. Restart waits (ui.js BUSY) until its POST answers, once the board is back up.
|
|
18
19
|
function restartRow(s, sub, busy) {
|
|
19
20
|
const href = esc(s.href);
|
|
20
21
|
return `<div class="row site-row"><a class="site-link" href="${href}">${s.icon}<span class="text"><span class="title">${esc(s.title)}</span><span class="sub wrap">${sub}</span></span></a>`
|
|
21
|
-
+ `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ?
|
|
22
|
+
+ `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? BoardUI.BUSY : ''}>Restart</button>`
|
|
22
23
|
+ `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
|
|
40
41
|
? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
|
|
41
42
|
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
|
|
42
|
-
sub: it.ready ? `<span class="st ok">
|
|
43
|
-
: `<span class="st wait">
|
|
43
|
+
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
|
|
44
|
+
: `<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('');
|
|
44
45
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
45
46
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
46
47
|
`;
|
|
@@ -52,11 +53,20 @@
|
|
|
52
53
|
return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// Restart hub, while the hub runs older code than its files (hub/views.mjs hubView's hub.restart). It waits (ui.js BUSY)
|
|
57
|
+
// from the tap until the hub answers with its new code and the page reloads.
|
|
58
|
+
const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
|
|
59
|
+
|
|
60
|
+
function html(d, { flash, prompts, restarting, restartingHub }) {
|
|
56
61
|
const { row, tile, nowRow } = HubUI;
|
|
57
62
|
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
63
|
+
// The status word is plain text, except while the hub runs older code: then it opens the Restart hub confirm and waits like that button.
|
|
64
|
+
const word = d.hub.restart
|
|
65
|
+
? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
|
|
66
|
+
: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
|
|
58
67
|
return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
|
|
59
|
-
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub:
|
|
68
|
+
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
|
|
69
|
+
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
60
70
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
61
71
|
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
|
|
62
72
|
<h2 class="label">Polling status</h2>
|
|
@@ -102,19 +112,50 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
102
112
|
restarting.delete(route);
|
|
103
113
|
if (B.alive()) { paint(); load(); }
|
|
104
114
|
}
|
|
115
|
+
// Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
|
|
116
|
+
// 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.
|
|
117
|
+
let restartingHub = false;
|
|
118
|
+
const HUB_WAIT = 120;
|
|
119
|
+
async function restartHub() {
|
|
120
|
+
if (restartingHub || !(await BoardDialogs.confirm('This restarts the hub on the host so it runs the code in its files. Its pages stop answering until it’s back, then this page reloads. Restart the hub?'))) return;
|
|
121
|
+
restartingHub = true;
|
|
122
|
+
flash = null;
|
|
123
|
+
paint();
|
|
124
|
+
let out = null, before = null;
|
|
125
|
+
try {
|
|
126
|
+
const r = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
|
|
127
|
+
out = await r.json().catch(() => null);
|
|
128
|
+
if (!r.ok || !out) throw new Error();
|
|
129
|
+
before = r.headers.get('x-hub-code');
|
|
130
|
+
} catch {
|
|
131
|
+
restartingHub = false;
|
|
132
|
+
flash = { text: out?.error || 'Could not restart the hub. Is the host awake?', error: true };
|
|
133
|
+
if (B.alive()) paint();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i < HUB_WAIT && B.alive(); i++) {
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
138
|
+
try {
|
|
139
|
+
const r = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
|
|
140
|
+
const code = r.headers.get('x-hub-code');
|
|
141
|
+
if (r.ok && code && code !== before) return AppShell.reload();
|
|
142
|
+
} catch { /* the hub is down while it restarts */ }
|
|
143
|
+
}
|
|
144
|
+
restartingHub = false;
|
|
145
|
+
flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
|
|
146
|
+
if (B.alive()) paint();
|
|
147
|
+
}
|
|
105
148
|
B.on(main, 'click', (e) => {
|
|
106
149
|
const opener = e.target.closest?.('[data-settings-open]');
|
|
107
150
|
if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
|
|
108
|
-
|
|
109
|
-
const jump = e.target.closest?.('[data-jump]');
|
|
110
|
-
if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
151
|
+
if (e.target.closest?.('[data-restart-hub]')) restartHub();
|
|
111
152
|
const restarter = e.target.closest?.('[data-restart]');
|
|
112
153
|
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
113
154
|
});
|
|
114
155
|
const foot = B.$('.hub-foot');
|
|
115
156
|
const paint = () => {
|
|
116
157
|
if (!last) return;
|
|
117
|
-
main.innerHTML = html(last, { flash, prompts, restarting });
|
|
158
|
+
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
118
159
|
foot.innerHTML = footer(last);
|
|
119
160
|
};
|
|
120
161
|
const prompts = PromptAnswers.wire(B, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
|
|
2
|
-
// (docs/adr/
|
|
2
|
+
// (docs/adr/2026-10-29-1816-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
|
|
3
3
|
// ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
|
|
4
4
|
(globalThis.AppViews ||= {}).notes = {
|
|
5
5
|
className: 'view-notes',
|