@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
2
|
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const { esc, time, etaLine, replyBody, attachments,
|
|
4
|
+
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
6
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
7
7
|
function cardLink(link) {
|
|
@@ -15,52 +15,79 @@
|
|
|
15
15
|
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
16
16
|
// A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
|
|
17
17
|
// pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
|
|
18
|
-
const PICK_UP = { working:
|
|
18
|
+
const PICK_UP = { working: thinkingLine, waiting: () => 'Waiting for Claude…' };
|
|
19
19
|
const msgFooter = (m) => {
|
|
20
|
-
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
|
|
20
|
+
const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
|
|
21
21
|
return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
|
|
22
22
|
};
|
|
23
23
|
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
24
24
|
// when other messages came between them. A reply with no time follows its message.
|
|
25
25
|
const withReplies = (msgs) => {
|
|
26
|
+
// A folded submit's answers each reply, quoting their own block.
|
|
26
27
|
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
27
|
-
...(m.eventId &&
|
|
28
|
+
...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
|
|
28
29
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
29
|
-
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' :
|
|
30
|
+
return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
|
|
30
31
|
};
|
|
31
32
|
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
32
33
|
// files it adds to the message, under composer id edit:<event id>.
|
|
33
34
|
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
|
|
34
35
|
<div class="pending-files" data-files></div>
|
|
35
|
-
<div class="
|
|
36
|
-
|
|
37
|
-
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
36
|
+
<div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
|
|
37
|
+
const line = (m, links, edits) => (m.kind === 'reply'
|
|
38
38
|
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
39
|
-
: m.kind === 'ask-link' ? `<div class="msg link"
|
|
40
|
-
|
|
41
|
-
: m.kind === '
|
|
39
|
+
: m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
|
|
40
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
|
|
41
|
+
: m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
42
|
+
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
|
+
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
|
+
: m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
|
+
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
|
+
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
|
+
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
48
|
+
: m.folded ? foldedRun(m, links, edits)
|
|
42
49
|
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
43
|
-
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${
|
|
44
|
-
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${
|
|
50
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
51
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
45
52
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
46
|
-
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
53
|
+
<div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
47
54
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
48
|
-
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log
|
|
55
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
|
|
49
56
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
50
|
-
${m.eventId
|
|
57
|
+
${m.eventId ? msgFooter(m) : ''}
|
|
51
58
|
</div>`);
|
|
52
59
|
|
|
60
|
+
// One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
|
|
61
|
+
// closed details line at its started line's place: the agent, running with its newest status text or finished, and
|
|
62
|
+
// no line count, and ending with a log link beside the summary when the run has a log; expanding it shows the run's lines.
|
|
63
|
+
function foldedRun(m, links, edits) {
|
|
64
|
+
const statuses = m.folded.filter((f) => f.kind === 'status');
|
|
65
|
+
const running = !m.folded.some((f) => f.finished);
|
|
66
|
+
const newest = statuses.at(-1);
|
|
67
|
+
const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
|
|
68
|
+
const log = m.folded.find((f) => f.log)?.log;
|
|
69
|
+
const logLink = log ? ` <a class="fold-log" href="log.html?file=${encodeURIComponent(log)}">log</a>` : '';
|
|
70
|
+
return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${running ? '' : ' class="finished"'}>${statusMark('status lines')}${escText(m.agent)} · ${tail}${logLink}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}</details>`;
|
|
71
|
+
}
|
|
72
|
+
function foldStatus(lines) {
|
|
73
|
+
const runs = new Map();
|
|
74
|
+
for (const m of lines) if (m.run && (m.kind === 'status' || m.kind === 'agent')) runs.set(m.run, [...(runs.get(m.run) || []), m]);
|
|
75
|
+
return lines.flatMap((m) => {
|
|
76
|
+
const run = m.run && (m.kind === 'status' || m.kind === 'agent') ? runs.get(m.run) : null;
|
|
77
|
+
if (!run) return [m];
|
|
78
|
+
return m === run[0] ? [{ ...m, folded: run }] : [];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
const STEP = 3;
|
|
54
83
|
const UNREAD_CAP = 10;
|
|
55
|
-
|
|
84
|
+
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
85
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
86
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
56
87
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
57
88
|
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
58
89
|
// orchestrator's answer to a builder's ask never is.
|
|
59
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
60
|
-
const batchedAt = (lines, i) => {
|
|
61
|
-
const next = lines.slice(i + 1).find(isMessage);
|
|
62
|
-
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
63
|
-
};
|
|
90
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
64
91
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
65
92
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
66
93
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
@@ -79,21 +106,23 @@
|
|
|
79
106
|
// draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
|
|
80
107
|
function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
|
|
81
108
|
if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
|
|
82
|
-
const lines = withReplies(msgs);
|
|
109
|
+
const lines = foldStatus(withReplies(msgs));
|
|
83
110
|
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
84
111
|
const shown = lines.slice(start);
|
|
85
112
|
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
86
|
-
return (hidden ? moreButton(hidden) : '') + shown.map((m
|
|
113
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
|
|
87
114
|
}
|
|
88
115
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
116
|
+
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
117
|
+
// An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends ask-done or
|
|
118
|
+
// card-done (app-cards.js); the board applies it before the session follows up
|
|
119
|
+
// (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
91
120
|
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
92
121
|
// (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
122
|
+
const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
|
|
93
123
|
function doneButton(it) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
124
|
+
const tone = doneTone(it);
|
|
125
|
+
return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
@@ -105,5 +134,5 @@
|
|
|
105
134
|
return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
|
|
106
135
|
}
|
|
107
136
|
|
|
108
|
-
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
|
|
137
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, doneTone, newQuestionsLine, SYSTEM_KINDS });
|
|
109
138
|
})();
|
package/board/public/ui.js
CHANGED
|
@@ -52,29 +52,66 @@
|
|
|
52
52
|
}
|
|
53
53
|
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
54
54
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
55
|
-
// A composer's text box, which attach.js outlines with Drop to
|
|
56
|
-
|
|
55
|
+
// A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
|
|
56
|
+
// attach while files are dragged over the composer. attach: false leaves the button out, for a composer that puts it
|
|
57
|
+
// on its row (app-threads.js).
|
|
58
|
+
const dropZone = (textarea, { attach = true } = {}) => `<div class="dz${attach ? '' : ' bare'}">${textarea}${attach ? attachButton() : ''}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
59
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
|
|
60
|
+
// A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
|
|
61
|
+
// wherever each sits in the row (Ctrl+↵ and
|
|
62
|
+
// Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
|
|
63
|
+
const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
|
|
64
|
+
const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
|
|
65
|
+
const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
|
|
66
|
+
// The send button an Enter keydown in form's box presses, or null.
|
|
67
|
+
const keyedSend = (ev, form) => {
|
|
68
|
+
if (ev.key !== 'Enter' || ev.isComposing) return null;
|
|
69
|
+
const nth = (ev.metaKey || ev.ctrlKey) && !ev.altKey ? 0 : ev.altKey && !ev.metaKey && !ev.ctrlKey ? 1 : -1;
|
|
70
|
+
return nth < 0 ? null : form.querySelector(`button[type="submit"][data-nth="${nth}"]`);
|
|
71
|
+
};
|
|
57
72
|
const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
58
73
|
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
59
74
|
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
75
|
+
// Escaped text with each ↗ (and a trailing U+FE0E) drawn as LINK_ARROW; for owner-facing text, never an attribute.
|
|
76
|
+
const escText = (s) => esc(s).replace(/\u2197\uFE0E?/g, LINK_ARROW);
|
|
60
77
|
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
61
78
|
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
62
79
|
const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
|
|
80
|
+
const IMAGE_PATHS = '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>';
|
|
63
81
|
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
|
|
64
82
|
// question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
|
|
65
83
|
const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
66
84
|
// The thread's fire, the eye's twin for a grill.
|
|
67
85
|
const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
|
|
86
|
+
// The thread's image, asking for a visual: a page an agent draws to answer in the thread (app-threads.js;
|
|
87
|
+
// docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
88
|
+
const visualButton = (id) => `<button type="button" class="attach proto-ask" data-visual="${esc(id)}" aria-label="Ask for a visual" title="Ask for a visual">${icon(IMAGE_PATHS)}</button>`;
|
|
68
89
|
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
69
90
|
// A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
|
|
70
|
-
// same place:
|
|
91
|
+
// same place: a command line's icon by its words (domain.mjs COMMANDS: the request's eye, fire or image, Implement's
|
|
92
|
+
// code brackets, Hold's pause, Resume's play, Done's circled check), Reply's arrow, answers' check, a reopened
|
|
93
|
+
// question's turn-back arrow, new questions' plain question mark, a builder's dot.
|
|
71
94
|
const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
95
|
+
const LINK = mini('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>');
|
|
72
96
|
const MARK_ICONS = {
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
97
|
+
'Prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
|
98
|
+
'Grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
|
|
99
|
+
'Visual requested': mini(IMAGE_PATHS),
|
|
100
|
+
'Re-ask requested': mini('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
|
|
101
|
+
visual: mini(IMAGE_PATHS),
|
|
102
|
+
'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
|
|
103
|
+
move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
|
|
104
|
+
'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
105
|
+
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
106
|
+
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
107
|
+
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
108
|
+
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
109
|
+
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
110
|
+
Implement: mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
|
|
76
111
|
'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
|
|
112
|
+
'question reopened': mini('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>'),
|
|
77
113
|
'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
114
|
+
'status lines': mini('<path d="m9 6 6 6-6 6"/>'),
|
|
78
115
|
questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
|
|
79
116
|
};
|
|
80
117
|
const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
|
|
@@ -93,6 +130,12 @@
|
|
|
93
130
|
? `<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>`
|
|
94
131
|
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
95
132
|
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
133
|
+
// Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
|
|
134
|
+
// board view (app.js) to read the hub's usage pause.
|
|
135
|
+
const PAUSED = '<span class="paused">paused</span>';
|
|
136
|
+
const isPaused = () => BoardUI.usagePause?.()?.state === 'paused';
|
|
137
|
+
// The orchestrator's "thinking" while its usage allows, else paused.
|
|
138
|
+
const thinkingLine = () => (isPaused() ? PAUSED : workingLine);
|
|
96
139
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
97
140
|
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
98
141
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
@@ -114,11 +157,11 @@
|
|
|
114
157
|
// under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
|
|
115
158
|
// element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
|
|
116
159
|
// its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
|
|
117
|
-
// matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
|
|
160
|
+
// matched by its owning [data-key] and summary text (a card itself by its key alone, its summary text changing as it ages). Open state is tracked page-wide, so content filled in after a
|
|
118
161
|
// parent rebuild reopens too.
|
|
119
162
|
const lastHtml = new WeakMap();
|
|
120
163
|
const openDetails = new Set();
|
|
121
|
-
const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
164
|
+
const detailsKey = (d) => d.matches('[data-key]') ? d.dataset.key : `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
|
|
122
165
|
globalThis.document?.addEventListener('toggle', (ev) => {
|
|
123
166
|
if (ev.target.tagName !== 'DETAILS') return;
|
|
124
167
|
if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
|
|
@@ -299,22 +342,83 @@
|
|
|
299
342
|
// until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
|
|
300
343
|
// drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
|
|
301
344
|
// keeps the spinner.
|
|
345
|
+
// A tap whose answer carries no board (a bare fetch) calls answered(id) with no time: any board then ends it, so its
|
|
346
|
+
// until() must hold only for a change the tap caused. start(id, until, onLate): if no board ends it within
|
|
347
|
+
// PENDING_LATE_MS the entry drops and onLate() runs (the page reloads once and says so).
|
|
348
|
+
const PENDING_LATE_MS = 60000;
|
|
302
349
|
const pendings = new Map();
|
|
303
350
|
const pending = {
|
|
304
|
-
start(id, until
|
|
305
|
-
|
|
306
|
-
|
|
351
|
+
start(id, until, onLate = null) {
|
|
352
|
+
pending.drop(id);
|
|
353
|
+
const late = onLate && setTimeout(() => { pendings.delete(id); onLate(); }, PENDING_LATE_MS);
|
|
354
|
+
pendings.set(id, { until, after: null, late });
|
|
355
|
+
},
|
|
356
|
+
answered(id, after = '') { const p = pendings.get(id); if (p) p.after = after; },
|
|
357
|
+
drop(id) { clearTimeout(pendings.get(id)?.late); pendings.delete(id); },
|
|
307
358
|
has: (id) => pendings.has(id),
|
|
359
|
+
// A tap whose change is drawn by the page's own load, not by a board (a thread, a page's redraw): resolves when work
|
|
360
|
+
// settles, or after PENDING_LATE_MS calls onLate() and resolves, so waitOn's spinner rests either way.
|
|
361
|
+
within(work, onLate) {
|
|
362
|
+
let timer;
|
|
363
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => { onLate(); resolve(); }, PENDING_LATE_MS); });
|
|
364
|
+
return Promise.race([work, late]).finally(() => clearTimeout(timer));
|
|
365
|
+
},
|
|
308
366
|
settle(board) {
|
|
309
|
-
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board))
|
|
367
|
+
for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
|
|
310
368
|
},
|
|
311
369
|
};
|
|
312
370
|
|
|
371
|
+
// Why a board request failed, from what the hub marks on its answer (hub/hub.mjs sendDown, hub/front-door.mjs refuse)
|
|
372
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). answer is the response, or null when
|
|
373
|
+
// nothing answered or the request timed out; online is navigator.onLine. → {state, line, hint, retries}: the landing's
|
|
374
|
+
// two lines, and whether the board can come back by itself, so the page keeps retrying.
|
|
375
|
+
const BACK = 'This page comes back by itself.';
|
|
376
|
+
const DOWN = {
|
|
377
|
+
restarting: { line: 'Board restarting' },
|
|
378
|
+
stopped: { line: 'Board stopped', hint: 'Nothing answers on its port. Its Claude session has to start it.' },
|
|
379
|
+
removed: { line: 'This board is gone', hint: 'The hub no longer lists it.', retries: false },
|
|
380
|
+
access: { line: 'You no longer have access', hint: 'Ask its owner to share it again.', retries: false },
|
|
381
|
+
erroring: { line: 'Board restarting', hint: 'It answers with errors. Retrying for 30 seconds.' },
|
|
382
|
+
unreachable: { line: 'Board offline', hint: 'The hub, your connection or the Mac is down. Retrying.' },
|
|
383
|
+
};
|
|
384
|
+
const RESTART_WHY = { code: 'Its code changed.', starting: 'It’s starting.', crashed: 'It stopped, and the hub is starting it again.' };
|
|
385
|
+
function downOf(answer, online = navigator.onLine !== false) {
|
|
386
|
+
const why = online && answer ? answer.headers.get('x-kickflip-down') : null;
|
|
387
|
+
const state = !online || !answer ? 'unreachable'
|
|
388
|
+
: RESTART_WHY[why] ? 'restarting'
|
|
389
|
+
: DOWN[why] ? why
|
|
390
|
+
: answer.headers.get('x-app-shell') ? 'access'
|
|
391
|
+
: why || answer.headers.get('x-kickflip-hub') ? 'erroring' : 'unreachable';
|
|
392
|
+
return { state, retries: true, ...DOWN[state], ...(state === 'restarting' ? { hint: `${RESTART_WHY[why]} ${BACK}` } : {}) };
|
|
393
|
+
}
|
|
394
|
+
// A view's place when it has no copy to show: the kickflip mark and down's two lines, as the no-access page has them.
|
|
395
|
+
const downLanding = (down) => `<div class="down-landing" data-down="${down.state}"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
|
|
396
|
+
<p class="down-landing-line">${esc(down.line)}</p><p class="down-landing-hint">${esc(down.hint)}</p></div>`;
|
|
397
|
+
// The line over a side page painted from its copy while the board can't be reached.
|
|
398
|
+
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
399
|
+
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
400
|
+
// label and size. Reading controls, folds, links, Edit and drafts stay live.
|
|
401
|
+
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-band-move], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
402
|
+
// While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
|
|
403
|
+
// tells the tap it is waiting. on is the view's B.on.
|
|
404
|
+
function holdSends(on, root, held, onHeld) {
|
|
405
|
+
const stop = (ev) => {
|
|
406
|
+
if (!held() || (ev.type === 'click' && !ev.target.closest?.(SENDS))) return;
|
|
407
|
+
ev.preventDefault();
|
|
408
|
+
ev.stopImmediatePropagation();
|
|
409
|
+
onHeld();
|
|
410
|
+
};
|
|
411
|
+
on(root, 'click', stop, true);
|
|
412
|
+
on(root, 'submit', stop, true);
|
|
413
|
+
}
|
|
414
|
+
|
|
313
415
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
314
|
-
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
315
|
-
// then throws once about limit ms have passed. Any other answer resolves
|
|
416
|
+
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs, down) and retries with a growing backoff,
|
|
417
|
+
// then throws once about limit ms have passed, the error carrying down (downOf). Any other answer resolves
|
|
418
|
+
// {ok, status, body}, a 404's {error} included.
|
|
316
419
|
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
317
420
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
421
|
+
const WAITING = 'Waiting for the board to reconnect';
|
|
318
422
|
const RESTARTING = 'Board restarting…';
|
|
319
423
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
320
424
|
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
@@ -322,15 +426,18 @@
|
|
|
322
426
|
let delay = 500;
|
|
323
427
|
for (;;) {
|
|
324
428
|
let failure;
|
|
429
|
+
let answer = null;
|
|
325
430
|
try {
|
|
326
431
|
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
327
432
|
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
328
433
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
329
434
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
435
|
+
answer = r;
|
|
330
436
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
331
437
|
} catch { failure = new Error('Offline. Is the host awake?'); }
|
|
438
|
+
failure.down = downOf(answer);
|
|
332
439
|
if (waited >= limit) throw failure;
|
|
333
|
-
onWait(waited);
|
|
440
|
+
onWait(waited, failure.down);
|
|
334
441
|
await sleep(delay);
|
|
335
442
|
waited += delay;
|
|
336
443
|
delay = Math.min(delay * 2, 4000);
|
|
@@ -338,8 +445,8 @@
|
|
|
338
445
|
}
|
|
339
446
|
|
|
340
447
|
globalThis.BoardUI = {
|
|
341
|
-
LINK_ARROW,
|
|
342
|
-
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
|
|
343
|
-
attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
448
|
+
LINK_ARROW, escText,
|
|
449
|
+
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
450
|
+
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
344
451
|
};
|
|
345
452
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
|
|
2
|
-
// 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
|
|
2
|
+
// settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. 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 and Slack's token do through linear-key.js; Done waits for
|
|
4
4
|
// a key's save to land. The rules editor saves through POST /_hub/settings/rules
|
|
5
5
|
// and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
|
|
6
6
|
(function () {
|
|
@@ -9,28 +9,10 @@
|
|
|
9
9
|
const { esc } = HubUI;
|
|
10
10
|
const ACTION = '/_hub/settings/notifications';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
function customBounds(input, reset = false) {
|
|
18
|
-
const now = Date.now();
|
|
19
|
-
input.min = localInput(new Date(now + 60_000));
|
|
20
|
-
input.max = localInput(new Date(now + 30 * 86_400_000));
|
|
21
|
-
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
|
|
25
|
-
// then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
|
|
26
|
-
// and a button that posts `name`=off.
|
|
27
|
-
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
|
28
|
-
if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
|
|
29
|
-
if (!choices) return '';
|
|
30
|
-
const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
31
|
-
return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
|
|
32
|
-
}
|
|
33
|
-
|
|
12
|
+
const { durationPicker } = HubKit;
|
|
13
|
+
// Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md), gray as every paused status.
|
|
14
|
+
const pauseRow = (p) => durationPicker({ id: 'pauseHub', title: 'Pause hub', icon: HubUI.tile('pause', 'var(--color-quiet)'),
|
|
15
|
+
action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
|
|
34
16
|
const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
|
|
35
17
|
choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
|
|
36
18
|
// Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
|
|
@@ -58,7 +40,7 @@
|
|
|
58
40
|
+ `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
43
|
+
const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
|
|
62
44
|
const sectionOf = (hash) => {
|
|
63
45
|
const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
|
|
64
46
|
return sections.some(([section]) => section === id) ? id : null;
|
|
@@ -79,39 +61,43 @@
|
|
|
79
61
|
let flash = null;
|
|
80
62
|
let rules = null;
|
|
81
63
|
let rulesJSON = '';
|
|
82
|
-
let
|
|
64
|
+
let tokenSlot = null;
|
|
65
|
+
const pickers = HubKit.durationPickers(B);
|
|
83
66
|
|
|
84
67
|
function paint() {
|
|
85
68
|
if (!last) return;
|
|
86
69
|
const { row, tile, deviceRow } = HubUI;
|
|
87
70
|
top.innerHTML = HubUI.flash(flash);
|
|
88
71
|
const scrollTop = B.$('.settings-body').scrollTop;
|
|
89
|
-
if (active === '
|
|
90
|
-
content.innerHTML = `<div class="group">${
|
|
72
|
+
if (active === 'pause') {
|
|
73
|
+
content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
|
|
74
|
+
} else if (active === 'notifications') {
|
|
75
|
+
content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
|
|
91
76
|
} else if (active === 'quiet') {
|
|
92
77
|
content.innerHTML = quietRows(last.quiet);
|
|
93
78
|
} else if (active === 'keep-awake') {
|
|
94
79
|
content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
|
|
95
80
|
: `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
|
|
96
|
-
} else if (active === 'linear') {
|
|
97
|
-
if (
|
|
81
|
+
} else if (active === 'linear' || active === 'slack') {
|
|
82
|
+
if (tokenSlot) tokenSlot.set(last[active]);
|
|
98
83
|
else {
|
|
99
|
-
|
|
84
|
+
tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
|
|
100
85
|
saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
|
|
101
86
|
busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
|
|
102
87
|
}
|
|
103
88
|
} else if (active === 'closed') {
|
|
104
89
|
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>`;
|
|
105
90
|
}
|
|
106
|
-
|
|
107
|
-
if (input) customBounds(input, true);
|
|
91
|
+
pickers.painted();
|
|
108
92
|
B.$('.settings-body').scrollTop = scrollTop;
|
|
109
93
|
B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
|
|
110
94
|
const summaries = {
|
|
95
|
+
pause: last.pause.summary,
|
|
111
96
|
notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
|
|
112
97
|
quiet: last.quiet.summary,
|
|
113
98
|
'keep-awake': last.awake.summary,
|
|
114
99
|
linear: last.linear?.summary || 'No key',
|
|
100
|
+
slack: last.slack?.summary || 'No token',
|
|
115
101
|
rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
|
|
116
102
|
closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
|
|
117
103
|
};
|
|
@@ -152,7 +138,7 @@
|
|
|
152
138
|
function closeModal(restore = true) {
|
|
153
139
|
unfitModal?.(); unfitModal = null;
|
|
154
140
|
modal.close();
|
|
155
|
-
|
|
141
|
+
tokenSlot?.destroy(); tokenSlot = null;
|
|
156
142
|
content.innerHTML = '';
|
|
157
143
|
rules = null;
|
|
158
144
|
active = null;
|
|
@@ -186,8 +172,8 @@
|
|
|
186
172
|
return true;
|
|
187
173
|
};
|
|
188
174
|
const dismiss = () => {
|
|
189
|
-
if (!
|
|
190
|
-
|
|
175
|
+
if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
|
|
176
|
+
tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
|
|
191
177
|
};
|
|
192
178
|
B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
|
|
193
179
|
B.on(root, 'click', (e) => {
|
|
@@ -206,37 +192,6 @@
|
|
|
206
192
|
B.atUnmount(() => closeModal(false));
|
|
207
193
|
const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
|
|
208
194
|
device.ready.then(() => { if (B.alive()) paint(); });
|
|
209
|
-
let openPicker = null;
|
|
210
|
-
B.on(root, 'click', (e) => {
|
|
211
|
-
const input = e.target.closest?.('[data-dur-at]');
|
|
212
|
-
if (!input) return;
|
|
213
|
-
openPicker = input;
|
|
214
|
-
customBounds(input, input.value === input.defaultValue);
|
|
215
|
-
try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
|
|
216
|
-
});
|
|
217
|
-
// A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
|
|
218
|
-
B.on(root, 'pointerdown', (e) => {
|
|
219
|
-
const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
|
|
220
|
-
if (!input) return;
|
|
221
|
-
openPicker = null;
|
|
222
|
-
input.value = input.defaultValue;
|
|
223
|
-
});
|
|
224
|
-
// Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
|
|
225
|
-
const saveCustom = (input) => {
|
|
226
|
-
if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
|
|
227
|
-
const at = new Date(input.value);
|
|
228
|
-
if (Number.isNaN(at.getTime())) return;
|
|
229
|
-
input.form.querySelector('input[type="hidden"]').value = at.toISOString();
|
|
230
|
-
input.form.requestSubmit();
|
|
231
|
-
openPicker = null;
|
|
232
|
-
input.defaultValue = input.value;
|
|
233
|
-
};
|
|
234
|
-
B.on(root, 'change', (e) => {
|
|
235
|
-
if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
|
|
236
|
-
});
|
|
237
|
-
B.on(root, 'focusout', (e) => {
|
|
238
|
-
if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
|
|
239
|
-
});
|
|
240
195
|
B.on(root, 'click', async (e) => {
|
|
241
196
|
const button = e.target.closest?.('[data-reopen]');
|
|
242
197
|
if (!button || reopening) return;
|