@lovek4d/kickflip 0.0.0-stage → 0.3.1
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 +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
// How long a toast fades in and out; the board's bump icon fades its fill on the same timing (app.js fadeBump).
|
|
3
|
+
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
4
|
+
const enterTime = () => reduced() ? 80 : 150;
|
|
5
|
+
const exitTime = () => reduced() ? 100 : 200;
|
|
6
|
+
|
|
7
|
+
function create(root) {
|
|
8
|
+
const stack = document.createElement('div');
|
|
9
|
+
stack.setAttribute('class', 'toast-stack');
|
|
10
|
+
stack.setAttribute('aria-label', 'Notifications');
|
|
11
|
+
root.appendChild(stack);
|
|
12
|
+
let entries = [];
|
|
13
|
+
let pending = [];
|
|
14
|
+
let frame = 0;
|
|
15
|
+
let destroyed = false;
|
|
16
|
+
const held = (item) => !item.forced && item.holds.size > 0;
|
|
17
|
+
const opacity = (item) => held(item) || item.phase === 'solid' ? 1
|
|
18
|
+
: item.phase === 'in' ? 1 - item.remaining / item.fade : item.from * item.remaining / item.fade;
|
|
19
|
+
|
|
20
|
+
function layout() {
|
|
21
|
+
let bottom = 0;
|
|
22
|
+
for (const item of [...entries].reverse()) {
|
|
23
|
+
bottom += item.el.getBoundingClientRect().height;
|
|
24
|
+
item.row.style.transform = `translateY(${-bottom}px)`;
|
|
25
|
+
bottom += 8;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function draw() {
|
|
30
|
+
for (const item of entries) item.el.style.opacity = Math.max(0, Math.min(1, opacity(item)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function dismiss(item) {
|
|
34
|
+
if (!entries.includes(item) || item.forced) return;
|
|
35
|
+
advance(item, performance.now());
|
|
36
|
+
item.from = Math.max(0, opacity(item));
|
|
37
|
+
item.phase = 'out';
|
|
38
|
+
item.remaining = item.fade = exitTime();
|
|
39
|
+
item.last = performance.now();
|
|
40
|
+
item.forced = true;
|
|
41
|
+
item.holds.clear();
|
|
42
|
+
draw();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function advance(item, now) {
|
|
46
|
+
if (!held(item)) item.remaining -= Math.max(0, now - item.last);
|
|
47
|
+
item.last = now;
|
|
48
|
+
while (item.remaining <= 0) {
|
|
49
|
+
if (item.phase === 'out') return false;
|
|
50
|
+
item.phase = item.phase === 'in' ? 'solid' : 'out';
|
|
51
|
+
item.from = 1;
|
|
52
|
+
item.fade = exitTime();
|
|
53
|
+
item.remaining += item.phase === 'solid' ? item.duration : item.fade;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hold(item, source, on) {
|
|
59
|
+
if (!entries.includes(item) || item.forced || item.holds.has(source) === on) return;
|
|
60
|
+
advance(item, performance.now());
|
|
61
|
+
if (on) {
|
|
62
|
+
item.holds.add(source);
|
|
63
|
+
if (item.phase === 'in') { item.phase = 'solid'; item.remaining = item.duration; }
|
|
64
|
+
if (item.phase === 'out') { item.remaining = item.fade = exitTime(); item.from = 1; }
|
|
65
|
+
} else item.holds.delete(source);
|
|
66
|
+
draw();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function attach(message) {
|
|
70
|
+
const row = document.createElement('div');
|
|
71
|
+
row.setAttribute('class', 'toast-row');
|
|
72
|
+
row.style.transition = 'none';
|
|
73
|
+
const el = document.createElement('div');
|
|
74
|
+
el.setAttribute('class', 'toast');
|
|
75
|
+
el.setAttribute('role', 'status');
|
|
76
|
+
el.setAttribute('aria-live', 'polite');
|
|
77
|
+
el.setAttribute('aria-atomic', 'true');
|
|
78
|
+
el.setAttribute('tabindex', '0');
|
|
79
|
+
el.textContent = message.text;
|
|
80
|
+
row.appendChild(el);
|
|
81
|
+
const item = { ...message, row, el, phase: 'in', remaining: enterTime(), fade: enterTime(),
|
|
82
|
+
from: 1, last: performance.now(), holds: new Set(), forced: false, off: [] };
|
|
83
|
+
const on = (type, fn) => {
|
|
84
|
+
el.addEventListener(type, fn);
|
|
85
|
+
item.off.push(() => el.removeEventListener(type, fn));
|
|
86
|
+
};
|
|
87
|
+
let touch = null;
|
|
88
|
+
let suppressClickUntil = 0;
|
|
89
|
+
on('pointerenter', (e) => { if (e.pointerType === 'mouse') hold(item, 'hover', true); });
|
|
90
|
+
on('pointerleave', (e) => { if (e.pointerType === 'mouse') hold(item, 'hover', false); });
|
|
91
|
+
on('pointerdown', (e) => {
|
|
92
|
+
if (e.pointerType === 'mouse') return;
|
|
93
|
+
touch = { id: e.pointerId, at: performance.now(), x: e.clientX, y: e.clientY };
|
|
94
|
+
el.setPointerCapture(e.pointerId);
|
|
95
|
+
hold(item, 'touch', true);
|
|
96
|
+
});
|
|
97
|
+
function release(e) {
|
|
98
|
+
if (!touch || touch.id !== e.pointerId) return;
|
|
99
|
+
const wasHold = performance.now() - touch.at > 350 || Math.hypot(e.clientX - touch.x, e.clientY - touch.y) > 10;
|
|
100
|
+
if (wasHold || e.type !== 'pointerup') suppressClickUntil = performance.now() + 600;
|
|
101
|
+
touch = null;
|
|
102
|
+
hold(item, 'touch', false);
|
|
103
|
+
}
|
|
104
|
+
on('pointerup', release);
|
|
105
|
+
on('pointercancel', release);
|
|
106
|
+
on('lostpointercapture', release);
|
|
107
|
+
on('contextmenu', (e) => e.preventDefault());
|
|
108
|
+
on('click', () => { if (performance.now() >= suppressClickUntil) dismiss(item); });
|
|
109
|
+
on('keydown', (e) => {
|
|
110
|
+
if (!['Escape', 'Enter', ' '].includes(e.key)) return;
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
dismiss(item);
|
|
113
|
+
});
|
|
114
|
+
entries.push(item);
|
|
115
|
+
stack.appendChild(row);
|
|
116
|
+
observer.observe(el);
|
|
117
|
+
layout();
|
|
118
|
+
row.getBoundingClientRect();
|
|
119
|
+
row.style.transition = '';
|
|
120
|
+
if (matchMedia('(hover: hover)').matches && el.matches(':hover')) hold(item, 'hover', true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function drain() {
|
|
124
|
+
while (pending.length && entries.length < 3) attach(pending.shift());
|
|
125
|
+
// The next arrival waits for eviction's fade so four messages never show together.
|
|
126
|
+
if (pending.length && entries.length === 3) dismiss(entries[0]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function remove(item) {
|
|
130
|
+
item.off.forEach((off) => off());
|
|
131
|
+
observer.unobserve(item.el);
|
|
132
|
+
item.row.remove();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tick(now) {
|
|
136
|
+
frame = 0;
|
|
137
|
+
const expired = entries.filter((item) => !advance(item, now));
|
|
138
|
+
for (const item of expired) remove(item);
|
|
139
|
+
if (expired.length) {
|
|
140
|
+
entries = entries.filter((item) => !expired.includes(item));
|
|
141
|
+
drain();
|
|
142
|
+
layout();
|
|
143
|
+
}
|
|
144
|
+
draw();
|
|
145
|
+
if (entries.length) frame = requestAnimationFrame(tick);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toast(text) {
|
|
149
|
+
if (destroyed || !text.trim()) return;
|
|
150
|
+
const length = Array.from(text.replace(/\s+/g, ' ').trim()).length;
|
|
151
|
+
pending.push({ text, duration: length > 100 ? 5000 : 3000 });
|
|
152
|
+
drain();
|
|
153
|
+
draw();
|
|
154
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function releaseHolds() {
|
|
158
|
+
const now = performance.now();
|
|
159
|
+
for (const item of entries) { advance(item, now); item.holds.clear(); }
|
|
160
|
+
draw();
|
|
161
|
+
}
|
|
162
|
+
const visibility = () => { if (document.hidden) releaseHolds(); };
|
|
163
|
+
window.addEventListener('blur', releaseHolds);
|
|
164
|
+
document.addEventListener('visibilitychange', visibility);
|
|
165
|
+
const observer = new ResizeObserver(layout);
|
|
166
|
+
observer.observe(stack);
|
|
167
|
+
function destroy() {
|
|
168
|
+
destroyed = true;
|
|
169
|
+
cancelAnimationFrame(frame);
|
|
170
|
+
entries.forEach(remove);
|
|
171
|
+
observer.disconnect();
|
|
172
|
+
window.removeEventListener('blur', releaseHolds);
|
|
173
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
174
|
+
stack.remove();
|
|
175
|
+
entries = [];
|
|
176
|
+
pending = [];
|
|
177
|
+
}
|
|
178
|
+
return { toast, destroy };
|
|
179
|
+
}
|
|
180
|
+
globalThis.BoardToasts = { create, enterTime, exitTime };
|
|
181
|
+
})();
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-enums.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
|
+
function prChips(it) {
|
|
6
|
+
const pr = it.pr;
|
|
7
|
+
if (!pr) return '';
|
|
8
|
+
const st = pr.state === 'MERGED' ? badge('ready', 'merged') : pr.state === 'CLOSED' ? badge('danger', 'closed') : '';
|
|
9
|
+
// An open PR's chip ends with its state, marked like the reviewer and CI chips.
|
|
10
|
+
const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: 'quiet', mark: '', label: '' };
|
|
11
|
+
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
12
|
+
const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
13
|
+
const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
|
|
14
|
+
const codex = pr.state === 'OPEN' && pr.codexOpen ? badge('notice', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
|
|
15
|
+
const name = `${repo}#${pr.number}`;
|
|
16
|
+
const label = open.label ? { title: open.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${open.label}` } } : { attrs: EXTERNAL };
|
|
17
|
+
return `${badge(open.tone, `${esc(name)}${open.mark}`, { href: pr.url, ...label })}${st}${ci}${codex}`;
|
|
18
|
+
}
|
|
19
|
+
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
20
|
+
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
|
|
21
|
+
const REVIEW = {
|
|
22
|
+
approved: { tone: 'ready', mark: '✓', label: 'approved' },
|
|
23
|
+
changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
|
|
24
|
+
sent: { tone: 'notice', mark: '●', label: 'sent, not approved yet' },
|
|
25
|
+
unsent: { tone: 'quiet', mark: '✗', label: 'not sent yet' },
|
|
26
|
+
};
|
|
27
|
+
const reviewAge = (rv) => {
|
|
28
|
+
const days = rv?.since ? Math.floor((Date.now() - new Date(rv.since).getTime()) / 86400000) : 0;
|
|
29
|
+
return days >= 1 ? ` ${days}d` : '';
|
|
30
|
+
};
|
|
31
|
+
function reviewChips(it) {
|
|
32
|
+
if (!it.pr || it.pr.state !== 'OPEN') return '';
|
|
33
|
+
return (it.reviewers || []).map((r) => {
|
|
34
|
+
const rv = globalThis.BoardUI.record(it.review, r);
|
|
35
|
+
const s = REVIEW[rv?.state || 'unsent'];
|
|
36
|
+
const age = reviewAge(rv).trim();
|
|
37
|
+
return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
38
|
+
}).join('');
|
|
39
|
+
}
|
|
40
|
+
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
41
|
+
// docs/adr/0045-the-lane-follows-who-acts-next.md
|
|
42
|
+
// Action needed, last of the time line's orange words, linking to what the owner acts on
|
|
43
|
+
// (docs/adr/0145-build-on-a-blocked-card-queues-it.md).
|
|
44
|
+
function actionWords(it) {
|
|
45
|
+
if (!it.action) return '';
|
|
46
|
+
const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
|
|
47
|
+
return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
|
|
48
|
+
}
|
|
49
|
+
// Each unfinished dep, linking to its card: "blocked by #53, #54".
|
|
50
|
+
function blockedWords(it) {
|
|
51
|
+
const deps = it.blockedBy || [];
|
|
52
|
+
return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
53
|
+
}
|
|
54
|
+
const tryWords = (kind) => kind === 'app' ? {
|
|
55
|
+
go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
|
|
56
|
+
current: 'Nothing new to build: Install already has this card’s latest push.',
|
|
57
|
+
busy: 'Building now. The badge turns back into Install when the new build is ready.',
|
|
58
|
+
autoSub: 'On: the hub builds each card after each push. Off: a card waits for your Build. Main builds again after every merge either way.',
|
|
59
|
+
saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
|
|
60
|
+
} : {
|
|
61
|
+
go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
|
|
62
|
+
current: 'Nothing new to serve: Visit already shows this card’s latest push.',
|
|
63
|
+
busy: 'Serving now. The badge turns back into Visit when the new server is up.',
|
|
64
|
+
autoSub: 'On: the hub serves each card after each push. Off: a card waits for your Serve. Main serves again after every merge either way.',
|
|
65
|
+
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
66
|
+
};
|
|
67
|
+
const spinner = '<span class="try-spin" aria-hidden="true"></span>';
|
|
68
|
+
const tryURL = (run) => run.kind === 'web' ? run.port ? `https://${location.hostname}:${run.port}/` : null : run.url;
|
|
69
|
+
function tryOf(it) {
|
|
70
|
+
if (it.lane === 'done') return null;
|
|
71
|
+
const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
|
|
72
|
+
const run = t ? { ...t, url: tryURL(t) } : null;
|
|
73
|
+
return it.builder?.kind === BUILDER_KIND.install ? { ...run, kind: run?.kind || 'app', state: 'running' } : run;
|
|
74
|
+
}
|
|
75
|
+
function installState(it) {
|
|
76
|
+
const t = tryOf(it);
|
|
77
|
+
if (!t) return null;
|
|
78
|
+
const words = tryWords(t.kind);
|
|
79
|
+
if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
|
|
80
|
+
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#item=${encodeURIComponent(it.key)}`,
|
|
81
|
+
title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
|
|
82
|
+
if (t.state !== 'ready' || !t.url) return null;
|
|
83
|
+
return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
|
|
84
|
+
title: t.sha ? `Built from ${t.sha}${t.stale ? '; the branch has newer commits' : ''}` : null, attrs: t.kind === 'web' ? EXTERNAL : {} };
|
|
85
|
+
}
|
|
86
|
+
function installChip(it) {
|
|
87
|
+
const s = installState(it);
|
|
88
|
+
return s ? badge(s.tone, s.html, { href: s.href, title: s.title, attrs: s.attrs }) : '';
|
|
89
|
+
}
|
|
90
|
+
function installButton(it) {
|
|
91
|
+
const s = installState(it);
|
|
92
|
+
if (!s) return '';
|
|
93
|
+
const attrs = Object.entries({ href: s.href, title: s.title, ...s.attrs }).filter(([, v]) => v != null)
|
|
94
|
+
.map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
95
|
+
const tag = s.href ? 'a' : 'span';
|
|
96
|
+
return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
|
|
97
|
+
}
|
|
98
|
+
// A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
|
|
99
|
+
// (docs/adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md).
|
|
100
|
+
function tryButton(it) {
|
|
101
|
+
const t = tryOf(it);
|
|
102
|
+
if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
|
|
103
|
+
const words = tryWords(t.kind);
|
|
104
|
+
const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
|
|
105
|
+
return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
|
|
106
|
+
}
|
|
107
|
+
const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
108
|
+
function mainBar(main) {
|
|
109
|
+
if (!main) return '';
|
|
110
|
+
const words = tryWords(main.kind);
|
|
111
|
+
const url = tryURL(main);
|
|
112
|
+
const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
|
|
113
|
+
: main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
|
|
114
|
+
: main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
115
|
+
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}</div><div class="meta">${chip}</div></div>`;
|
|
116
|
+
}
|
|
117
|
+
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
|
|
118
|
+
// while it is sent or waits on the session (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
|
|
119
|
+
function mergeButton(it, sending = false) {
|
|
120
|
+
if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
|
|
121
|
+
const merging = sending || it.merging;
|
|
122
|
+
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
|
|
123
|
+
}
|
|
124
|
+
// The open card's Implement, rightmost in its action row, on a card board/domain.mjs marks buildable: it confirms, then
|
|
125
|
+
// sends card-build, and reads "Starting…" while it is sent and the board server starts the implementer. Build and Serve
|
|
126
|
+
// mean the package only (docs/adr/0122-a-todo-card-that-nothing-blocks-has-build.md,
|
|
127
|
+
// docs/adr/0139-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/0166-a-todo-cards-button-reads-implement.md).
|
|
128
|
+
function buildButton(it, sending = false) {
|
|
129
|
+
if (!it.buildable) return '';
|
|
130
|
+
return `<button class="build" data-confirm="card-build" data-key="${esc(it.key)}"${sending ? ' disabled' : ''}>${sending ? 'Starting…' : 'Implement'}</button>`;
|
|
131
|
+
}
|
|
132
|
+
// A builder the Implement tap started reads "Waiting for orchestrator" until the session's builder posts its first line.
|
|
133
|
+
const builderLine = (b) => [b.progress || (b.waiting ? 'Waiting for orchestrator' : 'Starting'), b.at && ago(b.at)].filter(Boolean).join(' · ');
|
|
134
|
+
// Five bars at the end of the time row while a build runs on an In Progress card: done phases filled, the current one
|
|
135
|
+
// marked. The phases are store.mjs PHASES (docs/adr/0137-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
136
|
+
// docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
|
|
137
|
+
const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
|
|
138
|
+
function phaseBars(phase) {
|
|
139
|
+
const at = BUILD_PHASES.indexOf(phase);
|
|
140
|
+
if (at < 0) return '';
|
|
141
|
+
const name = `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
142
|
+
return `<span class="phase-bars" role="img" aria-label="${name}, step ${at + 1} of ${BUILD_PHASES.length}">${BUILD_PHASES.map((_, i) => `<i class="${i < at ? 'done' : i === at ? 'now' : ''}"></i>`).join('')}</span>`;
|
|
143
|
+
}
|
|
144
|
+
const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
|
|
145
|
+
button: true, title: 'No recent progress. Nudge Claude to check this implementer',
|
|
146
|
+
attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
|
|
147
|
+
}) : '';
|
|
148
|
+
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
149
|
+
// When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
|
|
150
|
+
// (docs/adr/0118-a-card-can-be-held-until-a-time.md).
|
|
151
|
+
function heldUntilWords(iso, now = new Date()) {
|
|
152
|
+
const d = new Date(iso);
|
|
153
|
+
const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
154
|
+
const days = Math.round((midnight(d) - midnight(now)) / 86400000);
|
|
155
|
+
if (days === 0) return clock(d);
|
|
156
|
+
if (days > 0 && days < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${clock(d)}`;
|
|
157
|
+
return `${d.toLocaleDateString([], { month: 'numeric', day: 'numeric' })}, ${clock(d)}`;
|
|
158
|
+
}
|
|
159
|
+
function holdWords(it) {
|
|
160
|
+
if (!it.held) return '';
|
|
161
|
+
const d = it.heldUntil && new Date(it.heldUntil);
|
|
162
|
+
const title = !d ? 'Held until you unhold it.'
|
|
163
|
+
: `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
|
|
164
|
+
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
165
|
+
}
|
|
166
|
+
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
|
|
167
|
+
// or ready, then a building card's phase bars, then its unread count, which app-cards.js takes away when the card opens
|
|
168
|
+
// (docs/adr/0154-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
|
|
169
|
+
const SEP = '<span class="sep"> · </span>';
|
|
170
|
+
function timeLine(it, unread, bars) {
|
|
171
|
+
const iso = it.sortAt;
|
|
172
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
|
|
173
|
+
const count = unread ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${unread} new message${unread === 1 ? '' : 's'}</span>` : '';
|
|
174
|
+
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
175
|
+
if (!parts.length && !count && !tail) return '';
|
|
176
|
+
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
177
|
+
}
|
|
178
|
+
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
179
|
+
// Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
180
|
+
// durationPicker's markup also uses.
|
|
181
|
+
function holdPicker(key, choices) {
|
|
182
|
+
const chips = choices.map((c) => `<button type="button" class="btn quiet" data-hold-for="${esc(key)}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
|
|
183
|
+
return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
|
|
184
|
+
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
185
|
+
}
|
|
186
|
+
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
187
|
+
// docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
|
|
188
|
+
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
189
|
+
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
190
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && `<span class="working">${workingLine}</span>`].filter(Boolean).join(' · ');
|
|
191
|
+
function workLines(it, { unread = 0, top = false } = {}) {
|
|
192
|
+
const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
|
|
193
|
+
// A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
|
|
194
|
+
const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${ticket}` : ticket;
|
|
195
|
+
const wave = it.lineup ? badge('quiet', `${WAVE_ICON}${esc(it.lineup.title)}`, { title: `In the wave ${it.lineup.title}` }) : '';
|
|
196
|
+
const chips = `${stalledChip(it)}${it.followUp ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
197
|
+
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
198
|
+
: it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
|
|
199
|
+
: it.builder ? `<div class="line3 sub agent${it.builder.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
|
|
200
|
+
: it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
|
|
201
|
+
: it.working ? `<div class="line3 sub agent">${workingLine}</div>`
|
|
202
|
+
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
|
|
203
|
+
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
|
|
204
|
+
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
205
|
+
${status}${eta}
|
|
206
|
+
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
207
|
+
${wave ? `<div class="line5 meta">${wave}</div>` : ''}
|
|
208
|
+
${timeLine(it, unread, it.lane === 'progress' && it.builder && !it.builder.planner && it.waitsOn !== 'checking' ? phaseBars(it.builder.phase) : '')}`;
|
|
209
|
+
}
|
|
210
|
+
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
211
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
212
|
+
function planButton(items) {
|
|
213
|
+
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
|
|
214
|
+
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON });
|
|
218
|
+
})();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A card's button names, which board/domain.mjs sets as its actionButton and resolvedBy, the kinds of its owner
|
|
2
|
+
// action, and the builder kinds the page reads. Each value is its name, since cards carry it in the board's JSON.
|
|
3
|
+
// Needs nothing else: board/domain.mjs runs
|
|
4
|
+
// this file in a vm context (docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
|
+
(function () {
|
|
6
|
+
const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
|
|
7
|
+
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
|
|
8
|
+
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND });
|
|
10
|
+
})();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The board header's time and the In Review header's GitHub line, with when a refresh asks for a check. Needs ui.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { since, esc } = globalThis.BoardUI;
|
|
4
|
+
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
|
|
5
|
+
// /api/board's checks.watcher (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md): a grey asleep while
|
|
6
|
+
// the hub can wake the session, waking… once a wake is under way (waking: the page asked for one and the board hasn't
|
|
7
|
+
// said so yet), and a red not listening when nothing will wake it
|
|
8
|
+
// (docs/adr/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
|
|
9
|
+
// 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
|
|
10
|
+
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
|
|
11
|
+
: link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
|
|
12
|
+
: link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
|
|
13
|
+
: watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
|
|
14
|
+
: watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
|
|
15
|
+
: watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
|
|
16
|
+
: at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
|
|
17
|
+
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
18
|
+
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
|
19
|
+
// fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
|
|
20
|
+
// stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
|
|
21
|
+
// something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
|
|
22
|
+
// (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
|
|
23
|
+
const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
|
|
24
|
+
const shortSince = (iso) => {
|
|
25
|
+
const s = (Date.now() - Date.parse(iso)) / 1000;
|
|
26
|
+
return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
|
|
27
|
+
};
|
|
28
|
+
// GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/0013-icons-are-sf-symbol-style-line-icons.md).
|
|
29
|
+
const GITHUB_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 16 16" fill="currentColor" role="img" aria-label="GitHub"><title>GitHub</title><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
|
|
30
|
+
// The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
|
|
31
|
+
// (docs/adr/0046-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
|
|
32
|
+
// when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
|
|
33
|
+
const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
|
|
34
|
+
const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
|
|
35
|
+
const linksMark = (links, mark = GITHUB_MARK) => {
|
|
36
|
+
const broken = brokenLinks(links).length;
|
|
37
|
+
const label = `Hub links${broken ? `: ${broken} not working` : ''}`;
|
|
38
|
+
return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${mark}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
|
|
39
|
+
};
|
|
40
|
+
// A board on a plain git remote has no GitHub checks, so the In Review header names its repo, host/path, instead
|
|
41
|
+
// (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
|
|
42
|
+
const REMOTE_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
43
|
+
const remoteLine = (remote, { links = null } = {}) => `<span class="github remote" title="This board’s repo">${linksMark(links, REMOTE_MARK)} <span class="remote-name">${esc(remote.name)}</span></span>`;
|
|
44
|
+
// The In Review header's right end; pending is whether a check-github request is waiting.
|
|
45
|
+
const githubLine = (checks, { pending = false, links = null } = {}) => {
|
|
46
|
+
const mark = linksMark(links);
|
|
47
|
+
if (pending) return `<span class="github">${mark} Checking…</span>`;
|
|
48
|
+
if (!checks?.at) return `<span class="github idle" title="GitHub not checked yet">${mark} not checked</span>`;
|
|
49
|
+
const title = `GitHub checked ${since(checks.at)}${checks.idle ? '; no card shows an open PR' : checks.late ? '; checks are late' : ''}`;
|
|
50
|
+
const note = checks.late && !checks.idle ? ' · checks are late' : '';
|
|
51
|
+
return `<span class="github${checks.late && !checks.idle ? ' late' : ''}${checks.idle ? ' idle' : ''}" title="${title}">${mark} ${shortSince(checks.at)}${note}</span>`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
|
|
55
|
+
// The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
|
|
56
|
+
// (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
|
|
57
|
+
const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
|
|
58
|
+
const linkRow = (l) => {
|
|
59
|
+
const st = linkState(l);
|
|
60
|
+
const [cls, label] = LINK_DOTS[st] || LINK_DOTS.broken;
|
|
61
|
+
const detail = st === 'settling' ? `Settling: ${l.detail}` : l.detail;
|
|
62
|
+
return `<li><span class="dot${cls ? ` ${cls}` : ''}" role="img" aria-label="${label}"></span><span><strong>${esc(LINK_NAMES[l.link] || l.link)}</strong><span class="sub">${esc(detail)}</span></span></li>`;
|
|
63
|
+
};
|
|
64
|
+
const linksList = (links) => (links?.length
|
|
65
|
+
? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
|
|
66
|
+
: '<p class="muted">The hub doesn\'t list this board yet. It picks a board up from the manifest on its next check.</p>');
|
|
67
|
+
|
|
68
|
+
// The checklist a new board shows at the top until its session answered the hello and GitHub checks reach it; setup
|
|
69
|
+
// is /api/board's, null once both pass. Cards can start meanwhile.
|
|
70
|
+
const setupCard = (setup) => {
|
|
71
|
+
if (!setup) return '';
|
|
72
|
+
const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
|
|
73
|
+
return `<h2>Connecting this board</h2><ul class="setup-steps">
|
|
74
|
+
${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
|
|
75
|
+
${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
|
|
76
|
+
</ul>`;
|
|
77
|
+
};
|
|
78
|
+
const checkLanded = (checks, pendingSince) => Boolean(checks?.at) && Date.parse(checks.at) >= pendingSince;
|
|
79
|
+
const refreshIcon = (size) => `<svg 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"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>`;
|
|
80
|
+
// The GitHub line's own refresh, spinning and resting while a request is pending. It sits in the lane's summary, so
|
|
81
|
+
// it is aria-disabled, not disabled: a resting tap must still reach app-header.js's handler, which keeps the lane from folding.
|
|
82
|
+
const githubRefresh = (checks, pendingSince) => `<button class="ghost gh-refresh${pendingSince ? ' spinning' : ''}" type="button" data-check-github aria-label="Check GitHub" title="Check GitHub"${pendingSince ? ' aria-disabled="true"' : ''}>${refreshIcon(14)}</button>`;
|
|
83
|
+
|
|
84
|
+
// Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
|
|
85
|
+
// both turn on together once the first board load has answered or failed.
|
|
86
|
+
// Offline, refresh stays on so a tap can retry the load (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md).
|
|
87
|
+
const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
|
|
88
|
+
const ready = wired && boardSettled && !reloading;
|
|
89
|
+
return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
|
|
93
|
+
})();
|