@lovek4d/kickflip 0.5.0 → 0.5.3
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 +47 -42
- package/README.md +1 -1
- package/SKILL.md +16 -16
- package/bin/kickflip.mjs +26 -17
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +65 -18
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +5 -4
- package/board/on-host.mjs +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +51 -27
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +70 -59
- package/board/public/app.js +38 -8
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +17 -38
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -5
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +108 -37
- package/board/public/ui-cards.js +41 -30
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-questions.js +25 -38
- package/board/public/ui-sidebar.js +18 -12
- package/board/public/ui-threads.js +3 -3
- package/board/public/ui.js +40 -8
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +142 -199
- package/board/public/view-settings.js +191 -72
- package/board/server.mjs +19 -20
- package/board/store.mjs +20 -15
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
3
|
const { $, store, state, toast, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages,
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, boltButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
|
|
5
5
|
|
|
6
6
|
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
7
|
// A /prototype or /grill word in a message, the last one winning, and the message without it.
|
|
@@ -10,31 +10,49 @@ BoardView.part((B) => {
|
|
|
10
10
|
|
|
11
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
12
12
|
|
|
13
|
-
//
|
|
13
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
|
|
14
|
+
// docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
|
|
15
|
+
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
16
|
+
// action: builder orange once the card is ready, quiet before. Any other card's box sends with Send, carrying no intent.
|
|
17
|
+
// The pair wraps onto its own line under the row.
|
|
18
|
+
const sendsOf = (item) => (item?.lane !== 'todo' ? 'one' : item.ready ? 'todo-ready' : 'todo');
|
|
19
|
+
const sends = (kind) => (kind === 'one'
|
|
20
|
+
? `<span data-sends="one">${sendButton('Send', 0)}</span>`
|
|
21
|
+
: `<span class="send-actions" data-sends="${kind}">${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}</span>`);
|
|
22
|
+
// The board, not the thread, decides the Done button and the send buttons, so they follow each board load even when
|
|
23
|
+
// the thread is unchanged.
|
|
14
24
|
function syncDone(el, key) {
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
17
|
-
const
|
|
25
|
+
const at = el.querySelector('form [data-sends]');
|
|
26
|
+
if (!at) return;
|
|
27
|
+
const item = state.board?.items.find((i) => i.key === key);
|
|
28
|
+
const kind = sendsOf(item);
|
|
29
|
+
const todo = item?.lane === 'todo';
|
|
30
|
+
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
|
|
31
|
+
const tools = el.querySelector('form .tools');
|
|
32
|
+
const bolt = tools.querySelector('[data-bolt]');
|
|
33
|
+
if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
|
|
34
|
+
else if (!todo) bolt?.remove();
|
|
35
|
+
const html = doneButton(item);
|
|
18
36
|
const current = el.querySelector('form [data-confirm="finish"]');
|
|
19
37
|
if (current?.outerHTML === html) return;
|
|
20
38
|
current?.remove();
|
|
21
|
-
if (html)
|
|
39
|
+
if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
25
|
-
// thread draws, so its unread messages start from its
|
|
26
|
-
//
|
|
43
|
+
// thread draws, so its unread messages start from its read mark from before it opened (B.seenBefore). A board left
|
|
44
|
+
// and opened again in this page keeps them, so its open threads show what they showed.
|
|
27
45
|
const kept = B.place?.threads;
|
|
28
|
-
const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
|
|
29
46
|
const moreTaps = new Map(kept?.moreTaps);
|
|
30
47
|
const firstDrawn = new Map(kept?.firstDrawn);
|
|
31
|
-
B.keep(() => ({ threads: {
|
|
48
|
+
B.keep(() => ({ threads: { moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
|
|
49
|
+
const seenAt = (key) => (B.seenBefore.has(key) ? B.seenBefore.get(key) : B.seen[key] || null);
|
|
32
50
|
const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
|
|
33
|
-
const viewOf = (key) => ({ seenAt:
|
|
51
|
+
const viewOf = (key) => ({ seenAt: seenAt(key), more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
|
|
34
52
|
B.on(B.root, 'toggle', (ev) => {
|
|
35
53
|
const el = ev.target;
|
|
36
54
|
if (!el.matches?.('details.item') || el.open) return;
|
|
37
|
-
seenBefore.
|
|
55
|
+
B.seenBefore.delete(el.dataset.key);
|
|
38
56
|
moreTaps.delete(el.dataset.key);
|
|
39
57
|
firstDrawn.delete(el.dataset.key);
|
|
40
58
|
}, true);
|
|
@@ -53,7 +71,7 @@ BoardView.part((B) => {
|
|
|
53
71
|
const edits = editsIn(key);
|
|
54
72
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
55
73
|
const view = viewOf(key);
|
|
56
|
-
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions,
|
|
74
|
+
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenAt(key));
|
|
57
75
|
const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
|
|
58
76
|
const sig = JSON.stringify([lines, [...edits.keys()], view]);
|
|
59
77
|
if (el.dataset.sig === sig) return;
|
|
@@ -62,11 +80,9 @@ BoardView.part((B) => {
|
|
|
62
80
|
if (!el.querySelector('.msgs')) {
|
|
63
81
|
const draft = store.get('draft:' + key, '');
|
|
64
82
|
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
|
|
65
|
-
<div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
|
|
66
83
|
<div class="pending-files" data-files></div>
|
|
67
|
-
<div class="row"><span class="tools">${
|
|
84
|
+
<div class="row"><span class="tools">${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button>${sends('one')}</div></form>`;
|
|
68
85
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
69
|
-
syncFlag(el.querySelector('form'));
|
|
70
86
|
}
|
|
71
87
|
syncDone(el, key);
|
|
72
88
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
@@ -74,23 +90,10 @@ BoardView.part((B) => {
|
|
|
74
90
|
B.flashChanged?.(el);
|
|
75
91
|
}
|
|
76
92
|
|
|
77
|
-
// docs/adr/2026-11-
|
|
78
|
-
// The composer's eye asks for a prototype and its fire for a grill:
|
|
79
|
-
//
|
|
93
|
+
// docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
|
|
94
|
+
// The composer's eye asks for a prototype and its fire for a grill: a tap sends the request at once, with whatever is
|
|
95
|
+
// typed, as /prototype or /grill in the text does on Reply. Nothing typed sends the bare request.
|
|
80
96
|
const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
|
|
81
|
-
const HINT = 'Tap the eye or the fire to request a prototype or a grill';
|
|
82
|
-
const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
|
|
83
|
-
function syncFlag(f) {
|
|
84
|
-
const chosen = f.dataset.flag || null;
|
|
85
|
-
f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
|
|
86
|
-
f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
|
|
87
|
-
const hint = f.querySelector('[data-hint]');
|
|
88
|
-
if (!hint) return;
|
|
89
|
-
const flag = flagOf(f);
|
|
90
|
-
const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
|
|
91
|
-
hint.classList.toggle('on', Boolean(flag));
|
|
92
|
-
hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
|
|
93
|
-
}
|
|
94
97
|
|
|
95
98
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
96
99
|
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
@@ -122,7 +125,6 @@ BoardView.part((B) => {
|
|
|
122
125
|
B.on(B.root, 'input', (ev) => {
|
|
123
126
|
const f = ev.target.closest('form[data-reply]');
|
|
124
127
|
if (!f || ev.target.tagName !== 'TEXTAREA') return;
|
|
125
|
-
syncFlag(f);
|
|
126
128
|
if (quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
127
129
|
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
128
130
|
});
|
|
@@ -130,44 +132,53 @@ BoardView.part((B) => {
|
|
|
130
132
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
131
133
|
if (!b) return;
|
|
132
134
|
const key = b.closest('form').dataset.reply;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
136
|
+
try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
|
|
137
|
+
catch (e) { B.failed(e); }
|
|
135
138
|
});
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
// docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
|
|
140
|
+
B.on(B.root, 'click', async (ev) => {
|
|
141
|
+
const b = ev.target.closest('form[data-reply] button[data-bolt]');
|
|
138
142
|
if (!b) return;
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
syncFlag(f);
|
|
143
|
+
const key = b.dataset.bolt;
|
|
144
|
+
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
|
|
145
|
+
catch { toast('Could not send'); }
|
|
143
146
|
});
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!f) return;
|
|
147
|
-
ev.preventDefault();
|
|
147
|
+
// Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
|
|
148
|
+
async function post(f, flag, button) {
|
|
148
149
|
const ta = f.querySelector('textarea');
|
|
149
150
|
ta.blur();
|
|
150
|
-
const flag = flagOf(f);
|
|
151
151
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
152
152
|
const id = f.dataset.composer;
|
|
153
153
|
const files = BoardAttach.files(id, B.site.root);
|
|
154
|
-
|
|
154
|
+
// An empty box still sends Implement, the card's build action.
|
|
155
|
+
const intent = flag ? null : button.dataset.intent || null;
|
|
156
|
+
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
155
157
|
const key = f.dataset.reply;
|
|
156
|
-
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}), ...(intent ? { intent } : {}) };
|
|
159
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
160
|
+
const sending = (async () => {
|
|
161
|
+
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
162
|
+
() => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data)));
|
|
160
163
|
BoardAttach.clear(id, B.site.root);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
await loadThread(key, { force: true });
|
|
165
|
+
})();
|
|
166
|
+
try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
|
|
167
|
+
}
|
|
168
|
+
B.on(B.root, 'click', (ev) => {
|
|
169
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
170
|
+
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
|
|
171
|
+
});
|
|
172
|
+
B.on(B.root, 'submit', (ev) => {
|
|
173
|
+
const f = ev.target.closest('form[data-reply]');
|
|
174
|
+
if (!f) return;
|
|
175
|
+
ev.preventDefault();
|
|
176
|
+
post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
|
|
165
177
|
});
|
|
166
|
-
//
|
|
178
|
+
// A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
|
|
167
179
|
B.on(B.root, 'attach-change', (ev) => {
|
|
168
|
-
const b
|
|
169
|
-
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
170
|
-
if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
|
|
180
|
+
for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
181
|
+
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
171
182
|
});
|
|
172
183
|
|
|
173
184
|
Object.assign(B, {
|
package/board/public/app.js
CHANGED
|
@@ -27,7 +27,9 @@ window.BoardView = (function () {
|
|
|
27
27
|
try {
|
|
28
28
|
const old = [];
|
|
29
29
|
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
30
|
-
|
|
30
|
+
// Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
|
|
31
|
+
for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
|
|
32
|
+
for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
31
33
|
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
32
34
|
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
33
35
|
localStorage.removeItem(k);
|
|
@@ -131,7 +133,7 @@ window.BoardView = (function () {
|
|
|
131
133
|
edits: new Map(),
|
|
132
134
|
githubPending: null,
|
|
133
135
|
// Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
|
|
134
|
-
mainPull: {
|
|
136
|
+
mainPull: { open: false },
|
|
135
137
|
// The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
|
|
136
138
|
// the archive holds past them, null before the first page.
|
|
137
139
|
archived: { cards: [], left: null },
|
|
@@ -187,7 +189,8 @@ window.BoardView = (function () {
|
|
|
187
189
|
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
|
|
188
190
|
B.renderMainLog(d.main, load);
|
|
189
191
|
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
190
|
-
|
|
192
|
+
B.takeMarks(d.items);
|
|
193
|
+
for (const it of d.items) if (state.openItems.has(it.key)) markSeen(it);
|
|
191
194
|
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
192
195
|
const strips = B.captureStrips();
|
|
193
196
|
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
@@ -229,6 +232,15 @@ window.BoardView = (function () {
|
|
|
229
232
|
ev.preventDefault();
|
|
230
233
|
readHash(); render(); scrollToFocus();
|
|
231
234
|
});
|
|
235
|
+
// Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
|
|
236
|
+
function focusOnBoard({ key, slug }) {
|
|
237
|
+
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
238
|
+
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
239
|
+
if (key) { location.hash = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
|
|
240
|
+
render();
|
|
241
|
+
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
232
244
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
233
245
|
function scrollToFocus(final = true) {
|
|
234
246
|
if (!focusKey) return;
|
|
@@ -336,10 +348,16 @@ window.BoardView = (function () {
|
|
|
336
348
|
const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
|
|
337
349
|
if (!B.alive()) return out;
|
|
338
350
|
answered?.();
|
|
339
|
-
land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
|
|
351
|
+
if (!land(out.board, B.boardLoad = (B.boardLoad || 0) + 1)) await boardAfter(out.board.generatedAt);
|
|
340
352
|
return out;
|
|
341
353
|
}
|
|
342
354
|
|
|
355
|
+
// A board that reload raced the POST left the tap's own board unlanded (land): the tap is not drawn until a board
|
|
356
|
+
// made after it is, so send waits for one, and a tap's spinner with it (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
357
|
+
async function boardAfter(generatedAt) {
|
|
358
|
+
for (let tries = 0; tries < 3 && B.alive() && !(state.view === 'fresh' && state.board.generatedAt >= generatedAt); tries++) await (reloading || loadBoard({ via: 'signal' }));
|
|
359
|
+
}
|
|
360
|
+
|
|
343
361
|
// The server signals each change to what the board reads, through the shell, and the board reloads at once; the
|
|
344
362
|
// 30 s poll stays as the fallback and the offline check (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
|
|
345
363
|
let reloading = null;
|
|
@@ -356,7 +374,14 @@ window.BoardView = (function () {
|
|
|
356
374
|
|
|
357
375
|
// The toast for a tap the server refused, with its reason, or that never reached it.
|
|
358
376
|
const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
|
|
359
|
-
|
|
377
|
+
// A tap's change no board has shown in BoardUI's PENDING_LATE_MS (ui.js pending): the board reloads once and the button rests.
|
|
378
|
+
function stillWaiting() {
|
|
379
|
+
if (!B.alive()) return;
|
|
380
|
+
B.toast('Still waiting on the board');
|
|
381
|
+
signalled();
|
|
382
|
+
render();
|
|
383
|
+
}
|
|
384
|
+
Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
360
385
|
const live = paintSnapshot();
|
|
361
386
|
B.headerWired();
|
|
362
387
|
if (live) B.linkUp();
|
|
@@ -371,15 +396,20 @@ window.BoardView = (function () {
|
|
|
371
396
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
372
397
|
}
|
|
373
398
|
|
|
374
|
-
|
|
399
|
+
// shared: the shell's data.shared for someone the board is shared with, view only, whose board opens with a strip saying so.
|
|
400
|
+
function markup({ title = '', shared = null } = {}) {
|
|
401
|
+
const strip = shared ? `<p class="view-only-strip"><b>View only.</b> Shared with you${shared.by ? ` by ${BoardUI.esc(shared.by)}` : ''}. You can look and open Visit links.</p>\n` : '';
|
|
375
402
|
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
|
|
403
|
+
<div class="split">
|
|
376
404
|
<main>
|
|
377
|
-
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
405
|
+
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
378
406
|
<section id="answers" class="answer-banner" hidden></section>
|
|
379
|
-
<section id="
|
|
407
|
+
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
380
408
|
<div id="tryMain"></div>
|
|
381
409
|
<div id="lanes"></div>
|
|
382
410
|
</main>
|
|
411
|
+
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
412
|
+
</div>
|
|
383
413
|
${BoardNewTask.markup()}
|
|
384
414
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
385
415
|
<div class="sheet-body">
|
package/board/public/attach.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// docs/adr/2026-11-03-1722-every-text-box-attaches-through-attach-js.md
|
|
2
|
-
// Files attached to a composer: any element with data-composer="<id>" holding
|
|
3
|
-
// BoardUI.
|
|
2
|
+
// Files attached to a composer: any element with data-composer="<id>" holding its text box in BoardUI.dropZone(), which
|
|
3
|
+
// puts BoardUI.attachButton() inside the box, and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
|
|
4
4
|
// uploads them at once to the api/upload of the site named by the nearest
|
|
5
5
|
// [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
|
|
6
6
|
// files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
|
package/board/public/dialogs.js
CHANGED
|
@@ -2,21 +2,18 @@
|
|
|
2
2
|
// Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
|
|
3
3
|
(function () {
|
|
4
4
|
// linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
|
|
5
|
-
// while Hub's key works; tapping it without one opens Hub's key modal over the page
|
|
6
|
-
// inline (linear-key.js,
|
|
5
|
+
// while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
|
|
7
6
|
// docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
|
|
8
7
|
// new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
|
|
9
8
|
// {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
|
|
10
9
|
// GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
|
|
11
10
|
// remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
12
11
|
// With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
|
|
13
|
-
// as a status word
|
|
14
|
-
|
|
15
|
-
// (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
|
|
16
|
-
function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
|
|
12
|
+
// as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
|
|
13
|
+
function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
|
|
17
14
|
const { esc } = BoardUI;
|
|
18
15
|
const draft = { prefix: 'INT', ...value };
|
|
19
|
-
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null
|
|
16
|
+
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
|
|
20
17
|
const works = () => Boolean(linear?.set && !linear.refused);
|
|
21
18
|
const keyWorks = () => Boolean(key?.set && !key.refused);
|
|
22
19
|
const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
|
|
@@ -26,31 +23,6 @@
|
|
|
26
23
|
const on = draft.source === 'linear' && keyWorks();
|
|
27
24
|
return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
|
|
28
25
|
}
|
|
29
|
-
// Hub's key field in keySlot. Once a key works, Linear is picked.
|
|
30
|
-
function openInline() {
|
|
31
|
-
if (!inline) {
|
|
32
|
-
keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
|
|
33
|
-
inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
|
|
34
|
-
saved: (out) => { key = out.linear; render(); },
|
|
35
|
-
worked: async () => {
|
|
36
|
-
closeInline();
|
|
37
|
-
draft.source = 'linear';
|
|
38
|
-
await load();
|
|
39
|
-
if (destroyed) return;
|
|
40
|
-
pickDefaults();
|
|
41
|
-
render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
|
|
42
|
-
} });
|
|
43
|
-
keySlot.hidden = false;
|
|
44
|
-
}
|
|
45
|
-
keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
|
|
46
|
-
}
|
|
47
|
-
function closeInline() {
|
|
48
|
-
if (!inline) return;
|
|
49
|
-
inline.destroy();
|
|
50
|
-
inline = null;
|
|
51
|
-
keySlot.hidden = true;
|
|
52
|
-
keySlot.innerHTML = '';
|
|
53
|
-
}
|
|
54
26
|
async function load() {
|
|
55
27
|
linear = { loading: true };
|
|
56
28
|
render();
|
|
@@ -114,13 +86,12 @@
|
|
|
114
86
|
if (!works()) {
|
|
115
87
|
draft.source = before;
|
|
116
88
|
render();
|
|
117
|
-
await keyed;
|
|
118
|
-
if (destroyed) return;
|
|
119
|
-
if (key) { openInline(); return; }
|
|
120
89
|
asking = true;
|
|
121
90
|
const added = await LinearKey.open();
|
|
122
91
|
asking = false;
|
|
123
92
|
if (destroyed || !added) return;
|
|
93
|
+
if (keyState) key = await keyState().catch(() => key);
|
|
94
|
+
if (destroyed) return;
|
|
124
95
|
draft.source = 'linear';
|
|
125
96
|
await load();
|
|
126
97
|
if (destroyed) return;
|
|
@@ -149,7 +120,6 @@
|
|
|
149
120
|
noGithub = button.dataset.tkSource === 'github' && !repo();
|
|
150
121
|
if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
|
|
151
122
|
if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
|
|
152
|
-
closeInline();
|
|
153
123
|
draft.source = button.dataset.tkSource;
|
|
154
124
|
render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
|
|
155
125
|
};
|
|
@@ -168,13 +138,13 @@
|
|
|
168
138
|
root.addEventListener('change', input);
|
|
169
139
|
render();
|
|
170
140
|
if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
|
|
171
|
-
// Without an answer the row has no status word
|
|
172
|
-
|
|
141
|
+
// Without an answer the row has no status word.
|
|
142
|
+
if (keyState) keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {});
|
|
173
143
|
return { read, valid, render, nameChanged() {
|
|
174
144
|
const field = root.querySelector('[data-tk="newName"]');
|
|
175
145
|
if (field && draft.newName === undefined) field.value = title();
|
|
176
146
|
},
|
|
177
|
-
destroy() { destroyed = true;
|
|
147
|
+
destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
178
148
|
}
|
|
179
149
|
function fitSheet(sheet) {
|
|
180
150
|
const vv = window.visualViewport;
|
package/board/public/hub-kit.js
CHANGED
|
@@ -113,6 +113,8 @@
|
|
|
113
113
|
const KEPT = 'kickflip.push.endpoint';
|
|
114
114
|
const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
|
|
115
115
|
const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
|
|
116
|
+
// The id the hub gave that subscription, so the Integrations list marks this device's line (view-hub.js).
|
|
117
|
+
const subscribe = (sub) => post('/_hub/push/subscribe', sub.toJSON()).then(({ id }) => { try { localStorage.setItem('kickflip.push.id', id); } catch { /* storage blocked here */ } });
|
|
116
118
|
const settle = (endpoint = null) => {
|
|
117
119
|
const old = kept();
|
|
118
120
|
if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
|
|
@@ -133,7 +135,7 @@
|
|
|
133
135
|
const sub = await reg?.pushManager.getSubscription();
|
|
134
136
|
settle(sub?.toJSON().endpoint);
|
|
135
137
|
if (sub) {
|
|
136
|
-
|
|
138
|
+
subscribe(sub).catch(() => {});
|
|
137
139
|
show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
|
|
138
140
|
} else {
|
|
139
141
|
show({ title: 'This device', sub: 'Off · Claude sessions tell you here when something needs you.', action: { label: 'Turn on', act: 'on' } });
|
|
@@ -148,7 +150,7 @@
|
|
|
148
150
|
await navigator.serviceWorker.ready;
|
|
149
151
|
const { key } = await (await fetch('/_hub/push/key')).json();
|
|
150
152
|
const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
|
|
151
|
-
await
|
|
153
|
+
await subscribe(sub);
|
|
152
154
|
await render();
|
|
153
155
|
changed();
|
|
154
156
|
} catch (err) { show({ title: 'Could not turn on notifications', sub: String(err?.message || err) }); }
|
|
@@ -1,44 +1,55 @@
|
|
|
1
|
-
// Hub's Linear key
|
|
2
|
-
// settings or New board when Linear is tapped without a working key
|
|
3
|
-
// (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A
|
|
4
|
-
// POST /_hub/settings
|
|
1
|
+
// Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
|
|
2
|
+
// control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
|
|
3
|
+
// (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A token saves on paste, change or Enter
|
|
4
|
+
// through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
|
|
5
5
|
(function () {
|
|
6
6
|
const FAILED = 'Could not save. Is the host awake?';
|
|
7
7
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
8
|
-
const works = (
|
|
8
|
+
const works = (state) => Boolean(state?.set && !state.refused);
|
|
9
|
+
const KINDS = {
|
|
10
|
+
linear: { field: 'key', input: 'linearKey', checking: 'Checking the key with Linear…', first: 'Paste a Linear API key', replace: 'Paste a new key to replace it', label: 'Linear API key',
|
|
11
|
+
note: 'Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.',
|
|
12
|
+
confirm: 'Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?' },
|
|
13
|
+
slack: { field: 'token', input: 'slackToken', checking: 'Checking the token with Slack…', first: 'Paste a Slack user token', replace: 'Paste a new token to replace it', label: 'Slack user token',
|
|
14
|
+
note: 'Stays on the host. The hub sends your reviewers their PR links, git_merged reactions and weekday digests as you, in your DMs with them.',
|
|
15
|
+
confirm: 'Reviewer links, reactions and digests stop until a token is back. Remove the Slack token?' },
|
|
16
|
+
};
|
|
9
17
|
|
|
10
|
-
function slot(
|
|
18
|
+
function slot(kind, state, { checking, flash }) {
|
|
11
19
|
const { esc, row, tile } = HubUI;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
+
|
|
20
|
+
const k = KINDS[kind];
|
|
21
|
+
const sub = checking ? `<span class="lk-checking">${SPIN}${k.checking}</span>`
|
|
22
|
+
: `${state.down ? `<span class="st down">${esc(state.down)}</span> · ` : ''}${state.keys
|
|
23
|
+
? esc(state.sub).replace(esc(state.keys.label), `<a href="${esc(state.keys.url)}" target="_blank" rel="noopener">${esc(state.keys.label)}</a>`)
|
|
24
|
+
: esc(state.sub)}`;
|
|
25
|
+
const remove = state.set && !checking ? `<button type="button" class="btn quiet danger" data-${kind}-remove>Remove</button>` : '';
|
|
26
|
+
const placeholder = checking ? 'Checking…' : state.set ? k.replace : k.first;
|
|
27
|
+
return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', state.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(state.title), sub, wrap: !works(state), trail: remove })}`
|
|
28
|
+
+ `<form class="block lk-form"><input id="${k.input}" name="${k.field}" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="${k.label}" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
|
|
29
|
+
+ `<p class="note">${k.note}</p>`;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
// Draws
|
|
24
|
-
// each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved
|
|
25
|
-
// → {busy(), settled(): a promise that resolves once no save is out,
|
|
26
|
-
function mount(root, { linear, saved = () => {}, worked = () => {}, busy = () => {} }) {
|
|
27
|
-
|
|
32
|
+
// Draws kind's slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
|
|
33
|
+
// each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved token the service accepted.
|
|
34
|
+
// → {busy(), settled(): a promise that resolves once no save is out, state(), set(state) unless busy, destroy()}
|
|
35
|
+
function mount(root, { kind = 'linear', state, saved = () => {}, worked = () => {}, busy = () => {} }) {
|
|
36
|
+
const k = KINDS[kind];
|
|
37
|
+
const input = `#${k.input}`;
|
|
38
|
+
let current = state, flash = null, checking = false, pending = null, destroyed = false;
|
|
28
39
|
const paint = () => {
|
|
29
|
-
const typed = root.querySelector(
|
|
30
|
-
root.innerHTML = slot(current, { checking, flash });
|
|
31
|
-
if (!checking) root.querySelector(
|
|
40
|
+
const typed = root.querySelector(input)?.value || '';
|
|
41
|
+
root.innerHTML = slot(kind, current, { checking, flash });
|
|
42
|
+
if (!checking) root.querySelector(input).value = typed;
|
|
32
43
|
};
|
|
33
44
|
function post(body) {
|
|
34
|
-
checking = body.has(
|
|
45
|
+
checking = body.has(k.field);
|
|
35
46
|
flash = null;
|
|
36
47
|
pending = (async () => {
|
|
37
48
|
try {
|
|
38
|
-
const r = await fetch(
|
|
49
|
+
const r = await fetch(`/_hub/settings/${kind}`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
|
|
39
50
|
const out = await r.json().catch(() => null);
|
|
40
51
|
if (destroyed) return;
|
|
41
|
-
if (r.ok && out) { current = out
|
|
52
|
+
if (r.ok && out) { current = out[kind]; saved(out); } else flash = { text: out?.error || FAILED, error: true };
|
|
42
53
|
} catch { flash = { text: FAILED, error: true }; }
|
|
43
54
|
const key = checking;
|
|
44
55
|
checking = false;
|
|
@@ -52,23 +63,23 @@
|
|
|
52
63
|
paint();
|
|
53
64
|
return pending;
|
|
54
65
|
}
|
|
55
|
-
const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({
|
|
66
|
+
const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ [k.field]: value.trim() })); };
|
|
56
67
|
const onPaste = (e) => {
|
|
57
68
|
const text = e.clipboardData?.getData('text');
|
|
58
|
-
if (!e.target.matches?.(
|
|
69
|
+
if (!e.target.matches?.(input) || !text) return;
|
|
59
70
|
e.preventDefault();
|
|
60
71
|
e.target.value = text.trim();
|
|
61
72
|
save(e.target.value);
|
|
62
73
|
};
|
|
63
|
-
const onChange = (e) => { if (e.target.matches?.(
|
|
74
|
+
const onChange = (e) => { if (e.target.matches?.(input)) save(e.target.value); };
|
|
64
75
|
const onSubmit = (e) => {
|
|
65
76
|
if (!e.target.matches?.('.lk-form')) return;
|
|
66
77
|
e.preventDefault();
|
|
67
|
-
save(e.target.querySelector(
|
|
78
|
+
save(e.target.querySelector(input).value);
|
|
68
79
|
};
|
|
69
80
|
const onClick = async (e) => {
|
|
70
|
-
if (!e.target.closest?.(
|
|
71
|
-
if (await BoardDialogs.confirm(
|
|
81
|
+
if (!e.target.closest?.(`[data-${kind}-remove]`) || pending) return;
|
|
82
|
+
if (await BoardDialogs.confirm(k.confirm) && !destroyed) post(new URLSearchParams({ remove: '1' }));
|
|
72
83
|
};
|
|
73
84
|
const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
|
|
74
85
|
for (const [type, fn] of listeners) root.addEventListener(type, fn);
|
|
@@ -76,7 +87,7 @@
|
|
|
76
87
|
return {
|
|
77
88
|
busy: () => Boolean(pending),
|
|
78
89
|
settled: () => pending || Promise.resolve(),
|
|
79
|
-
|
|
90
|
+
state: () => current,
|
|
80
91
|
set(next) { if (!pending) { current = next; paint(); } },
|
|
81
92
|
destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
|
|
82
93
|
};
|
|
@@ -115,7 +126,7 @@
|
|
|
115
126
|
dialog.close();
|
|
116
127
|
wrap.remove();
|
|
117
128
|
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
118
|
-
resolve(works(key?.
|
|
129
|
+
resolve(works(key?.state()));
|
|
119
130
|
}
|
|
120
131
|
function close() {
|
|
121
132
|
if (!key?.busy()) { finish(); return; }
|
|
@@ -134,7 +145,7 @@
|
|
|
134
145
|
wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
|
|
135
146
|
fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
|
|
136
147
|
if (finished) return;
|
|
137
|
-
key = mount(content, {
|
|
148
|
+
key = mount(content, { state: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
|
|
138
149
|
}).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
|
|
139
150
|
return result;
|
|
140
151
|
}
|