@lovek4d/kickflip 0.0.0-stage → 0.1.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 +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -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 +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -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 +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -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/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -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 +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -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 +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
function create(root) {
|
|
3
|
+
const stack = document.createElement('div');
|
|
4
|
+
stack.setAttribute('class', 'toast-stack');
|
|
5
|
+
stack.setAttribute('aria-label', 'Notifications');
|
|
6
|
+
root.appendChild(stack);
|
|
7
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
8
|
+
const enterTime = () => reduced.matches ? 80 : 150;
|
|
9
|
+
const exitTime = () => reduced.matches ? 100 : 200;
|
|
10
|
+
let entries = [];
|
|
11
|
+
let pending = [];
|
|
12
|
+
let frame = 0;
|
|
13
|
+
let destroyed = false;
|
|
14
|
+
const held = (item) => !item.forced && item.holds.size > 0;
|
|
15
|
+
const opacity = (item) => held(item) || item.phase === 'solid' ? 1
|
|
16
|
+
: item.phase === 'in' ? 1 - item.remaining / item.fade : item.from * item.remaining / item.fade;
|
|
17
|
+
|
|
18
|
+
function layout() {
|
|
19
|
+
let bottom = 0;
|
|
20
|
+
for (const item of [...entries].reverse()) {
|
|
21
|
+
bottom += item.el.getBoundingClientRect().height;
|
|
22
|
+
item.row.style.transform = `translateY(${-bottom}px)`;
|
|
23
|
+
bottom += 8;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function draw() {
|
|
28
|
+
for (const item of entries) item.el.style.opacity = Math.max(0, Math.min(1, opacity(item)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dismiss(item) {
|
|
32
|
+
if (!entries.includes(item) || item.forced) return;
|
|
33
|
+
advance(item, performance.now());
|
|
34
|
+
item.from = Math.max(0, opacity(item));
|
|
35
|
+
item.phase = 'out';
|
|
36
|
+
item.remaining = item.fade = exitTime();
|
|
37
|
+
item.last = performance.now();
|
|
38
|
+
item.forced = true;
|
|
39
|
+
item.holds.clear();
|
|
40
|
+
draw();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function advance(item, now) {
|
|
44
|
+
if (!held(item)) item.remaining -= Math.max(0, now - item.last);
|
|
45
|
+
item.last = now;
|
|
46
|
+
while (item.remaining <= 0) {
|
|
47
|
+
if (item.phase === 'out') return false;
|
|
48
|
+
item.phase = item.phase === 'in' ? 'solid' : 'out';
|
|
49
|
+
item.from = 1;
|
|
50
|
+
item.fade = exitTime();
|
|
51
|
+
item.remaining += item.phase === 'solid' ? item.duration : item.fade;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hold(item, source, on) {
|
|
57
|
+
if (!entries.includes(item) || item.forced || item.holds.has(source) === on) return;
|
|
58
|
+
advance(item, performance.now());
|
|
59
|
+
if (on) {
|
|
60
|
+
item.holds.add(source);
|
|
61
|
+
if (item.phase === 'in') { item.phase = 'solid'; item.remaining = item.duration; }
|
|
62
|
+
if (item.phase === 'out') { item.remaining = item.fade = exitTime(); item.from = 1; }
|
|
63
|
+
} else item.holds.delete(source);
|
|
64
|
+
draw();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function attach(message) {
|
|
68
|
+
const row = document.createElement('div');
|
|
69
|
+
row.setAttribute('class', 'toast-row');
|
|
70
|
+
row.style.transition = 'none';
|
|
71
|
+
const el = document.createElement('div');
|
|
72
|
+
el.setAttribute('class', 'toast');
|
|
73
|
+
el.setAttribute('role', 'status');
|
|
74
|
+
el.setAttribute('aria-live', 'polite');
|
|
75
|
+
el.setAttribute('aria-atomic', 'true');
|
|
76
|
+
el.setAttribute('tabindex', '0');
|
|
77
|
+
el.textContent = message.text;
|
|
78
|
+
row.appendChild(el);
|
|
79
|
+
const item = { ...message, row, el, phase: 'in', remaining: enterTime(), fade: enterTime(),
|
|
80
|
+
from: 1, last: performance.now(), holds: new Set(), forced: false, off: [] };
|
|
81
|
+
const on = (type, fn) => {
|
|
82
|
+
el.addEventListener(type, fn);
|
|
83
|
+
item.off.push(() => el.removeEventListener(type, fn));
|
|
84
|
+
};
|
|
85
|
+
let touch = null;
|
|
86
|
+
let suppressClickUntil = 0;
|
|
87
|
+
on('pointerenter', (e) => { if (e.pointerType === 'mouse') hold(item, 'hover', true); });
|
|
88
|
+
on('pointerleave', (e) => { if (e.pointerType === 'mouse') hold(item, 'hover', false); });
|
|
89
|
+
on('pointerdown', (e) => {
|
|
90
|
+
if (e.pointerType === 'mouse') return;
|
|
91
|
+
touch = { id: e.pointerId, at: performance.now(), x: e.clientX, y: e.clientY };
|
|
92
|
+
el.setPointerCapture(e.pointerId);
|
|
93
|
+
hold(item, 'touch', true);
|
|
94
|
+
});
|
|
95
|
+
function release(e) {
|
|
96
|
+
if (!touch || touch.id !== e.pointerId) return;
|
|
97
|
+
const wasHold = performance.now() - touch.at > 350 || Math.hypot(e.clientX - touch.x, e.clientY - touch.y) > 10;
|
|
98
|
+
if (wasHold || e.type !== 'pointerup') suppressClickUntil = performance.now() + 600;
|
|
99
|
+
touch = null;
|
|
100
|
+
hold(item, 'touch', false);
|
|
101
|
+
}
|
|
102
|
+
on('pointerup', release);
|
|
103
|
+
on('pointercancel', release);
|
|
104
|
+
on('lostpointercapture', release);
|
|
105
|
+
on('contextmenu', (e) => e.preventDefault());
|
|
106
|
+
on('click', () => { if (performance.now() >= suppressClickUntil) dismiss(item); });
|
|
107
|
+
on('keydown', (e) => {
|
|
108
|
+
if (!['Escape', 'Enter', ' '].includes(e.key)) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
dismiss(item);
|
|
111
|
+
});
|
|
112
|
+
entries.push(item);
|
|
113
|
+
stack.appendChild(row);
|
|
114
|
+
observer.observe(el);
|
|
115
|
+
layout();
|
|
116
|
+
row.getBoundingClientRect();
|
|
117
|
+
row.style.transition = '';
|
|
118
|
+
if (matchMedia('(hover: hover)').matches && el.matches(':hover')) hold(item, 'hover', true);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function drain() {
|
|
122
|
+
while (pending.length && entries.length < 3) attach(pending.shift());
|
|
123
|
+
// The next arrival waits for eviction's fade so four messages never show together.
|
|
124
|
+
if (pending.length && entries.length === 3) dismiss(entries[0]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function remove(item) {
|
|
128
|
+
item.off.forEach((off) => off());
|
|
129
|
+
observer.unobserve(item.el);
|
|
130
|
+
item.row.remove();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tick(now) {
|
|
134
|
+
frame = 0;
|
|
135
|
+
const expired = entries.filter((item) => !advance(item, now));
|
|
136
|
+
for (const item of expired) remove(item);
|
|
137
|
+
if (expired.length) {
|
|
138
|
+
entries = entries.filter((item) => !expired.includes(item));
|
|
139
|
+
drain();
|
|
140
|
+
layout();
|
|
141
|
+
}
|
|
142
|
+
draw();
|
|
143
|
+
if (entries.length) frame = requestAnimationFrame(tick);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toast(text) {
|
|
147
|
+
if (destroyed || !text.trim()) return;
|
|
148
|
+
const length = Array.from(text.replace(/\s+/g, ' ').trim()).length;
|
|
149
|
+
pending.push({ text, duration: length > 100 ? 5000 : 3000 });
|
|
150
|
+
drain();
|
|
151
|
+
draw();
|
|
152
|
+
if (!frame) frame = requestAnimationFrame(tick);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function releaseHolds() {
|
|
156
|
+
const now = performance.now();
|
|
157
|
+
for (const item of entries) { advance(item, now); item.holds.clear(); }
|
|
158
|
+
draw();
|
|
159
|
+
}
|
|
160
|
+
const visibility = () => { if (document.hidden) releaseHolds(); };
|
|
161
|
+
window.addEventListener('blur', releaseHolds);
|
|
162
|
+
document.addEventListener('visibilitychange', visibility);
|
|
163
|
+
const observer = new ResizeObserver(layout);
|
|
164
|
+
observer.observe(stack);
|
|
165
|
+
function destroy() {
|
|
166
|
+
destroyed = true;
|
|
167
|
+
cancelAnimationFrame(frame);
|
|
168
|
+
entries.forEach(remove);
|
|
169
|
+
observer.disconnect();
|
|
170
|
+
window.removeEventListener('blur', releaseHolds);
|
|
171
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
172
|
+
stack.remove();
|
|
173
|
+
entries = [];
|
|
174
|
+
pending = [];
|
|
175
|
+
}
|
|
176
|
+
return { toast, destroy };
|
|
177
|
+
}
|
|
178
|
+
globalThis.BoardToasts = { create };
|
|
179
|
+
})();
|
|
@@ -0,0 +1,153 @@
|
|
|
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, time, 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: 'normal', 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('normal', 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(it.ticket.source === 'internal' ? 'quiet' : 'normal', 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
|
+
return badge(s.tone, `${esc(r[0])} ${s.mark}${reviewAge(rv)}`, { title: `${r}: ${s.label}` });
|
|
37
|
+
}).join('');
|
|
38
|
+
}
|
|
39
|
+
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
40
|
+
// docs/adr/0045-the-lane-follows-who-acts-next.md
|
|
41
|
+
function actionChip(it) {
|
|
42
|
+
if (!it.action) return '';
|
|
43
|
+
const external = it.action.kind === ACTION_KIND.ready ? EXTERNAL : {};
|
|
44
|
+
return badge('notice', 'Action needed', { href: it.action.url, title: it.action.label, attrs: { ...external, 'aria-label': `Action needed: ${it.action.label}` } });
|
|
45
|
+
}
|
|
46
|
+
const tryWords = (kind) => kind === 'app' ? {
|
|
47
|
+
go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
|
|
48
|
+
none: 'No changes to build: this card’s branch matches main.',
|
|
49
|
+
current: 'Nothing new to build: Install already has this card’s latest push.',
|
|
50
|
+
busy: 'Building now. The badge turns back into Install when the new build is ready.',
|
|
51
|
+
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.',
|
|
52
|
+
saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
|
|
53
|
+
} : {
|
|
54
|
+
go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
|
|
55
|
+
none: 'No changes to serve: this card’s branch matches main.',
|
|
56
|
+
current: 'Nothing new to serve: Visit already shows this card’s latest push.',
|
|
57
|
+
busy: 'Serving now. The badge turns back into Visit when the new server is up.',
|
|
58
|
+
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.',
|
|
59
|
+
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
60
|
+
};
|
|
61
|
+
const spinner = '<span class="try-spin" aria-hidden="true"></span>';
|
|
62
|
+
function tryOf(it) {
|
|
63
|
+
if (it.lane === 'done') return null;
|
|
64
|
+
const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
|
|
65
|
+
return it.builder?.kind === BUILDER_KIND.install ? { ...t, kind: t?.kind || 'app', state: 'running' } : t;
|
|
66
|
+
}
|
|
67
|
+
function installState(it) {
|
|
68
|
+
const t = tryOf(it);
|
|
69
|
+
if (!t) return null;
|
|
70
|
+
const words = tryWords(t.kind);
|
|
71
|
+
if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
|
|
72
|
+
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#item=${encodeURIComponent(it.key)}`,
|
|
73
|
+
title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
|
|
74
|
+
if (t.state !== 'ready' || !t.url) return null;
|
|
75
|
+
return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
|
|
76
|
+
title: t.sha ? `Built from ${t.sha}${t.stale ? '; the branch has newer commits' : ''}` : null, attrs: t.kind === 'web' ? EXTERNAL : {} };
|
|
77
|
+
}
|
|
78
|
+
function installChip(it) {
|
|
79
|
+
const s = installState(it);
|
|
80
|
+
return s ? badge(s.tone, s.html, { href: s.href, title: s.title, attrs: s.attrs }) : '';
|
|
81
|
+
}
|
|
82
|
+
function installButton(it) {
|
|
83
|
+
const s = installState(it);
|
|
84
|
+
if (!s) return '';
|
|
85
|
+
const attrs = Object.entries({ href: s.href, title: s.title, ...s.attrs }).filter(([, v]) => v != null)
|
|
86
|
+
.map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
87
|
+
const tag = s.href ? 'a' : 'span';
|
|
88
|
+
return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
|
|
89
|
+
}
|
|
90
|
+
function tryButton(it) {
|
|
91
|
+
const t = tryOf(it);
|
|
92
|
+
if (it.kind !== 'work' || !t?.script) return '';
|
|
93
|
+
const words = tryWords(t.kind);
|
|
94
|
+
const reason = !t.changes ? words.none : t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
|
|
95
|
+
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>`;
|
|
96
|
+
}
|
|
97
|
+
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>';
|
|
98
|
+
function mainBar(main) {
|
|
99
|
+
if (!main) return '';
|
|
100
|
+
const words = tryWords(main.kind);
|
|
101
|
+
const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
|
|
102
|
+
: main.state === 'failed' ? badge('danger', esc(words.failed), { title: 'The build script stopped with an error. Its log is data/try/main.log in the board’s folder.' })
|
|
103
|
+
: main.state === 'ready' && main.url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: main.url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
104
|
+
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>`;
|
|
105
|
+
}
|
|
106
|
+
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
|
|
107
|
+
// while it is sent or waits on the session (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
|
|
108
|
+
function mergeButton(it, sending = false) {
|
|
109
|
+
if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
|
|
110
|
+
const merging = sending || it.merging;
|
|
111
|
+
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
|
|
112
|
+
}
|
|
113
|
+
const builderLine = (b) => [b.progress || 'Building', b.at && ago(b.at)].filter(Boolean).join(' · ');
|
|
114
|
+
const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
|
|
115
|
+
button: true, title: 'No recent progress. Nudge Claude to check this builder',
|
|
116
|
+
attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
|
|
117
|
+
}) : '';
|
|
118
|
+
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
119
|
+
const unreadChip = (n) => {
|
|
120
|
+
if (!n) return '';
|
|
121
|
+
const label = `${n} unread message${n === 1 ? '' : 's'}`;
|
|
122
|
+
return `<span class="chip unread" title="${label}" aria-label="${label}">${n} new</span>`;
|
|
123
|
+
};
|
|
124
|
+
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
125
|
+
// docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
|
|
126
|
+
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
127
|
+
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, top)}</div>` : '');
|
|
128
|
+
const ASK_STATE = { waiting: 'Waiting', working: `<span class="working">${workingLine}</span>` };
|
|
129
|
+
const askLine = (ask) => `${ask.from ? `Moved from ${esc(ask.from)} · ` : ''}${esc(time(ask.askedAt))} · ${ask.edited ? 'Edited · ' : ''}${ASK_STATE[ask.state] || ''}`;
|
|
130
|
+
function workLines(it, { unread = 0, top = false } = {}) {
|
|
131
|
+
const ids = it.kind === 'ask' ? askChip(it) : 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);
|
|
132
|
+
const blocked = (it.blockedBy || []).map((d) => badge('notice', `blocked by ${esc(d.ref)}`, { href: `#item=${encodeURIComponent(d.key)}` })).join('');
|
|
133
|
+
const ready = !blocked && !it.action && it.deps?.length && it.lane === 'todo' ? badge('ready', 'ready to start') : '';
|
|
134
|
+
const chips = `${it.todoAge ? badge('quiet', esc(it.todoAge)) : ''}${stalledChip(it)}${unreadChip(unread)}${actionChip(it)}${it.kind === 'followup' ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.held ? badge('quiet', 'held') : ''}${it.risky ? badge('notice', 'risky') : ''}${it.bumpedAt ? badge('normal', 'bumped') : ''}${ready}${installChip(it)}`;
|
|
135
|
+
const status = it.isNew && !it.action ? `<div class="line3 sub">${askLine(it.ask)}</div>`
|
|
136
|
+
: it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
|
|
137
|
+
: it.builder ? `<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
|
|
138
|
+
: it.working ? `<div class="line3 sub agent">${workingLine}</div>`
|
|
139
|
+
: it.next ? `<div class="line3 sub next">Next: ${esc(it.next)}</div>` : '';
|
|
140
|
+
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
|
|
141
|
+
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
142
|
+
${status}${eta}
|
|
143
|
+
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
144
|
+
${blocked ? `<div class="line5 meta blocked">${blocked}</div>` : ''}`;
|
|
145
|
+
}
|
|
146
|
+
const askChip = (it) => badge('normal', it.isNew ? 'New' : 'Task');
|
|
147
|
+
function planButton(items) {
|
|
148
|
+
const keys = items.filter((i) => i.kind === 'followup').map((i) => i.key);
|
|
149
|
+
return keys.length > 1 ? `<button class="link" data-plan="${esc(keys.join(' '))}">Plan a wave from these ${keys.length} follow-ups →</button>` : '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionChip, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, unreadCount, workLines, askChip, askActions, askLine, planButton });
|
|
153
|
+
})();
|
|
@@ -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,84 @@
|
|
|
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). link is 'online',
|
|
6
|
+
// 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
|
|
7
|
+
const statusLine = (at, { link = 'online', watcher, pause } = {}) => (pause?.state === 'paused' ? `<span class="late">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
|
|
8
|
+
: link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
|
|
9
|
+
: link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
|
|
10
|
+
: watcher === 'stopped' ? '<span class="claude late" title="Claude not listening">not listening</span>'
|
|
11
|
+
: at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
|
|
12
|
+
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
13
|
+
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
|
14
|
+
// fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
|
|
15
|
+
// stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
|
|
16
|
+
// something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
|
|
17
|
+
// (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
|
|
18
|
+
const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
|
|
19
|
+
const shortSince = (iso) => {
|
|
20
|
+
const s = (Date.now() - Date.parse(iso)) / 1000;
|
|
21
|
+
return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
|
|
22
|
+
};
|
|
23
|
+
// 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).
|
|
24
|
+
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>';
|
|
25
|
+
// The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
|
|
26
|
+
// (docs/adr/0046-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
|
|
27
|
+
// when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
|
|
28
|
+
const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
|
|
29
|
+
const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
|
|
30
|
+
const linksMark = (links) => {
|
|
31
|
+
const broken = brokenLinks(links).length;
|
|
32
|
+
const label = `Hub links${broken ? `: ${broken} not working` : ''}`;
|
|
33
|
+
return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${GITHUB_MARK}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
|
|
34
|
+
};
|
|
35
|
+
// The In Review header's right end; pending is whether a check-github request is waiting.
|
|
36
|
+
const githubLine = (checks, { pending = false, links = null } = {}) => {
|
|
37
|
+
const mark = linksMark(links);
|
|
38
|
+
if (pending) return `<span class="github">${mark} Checking…</span>`;
|
|
39
|
+
if (!checks?.at) return `<span class="github idle" title="GitHub not checked yet">${mark} not checked</span>`;
|
|
40
|
+
const title = `GitHub checked ${since(checks.at)}${checks.idle ? '; no card shows an open PR' : checks.late ? '; checks are late' : ''}`;
|
|
41
|
+
const note = checks.late && !checks.idle ? ' · checks are late' : '';
|
|
42
|
+
return `<span class="github${checks.late && !checks.idle ? ' late' : ''}${checks.idle ? ' idle' : ''}" title="${title}">${mark} ${shortSince(checks.at)}${note}</span>`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const LINK_NAMES = { github: 'GitHub polling', events: 'Events', wakes: 'Daily wakes', push: 'Push notifications', slack: 'Reviewer links' };
|
|
46
|
+
// The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
|
|
47
|
+
// (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
|
|
48
|
+
const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
|
|
49
|
+
const linkRow = (l) => {
|
|
50
|
+
const st = linkState(l);
|
|
51
|
+
const [cls, label] = LINK_DOTS[st] || LINK_DOTS.broken;
|
|
52
|
+
const detail = st === 'settling' ? `Settling: ${l.detail}` : l.detail;
|
|
53
|
+
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>`;
|
|
54
|
+
};
|
|
55
|
+
const linksList = (links) => (links?.length
|
|
56
|
+
? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
|
|
57
|
+
: '<p class="muted">The hub doesn\'t list this board yet. It picks a board up from the manifest on its next check.</p>');
|
|
58
|
+
|
|
59
|
+
// The checklist a new board shows at the top until its session answered the hello and GitHub checks reach it; setup
|
|
60
|
+
// is /api/board's, null once both pass. Cards can start meanwhile.
|
|
61
|
+
const setupCard = (setup) => {
|
|
62
|
+
if (!setup) return '';
|
|
63
|
+
const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
|
|
64
|
+
return `<h2>Connecting this board</h2><ul class="setup-steps">
|
|
65
|
+
${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
|
|
66
|
+
${step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
|
|
67
|
+
</ul>`;
|
|
68
|
+
};
|
|
69
|
+
const checkLanded = (checks, pendingSince) => Boolean(checks?.at) && Date.parse(checks.at) >= pendingSince;
|
|
70
|
+
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>`;
|
|
71
|
+
// The GitHub line's own refresh, spinning and resting while a request is pending. It sits in the lane's summary, so
|
|
72
|
+
// it is aria-disabled, not disabled: a resting tap must still reach app-header.js's handler, which keeps the lane from folding.
|
|
73
|
+
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>`;
|
|
74
|
+
|
|
75
|
+
// Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
|
|
76
|
+
// both turn on together once the first board load has answered or failed.
|
|
77
|
+
// Offline, refresh stays on so a tap can retry the load (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md).
|
|
78
|
+
const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
|
|
79
|
+
const ready = wired && boardSettled && !reloading;
|
|
80
|
+
return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
|
|
84
|
+
})();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), which hub.css styles
|
|
2
|
+
// under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
|
|
3
|
+
(function () {
|
|
4
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
5
|
+
const TILE_PATHS = {
|
|
6
|
+
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
7
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
8
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
9
|
+
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
|
10
|
+
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
|
|
11
|
+
snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
|
|
12
|
+
laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
|
|
13
|
+
key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
|
|
14
|
+
};
|
|
15
|
+
const tile = (key, color) => `<span class="icon sm solid" style="--c:${color}"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${TILE_PATHS[key]}</svg></span>`;
|
|
16
|
+
const CHEVRON = '<span class="chev" aria-hidden="true">›</span>';
|
|
17
|
+
|
|
18
|
+
function row({ icon = '', title, sub = '', wrap = false, trail = '', href, attrs = '' }) {
|
|
19
|
+
const inner = `${icon}<span class="body"><span class="text"><span class="title">${title}</span>${sub ? `<span class="sub${wrap ? ' wrap' : ''}">${sub}</span>` : ''}</span>${trail}</span>`;
|
|
20
|
+
return href ? `<a class="row" href="${esc(href)}"${attrs}>${inner}</a>` : `<div class="row"${attrs}>${inner}</div>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// flash: {text, error} or null, a form's answer shown above the view.
|
|
24
|
+
const flash = (f) => (f ? `<div class="flash${f.error ? ' error' : ''}" role="${f.error ? 'alert' : 'status'}">${esc(f.text)}</div>` : '');
|
|
25
|
+
|
|
26
|
+
// hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
|
|
27
|
+
const holdBanner = (hold) => (hold ? `<div class="hold-banner"><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">${TILE_PATHS.moon}</svg><span>${esc(hold.text)}</span>${hold.end ? '<form method="post" action="/_hub/settings/notifications"><button class="btn quiet" name="snooze" value="off">End snooze</button></form>' : ''}</div>` : '');
|
|
28
|
+
|
|
29
|
+
function pauseBanner(pause) {
|
|
30
|
+
if (!pause || pause.state === 'normal') return '';
|
|
31
|
+
const going = pause.state === 'going';
|
|
32
|
+
const until = BoardUI.usageClock(pause.resets_at);
|
|
33
|
+
const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
|
|
34
|
+
const text = going ? `Past the buffer: spending usage until ${until}`
|
|
35
|
+
: `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
|
|
36
|
+
return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><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">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bufferControl(pause) {
|
|
40
|
+
const at = pause.pause_at;
|
|
41
|
+
const off = at === 100;
|
|
42
|
+
return `<div class="u-buffer"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
|
|
43
|
+
+ `<form class="segments ub-step" method="post" action="/_hub/settings/pause-at" role="group" aria-label="Pause work at"><button name="value" value="${at - 5}" aria-label="Pause 5% sooner"${at <= 50 ? ' disabled' : ''}>−</button>`
|
|
44
|
+
+ `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
|
|
45
|
+
+ `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
|
|
46
|
+
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : 'Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to Opus builders.'}</p></div>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
50
|
+
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
51
|
+
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
52
|
+
|
|
53
|
+
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
|
|
54
|
+
// null while unknown, which keeps the row hidden.
|
|
55
|
+
function deviceRow(state, { compact = false } = {}) {
|
|
56
|
+
if (!state) return `<div class="row" id="push" hidden>${tile('bell', '#ef4444')}</div>`;
|
|
57
|
+
const a = state.action;
|
|
58
|
+
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>` : '';
|
|
59
|
+
return `<div class="row" id="push">${tile('bell', '#ef4444')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
|
|
63
|
+
// view-hub.js sends. While Codex isn't usable the choices are disabled, with the reason and the command to run.
|
|
64
|
+
function codexShare(codex) {
|
|
65
|
+
const off = !codex.usable;
|
|
66
|
+
const buttons = codex.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}"${off ? ' disabled' : ''}>${esc(c.label)}</button>`).join('');
|
|
67
|
+
return `<div class="u-share"><form class="segments${off ? ' is-disabled' : ''}" method="post" action="/_hub/settings/codex" role="group" aria-label="Codex share"${off ? ' aria-disabled="true"' : ''}>${buttons}</form>`
|
|
68
|
+
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}${codex.note ? `<p class="u-share-note">${esc(codex.note)}</p>` : ''}</div>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// data is the usage reading; codex, when given, draws Codex's block even without a reading: the Codex share control
|
|
72
|
+
// first, then Codex's bars while share isn't Off.
|
|
73
|
+
function usageCard(data, codex, pause) {
|
|
74
|
+
const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
|
|
75
|
+
const products = usageProducts(data);
|
|
76
|
+
if (codex && !products.includes('codex')) products.push('codex');
|
|
77
|
+
if (!products.length) return '';
|
|
78
|
+
const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
|
|
79
|
+
const asof = (r) => (!r.at ? 'no reading' : `${r.five_hour?.state === 'stale' ? 'last seen' : 'read'} ${usageClock(r.at)}`);
|
|
80
|
+
const windows = (r) => [['5-hour', 'five_hour'], ['Week', 'seven_day']].map(([label, key]) =>
|
|
81
|
+
`<div class="u-tone-${usageTone(r[key])}${pause?.pause_at < 100 ? ' ub-marked' : ''}"><div class="u-win"><span>${label}</span><span>${usageWords(r, key)}</span></div>${usageBar(r[key]).replace('</span>', `${pause?.pause_at < 100 ? `<span class="ub-mark" style="left:${pause.pause_at}%"></span>` : ''}</span>`)}</div>`).join('');
|
|
82
|
+
const blocks = products.map((p) => {
|
|
83
|
+
const r = data?.[p] || { at: null };
|
|
84
|
+
if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}${p === 'claude' && pause ? bufferControl(pause) : ''}</div>`;
|
|
85
|
+
const bars = codex.usable && codex.share !== 'off';
|
|
86
|
+
return `<div class="block u-block">${head(usageNames.codex, codex.tag || (bars ? asof(r) : ''))}${codexShare(codex)}${bars ? `<div class="u-codex-bars">${windows(r)}</div>` : ''}</div>`;
|
|
87
|
+
}).join('');
|
|
88
|
+
const source = [data?.claude && 'Claude Code’s status line, which every busy session redraws', data?.codex && 'Codex’s newest session logs'].filter(Boolean).join(', and ');
|
|
89
|
+
const note = source ? `<p class="note">From ${source}. A window whose reset time passed reads as reset.</p>` : '';
|
|
90
|
+
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}</div>${note}</section>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
|
|
94
|
+
})();
|