@lovek4d/kickflip 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Waiting on you, /<route>/questions.html#<question id>, a view of the app shell (shell.js). A topic's questions show
|
|
2
|
+
// on one page with one Send at the bottom, which sends an `owner-answer` event per question answered there; the rest
|
|
3
|
+
// stay open for a later visit. Once every answer has sent, a topic with a question still open stays; one with none
|
|
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
|
+
// open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
|
|
6
|
+
// with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
|
|
7
|
+
// whose options are prototype takes frames each take with ?take= (docs/adr/0048-a-questions-takes-show-inline.md).
|
|
8
|
+
// fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
|
|
9
|
+
(globalThis.AppViews ||= {}).questions = ((fits) => ({
|
|
10
|
+
className: 'view-questions',
|
|
11
|
+
title: () => 'Waiting On You',
|
|
12
|
+
markup: () => `${BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
|
|
13
|
+
<main>
|
|
14
|
+
<section id="page"></section>
|
|
15
|
+
</main>`,
|
|
16
|
+
|
|
17
|
+
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
18
|
+
const { $, site } = B;
|
|
19
|
+
const { esc, choiceLabel, questionCard, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
20
|
+
// prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
|
|
21
|
+
const draftKey = (id) => 'qdraft:' + id;
|
|
22
|
+
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
|
|
23
|
+
const setDraft = (id, v) => { try { localStorage.setItem(draftKey(id), JSON.stringify(v)); } catch {} };
|
|
24
|
+
const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
25
|
+
|
|
26
|
+
let all = [];
|
|
27
|
+
let items = [];
|
|
28
|
+
let topics = [];
|
|
29
|
+
let topicId = null;
|
|
30
|
+
let soloId = null;
|
|
31
|
+
let shown = '';
|
|
32
|
+
let sentLine = '';
|
|
33
|
+
let scrollTake = null;
|
|
34
|
+
const takeResources = new Map();
|
|
35
|
+
const clearTakeResources = (within = root) => {
|
|
36
|
+
for (const [shot, release] of takeResources) {
|
|
37
|
+
if (!shot.isConnected || within.contains(shot)) { release(); takeResources.delete(shot); }
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
B.atUnmount(() => clearTakeResources());
|
|
41
|
+
|
|
42
|
+
const byId = (id) => all.find((q) => q.id === id);
|
|
43
|
+
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
44
|
+
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
45
|
+
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
46
|
+
const urgentOpen = () => all.find((q) => q.urgent && q.state === 'open');
|
|
47
|
+
|
|
48
|
+
// docs/adr/0038-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
49
|
+
function topicBar() {
|
|
50
|
+
const list = topicRow(topics, topicId).pills;
|
|
51
|
+
if (list.length < 2) return '';
|
|
52
|
+
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
|
|
53
|
+
`<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>`;
|
|
54
|
+
}
|
|
55
|
+
const topicHead = (t) => t ? `<div class="topichead muted small">${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</div>` : '';
|
|
56
|
+
|
|
57
|
+
// Only questions changed here since their last answer, or with files attached, are sent.
|
|
58
|
+
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
59
|
+
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
60
|
+
function pending(qs) {
|
|
61
|
+
return qs.filter((q) => {
|
|
62
|
+
const d = getDraft(q.id);
|
|
63
|
+
if (filesOf(q).length) return true;
|
|
64
|
+
if (!('choice' in d) && !d.note) return false;
|
|
65
|
+
const last = q.answers[q.answers.length - 1];
|
|
66
|
+
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function visible() {
|
|
71
|
+
if (soloId) return [byId(soloId)].filter(Boolean);
|
|
72
|
+
const t = topics.find((x) => x.id === topicId);
|
|
73
|
+
return t ? t.questions.map(byId).filter(Boolean) : [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function render() {
|
|
77
|
+
clearTakeResources();
|
|
78
|
+
const el = $('#page');
|
|
79
|
+
const qs = visible();
|
|
80
|
+
if (!qs.length) {
|
|
81
|
+
el.innerHTML = '<p class="muted">Nothing is waiting on you.</p>' + waitingTopics() + resolvedTopics();
|
|
82
|
+
$('#progress').textContent = '';
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const t = topics.find((x) => x.id === topicId);
|
|
86
|
+
const live = qs.filter((q) => q.state !== 'resolved');
|
|
87
|
+
const done = qs.filter((q) => q.state === 'resolved');
|
|
88
|
+
const head = soloId
|
|
89
|
+
? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
|
|
90
|
+
: `${topicBar()}${topicHead(t)}`;
|
|
91
|
+
el.innerHTML = `${head}<form id="qform">
|
|
92
|
+
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
|
|
93
|
+
${done.length ? `<details class="done"><summary>Resolved · ${done.length}</summary>${done.map((q) => questionCard(q, { draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}</details>` : ''}
|
|
94
|
+
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${esc(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>
|
|
95
|
+
</form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
|
|
96
|
+
BoardAttach.paintAll();
|
|
97
|
+
el.querySelectorAll('.take-shot').forEach(fitTake);
|
|
98
|
+
updateBar();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A width sets its page's height, so a size measured at another width is measured again.
|
|
102
|
+
const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
|
|
103
|
+
const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
|
|
104
|
+
// A phone's header is a bar at the bottom (docs/adr/0070-phone-headers-sit-at-the-bottom.md), so only a header
|
|
105
|
+
// at the top covers the frame's top.
|
|
106
|
+
const frameTop = (shot) => {
|
|
107
|
+
const head = $('header.top')?.getBoundingClientRect();
|
|
108
|
+
const over = head && head.top < innerHeight / 2 ? head.bottom : 0;
|
|
109
|
+
return Math.max(0, scrollY + shot.getBoundingClientRect().top - over);
|
|
110
|
+
};
|
|
111
|
+
function repaintTakes(sec) {
|
|
112
|
+
if (!sec.querySelector('.takes')) return;
|
|
113
|
+
const q = byId(sec.dataset.q);
|
|
114
|
+
const d = getDraft(q.id);
|
|
115
|
+
const chosen = d.choice ?? q.answers[q.answers.length - 1]?.choice;
|
|
116
|
+
const old = sec.querySelector('.takes');
|
|
117
|
+
clearTakeResources(old);
|
|
118
|
+
old.outerHTML = takes(q, chosen, links(q), fitOf(q));
|
|
119
|
+
sec.querySelectorAll('.take-shot').forEach(fitTake);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A full-screen take gets the screen between the header and the Send bar.
|
|
123
|
+
const screenH = () => Math.max(320, innerHeight - ($('header.top')?.offsetHeight || 0) - ($('#qform .bar')?.offsetHeight || 0));
|
|
124
|
+
|
|
125
|
+
// A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
|
|
126
|
+
// any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
|
|
127
|
+
// height, turns its question into one full-width take under a choice row
|
|
128
|
+
// (docs/adr/0048-a-questions-takes-show-inline.md).
|
|
129
|
+
function fitTake(shot) {
|
|
130
|
+
const frame = shot.querySelector('iframe');
|
|
131
|
+
const sec = shot.closest('section.q');
|
|
132
|
+
// A switch in place (inPlace) changes the take a frame shows, and so the key its size is kept under.
|
|
133
|
+
const key = () => fitKey(sec.dataset.q, shot.closest('.take').dataset.opt);
|
|
134
|
+
const full = Boolean(shot.closest('.takes-full'));
|
|
135
|
+
let watcher = null;
|
|
136
|
+
let probing = false;
|
|
137
|
+
let raf = 0;
|
|
138
|
+
const measure = () => {
|
|
139
|
+
try {
|
|
140
|
+
const doc = frame.contentDocument;
|
|
141
|
+
// The body's own height: the document's scrollHeight never drops below the frame's.
|
|
142
|
+
return doc?.body && doc.location.href !== 'about:blank' ? Math.ceil(doc.body.getBoundingClientRect().height) : null;
|
|
143
|
+
} catch { return null; }
|
|
144
|
+
};
|
|
145
|
+
const sizeNow = () => { const f = fits.get(key()); return f?.w === window.innerWidth ? { ...f } : { w: window.innerWidth }; };
|
|
146
|
+
const fillsFrame = (h) => Math.abs(h - frame.clientHeight) <= 1;
|
|
147
|
+
// A page as tall as its frame is grown once: a page sized by its content keeps its height, a full-screen one
|
|
148
|
+
// follows the frame.
|
|
149
|
+
const probe = () => {
|
|
150
|
+
probing = true;
|
|
151
|
+
shot.style.height = `${frame.clientHeight + 202}px`;
|
|
152
|
+
raf = requestAnimationFrame(() => raf = requestAnimationFrame(() => {
|
|
153
|
+
raf = 0;
|
|
154
|
+
probing = false;
|
|
155
|
+
const h = measure();
|
|
156
|
+
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
157
|
+
fits.set(key(), { ...sizeNow(), fills: fillsFrame(h) });
|
|
158
|
+
apply();
|
|
159
|
+
}));
|
|
160
|
+
};
|
|
161
|
+
const apply = () => {
|
|
162
|
+
if (probing) return;
|
|
163
|
+
const h = measure();
|
|
164
|
+
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
165
|
+
const size = sizeNow();
|
|
166
|
+
if (size.fills === undefined) {
|
|
167
|
+
if (fillsFrame(h)) { probe(); return; }
|
|
168
|
+
size.fills = false;
|
|
169
|
+
}
|
|
170
|
+
// The shot's border is 1px each side, and the frame fills the rest.
|
|
171
|
+
const height = size.fills ? screenH() : h + 2;
|
|
172
|
+
if (full) size.fullH = height;
|
|
173
|
+
else { size.h = height; size.scrolls = size.fills || h > window.innerHeight * 0.8; }
|
|
174
|
+
fits.set(key(), size);
|
|
175
|
+
const q = byId(sec.dataset.q);
|
|
176
|
+
if (!full && size.scrolls && q.options.every((o) => !o.diagram && !o.practice)) {
|
|
177
|
+
repaintTakes(sec);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
shot.style.height = `${height}px`;
|
|
181
|
+
shot.classList.remove('measuring');
|
|
182
|
+
if (scrollTake === shot) { scrollTo(0, frameTop(shot)); scrollTake = null; }
|
|
183
|
+
};
|
|
184
|
+
const off = B.on(frame, 'load', () => {
|
|
185
|
+
if (measure() === null || !B.alive()) return;
|
|
186
|
+
watcher?.disconnect();
|
|
187
|
+
watcher = null;
|
|
188
|
+
apply();
|
|
189
|
+
// A page that grows after it loads, or narrows with the view, is measured again.
|
|
190
|
+
if (!shot.isConnected) return;
|
|
191
|
+
try {
|
|
192
|
+
watcher = new frame.contentWindow.ResizeObserver(apply);
|
|
193
|
+
watcher.observe(frame.contentDocument.body);
|
|
194
|
+
} catch {}
|
|
195
|
+
});
|
|
196
|
+
takeResources.set(shot, () => { off(); watcher?.disconnect(); if (raf) cancelAnimationFrame(raf); });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function waitingTopics() {
|
|
200
|
+
const list = topicRow(topics, topicId).waiting;
|
|
201
|
+
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
202
|
+
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function resolvedTopics() {
|
|
206
|
+
const done = topics.filter((t) => t.state === 'resolved' && t.id !== topicId);
|
|
207
|
+
return done.length ? `<details class="done"><summary>Resolved topics · ${done.length}</summary>${done.map((t) =>
|
|
208
|
+
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function updateBar() {
|
|
212
|
+
const qs = visible();
|
|
213
|
+
const n = pending(qs).length;
|
|
214
|
+
const b = $('#send');
|
|
215
|
+
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : n ? `Send ${n} answer${n === 1 ? '' : 's'}` : 'Send'; }
|
|
216
|
+
const live = qs.filter((q) => q.state !== 'resolved');
|
|
217
|
+
const answered = live.filter((q) => q.state !== 'open').length;
|
|
218
|
+
$('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pick(hash) {
|
|
222
|
+
const u = urgentOpen();
|
|
223
|
+
if (u && (!hash || hash === u.id)) { soloId = u.id; topicId = topicOf(u.id)?.id ?? null; return; }
|
|
224
|
+
soloId = null;
|
|
225
|
+
const t = (hash && topicOf(hash)) || openTopics().find((x) => x.open) || openTopics()[0];
|
|
226
|
+
topicId = t ? t.id : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
230
|
+
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
231
|
+
// while one runs shares it. → true when the board's answer landed in this view.
|
|
232
|
+
let loading = null;
|
|
233
|
+
let loaded = false;
|
|
234
|
+
const load = (first) => (loading ||= fetchBoard(first).finally(() => { loading = null; }));
|
|
235
|
+
async function fetchBoard(first) {
|
|
236
|
+
const page = $('#page');
|
|
237
|
+
try {
|
|
238
|
+
const { body: d } = await fetchJSON(site.url('api/board'), { onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
|
|
239
|
+
if (!B.alive()) return false;
|
|
240
|
+
all = d.owner || [];
|
|
241
|
+
items = d.items || [];
|
|
242
|
+
topics = d.ownerTopics || [];
|
|
243
|
+
site.syncBadge();
|
|
244
|
+
if (first) pick(hashId());
|
|
245
|
+
else if (soloId && byId(soloId)?.state !== 'open') pick('');
|
|
246
|
+
else if (!topics.some((t) => t.id === topicId)) pick('');
|
|
247
|
+
loaded = true;
|
|
248
|
+
const sig = sigNow();
|
|
249
|
+
const active = document.activeElement;
|
|
250
|
+
if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
|
|
251
|
+
shown = sig;
|
|
252
|
+
const y = scrollY;
|
|
253
|
+
render();
|
|
254
|
+
scrollTo(0, first ? 0 : y);
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (first) page.innerHTML = `<p class="muted">Could not load the questions: ${esc(e.message)}</p>`;
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function moveOn(from, pills, what) {
|
|
264
|
+
soloId = null;
|
|
265
|
+
const next = nextQuestion(all, pills, { topic: from });
|
|
266
|
+
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
267
|
+
if (next?.topic === from) {
|
|
268
|
+
topicId = from;
|
|
269
|
+
sentLine = `${what} ${left} still open, answer ${left === 1 ? 'it' : 'them'} any time.`;
|
|
270
|
+
shown = sigNow();
|
|
271
|
+
const y = scrollY;
|
|
272
|
+
render();
|
|
273
|
+
scrollTo(0, y);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
sentLine = what;
|
|
277
|
+
if (!next) {
|
|
278
|
+
sentNotice.put(B.route, what);
|
|
279
|
+
if (ctx.up?.()) return;
|
|
280
|
+
sentNotice.take(B.route);
|
|
281
|
+
topicId = from;
|
|
282
|
+
} else {
|
|
283
|
+
topicId = next.topic;
|
|
284
|
+
history.replaceState(history.state, '', '#' + encodeURIComponent(next.id));
|
|
285
|
+
}
|
|
286
|
+
shown = sigNow();
|
|
287
|
+
render();
|
|
288
|
+
scrollTo(0, 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
B.on(root, 'click', (ev) => {
|
|
292
|
+
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
293
|
+
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
294
|
+
if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
|
|
295
|
+
});
|
|
296
|
+
// The full-width frame shows another take without reloading, where its page's switcher can
|
|
297
|
+
// (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
|
|
298
|
+
function inPlace(full, choice) {
|
|
299
|
+
const take = full.querySelector('.take');
|
|
300
|
+
const frame = take?.querySelector('iframe');
|
|
301
|
+
let sw = null;
|
|
302
|
+
try { sw = frame?.contentWindow?.PrototypeSwitcher?.current; } catch { sw = null; }
|
|
303
|
+
if (!sw?.go || !sw.takes?.includes(choice)) return false;
|
|
304
|
+
sw.go(choice);
|
|
305
|
+
take.dataset.opt = choice;
|
|
306
|
+
for (const label of full.querySelectorAll('.take-choices .opt')) {
|
|
307
|
+
if (label.querySelector('input')?.value === choice) label.setAttribute('aria-current', 'true');
|
|
308
|
+
else label.removeAttribute('aria-current');
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
B.on(root, 'change', (ev) => {
|
|
313
|
+
const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
|
|
314
|
+
const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
315
|
+
const full = sec.querySelector('.takes-full');
|
|
316
|
+
if (full && inPlace(full, d.choice)) return;
|
|
317
|
+
if (full) {
|
|
318
|
+
const top = frameTop(full.querySelector('.take-shot'));
|
|
319
|
+
repaintTakes(sec);
|
|
320
|
+
scrollTake = sec.querySelector('.take-shot');
|
|
321
|
+
sec.querySelector(`input[value="${CSS.escape(d.choice)}"]`)?.focus({ preventScroll: true });
|
|
322
|
+
scrollTo(0, top);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
B.on(root, 'input', (ev) => {
|
|
326
|
+
const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
|
|
327
|
+
const d = getDraft(sec.dataset.q); d.note = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
328
|
+
});
|
|
329
|
+
B.on(root, 'keydown', (ev) => {
|
|
330
|
+
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing || ev.target.tagName !== 'TEXTAREA') return;
|
|
331
|
+
const form = ev.target.closest('#qform');
|
|
332
|
+
if (!form) return;
|
|
333
|
+
ev.preventDefault();
|
|
334
|
+
form.requestSubmit();
|
|
335
|
+
});
|
|
336
|
+
B.on(root, 'submit', async (ev) => {
|
|
337
|
+
if (ev.target.id !== 'qform') return;
|
|
338
|
+
ev.preventDefault();
|
|
339
|
+
const list = pending(visible());
|
|
340
|
+
const st = $('#qstatus');
|
|
341
|
+
if (uploading(visible())) return;
|
|
342
|
+
if (!list.length) { st.textContent = 'Pick an option or write a note first.'; return; }
|
|
343
|
+
st.textContent = 'Sending…';
|
|
344
|
+
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
345
|
+
const pills = topicRow(topics, from).pills;
|
|
346
|
+
const name = topicName(topics.find((t) => t.id === from) || { title: '' });
|
|
347
|
+
let sent = 0;
|
|
348
|
+
try {
|
|
349
|
+
for (const q of list) {
|
|
350
|
+
const d = getDraft(q.id);
|
|
351
|
+
const last = q.answers[q.answers.length - 1];
|
|
352
|
+
const choice = d.choice ?? (last ? last.choice : null);
|
|
353
|
+
const files = filesOf(q);
|
|
354
|
+
await site.send('owner-answer', { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) });
|
|
355
|
+
setDraft(q.id, {});
|
|
356
|
+
BoardAttach.clear('q:' + q.id, site.root);
|
|
357
|
+
sent++;
|
|
358
|
+
}
|
|
359
|
+
const what = `Sent ${sent} answer${sent === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
|
|
360
|
+
// A load that started before these answers went out would read them as still open.
|
|
361
|
+
await loading;
|
|
362
|
+
if (await load(false)) moveOn(from, pills, what);
|
|
363
|
+
else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
|
|
364
|
+
} catch { st.textContent = sent ? `Sent ${sent} of ${list.length}. Is the host awake?` : 'Could not send. Is the host awake?'; }
|
|
365
|
+
});
|
|
366
|
+
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
367
|
+
B.on(root, 'attach-change', updateBar);
|
|
368
|
+
// A link to another question here only moves the hash.
|
|
369
|
+
B.on(window, 'hashchange', () => {
|
|
370
|
+
if (!loaded) return;
|
|
371
|
+
pick(hashId());
|
|
372
|
+
sentLine = '';
|
|
373
|
+
shown = '';
|
|
374
|
+
render();
|
|
375
|
+
scrollTo(0, 0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
load(true);
|
|
379
|
+
B.every(15000, () => { if (!document.hidden) load(false); });
|
|
380
|
+
B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
|
|
381
|
+
// An answer sent from a standalone prototype is reloaded when this page returns from the browser cache.
|
|
382
|
+
B.on(window, 'pageshow', (ev) => { if (ev.persisted) { shown = ''; load(false); } });
|
|
383
|
+
// A new width needs grid measurements before choosing either layout again.
|
|
384
|
+
let width = window.innerWidth;
|
|
385
|
+
B.on(window, 'resize', () => {
|
|
386
|
+
if (window.innerWidth === width) return;
|
|
387
|
+
width = window.innerWidth;
|
|
388
|
+
root.querySelectorAll('section.q').forEach(repaintTakes);
|
|
389
|
+
});
|
|
390
|
+
}),
|
|
391
|
+
}))(new Map());
|