@lovek4d/kickflip 0.4.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 +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- 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 +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- 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/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- 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 +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- 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 +7 -7
- 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/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
|
@@ -4,28 +4,34 @@
|
|
|
4
4
|
// opens the topic whose pill was to its left before the Send, else to its right, and with no topic left that has an
|
|
5
5
|
// open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
|
|
6
6
|
// with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
|
|
7
|
-
// whose
|
|
7
|
+
// whose url is a prototype page shows its options and a View prototypes link to its full-page viewer
|
|
8
|
+
// (docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
|
|
8
9
|
// Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
|
|
9
10
|
// with its note box as the note and its pick so far as the choice (docs/adr/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
|
|
10
|
-
// The
|
|
11
|
-
// (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
12
|
+
// recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
|
|
13
|
+
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
14
|
+
// A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
|
|
15
|
+
// `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
|
|
16
|
+
// A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
|
|
17
|
+
// update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
|
|
18
|
+
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md).
|
|
19
|
+
// markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
|
|
20
|
+
// and the board's URL and scroll are left alone.
|
|
21
|
+
(globalThis.AppViews ||= {}).questions = {
|
|
14
22
|
className: 'view-questions',
|
|
15
23
|
title: () => 'Waiting On You',
|
|
16
|
-
markup: () => `${BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
|
|
24
|
+
markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
|
|
17
25
|
<main>
|
|
18
|
-
<section id="page"></section>
|
|
26
|
+
${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
|
|
19
27
|
</main>`,
|
|
20
28
|
|
|
21
29
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
22
30
|
const { $, site } = B;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const setDraft = (id, v) => { try { localStorage.setItem(draftKey(id), JSON.stringify(v)); } catch {} };
|
|
28
|
-
const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
31
|
+
const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
|
|
32
|
+
const setHash = (id) => { if (!ctx.embedded) history.replaceState(history.state, '', '#' + encodeURIComponent(id)); };
|
|
33
|
+
const { esc, getDraft, setDraft, choiceLabel, choiceOf, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
34
|
+
const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
29
35
|
|
|
30
36
|
let all = [];
|
|
31
37
|
// The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
|
|
@@ -42,15 +48,9 @@
|
|
|
42
48
|
let sentLine = '';
|
|
43
49
|
// The footer after a prototype request: the blue spinner, then these words.
|
|
44
50
|
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
51
|
+
// The bar's line while a suggested option is still pre-checked.
|
|
52
|
+
const SUGGESTED = 'suggested options are selected';
|
|
45
53
|
const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
|
|
46
|
-
let scrollTake = null;
|
|
47
|
-
const takeResources = new Map();
|
|
48
|
-
const clearTakeResources = (within = root) => {
|
|
49
|
-
for (const [shot, release] of takeResources) {
|
|
50
|
-
if (!shot.isConnected || within.contains(shot)) { release(); takeResources.delete(shot); }
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
B.atUnmount(() => clearTakeResources());
|
|
54
54
|
|
|
55
55
|
const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
|
|
56
56
|
const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
|
|
@@ -63,20 +63,23 @@
|
|
|
63
63
|
function topicBar() {
|
|
64
64
|
const list = topicRow(topics, topicId).pills;
|
|
65
65
|
if (list.length < 2) return '';
|
|
66
|
-
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
|
|
67
|
-
`<button data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`).join('')}</div>`;
|
|
66
|
+
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
|
|
68
67
|
}
|
|
68
|
+
// An arrival (arrive) slides in with a glow and settles (style.css .arrived).
|
|
69
|
+
const pillHtml = (t, arrived = false) => `<button${arrived ? ' class="arrived"' : ''} data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
|
|
69
70
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
70
71
|
|
|
71
|
-
// Only questions changed here since their last answer,
|
|
72
|
+
// Only open questions changed here since their last answer, with files attached, or with a suggested option
|
|
73
|
+
// pre-checked and never answered, are sent; a resolved one is read-only.
|
|
72
74
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
73
75
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
74
76
|
function pending(qs) {
|
|
75
|
-
return qs.filter((q) => {
|
|
77
|
+
return qs.filter((q) => q.state !== 'resolved' && q.state !== 'requested').filter((q) => {
|
|
76
78
|
const d = getDraft(q.id);
|
|
77
79
|
if (filesOf(q).length) return true;
|
|
78
|
-
if (!('choice' in d) && !d.note) return false;
|
|
79
80
|
const last = q.answers[q.answers.length - 1];
|
|
81
|
+
if (!last && q.suggested) return true;
|
|
82
|
+
if (!('choice' in d) && !d.note) return false;
|
|
80
83
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
81
84
|
});
|
|
82
85
|
}
|
|
@@ -87,12 +90,14 @@
|
|
|
87
90
|
return t ? t.questions.map(byId).filter(Boolean) : [];
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
// → the fetch of the topic's resolved questions, or null when they are held. force asks again; the questions held stay
|
|
94
|
+
// drawn until the new ones land.
|
|
95
|
+
function loadResolved(force = false) {
|
|
91
96
|
const t = soloId ? null : topics.find((x) => x.id === topicId);
|
|
92
|
-
if (!t?.resolved || (resolved.topic === t.id && resolved.n === t.resolved)) return;
|
|
93
|
-
resolved = { topic: t.id, n: t.resolved, questions: [] };
|
|
97
|
+
if (!t?.resolved || (!force && resolved.topic === t.id && resolved.n === t.resolved)) return null;
|
|
98
|
+
resolved = { topic: t.id, n: t.resolved, questions: resolved.topic === t.id ? resolved.questions : [] };
|
|
94
99
|
const asked = resolved;
|
|
95
|
-
fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
100
|
+
return fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
96
101
|
if (!B.alive() || resolved !== asked) return;
|
|
97
102
|
asked.questions = body.questions || [];
|
|
98
103
|
repaint(false);
|
|
@@ -100,8 +105,8 @@
|
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
function render() {
|
|
103
|
-
clearTakeResources();
|
|
104
108
|
loadResolved();
|
|
109
|
+
drawn = drawnOf();
|
|
105
110
|
const el = $('#page');
|
|
106
111
|
const qs = visible();
|
|
107
112
|
if (!qs.length) {
|
|
@@ -116,113 +121,14 @@
|
|
|
116
121
|
? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
|
|
117
122
|
: `${topicBar()}${topicHead(t)}`;
|
|
118
123
|
el.innerHTML = `${head}<form id="qform">
|
|
119
|
-
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q)
|
|
120
|
-
${done.length ? `<details class="done"><summary>
|
|
124
|
+
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q) })).join('')}
|
|
125
|
+
${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span><span class="names">${done.map((q) => esc(q.id)).join(', ')}</span></summary>${done.map((q) => questionCard(q, { links: links(q) })).join('')}</details>` : ''}
|
|
121
126
|
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
|
|
122
127
|
</form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
|
|
123
128
|
BoardAttach.paintAll();
|
|
124
|
-
el.querySelectorAll('.take-shot').forEach(fitTake);
|
|
125
129
|
updateBar();
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
// A width sets its page's height, so a size measured at another width is measured again.
|
|
129
|
-
const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
|
|
130
|
-
const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
|
|
131
|
-
// A phone's header is a bar at the bottom (docs/adr/2026-10-31-1532-phone-headers-sit-at-the-bottom.md), so only a header
|
|
132
|
-
// at the top covers the frame's top.
|
|
133
|
-
const frameTop = (shot) => {
|
|
134
|
-
const head = $('header.top')?.getBoundingClientRect();
|
|
135
|
-
const over = head && head.top < innerHeight / 2 ? head.bottom : 0;
|
|
136
|
-
return Math.max(0, scrollY + shot.getBoundingClientRect().top - over);
|
|
137
|
-
};
|
|
138
|
-
function repaintTakes(sec) {
|
|
139
|
-
if (!sec.querySelector('.takes')) return;
|
|
140
|
-
const q = byId(sec.dataset.q);
|
|
141
|
-
const d = getDraft(q.id);
|
|
142
|
-
const chosen = d.choice ?? q.answers[q.answers.length - 1]?.choice;
|
|
143
|
-
const old = sec.querySelector('.takes');
|
|
144
|
-
clearTakeResources(old);
|
|
145
|
-
old.outerHTML = takes(q, chosen, links(q), fitOf(q));
|
|
146
|
-
sec.querySelectorAll('.take-shot').forEach(fitTake);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// A full-screen take gets the screen between the header and the Send bar.
|
|
150
|
-
const screenH = () => Math.max(320, innerHeight - ($('header.top')?.offsetHeight || 0) - ($('#qform .bar')?.offsetHeight || 0));
|
|
151
|
-
|
|
152
|
-
// A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
|
|
153
|
-
// any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
|
|
154
|
-
// height, turns its question into one full-width take under a choice row
|
|
155
|
-
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
156
|
-
function fitTake(shot) {
|
|
157
|
-
const frame = shot.querySelector('iframe');
|
|
158
|
-
const sec = shot.closest('section.q');
|
|
159
|
-
// A switch in place (inPlace) changes the take a frame shows, and so the key its size is kept under.
|
|
160
|
-
const key = () => fitKey(sec.dataset.q, shot.closest('.take').dataset.opt);
|
|
161
|
-
const full = Boolean(shot.closest('.takes-full'));
|
|
162
|
-
let watcher = null;
|
|
163
|
-
let probing = false;
|
|
164
|
-
let raf = 0;
|
|
165
|
-
const measure = () => {
|
|
166
|
-
try {
|
|
167
|
-
const doc = frame.contentDocument;
|
|
168
|
-
// The body's own height: the document's scrollHeight never drops below the frame's.
|
|
169
|
-
return doc?.body && doc.location.href !== 'about:blank' ? Math.ceil(doc.body.getBoundingClientRect().height) : null;
|
|
170
|
-
} catch { return null; }
|
|
171
|
-
};
|
|
172
|
-
const sizeNow = () => { const f = fits.get(key()); return f?.w === window.innerWidth ? { ...f } : { w: window.innerWidth }; };
|
|
173
|
-
const fillsFrame = (h) => Math.abs(h - frame.clientHeight) <= 1;
|
|
174
|
-
// A page as tall as its frame is grown once: a page sized by its content keeps its height, a full-screen one
|
|
175
|
-
// follows the frame.
|
|
176
|
-
const probe = () => {
|
|
177
|
-
probing = true;
|
|
178
|
-
shot.style.height = `${frame.clientHeight + 202}px`;
|
|
179
|
-
raf = requestAnimationFrame(() => raf = requestAnimationFrame(() => {
|
|
180
|
-
raf = 0;
|
|
181
|
-
probing = false;
|
|
182
|
-
const h = measure();
|
|
183
|
-
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
184
|
-
fits.set(key(), { ...sizeNow(), fills: fillsFrame(h) });
|
|
185
|
-
apply();
|
|
186
|
-
}));
|
|
187
|
-
};
|
|
188
|
-
const apply = () => {
|
|
189
|
-
if (probing) return;
|
|
190
|
-
const h = measure();
|
|
191
|
-
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
192
|
-
const size = sizeNow();
|
|
193
|
-
if (size.fills === undefined) {
|
|
194
|
-
if (fillsFrame(h)) { probe(); return; }
|
|
195
|
-
size.fills = false;
|
|
196
|
-
}
|
|
197
|
-
// The shot's border is 1px each side, and the frame fills the rest.
|
|
198
|
-
const height = size.fills ? screenH() : h + 2;
|
|
199
|
-
if (full) size.fullH = height;
|
|
200
|
-
else { size.h = height; size.scrolls = size.fills || h > window.innerHeight * 0.8; }
|
|
201
|
-
fits.set(key(), size);
|
|
202
|
-
const q = byId(sec.dataset.q);
|
|
203
|
-
if (!full && size.scrolls && q.options.every((o) => !o.diagram && !o.practice)) {
|
|
204
|
-
repaintTakes(sec);
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
shot.style.height = `${height}px`;
|
|
208
|
-
shot.classList.remove('measuring');
|
|
209
|
-
if (scrollTake === shot) { scrollTo(0, frameTop(shot)); scrollTake = null; }
|
|
210
|
-
};
|
|
211
|
-
const off = B.on(frame, 'load', () => {
|
|
212
|
-
if (measure() === null || !B.alive()) return;
|
|
213
|
-
watcher?.disconnect();
|
|
214
|
-
watcher = null;
|
|
215
|
-
apply();
|
|
216
|
-
// A page that grows after it loads, or narrows with the view, is measured again.
|
|
217
|
-
if (!shot.isConnected) return;
|
|
218
|
-
try {
|
|
219
|
-
watcher = new frame.contentWindow.ResizeObserver(apply);
|
|
220
|
-
watcher.observe(frame.contentDocument.body);
|
|
221
|
-
} catch {}
|
|
222
|
-
});
|
|
223
|
-
takeResources.set(shot, () => { off(); watcher?.disconnect(); if (raf) cancelAnimationFrame(raf); });
|
|
224
|
-
}
|
|
225
|
-
|
|
226
132
|
function waitingTopics() {
|
|
227
133
|
const list = topicRow(topics, topicId).waiting;
|
|
228
134
|
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
@@ -250,19 +156,44 @@
|
|
|
250
156
|
moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
|
|
251
157
|
}
|
|
252
158
|
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
159
|
+
// A tap's change no load has drawn in BoardUI's PENDING_LATE_MS: the board loads once and the button rests.
|
|
160
|
+
function stillWaiting() {
|
|
161
|
+
if (!B.alive()) return;
|
|
162
|
+
B.toast('Still waiting on the board');
|
|
163
|
+
shown = '';
|
|
164
|
+
load(false);
|
|
165
|
+
}
|
|
166
|
+
// A refusal rests the button with its reason on the status line, as before.
|
|
167
|
+
const refused = (e) => { $('#qstatus').textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; };
|
|
168
|
+
|
|
169
|
+
// The tapped Prototype button waits (ui.js waitOn) until the page shows the request
|
|
170
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
171
|
+
function askPrototype(q, button) {
|
|
256
172
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
257
173
|
const at = leaving();
|
|
258
|
-
|
|
174
|
+
$('#qstatus').textContent = '';
|
|
259
175
|
const d = getDraft(q.id);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
176
|
+
return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
177
|
+
let out;
|
|
178
|
+
try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: choiceOf(q, d) }); } catch (e) { refused(e); return; }
|
|
179
|
+
setDraft(q.id, {});
|
|
180
|
+
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
181
|
+
})(), stillWaiting));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
|
|
185
|
+
// The topic's resolved questions load again, so the question shows its re-ask under way.
|
|
186
|
+
async function askAgain(q, button) {
|
|
187
|
+
if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
|
|
188
|
+
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
189
|
+
await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
190
|
+
let out;
|
|
191
|
+
try { out = await send('reask-request', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
|
|
192
|
+
if (!B.alive()) return;
|
|
193
|
+
if (!land(out.board, false)) await load(false);
|
|
194
|
+
await loadResolved(true);
|
|
195
|
+
repaint(false);
|
|
196
|
+
})(), stillWaiting));
|
|
266
197
|
}
|
|
267
198
|
|
|
268
199
|
function updateBar() {
|
|
@@ -272,6 +203,10 @@
|
|
|
272
203
|
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
273
204
|
const live = qs.filter((q) => q.state !== 'resolved');
|
|
274
205
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
206
|
+
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
207
|
+
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
208
|
+
const st = $('#qstatus');
|
|
209
|
+
if (st && !sentLine && (prechecked || st.textContent === SUGGESTED)) st.textContent = prechecked ? SUGGESTED : '';
|
|
275
210
|
$('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
|
|
276
211
|
}
|
|
277
212
|
|
|
@@ -283,7 +218,7 @@
|
|
|
283
218
|
topicId = t ? t.id : null;
|
|
284
219
|
}
|
|
285
220
|
|
|
286
|
-
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
221
|
+
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])]);
|
|
287
222
|
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
288
223
|
// while one runs shares it. → true when the board's answer landed in this view.
|
|
289
224
|
let loading = null;
|
|
@@ -318,17 +253,66 @@
|
|
|
318
253
|
loaded = true;
|
|
319
254
|
return true;
|
|
320
255
|
}
|
|
321
|
-
// Draws what changed, unless the owner is typing a note.
|
|
256
|
+
// Draws what changed: an arrival in place (arrive), else the whole page, unless the owner is typing a note.
|
|
322
257
|
function repaint(first) {
|
|
323
258
|
const sig = sigNow();
|
|
259
|
+
if (sig === shown) return;
|
|
260
|
+
if (!first && arrive()) { shown = sig; return; }
|
|
324
261
|
const active = document.activeElement;
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
262
|
+
if (active && active.tagName === 'TEXTAREA') return;
|
|
263
|
+
shown = sig;
|
|
264
|
+
const y = scrollY;
|
|
265
|
+
render();
|
|
266
|
+
scroll(first ? 0 : y);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// What render last drew of the topic: its questions in order as they stood, and its resolved count.
|
|
270
|
+
const drawnOf = () => {
|
|
271
|
+
const t = topics.find((x) => x.id === topicId);
|
|
272
|
+
return { solo: soloId, topic: topicId, resolved: t?.resolved || 0, qs: visible().map((q) => JSON.stringify([q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])) };
|
|
273
|
+
};
|
|
274
|
+
let drawn = null;
|
|
275
|
+
// A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
|
|
276
|
+
// pill keeps its node and a new one lands at its place in the row, a new question's card goes after the last open
|
|
277
|
+
// one, and the topic line, the folds under the form and the bar update. → false when anything shown changed.
|
|
278
|
+
function arrive() {
|
|
279
|
+
const form = $('#qform');
|
|
280
|
+
const now = drawnOf();
|
|
281
|
+
if (!form || !drawn || now.solo || drawn.solo || now.topic !== drawn.topic || now.resolved !== drawn.resolved
|
|
282
|
+
|| drawn.qs.some((s, i) => now.qs[i] !== s)) return false;
|
|
283
|
+
const added = visible().slice(drawn.qs.length);
|
|
284
|
+
if (added.some((q) => q.state !== 'open')) return false;
|
|
285
|
+
const page = $('#page');
|
|
286
|
+
const list = topicRow(topics, topicId).pills;
|
|
287
|
+
const bar = page.querySelector('.topics');
|
|
288
|
+
if (list.length < 2) bar?.remove();
|
|
289
|
+
else if (!bar) page.insertAdjacentHTML('afterbegin', topicBar());
|
|
290
|
+
else {
|
|
291
|
+
const pills = list.map((t) => bar.querySelector(`button[data-topic="${CSS.escape(t.id)}"]`) || fragmentOf(pillHtml(t, true)));
|
|
292
|
+
for (const b of [...bar.children]) if (!pills.includes(b)) b.remove();
|
|
293
|
+
pills.forEach((b, i) => {
|
|
294
|
+
const t = list[i];
|
|
295
|
+
b.setAttribute('aria-pressed', String(t.id === topicId));
|
|
296
|
+
b.querySelector('.n').textContent = t.state === 'resolved' ? 'resolved' : String(t.open);
|
|
297
|
+
if (bar.children[i] !== b) bar.insertBefore(b, bar.children[i] || null);
|
|
298
|
+
});
|
|
330
299
|
}
|
|
300
|
+
const head = page.querySelector('.topichead');
|
|
301
|
+
const headHtml = topicHead(topics.find((x) => x.id === topicId));
|
|
302
|
+
if (head && head.outerHTML !== headHtml) head.outerHTML = headHtml;
|
|
303
|
+
const live = visible().filter((q) => q.state !== 'resolved').length - added.length;
|
|
304
|
+
const before = form.querySelector('details.resolved-group') || form.querySelector('.bar');
|
|
305
|
+
added.forEach((q, i) => before.insertAdjacentHTML('beforebegin', questionCard(q, { n: live + i + 1, draft: getDraft(q.id), links: links(q) })));
|
|
306
|
+
const opened = new Set();
|
|
307
|
+
for (const d of [...page.children]) if (d.matches('details.done')) { if (d.open) opened.add(d.querySelector('summary').textContent.split(' ·')[0]); d.remove(); }
|
|
308
|
+
page.insertAdjacentHTML('beforeend', waitingTopics() + resolvedTopics());
|
|
309
|
+
for (const d of page.children) if (d.matches('details.done') && opened.has(d.querySelector('summary').textContent.split(' ·')[0])) d.open = true;
|
|
310
|
+
BoardAttach.paintAll();
|
|
311
|
+
drawn = now;
|
|
312
|
+
updateBar();
|
|
313
|
+
return true;
|
|
331
314
|
}
|
|
315
|
+
const fragmentOf = (html) => { const box = document.createElement('div'); box.innerHTML = html; return box.firstElementChild; };
|
|
332
316
|
|
|
333
317
|
function moveOn(from, pills, what, footer = what, countOpen = true) {
|
|
334
318
|
soloId = null;
|
|
@@ -340,7 +324,7 @@
|
|
|
340
324
|
shown = sigNow();
|
|
341
325
|
const y = scrollY;
|
|
342
326
|
render();
|
|
343
|
-
|
|
327
|
+
scroll(y);
|
|
344
328
|
return;
|
|
345
329
|
}
|
|
346
330
|
sentLine = footer;
|
|
@@ -351,48 +335,30 @@
|
|
|
351
335
|
topicId = from;
|
|
352
336
|
} else {
|
|
353
337
|
topicId = next.topic;
|
|
354
|
-
|
|
338
|
+
setHash(next.id);
|
|
355
339
|
}
|
|
356
340
|
shown = sigNow();
|
|
357
341
|
render();
|
|
358
|
-
|
|
342
|
+
scroll(0);
|
|
359
343
|
}
|
|
360
344
|
|
|
361
345
|
B.on(root, 'click', (ev) => {
|
|
362
346
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
363
347
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
364
348
|
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
349
|
+
const reask = ev.target.closest && ev.target.closest('[data-reask]');
|
|
350
|
+
if (reask) { if (!reask.disabled) askAgain(byId(reask.dataset.reask), reask); return; }
|
|
365
351
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
366
|
-
if (b) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const take = full.querySelector('.take');
|
|
372
|
-
const frame = take?.querySelector('iframe');
|
|
373
|
-
let sw = null;
|
|
374
|
-
try { sw = frame?.contentWindow?.PrototypeSwitcher?.current; } catch { sw = null; }
|
|
375
|
-
if (!sw?.go || !sw.takes?.includes(choice)) return false;
|
|
376
|
-
sw.go(choice);
|
|
377
|
-
take.dataset.opt = choice;
|
|
378
|
-
for (const label of full.querySelectorAll('.take-choices .opt')) {
|
|
379
|
-
if (label.querySelector('input')?.value === choice) label.setAttribute('aria-current', 'true');
|
|
380
|
-
else label.removeAttribute('aria-current');
|
|
352
|
+
if (b) {
|
|
353
|
+
soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
|
|
354
|
+
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
355
|
+
if (first) setHash(first);
|
|
356
|
+
render(); scroll(0);
|
|
381
357
|
}
|
|
382
|
-
|
|
383
|
-
}
|
|
358
|
+
});
|
|
384
359
|
B.on(root, 'change', (ev) => {
|
|
385
360
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
|
|
386
361
|
const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
387
|
-
const full = sec.querySelector('.takes-full');
|
|
388
|
-
if (full && inPlace(full, d.choice)) return;
|
|
389
|
-
if (full) {
|
|
390
|
-
const top = frameTop(full.querySelector('.take-shot'));
|
|
391
|
-
repaintTakes(sec);
|
|
392
|
-
scrollTake = sec.querySelector('.take-shot');
|
|
393
|
-
sec.querySelector(`input[value="${CSS.escape(d.choice)}"]`)?.focus({ preventScroll: true });
|
|
394
|
-
scrollTo(0, top);
|
|
395
|
-
}
|
|
396
362
|
});
|
|
397
363
|
B.on(root, 'input', (ev) => {
|
|
398
364
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
|
|
@@ -417,46 +383,52 @@
|
|
|
417
383
|
// docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
|
|
418
384
|
const answers = list.map((q) => {
|
|
419
385
|
const d = getDraft(q.id);
|
|
420
|
-
const
|
|
421
|
-
const choice = d.choice ?? (last ? last.choice : null);
|
|
386
|
+
const choice = choiceOf(q, d);
|
|
422
387
|
const files = filesOf(q);
|
|
423
388
|
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
|
|
424
389
|
});
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
390
|
+
// Send waits until the page has moved on (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
391
|
+
await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
|
|
392
|
+
let out;
|
|
393
|
+
try { out = await send('owner-answers', { answers }); } catch (e) { refused(e); return; }
|
|
394
|
+
for (const q of list) {
|
|
395
|
+
setDraft(q.id, {});
|
|
396
|
+
BoardAttach.clear('q:' + q.id, site.root);
|
|
397
|
+
}
|
|
398
|
+
await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
|
|
399
|
+
const open = all.filter((q) => q.state === 'open').length;
|
|
400
|
+
return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
|
|
401
|
+
});
|
|
402
|
+
})(), stillWaiting));
|
|
436
403
|
});
|
|
437
404
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
438
405
|
B.on(root, 'attach-change', updateBar);
|
|
406
|
+
// While a text field is focused and the keyboard is up, the bar docks on the keyboard (style.css .kb-up) from
|
|
407
|
+
// visualViewport, which iOS reports apart from the layout viewport the sticky bar follows.
|
|
408
|
+
const vv = window.visualViewport;
|
|
409
|
+
let typing = false;
|
|
410
|
+
const dock = () => {
|
|
411
|
+
const kb = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
|
|
412
|
+
root.style.setProperty('--kb', kb + 'px');
|
|
413
|
+
root.classList.toggle('kb-up', typing && kb > 0);
|
|
414
|
+
};
|
|
415
|
+
B.on(root, 'focusin', (ev) => { typing = ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false; dock(); });
|
|
416
|
+
B.on(root, 'focusout', () => { typing = false; dock(); });
|
|
417
|
+
if (vv) { B.on(vv, 'resize', dock); B.on(vv, 'scroll', dock); }
|
|
439
418
|
// A link to another question here only moves the hash.
|
|
440
|
-
B.on(window, 'hashchange', () => {
|
|
419
|
+
if (!ctx.embedded) B.on(window, 'hashchange', () => {
|
|
441
420
|
if (!loaded) return;
|
|
442
421
|
pick(hashId());
|
|
443
422
|
sentLine = '';
|
|
444
423
|
shown = '';
|
|
445
424
|
render();
|
|
446
|
-
|
|
425
|
+
scroll(0);
|
|
447
426
|
});
|
|
448
427
|
|
|
449
428
|
load(true);
|
|
450
429
|
B.every(15000, () => { if (!document.hidden) load(false); });
|
|
451
430
|
B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
|
|
452
|
-
//
|
|
431
|
+
// A pick drafted on a standalone prototype page shows when this page returns from the browser cache.
|
|
453
432
|
B.on(window, 'pageshow', (ev) => { if (ev.persisted) { shown = ''; load(false); } });
|
|
454
|
-
// A new width needs grid measurements before choosing either layout again.
|
|
455
|
-
let width = window.innerWidth;
|
|
456
|
-
B.on(window, 'resize', () => {
|
|
457
|
-
if (window.innerWidth === width) return;
|
|
458
|
-
width = window.innerWidth;
|
|
459
|
-
root.querySelectorAll('section.q').forEach(repaintTakes);
|
|
460
|
-
});
|
|
461
433
|
}),
|
|
462
|
-
}
|
|
434
|
+
};
|