@lovek4d/kickflip 0.3.1 → 0.5.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 +170 -118
- package/README.md +23 -14
- package/SKILL.md +40 -38
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +213 -74
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +4 -0
- package/board/headless.mjs +4 -0
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +39 -103
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +166 -73
- package/board/public/app-core.js +2 -23
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +53 -42
- package/board/public/app.html +4 -1
- package/board/public/app.js +78 -96
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +6 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/new-task.js +121 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +24 -6
- package/board/public/sidebar.css +22 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +245 -64
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +205 -75
- package/board/public/ui-enums.js +6 -5
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +61 -29
- package/board/public/ui-sidebar.js +54 -24
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +40 -21
- package/board/public/ui.js +116 -18
- package/board/public/view-frame.js +2 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +142 -62
- package/board/public/view-settings.js +189 -52
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -12
- package/board/store.mjs +15 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -15
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -14
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/owner-check.mjs +3 -0
- package/shared/repos.mjs +1 -1
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -1,7 +1,12 @@
|
|
|
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, attachButton, protoButton,
|
|
4
|
+
const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
|
|
5
|
+
|
|
6
|
+
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
|
+
// A /prototype or /grill word in a message, the last one winning, and the message without it.
|
|
8
|
+
const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
|
|
9
|
+
const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
|
|
5
10
|
|
|
6
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
12
|
|
|
@@ -48,25 +53,43 @@ BoardView.part((B) => {
|
|
|
48
53
|
const edits = editsIn(key);
|
|
49
54
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
50
55
|
const view = viewOf(key);
|
|
51
|
-
const
|
|
56
|
+
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
|
|
57
|
+
const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
|
|
58
|
+
const sig = JSON.stringify([lines, [...edits.keys()], view]);
|
|
52
59
|
if (el.dataset.sig === sig) return;
|
|
53
60
|
el.dataset.sig = sig;
|
|
54
61
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
55
62
|
if (!el.querySelector('.msgs')) {
|
|
56
63
|
const draft = store.get('draft:' + key, '');
|
|
57
|
-
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"
|
|
64
|
+
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>
|
|
58
66
|
<div class="pending-files" data-files></div>
|
|
59
|
-
<div class="row">${attachButton()}${protoButton(key)}
|
|
67
|
+
<div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
|
|
60
68
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
69
|
+
syncFlag(el.querySelector('form'));
|
|
61
70
|
}
|
|
62
71
|
syncDone(el, key);
|
|
63
|
-
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(
|
|
72
|
+
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
73
|
+
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
64
74
|
B.flashChanged?.(el);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
|
|
78
|
+
// The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
|
|
79
|
+
// with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
|
|
80
|
+
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`;
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
@@ -98,40 +121,25 @@ BoardView.part((B) => {
|
|
|
98
121
|
|
|
99
122
|
B.on(B.root, 'input', (ev) => {
|
|
100
123
|
const f = ev.target.closest('form[data-reply]');
|
|
101
|
-
if (!f || ev.target.tagName !== 'TEXTAREA'
|
|
124
|
+
if (!f || ev.target.tagName !== 'TEXTAREA') return;
|
|
125
|
+
syncFlag(f);
|
|
126
|
+
if (quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
102
127
|
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
103
128
|
});
|
|
104
129
|
B.on(B.root, 'click', async (ev) => {
|
|
105
130
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
106
131
|
if (!b) return;
|
|
107
132
|
const key = b.closest('form').dataset.reply;
|
|
108
|
-
b.
|
|
109
|
-
try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
|
|
133
|
+
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
|
|
110
134
|
catch { toast('Could not send'); }
|
|
111
|
-
b.disabled = false;
|
|
112
135
|
});
|
|
113
|
-
B.on(B.root, 'click',
|
|
114
|
-
const b = ev.target.closest('form[data-reply] button[data-proto]');
|
|
136
|
+
B.on(B.root, 'click', (ev) => {
|
|
137
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
115
138
|
if (!b) return;
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
|
|
121
|
-
const n = protoNotice(pending, latest);
|
|
122
|
-
if (!n.bump) { B.notice(n.text); return; }
|
|
123
|
-
if (!(await B.confirm(n.text))) return;
|
|
124
|
-
await B.bumpAsk(pending);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
128
|
-
b.disabled = true;
|
|
129
|
-
try {
|
|
130
|
-
await B.sentHere(key, () => B.send('prototype-request', { key }));
|
|
131
|
-
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
132
|
-
toast('Asked Claude for prototypes');
|
|
133
|
-
loadThread(key, { force: true });
|
|
134
|
-
} catch { b.disabled = false; toast('Could not send'); }
|
|
139
|
+
const f = b.closest('form');
|
|
140
|
+
const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
|
|
141
|
+
if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
|
|
142
|
+
syncFlag(f);
|
|
135
143
|
});
|
|
136
144
|
B.on(B.root, 'submit', async (ev) => {
|
|
137
145
|
const f = ev.target.closest('form[data-reply]');
|
|
@@ -139,24 +147,27 @@ BoardView.part((B) => {
|
|
|
139
147
|
ev.preventDefault();
|
|
140
148
|
const ta = f.querySelector('textarea');
|
|
141
149
|
ta.blur();
|
|
142
|
-
const
|
|
150
|
+
const flag = flagOf(f);
|
|
151
|
+
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
143
152
|
const id = f.dataset.composer;
|
|
144
153
|
const files = BoardAttach.files(id, B.site.root);
|
|
145
|
-
if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
154
|
+
if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
146
155
|
const key = f.dataset.reply;
|
|
147
|
-
const
|
|
156
|
+
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
|
|
148
157
|
try {
|
|
149
|
-
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
150
|
-
() => B.sentHere(key, () => B.send('item-message',
|
|
158
|
+
await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
159
|
+
() => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
|
|
151
160
|
BoardAttach.clear(id, B.site.root);
|
|
152
|
-
|
|
161
|
+
delete f.dataset.flag;
|
|
162
|
+
syncFlag(f);
|
|
153
163
|
loadThread(f.dataset.reply, { force: true });
|
|
154
|
-
} catch { B.notice('Could not send. Is the host awake?');
|
|
164
|
+
} catch { B.notice('Could not send. Is the host awake?'); }
|
|
155
165
|
});
|
|
156
166
|
// Send waits while a composer's files upload, the ask's included (attach.js attach-change).
|
|
157
167
|
B.on(B.root, 'attach-change', (ev) => {
|
|
158
168
|
const b = ev.target.querySelector('button[type=submit]');
|
|
159
169
|
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
170
|
+
if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
|
|
160
171
|
});
|
|
161
172
|
|
|
162
173
|
Object.assign(B, {
|
package/board/public/app.html
CHANGED
|
@@ -41,7 +41,9 @@ window.BoardBoot = (function () {
|
|
|
41
41
|
<script src="/_hub/app/ui.js"></script>
|
|
42
42
|
<script src="/_hub/app/ui-links.js"></script>
|
|
43
43
|
<script src="/_hub/app/ui-enums.js"></script>
|
|
44
|
+
<script src="/_hub/app/ui-phase-bars.js"></script>
|
|
44
45
|
<script src="/_hub/app/ui-order.js"></script>
|
|
46
|
+
<script src="/_hub/app/ui-patch.js"></script>
|
|
45
47
|
<script src="/_hub/app/ui-threads.js"></script>
|
|
46
48
|
<script src="/_hub/app/ui-cards.js"></script>
|
|
47
49
|
<script src="/_hub/app/ui-header.js"></script>
|
|
@@ -62,6 +64,7 @@ window.BoardBoot = (function () {
|
|
|
62
64
|
<script src="/_hub/app/linear-key.js"></script>
|
|
63
65
|
<script src="/_hub/app/sidebar.js"></script>
|
|
64
66
|
<script src="/_hub/app/app.js"></script>
|
|
67
|
+
<script src="/_hub/app/new-task.js"></script>
|
|
65
68
|
<script src="/_hub/app/app-core.js"></script>
|
|
66
69
|
<script src="/_hub/app/app-threads.js"></script>
|
|
67
70
|
<script src="/_hub/app/app-cards.js"></script>
|
|
@@ -70,6 +73,7 @@ window.BoardBoot = (function () {
|
|
|
70
73
|
<script src="/_hub/app/app-needs.js"></script>
|
|
71
74
|
<script src="/_hub/app/app-header.js"></script>
|
|
72
75
|
<script src="/_hub/app/app-arch.js"></script>
|
|
76
|
+
<script src="/_hub/app/app-reader.js"></script>
|
|
73
77
|
<script src="/_hub/app/app-banner.js"></script>
|
|
74
78
|
<script src="/_hub/app/app-snapshot.js"></script>
|
|
75
79
|
<script src="/_hub/app/view-settings.js"></script>
|
|
@@ -77,7 +81,6 @@ window.BoardBoot = (function () {
|
|
|
77
81
|
<script src="/_hub/app/view-notes.js"></script>
|
|
78
82
|
<script src="/_hub/app/view-hub.js"></script>
|
|
79
83
|
<script src="/_hub/app/view-hub-settings.js"></script>
|
|
80
|
-
<script src="/_hub/app/view-polling.js"></script>
|
|
81
84
|
<script src="/_hub/app/view-frame.js"></script>
|
|
82
85
|
<script src="/_hub/app/router.js"></script>
|
|
83
86
|
<script src="/_hub/app/shell.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -86,7 +86,26 @@ window.BoardView = (function () {
|
|
|
86
86
|
}
|
|
87
87
|
toasts.toast(text);
|
|
88
88
|
};
|
|
89
|
-
const
|
|
89
|
+
const store = storeFor(route);
|
|
90
|
+
// iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
|
|
91
|
+
// So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
|
|
92
|
+
const quiet = new Map();
|
|
93
|
+
function quieted(draftKey, ta) {
|
|
94
|
+
const until = quiet.get(draftKey) || 0;
|
|
95
|
+
if (until <= Date.now()) return false;
|
|
96
|
+
if (until !== Infinity) ta.value = '';
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async function sendDraft(draftKey, ta, current, send) {
|
|
100
|
+
ta.blur();
|
|
101
|
+
quiet.set(draftKey, Infinity);
|
|
102
|
+
try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
|
|
103
|
+
store.set(draftKey, '');
|
|
104
|
+
const now = current();
|
|
105
|
+
if (now) now.value = '';
|
|
106
|
+
quiet.set(draftKey, Date.now() + 1000);
|
|
107
|
+
}
|
|
108
|
+
const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
|
|
90
109
|
return {
|
|
91
110
|
B,
|
|
92
111
|
start() { alive = true; },
|
|
@@ -100,7 +119,7 @@ window.BoardView = (function () {
|
|
|
100
119
|
|
|
101
120
|
function create(root, siteRoot, opts = {}) {
|
|
102
121
|
const { B, start: begin, stop } = base(root, siteRoot);
|
|
103
|
-
const store =
|
|
122
|
+
const { store } = B;
|
|
104
123
|
// board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
|
|
105
124
|
// header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
|
|
106
125
|
// {thread, data, text}, data being what ask-edited sends besides the text.
|
|
@@ -109,10 +128,13 @@ window.BoardView = (function () {
|
|
|
109
128
|
fresh: null,
|
|
110
129
|
openItems: new Set(store.get('openItems', [])),
|
|
111
130
|
threadCache: new Map(),
|
|
112
|
-
withdrawnAsks: new Set(),
|
|
113
131
|
edits: new Map(),
|
|
114
132
|
githubPending: null,
|
|
115
|
-
|
|
133
|
+
// 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: { sending: false, open: false },
|
|
135
|
+
// The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
|
|
136
|
+
// the archive holds past them, null before the first page.
|
|
137
|
+
archived: { cards: [], left: null },
|
|
116
138
|
};
|
|
117
139
|
const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
|
|
118
140
|
// B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
|
|
@@ -120,7 +142,7 @@ window.BoardView = (function () {
|
|
|
120
142
|
const keep = (fn) => { if (B.alive()) keepers.push(fn); };
|
|
121
143
|
const place = places.get(B.route) || null;
|
|
122
144
|
places.delete(B.route);
|
|
123
|
-
Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, place, keep });
|
|
145
|
+
Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
|
|
124
146
|
|
|
125
147
|
function mount() {
|
|
126
148
|
if (B.alive()) return;
|
|
@@ -155,38 +177,40 @@ window.BoardView = (function () {
|
|
|
155
177
|
// Renders open threads from state.threadCache; loadBoard and opening a card load them.
|
|
156
178
|
function render(load = null) {
|
|
157
179
|
const d = state.board;
|
|
180
|
+
BoardUI.pending.settle(d);
|
|
158
181
|
const title = $(':scope > .top h1');
|
|
159
182
|
if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
|
|
160
183
|
if (d.title && document.title !== d.title) document.title = d.title;
|
|
161
184
|
steady(() => {
|
|
162
185
|
B.renderHeader(d);
|
|
163
186
|
B.renderArch(d);
|
|
164
|
-
|
|
165
|
-
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
|
|
187
|
+
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
|
|
166
188
|
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
189
|
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
170
190
|
for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
|
|
171
191
|
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
172
|
-
|
|
192
|
+
const strips = B.captureStrips();
|
|
193
|
+
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
173
194
|
for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
|
|
195
|
+
B.moveStrips(d.items, strips);
|
|
174
196
|
B.renderNeeds(d);
|
|
175
197
|
B.renderBanner(d);
|
|
176
198
|
});
|
|
177
|
-
paintFades();
|
|
178
199
|
B.flashChanged();
|
|
179
200
|
B.signCards(load);
|
|
180
201
|
}
|
|
181
202
|
|
|
203
|
+
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
182
204
|
// #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
|
|
183
|
-
// (docs/adr/
|
|
205
|
+
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
184
206
|
let focusKey = null;
|
|
185
207
|
let replyKey = null;
|
|
186
208
|
let hashRead = false;
|
|
187
209
|
// 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) });
|
|
210
|
+
const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
|
|
189
211
|
function readHash() {
|
|
212
|
+
const review = /^#review=(\d+)$/.exec(location.hash);
|
|
213
|
+
if (review && state.board) { B.openReview(Number(review[1])); return; }
|
|
190
214
|
const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
|
|
191
215
|
if (!m) return;
|
|
192
216
|
focusKey = decodeURIComponent(m[1]);
|
|
@@ -194,7 +218,7 @@ window.BoardView = (function () {
|
|
|
194
218
|
replyKey = m[2] ? focusKey : null;
|
|
195
219
|
state.openItems.add(focusKey);
|
|
196
220
|
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); }
|
|
221
|
+
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
198
222
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
199
223
|
}
|
|
200
224
|
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
@@ -228,13 +252,13 @@ window.BoardView = (function () {
|
|
|
228
252
|
}
|
|
229
253
|
|
|
230
254
|
// 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/
|
|
255
|
+
// spinning, and the header's time is the snapshot's until a load lands (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
|
|
232
256
|
// → true when it painted live (B.live), so the controls turn on at once.
|
|
233
257
|
function paintSnapshot() {
|
|
234
258
|
const snap = B.readSnapshot();
|
|
235
259
|
if (!snap) return false;
|
|
236
260
|
const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
|
|
237
|
-
|
|
261
|
+
state.board = snap.board;
|
|
238
262
|
for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
|
|
239
263
|
state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
|
|
240
264
|
B.setView(live ? 'fresh' : 'snapshot');
|
|
@@ -262,13 +286,17 @@ window.BoardView = (function () {
|
|
|
262
286
|
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
263
287
|
let landed = false;
|
|
264
288
|
try {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
// A reload names the version of the board on screen: an unchanged board answers 304 with no body
|
|
290
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch from it
|
|
291
|
+
// (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md).
|
|
292
|
+
const tag = state.view === 'fresh' ? state.board.version : null;
|
|
293
|
+
const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
|
|
294
|
+
.then((r) => (r.status === 304 ? null : r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
|
|
295
|
+
if (board) land(board, load); else B.linkUp();
|
|
268
296
|
landed = true;
|
|
269
297
|
if (!preloaded) {
|
|
270
298
|
preloaded = true;
|
|
271
|
-
for (const key of preloadKeys(board.items, state.openItems)) B.loadThread(key);
|
|
299
|
+
for (const key of preloadKeys(state.board.items, state.openItems)) B.loadThread(key);
|
|
272
300
|
}
|
|
273
301
|
} catch (e) {
|
|
274
302
|
B.linkDown();
|
|
@@ -278,16 +306,15 @@ window.BoardView = (function () {
|
|
|
278
306
|
if (state.view === 'fresh') B.endFlash();
|
|
279
307
|
}
|
|
280
308
|
|
|
281
|
-
// A board from the server, a load's or an events POST's,
|
|
282
|
-
// before the board on screen leaves it as it is, so a slow load can't undo a newer one
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (
|
|
309
|
+
// A board from the server, a load's or an events POST's, or a patch from the board on screen (ui-patch.js). One built
|
|
310
|
+
// before the board on screen leaves it as it is, so a slow load can't undo a newer one; a newer patch from a board
|
|
311
|
+
// no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
|
|
312
|
+
function land(answer, load) {
|
|
313
|
+
if (state.view === 'fresh' && state.board && answer.generatedAt < state.board.generatedAt) return false;
|
|
314
|
+
const board = answer.since === undefined ? answer : BoardUI.applyPatch(state.view === 'fresh' ? state.board : null, answer);
|
|
315
|
+
if (!board) { signalled(); return false; }
|
|
288
316
|
const first = state.view !== 'fresh';
|
|
289
|
-
|
|
290
|
-
state.board = drawn();
|
|
317
|
+
state.board = board;
|
|
291
318
|
B.setView('fresh');
|
|
292
319
|
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
293
320
|
B.linkUp();
|
|
@@ -298,61 +325,23 @@ window.BoardView = (function () {
|
|
|
298
325
|
B.saveSnapshotSoon();
|
|
299
326
|
return true;
|
|
300
327
|
}
|
|
301
|
-
function undraw(draw) {
|
|
302
|
-
drawing.delete(draw);
|
|
303
|
-
state.board = drawn();
|
|
304
|
-
render();
|
|
305
|
-
}
|
|
306
328
|
|
|
307
|
-
// An owner tap (docs/adr/
|
|
308
|
-
// the board as the tap left it, which lands as a load of its own,
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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);
|
|
329
|
+
// An owner tap that saves something (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md): the events POST
|
|
330
|
+
// answers once the server has recorded the tap, with the board as the tap left it, which lands as a load of its own,
|
|
331
|
+
// so the tap needs no reload (docs/adr/2026-11-03-0500-every-owner-tap-the-server-can-apply-draws-at-once.md); it names
|
|
332
|
+
// the version on screen, so the answer is a patch from it. Nothing draws
|
|
333
|
+
// before that answer. answered() runs once it comes, before its board lands. A refusal throws, carrying the
|
|
334
|
+
// server's reason for failed.
|
|
335
|
+
async function send(kind, data, answered = null) {
|
|
336
|
+
const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
|
|
337
|
+
if (!B.alive()) return out;
|
|
338
|
+
answered?.();
|
|
339
|
+
land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
|
|
326
340
|
return out;
|
|
327
341
|
}
|
|
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
342
|
|
|
354
343
|
// 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/
|
|
344
|
+
// 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).
|
|
356
345
|
let reloading = null;
|
|
357
346
|
let reloadAgain = false;
|
|
358
347
|
let signalMissed = false;
|
|
@@ -365,9 +354,9 @@ window.BoardView = (function () {
|
|
|
365
354
|
});
|
|
366
355
|
}
|
|
367
356
|
|
|
368
|
-
// The toast for a tap
|
|
369
|
-
const
|
|
370
|
-
Object.assign(B, { render, loadBoard, send,
|
|
357
|
+
// The toast for a tap the server refused, with its reason, or that never reached it.
|
|
358
|
+
const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
|
|
359
|
+
Object.assign(B, { render, loadBoard, send, failed, reload: signalled });
|
|
371
360
|
const live = paintSnapshot();
|
|
372
361
|
B.headerWired();
|
|
373
362
|
if (live) B.linkUp();
|
|
@@ -383,27 +372,15 @@ window.BoardView = (function () {
|
|
|
383
372
|
}
|
|
384
373
|
|
|
385
374
|
function markup({ title = '' } = {}) {
|
|
386
|
-
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true })}
|
|
375
|
+
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
|
|
387
376
|
<main>
|
|
388
377
|
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
389
378
|
<section id="answers" class="answer-banner" hidden></section>
|
|
390
379
|
<section id="waiting" class="waiting" hidden></section>
|
|
391
|
-
<div id="waves"></div>
|
|
392
380
|
<div id="tryMain"></div>
|
|
393
381
|
<div id="lanes"></div>
|
|
394
382
|
</main>
|
|
395
|
-
|
|
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>
|
|
383
|
+
${BoardNewTask.markup()}
|
|
407
384
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
408
385
|
<div class="sheet-body">
|
|
409
386
|
<div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
|
|
@@ -426,6 +403,11 @@ window.BoardView = (function () {
|
|
|
426
403
|
<div id="archBody" class="arch-body"></div>
|
|
427
404
|
</div>
|
|
428
405
|
</dialog>
|
|
406
|
+
<dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
|
|
407
|
+
<div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
|
|
408
|
+
<div class="reader-body" tabindex="0"></div>
|
|
409
|
+
<button type="button" class="reader-new" data-reader="end" hidden></button>
|
|
410
|
+
</dialog>
|
|
429
411
|
<dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
|
|
430
412
|
<div class="sheet-body">
|
|
431
413
|
<div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
|
package/board/public/attach.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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 BoardUI.attachButton(), its text box in
|
|
3
|
+
// BoardUI.dropZone(), and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
|
|
4
|
+
// uploads them at once to the api/upload of the site named by the nearest
|
|
3
5
|
// [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
|
|
4
6
|
// files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
|
|
5
7
|
// again, and a board the app shell switches away from keeps its own. site defaults to the page's own below.
|
|
@@ -65,9 +67,43 @@ window.BoardAttach = (function () {
|
|
|
65
67
|
const input = ev.target.closest && ev.target.closest('input[data-attach]');
|
|
66
68
|
const c = input && input.closest('[data-composer]');
|
|
67
69
|
if (!c) return;
|
|
68
|
-
|
|
70
|
+
addFiles(c, input.files);
|
|
69
71
|
input.value = '';
|
|
70
72
|
});
|
|
73
|
+
// A drag of files over a composer marks it .dragging, which outlines its drop zone. dragenter and dragleave fire on
|
|
74
|
+
// each child it crosses, so the composer counts them, and a drop ends the drag.
|
|
75
|
+
const depth = new WeakMap();
|
|
76
|
+
const composerOf = (ev) => (ev.target.closest && ev.target.closest('[data-composer]')) || null;
|
|
77
|
+
const carriesFiles = (ev) => [...(ev.dataTransfer?.types || [])].includes('Files');
|
|
78
|
+
const setDepth = (c, n) => { depth.set(c, n); c.classList.toggle('dragging', n > 0); };
|
|
79
|
+
const addFiles = (c, files) => { for (const f of files) upload(siteOf(c), c.dataset.composer, f); };
|
|
80
|
+
document.addEventListener('dragenter', (ev) => {
|
|
81
|
+
const c = composerOf(ev);
|
|
82
|
+
if (c && carriesFiles(ev)) setDepth(c, (depth.get(c) || 0) + 1);
|
|
83
|
+
});
|
|
84
|
+
document.addEventListener('dragleave', (ev) => {
|
|
85
|
+
const c = composerOf(ev);
|
|
86
|
+
if (c && depth.get(c)) setDepth(c, depth.get(c) - 1);
|
|
87
|
+
});
|
|
88
|
+
// A file drag is taken anywhere on the page, so a drop that misses every composer does nothing in place of the
|
|
89
|
+
// browser opening the file over the board.
|
|
90
|
+
document.addEventListener('dragover', (ev) => { if (carriesFiles(ev)) ev.preventDefault(); });
|
|
91
|
+
document.addEventListener('drop', (ev) => {
|
|
92
|
+
if (!carriesFiles(ev)) return;
|
|
93
|
+
ev.preventDefault();
|
|
94
|
+
const c = composerOf(ev);
|
|
95
|
+
if (!c) return;
|
|
96
|
+
setDepth(c, 0);
|
|
97
|
+
addFiles(c, ev.dataTransfer.files);
|
|
98
|
+
});
|
|
99
|
+
// A paste that carries files attaches them in place of pasting; a paste of text alone pastes as usual.
|
|
100
|
+
document.addEventListener('paste', (ev) => {
|
|
101
|
+
const c = composerOf(ev);
|
|
102
|
+
const files = [...(ev.clipboardData?.files || [])];
|
|
103
|
+
if (!c || !files.length) return;
|
|
104
|
+
ev.preventDefault();
|
|
105
|
+
addFiles(c, files);
|
|
106
|
+
});
|
|
71
107
|
document.addEventListener('click', (ev) => {
|
|
72
108
|
const b = ev.target.closest && ev.target.closest('[data-unattach]');
|
|
73
109
|
const c = b && b.closest('[data-composer]');
|