@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,438 @@
|
|
|
1
|
+
// The board view. Each app-*.js file registers a part with BoardView.part((B) => …); BoardView.create(root, site, opts)
|
|
2
|
+
// builds one board's B over its root element and its site root (such as '/hub-orchestrator', through ClaudeSite.site),
|
|
3
|
+
// and mount() runs the parts in load order, then renders the board from /api/board and per-item threads from
|
|
4
|
+
// /api/thread. Actions go out as events through B.site.send (each wakes Claude). Parts add listeners, intervals,
|
|
5
|
+
// timers, watches and unmount steps only through B.on, B.every, B.later, B.watch and B.atUnmount, so unmount() undoes
|
|
6
|
+
// them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
|
|
7
|
+
// BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
|
|
8
|
+
// (view-*.js) the same site, $ and recorders.
|
|
9
|
+
// opts, both from the app shell (shell.js):
|
|
10
|
+
// signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
|
|
11
|
+
// live {fresh, generatedAt}
|
|
12
|
+
// this board was left fresh and hasn't changed since, so a stored last visit whose board has
|
|
13
|
+
// that generatedAt paints live, with the header time `fresh`, rather than dimmed
|
|
14
|
+
// Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
|
|
15
|
+
window.BoardView = (function () {
|
|
16
|
+
const parts = [];
|
|
17
|
+
const part = (fn) => { parts.push(fn); };
|
|
18
|
+
// route → what a board's view kept as it unmounted, which its next mount in this page reads as B.place. A reload
|
|
19
|
+
// starts with none.
|
|
20
|
+
const places = new Map();
|
|
21
|
+
|
|
22
|
+
// Every board shares the hub's one origin, so each keeps its keys under site:<route>:; app.html's preload reads
|
|
23
|
+
// openItems by the same key. Thread and question drafts are already per card and keep their own keys.
|
|
24
|
+
function storeFor(route) {
|
|
25
|
+
const keyOf = (k) => (/^(draft:(?!ask$)|qdraft:)/.test(k) ? k : `site:${route}:${k}`);
|
|
26
|
+
// The keys boards kept unprefixed move once, to the first board opened; a key whose new name is taken stays.
|
|
27
|
+
try {
|
|
28
|
+
const old = [];
|
|
29
|
+
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
30
|
+
for (const k of old.filter((k) => /^(openItems|wave|lanes|seenThreads|dismissedAnswers|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
31
|
+
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
32
|
+
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
33
|
+
localStorage.removeItem(k);
|
|
34
|
+
}
|
|
35
|
+
} catch { /* no storage: nothing to move */ }
|
|
36
|
+
return {
|
|
37
|
+
get(k, d) { try { const v = localStorage.getItem(keyOf(k)); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
|
38
|
+
set(k, v) { try { localStorage.setItem(keyOf(k), JSON.stringify(v)); } catch {} },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// What every view of one site shares: its ClaudeSite.site, a $ and $$ scoped to its root, and the recorders. Each
|
|
43
|
+
// recorder returns a function that undoes what it added, and stop() runs every one still pending, so a view adds
|
|
44
|
+
// listeners, intervals, timers, watches and unmount steps only through them. Before start() and after
|
|
45
|
+
// stop() they add nothing. B.sleep(ms) resolves after ms, and never once the view is gone, so a retry loop stops.
|
|
46
|
+
function base(root, siteRoot) {
|
|
47
|
+
const site = ClaudeSite.site(siteRoot);
|
|
48
|
+
const route = new URL(site.url('')).pathname.replace(/^\/+|\/+$/g, '');
|
|
49
|
+
// attach.js uploads each composer's files to the site of the [data-site] around it.
|
|
50
|
+
root.dataset.site = site.root;
|
|
51
|
+
let alive = false;
|
|
52
|
+
const undo = new Set();
|
|
53
|
+
const own = (off) => { undo.add(off); return () => { undo.delete(off); off(); }; };
|
|
54
|
+
const on = (target, type, fn, opts) => {
|
|
55
|
+
if (!alive || !target) return () => {};
|
|
56
|
+
target.addEventListener(type, fn, opts);
|
|
57
|
+
return own(() => target.removeEventListener(type, fn, opts));
|
|
58
|
+
};
|
|
59
|
+
const every = (ms, fn) => {
|
|
60
|
+
if (!alive) return () => {};
|
|
61
|
+
const t = setInterval(fn, ms);
|
|
62
|
+
return own(() => clearInterval(t));
|
|
63
|
+
};
|
|
64
|
+
const later = (ms, fn) => {
|
|
65
|
+
if (!alive) return () => {};
|
|
66
|
+
const off = () => clearTimeout(t);
|
|
67
|
+
const t = setTimeout(() => { undo.delete(off); fn(); }, ms);
|
|
68
|
+
return own(off);
|
|
69
|
+
};
|
|
70
|
+
const sleep = (ms) => new Promise((resolve) => { later(ms, resolve); });
|
|
71
|
+
const watch = (fn, opts) => {
|
|
72
|
+
if (!alive) return null;
|
|
73
|
+
const w = site.watch(fn, opts);
|
|
74
|
+
own(() => w.stop());
|
|
75
|
+
return w;
|
|
76
|
+
};
|
|
77
|
+
const atUnmount = (fn) => { if (alive) own(fn); };
|
|
78
|
+
const $ = (s, el = root) => el.querySelector(s);
|
|
79
|
+
const $$ = (s, el = root) => el.querySelectorAll(s);
|
|
80
|
+
let toasts = null;
|
|
81
|
+
const toast = (text) => {
|
|
82
|
+
if (!alive) return;
|
|
83
|
+
if (!toasts) {
|
|
84
|
+
toasts = BoardToasts.create(root);
|
|
85
|
+
own(() => { toasts.destroy(); toasts = null; });
|
|
86
|
+
}
|
|
87
|
+
toasts.toast(text);
|
|
88
|
+
};
|
|
89
|
+
const B = { root, site, route, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
|
|
90
|
+
return {
|
|
91
|
+
B,
|
|
92
|
+
start() { alive = true; },
|
|
93
|
+
stop() {
|
|
94
|
+
alive = false;
|
|
95
|
+
for (const off of [...undo]) off();
|
|
96
|
+
undo.clear();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function create(root, siteRoot, opts = {}) {
|
|
102
|
+
const { B, start: begin, stop } = base(root, siteRoot);
|
|
103
|
+
const store = storeFor(B.route);
|
|
104
|
+
// board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
|
|
105
|
+
// header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
|
|
106
|
+
// {thread, data, text}, data being what ask-edited sends besides the text.
|
|
107
|
+
const state = {
|
|
108
|
+
board: null,
|
|
109
|
+
fresh: null,
|
|
110
|
+
openItems: new Set(store.get('openItems', [])),
|
|
111
|
+
threadCache: new Map(),
|
|
112
|
+
withdrawnAsks: new Set(),
|
|
113
|
+
edits: new Map(),
|
|
114
|
+
githubPending: null,
|
|
115
|
+
waveFilter: store.get('wave', null),
|
|
116
|
+
};
|
|
117
|
+
const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
|
|
118
|
+
// B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
|
|
119
|
+
const keepers = [];
|
|
120
|
+
const keep = (fn) => { if (B.alive()) keepers.push(fn); };
|
|
121
|
+
const place = places.get(B.route) || null;
|
|
122
|
+
places.delete(B.route);
|
|
123
|
+
Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, place, keep });
|
|
124
|
+
|
|
125
|
+
function mount() {
|
|
126
|
+
if (B.alive()) return;
|
|
127
|
+
begin();
|
|
128
|
+
for (const p of parts) p(B);
|
|
129
|
+
start(B);
|
|
130
|
+
}
|
|
131
|
+
function unmount() {
|
|
132
|
+
if (B.alive()) places.set(B.route, Object.assign({}, ...keepers.map((fn) => fn())));
|
|
133
|
+
keepers.length = 0;
|
|
134
|
+
stop();
|
|
135
|
+
}
|
|
136
|
+
return { B, mount, unmount };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A view other than the board: run(B) adds what it needs through B's recorders when the view mounts.
|
|
140
|
+
// → {B, mount, unmount}
|
|
141
|
+
function page(root, siteRoot, run) {
|
|
142
|
+
const { B, start: begin, stop } = base(root, siteRoot);
|
|
143
|
+
return {
|
|
144
|
+
B,
|
|
145
|
+
mount() { if (B.alive()) return; begin(); run(B); },
|
|
146
|
+
unmount: stop,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The board itself, after every part: rendering, the #item= hash, loading and the change signal.
|
|
151
|
+
function start(B) {
|
|
152
|
+
const { $, $$, store, state, steady, markSeen } = B;
|
|
153
|
+
const { setHTML, preloadKeys } = BoardUI;
|
|
154
|
+
|
|
155
|
+
// Renders open threads from state.threadCache; loadBoard and opening a card load them.
|
|
156
|
+
function render(load = null) {
|
|
157
|
+
const d = state.board;
|
|
158
|
+
const title = $(':scope > .top h1');
|
|
159
|
+
if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
|
|
160
|
+
if (d.title && document.title !== d.title) document.title = d.title;
|
|
161
|
+
steady(() => {
|
|
162
|
+
B.renderHeader(d);
|
|
163
|
+
B.renderArch(d);
|
|
164
|
+
B.renderWaves(d);
|
|
165
|
+
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
|
|
166
|
+
B.renderMainLog(d.main, load);
|
|
167
|
+
const live = d.items.filter((i) => !state.withdrawnAsks.has(i.id));
|
|
168
|
+
const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter || i.lineup?.slug === state.waveFilter) : live;
|
|
169
|
+
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
170
|
+
for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
|
|
171
|
+
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
172
|
+
setHTML($('#lanes'), B.lanesHtml(d, shown), { keep: '[data-thread]' });
|
|
173
|
+
for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
174
|
+
B.renderNeeds(d);
|
|
175
|
+
B.renderBanner(d);
|
|
176
|
+
});
|
|
177
|
+
paintFades();
|
|
178
|
+
B.flashChanged();
|
|
179
|
+
B.signCards(load);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
|
|
183
|
+
// (docs/adr/0036-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
184
|
+
let focusKey = null;
|
|
185
|
+
let replyKey = null;
|
|
186
|
+
let hashRead = false;
|
|
187
|
+
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
188
|
+
const returnToPlace = () => B.restorePlace({ linked: /^#item=/.test(location.hash) });
|
|
189
|
+
function readHash() {
|
|
190
|
+
const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
|
|
191
|
+
if (!m) return;
|
|
192
|
+
focusKey = decodeURIComponent(m[1]);
|
|
193
|
+
focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
|
|
194
|
+
replyKey = m[2] ? focusKey : null;
|
|
195
|
+
state.openItems.add(focusKey);
|
|
196
|
+
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
197
|
+
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); }
|
|
198
|
+
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
199
|
+
}
|
|
200
|
+
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
201
|
+
// A card link whose hash is already the page's fires no hashchange, so its tap opens the card here.
|
|
202
|
+
B.on(B.root, 'click', (ev) => {
|
|
203
|
+
const a = ev.target.closest?.('a[href^="#item="]');
|
|
204
|
+
if (!a || ev.defaultPrevented || a.getAttribute('href') !== location.hash || !state.board) return;
|
|
205
|
+
ev.preventDefault();
|
|
206
|
+
readHash(); render(); scrollToFocus();
|
|
207
|
+
});
|
|
208
|
+
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
209
|
+
function scrollToFocus(final = true) {
|
|
210
|
+
if (!focusKey) return;
|
|
211
|
+
const el = $(`details.item[data-key="${CSS.escape(focusKey)}"]`);
|
|
212
|
+
// The desktop header, or below 900px the status bar, would cover the card's title row.
|
|
213
|
+
if (el) {
|
|
214
|
+
el.style.scrollMarginTop = matchMedia('(min-width: 900px)').matches
|
|
215
|
+
? `${($(':scope > .top')?.offsetHeight || 0) + 8}px` : 'calc(env(safe-area-inset-top) + 8px)';
|
|
216
|
+
el.scrollIntoView({ block: 'start' });
|
|
217
|
+
}
|
|
218
|
+
if (el && replyKey === focusKey) focusReply(replyKey);
|
|
219
|
+
if (el || final) focusKey = replyKey = null;
|
|
220
|
+
}
|
|
221
|
+
// The thread loads after the card opens, so its reply box is waited for, briefly. iOS shows no keyboard for a
|
|
222
|
+
// focus that no tap started, so the box is also brought into view: one tap on it then types.
|
|
223
|
+
function focusReply(key, tries = 20) {
|
|
224
|
+
const ta = $(`form[data-reply="${CSS.escape(key)}"] textarea`);
|
|
225
|
+
if (!ta) { if (tries > 0) B.later(150, () => focusReply(key, tries - 1)); return; }
|
|
226
|
+
ta.scrollIntoView({ block: 'center' });
|
|
227
|
+
ta.focus({ preventScroll: true });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
|
|
231
|
+
// spinning, and the header's time is the snapshot's until a load lands (docs/adr/0028-the-board-opens-on-the-last-visit.md).
|
|
232
|
+
// → true when it painted live (B.live), so the controls turn on at once.
|
|
233
|
+
function paintSnapshot() {
|
|
234
|
+
const snap = B.readSnapshot();
|
|
235
|
+
if (!snap) return false;
|
|
236
|
+
const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
|
|
237
|
+
served = state.board = snap.board;
|
|
238
|
+
for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
|
|
239
|
+
state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
|
|
240
|
+
B.setView(live ? 'fresh' : 'snapshot');
|
|
241
|
+
readHash();
|
|
242
|
+
hashRead = true;
|
|
243
|
+
render();
|
|
244
|
+
scrollToFocus(false);
|
|
245
|
+
returnToPlace();
|
|
246
|
+
if (!live) B.holdForFlash();
|
|
247
|
+
return live;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
|
|
251
|
+
// its own board. Each load fetches the open cards' threads beside the
|
|
252
|
+
// board. via is ui-header.js nextFresh's: a refresh or a change signal fetches every open thread anew rather than
|
|
253
|
+
// share a load that started before the change.
|
|
254
|
+
let boot = globalThis.BoardBoot || null;
|
|
255
|
+
globalThis.BoardBoot = null;
|
|
256
|
+
if (boot?.route && boot.route !== B.route) boot = null;
|
|
257
|
+
let preloaded = false;
|
|
258
|
+
async function loadBoard({ via = 'poll' } = {}) {
|
|
259
|
+
const load = B.boardLoad = (B.boardLoad || 0) + 1;
|
|
260
|
+
const started = boot;
|
|
261
|
+
boot = null;
|
|
262
|
+
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
263
|
+
let landed = false;
|
|
264
|
+
try {
|
|
265
|
+
const board = await (started?.board || fetch(B.site.url('api/board'), { cache: 'no-store' })
|
|
266
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
|
|
267
|
+
land(board, load);
|
|
268
|
+
landed = true;
|
|
269
|
+
if (!preloaded) {
|
|
270
|
+
preloaded = true;
|
|
271
|
+
for (const key of preloadKeys(board.items, state.openItems)) B.loadThread(key);
|
|
272
|
+
}
|
|
273
|
+
} catch (e) {
|
|
274
|
+
B.linkDown();
|
|
275
|
+
}
|
|
276
|
+
await Promise.all(threads);
|
|
277
|
+
if (landed) B.landed(via);
|
|
278
|
+
if (state.view === 'fresh') B.endFlash();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A board from the server, a load's or an events POST's, with the taps still in flight drawn over it. One built
|
|
282
|
+
// before the board on screen leaves it as it is, so a slow load can't undo a newer one. → whether it landed
|
|
283
|
+
let served = null;
|
|
284
|
+
const drawing = new Set();
|
|
285
|
+
const drawn = () => [...drawing].reduce((board, draw) => draw(board), served);
|
|
286
|
+
function land(board, load) {
|
|
287
|
+
if (state.view === 'fresh' && served && board.generatedAt < served.generatedAt) return false;
|
|
288
|
+
const first = state.view !== 'fresh';
|
|
289
|
+
served = board;
|
|
290
|
+
state.board = drawn();
|
|
291
|
+
B.setView('fresh');
|
|
292
|
+
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
293
|
+
B.linkUp();
|
|
294
|
+
render(load);
|
|
295
|
+
scrollToFocus();
|
|
296
|
+
returnToPlace();
|
|
297
|
+
B.site.syncBadge();
|
|
298
|
+
B.saveSnapshotSoon();
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
function undraw(draw) {
|
|
302
|
+
drawing.delete(draw);
|
|
303
|
+
state.board = drawn();
|
|
304
|
+
render();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// An owner tap (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md): the events POST answers with
|
|
308
|
+
// the board as the tap left it, which lands as a load of its own, so the tap needs no reload. draw(board) → board is
|
|
309
|
+
// what the tap changes when its result follows from the tap alone: the page draws it before the POST and keeps it
|
|
310
|
+
// over every board that lands until the POST settles; a failed POST takes it back off and throws.
|
|
311
|
+
async function send(kind, data, draw = null) {
|
|
312
|
+
if (draw) {
|
|
313
|
+
drawing.add(draw);
|
|
314
|
+
state.board = drawn();
|
|
315
|
+
render();
|
|
316
|
+
}
|
|
317
|
+
let out;
|
|
318
|
+
try { out = await B.site.send(kind, data); }
|
|
319
|
+
catch (e) {
|
|
320
|
+
if (draw && B.alive()) undraw(draw);
|
|
321
|
+
throw e;
|
|
322
|
+
}
|
|
323
|
+
drawing.delete(draw);
|
|
324
|
+
// A board newer than the POST's already holds the tap.
|
|
325
|
+
if (B.alive() && !land(out.board, B.boardLoad = (B.boardLoad || 0) + 1) && draw) undraw(draw);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
// A draw that changes the item with that key, the board's order kept as the server sorts it.
|
|
329
|
+
const drawItem = (key, change) => (board) => ({ ...board, items: board.items.map((i) => (i.key === key ? change({ ...i }) : i)).sort(BoardUI.laneOrder) });
|
|
330
|
+
|
|
331
|
+
// The bump icon's fill fades in as a tap bumps a card and out as one unbumps it, on a toast's timing (toasts.js), and
|
|
332
|
+
// keeps fading through any redraw meanwhile. selector names the icon.
|
|
333
|
+
const fades = new Map();
|
|
334
|
+
function paintFades() {
|
|
335
|
+
for (const [selector, { name, ms }] of fades) {
|
|
336
|
+
const icon = $(selector);
|
|
337
|
+
if (!icon) continue;
|
|
338
|
+
icon.classList.add(name);
|
|
339
|
+
icon.style.setProperty('--bump-fade', `${ms}ms`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function fadeBump(selector, on) {
|
|
343
|
+
const fade = { name: on ? 'bump-in' : 'bump-out', ms: on ? BoardToasts.enterTime() : BoardToasts.exitTime() };
|
|
344
|
+
$(selector)?.classList.remove('bump-in', 'bump-out');
|
|
345
|
+
fades.set(selector, fade);
|
|
346
|
+
paintFades();
|
|
347
|
+
B.later(fade.ms, () => {
|
|
348
|
+
if (fades.get(selector) !== fade) return;
|
|
349
|
+
fades.delete(selector);
|
|
350
|
+
$(selector)?.classList.remove(fade.name);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The server signals each change to what the board reads, through the shell, and the board reloads at once; the
|
|
355
|
+
// 30 s poll stays as the fallback and the offline check (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
|
|
356
|
+
let reloading = null;
|
|
357
|
+
let reloadAgain = false;
|
|
358
|
+
let signalMissed = false;
|
|
359
|
+
function signalled() {
|
|
360
|
+
if (document.hidden) { signalMissed = true; return; }
|
|
361
|
+
if (reloading) { reloadAgain = true; return; }
|
|
362
|
+
reloading = loadBoard({ via: 'signal' }).finally(() => {
|
|
363
|
+
reloading = null;
|
|
364
|
+
if (reloadAgain) { reloadAgain = false; signalled(); }
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The toast for a tap drawn at once whose POST failed, as the page takes the drawing back off.
|
|
369
|
+
const FAILED = 'That didn’t go through. Is the host awake?';
|
|
370
|
+
Object.assign(B, { render, loadBoard, send, drawItem, fadeBump, reload: signalled, FAILED });
|
|
371
|
+
const live = paintSnapshot();
|
|
372
|
+
B.headerWired();
|
|
373
|
+
if (live) B.linkUp();
|
|
374
|
+
const sent = BoardUI.sentNotice.take(B.route);
|
|
375
|
+
if (sent) B.toast(sent);
|
|
376
|
+
B.on(document, 'visibilitychange', () => {
|
|
377
|
+
if (document.hidden) return;
|
|
378
|
+
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
379
|
+
});
|
|
380
|
+
loadBoard();
|
|
381
|
+
B.signal?.(signalled);
|
|
382
|
+
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function markup({ title = '' } = {}) {
|
|
386
|
+
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true })}
|
|
387
|
+
<main>
|
|
388
|
+
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
389
|
+
<section id="answers" class="answer-banner" hidden></section>
|
|
390
|
+
<section id="waiting" class="waiting" hidden></section>
|
|
391
|
+
<div id="waves"></div>
|
|
392
|
+
<div id="tryMain"></div>
|
|
393
|
+
<div id="lanes"></div>
|
|
394
|
+
</main>
|
|
395
|
+
<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
396
|
+
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
397
|
+
<form id="askForm" data-composer="ask">
|
|
398
|
+
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
399
|
+
<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>
|
|
400
|
+
<div class="pending-files" data-files></div>
|
|
401
|
+
<div class="row">
|
|
402
|
+
<span class="muted small" id="askStatus"></span>
|
|
403
|
+
<button type="submit" class="primary">Send</button>
|
|
404
|
+
</div>
|
|
405
|
+
</form>
|
|
406
|
+
</dialog>
|
|
407
|
+
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
408
|
+
<div class="sheet-body">
|
|
409
|
+
<div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
|
|
410
|
+
<p id="mainLogSource" class="main-log-source muted small"></p>
|
|
411
|
+
<pre id="mainLogLines" class="main-log-lines" tabindex="0" aria-label="Main’s build log"></pre>
|
|
412
|
+
<p id="mainLogReason" class="small" role="status" hidden></p>
|
|
413
|
+
<div class="main-log-actions"><button type="button" class="primary" id="mainLogRetry">Retry</button></div>
|
|
414
|
+
</div>
|
|
415
|
+
</dialog>
|
|
416
|
+
<dialog id="holdSheet" class="sheet hold-sheet" aria-labelledby="holdTitle">
|
|
417
|
+
<div class="sheet-body">
|
|
418
|
+
<div class="sheet-head"><h2 id="holdTitle" tabindex="-1"></h2><button type="button" class="ghost" id="holdClose" aria-label="Close">✕</button></div>
|
|
419
|
+
<p id="holdLede" class="hold-lede muted"></p>
|
|
420
|
+
<div id="holdPicker"></div>
|
|
421
|
+
</div>
|
|
422
|
+
</dialog>
|
|
423
|
+
<dialog id="archSheet" class="sheet arch-sheet" aria-labelledby="archTitle">
|
|
424
|
+
<div class="sheet-body">
|
|
425
|
+
<div class="sheet-head"><h2 id="archTitle">Architecture review</h2><button type="button" class="ghost" id="archClose" aria-label="Close sheet">✕</button></div>
|
|
426
|
+
<div id="archBody" class="arch-body"></div>
|
|
427
|
+
</div>
|
|
428
|
+
</dialog>
|
|
429
|
+
<dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
|
|
430
|
+
<div class="sheet-body">
|
|
431
|
+
<div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
|
|
432
|
+
<div id="linksList"></div>
|
|
433
|
+
</div>
|
|
434
|
+
</dialog>`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { part, create, page, markup };
|
|
438
|
+
})();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton()
|
|
2
|
+
// and a [data-files] list. Picking files uploads them at once to the api/upload of the site named by the nearest
|
|
3
|
+
// [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
|
|
4
|
+
// files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
|
|
5
|
+
// again, and a board the app shell switches away from keeps its own. site defaults to the page's own below.
|
|
6
|
+
// BoardAttach.files(id, site) the uploaded files, ready for an event's data.files
|
|
7
|
+
// BoardAttach.busy(id, site) true while any upload is running
|
|
8
|
+
// BoardAttach.clear(id, site) after sending
|
|
9
|
+
// BoardAttach.onChange(fn) fn(id) after each change, such as to disable Send while busy; each composer
|
|
10
|
+
// painted also gets a bubbling attach-change event with detail {id}
|
|
11
|
+
window.BoardAttach = (function () {
|
|
12
|
+
const LIMIT = 20 * 1024 * 1024;
|
|
13
|
+
const state = new Map();
|
|
14
|
+
const listeners = [];
|
|
15
|
+
let seq = 0;
|
|
16
|
+
const own = () => ClaudeSite.root;
|
|
17
|
+
const siteOf = (el) => el.closest('[data-site]')?.dataset.site || own();
|
|
18
|
+
const keyOf = (site, id) => `${site}|${id}`;
|
|
19
|
+
const list = (site, id) => state.get(keyOf(site, id)) || [];
|
|
20
|
+
const composers = (site, id) => [...document.querySelectorAll(`[data-composer="${CSS.escape(id)}"]`)].filter((c) => siteOf(c) === site);
|
|
21
|
+
|
|
22
|
+
function paint(id, site = own()) {
|
|
23
|
+
for (const c of composers(site, id)) {
|
|
24
|
+
const el = c.querySelector('[data-files]');
|
|
25
|
+
if (el) el.innerHTML = BoardUI.pendingFiles(list(site, id));
|
|
26
|
+
c.dispatchEvent(new CustomEvent('attach-change', { bubbles: true, detail: { id } }));
|
|
27
|
+
}
|
|
28
|
+
for (const fn of listeners) fn(id);
|
|
29
|
+
}
|
|
30
|
+
const paintAll = () => { for (const c of document.querySelectorAll('[data-composer]')) paint(c.dataset.composer, siteOf(c)); };
|
|
31
|
+
|
|
32
|
+
function upload(site, id, file) {
|
|
33
|
+
const p = { id: 'f' + ++seq, name: file.name || 'file', type: file.type || 'application/octet-stream', status: 'uploading', progress: 0 };
|
|
34
|
+
if (/^image\//.test(p.type)) p.preview = URL.createObjectURL(file);
|
|
35
|
+
state.set(keyOf(site, id), [...list(site, id), p]);
|
|
36
|
+
if (file.size > LIMIT) { p.status = 'error'; p.error = 'Over 20 MB'; paint(id, site); return; }
|
|
37
|
+
paint(id, site);
|
|
38
|
+
const xhr = new XMLHttpRequest();
|
|
39
|
+
p.xhr = xhr;
|
|
40
|
+
xhr.open('POST', `${site}/api/upload`);
|
|
41
|
+
xhr.setRequestHeader('x-filename', encodeURIComponent(p.name));
|
|
42
|
+
xhr.setRequestHeader('content-type', p.type);
|
|
43
|
+
xhr.upload.onprogress = (e) => { if (e.lengthComputable) { p.progress = e.loaded / e.total; paint(id, site); } };
|
|
44
|
+
xhr.onload = () => {
|
|
45
|
+
let body = null;
|
|
46
|
+
try { body = JSON.parse(xhr.responseText); } catch {}
|
|
47
|
+
if (xhr.status === 200 && body) { p.status = 'done'; p.file = body; }
|
|
48
|
+
else { p.status = 'error'; p.error = xhr.status === 413 ? 'Over 20 MB' : (body && body.error) || 'Upload failed'; }
|
|
49
|
+
paint(id, site);
|
|
50
|
+
};
|
|
51
|
+
xhr.onerror = () => { p.status = 'error'; p.error = 'Upload failed'; paint(id, site); };
|
|
52
|
+
xhr.send(file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function remove(site, id, fileId) {
|
|
56
|
+
const p = list(site, id).find((x) => x.id === fileId);
|
|
57
|
+
if (!p) return;
|
|
58
|
+
if (p.status === 'uploading') p.xhr.abort();
|
|
59
|
+
if (p.preview) URL.revokeObjectURL(p.preview);
|
|
60
|
+
state.set(keyOf(site, id), list(site, id).filter((x) => x !== p));
|
|
61
|
+
paint(id, site);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
document.addEventListener('change', (ev) => {
|
|
65
|
+
const input = ev.target.closest && ev.target.closest('input[data-attach]');
|
|
66
|
+
const c = input && input.closest('[data-composer]');
|
|
67
|
+
if (!c) return;
|
|
68
|
+
for (const f of input.files) upload(siteOf(c), c.dataset.composer, f);
|
|
69
|
+
input.value = '';
|
|
70
|
+
});
|
|
71
|
+
document.addEventListener('click', (ev) => {
|
|
72
|
+
const b = ev.target.closest && ev.target.closest('[data-unattach]');
|
|
73
|
+
const c = b && b.closest('[data-composer]');
|
|
74
|
+
if (c) { ev.preventDefault(); remove(siteOf(c), c.dataset.composer, b.dataset.unattach); }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
files: (id, site = own()) => list(site, id).filter((p) => p.status === 'done').map((p) => p.file),
|
|
79
|
+
busy: (id, site = own()) => list(site, id).some((p) => p.status === 'uploading'),
|
|
80
|
+
clear(id, site = own()) { for (const p of list(site, id)) if (p.preview) URL.revokeObjectURL(p.preview); state.delete(keyOf(site, id)); paint(id, site); },
|
|
81
|
+
onChange(fn) { listeners.push(fn); },
|
|
82
|
+
paint, paintAll,
|
|
83
|
+
};
|
|
84
|
+
})();
|