@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +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/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -4,31 +4,41 @@
|
|
|
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
11
|
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
11
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,
|
|
12
13
|
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
13
|
-
// A
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
14
|
+
// A question the owner has sent an answer to is resolved: read-only, green, with a Resolved badge. One that resolves
|
|
15
|
+
// while its topic shows stays in place until you leave that topic, by a pill, a link or the move on after the last
|
|
16
|
+
// Send; a topic's sent questions otherwise fold under "resolved (N)", which names only the count, above its open ones. A sent question's badge, once confirmed, sends `question-reopen {key?, question}`, and the question
|
|
17
|
+
// opens again with its last pick and note for a new Send. Each question is numbered by its place on its card
|
|
18
|
+
// (docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md,
|
|
19
|
+
// docs/adr/2026-11-06-1100-resolved-questions-fold-away-once-you-leave-their-topic.md).
|
|
20
|
+
// A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
|
|
21
|
+
// update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
|
|
22
|
+
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md).
|
|
23
|
+
// The page shows open questions only: it ends at the Send bar, opens by itself only a topic with an open question, and
|
|
24
|
+
// with none shows the hub's moon and "no open questions". A link to a question still opens its topic, as a work item's
|
|
25
|
+
// Questions button does (docs/adr/2026-11-06-0037-the-open-questions-page-shows-open-questions-only.md).
|
|
26
|
+
// markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
|
|
27
|
+
// and the board's URL and scroll are left alone.
|
|
28
|
+
(globalThis.AppViews ||= {}).questions = {
|
|
17
29
|
className: 'view-questions',
|
|
18
30
|
title: () => 'Waiting On You',
|
|
19
|
-
markup: () => `${BoardUI.header({ lead: '
|
|
31
|
+
markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'logo', title: 'Waiting on you', logoSrc: '/_hub/kickflip.png', controls: false, trail: '<span class="muted small" id="progress"></span>', pages: 'board' })}
|
|
20
32
|
<main>
|
|
21
|
-
<section id="page"></section>
|
|
33
|
+
${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
|
|
22
34
|
</main>`,
|
|
23
35
|
|
|
24
36
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
25
37
|
const { $, site } = B;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const setDraft = (id, v) => { try { localStorage.setItem(draftKey(id), JSON.stringify(v)); } catch {} };
|
|
31
|
-
const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
38
|
+
const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
|
|
39
|
+
const setHash = (id) => { if (!ctx.embedded) history.replaceState(history.state, '', '#' + encodeURIComponent(id)); };
|
|
40
|
+
const { esc, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, downLanding } = BoardUI;
|
|
41
|
+
const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
32
42
|
|
|
33
43
|
let all = [];
|
|
34
44
|
// The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
|
|
@@ -43,46 +53,52 @@
|
|
|
43
53
|
let soloId = null;
|
|
44
54
|
let shown = '';
|
|
45
55
|
let sentLine = '';
|
|
56
|
+
// The shown topic's questions this page has drawn unsent, which stay in place once sent or resolved until you leave
|
|
57
|
+
// the topic (setTopic).
|
|
58
|
+
const inPlace = new Set();
|
|
46
59
|
// The footer after a prototype request: the blue spinner, then these words.
|
|
47
60
|
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
48
61
|
// The bar's line while a suggested option is still pre-checked.
|
|
49
62
|
const SUGGESTED = 'suggested options are selected';
|
|
50
63
|
const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
|
|
51
|
-
let scrollTake = null;
|
|
52
|
-
const takeResources = new Map();
|
|
53
|
-
const clearTakeResources = (within = root) => {
|
|
54
|
-
for (const [shot, release] of takeResources) {
|
|
55
|
-
if (!shot.isConnected || within.contains(shot)) { release(); takeResources.delete(shot); }
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
B.atUnmount(() => clearTakeResources());
|
|
59
64
|
|
|
60
65
|
const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
|
|
61
66
|
const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
|
|
62
67
|
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
63
68
|
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
64
69
|
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
65
|
-
|
|
70
|
+
// Shows a topic, or none; the topic left folds its sent questions.
|
|
71
|
+
const setTopic = (id) => {
|
|
72
|
+
if (id !== topicId) for (const qid of topics.find((t) => t.id === topicId)?.questions || []) inPlace.delete(qid);
|
|
73
|
+
topicId = id;
|
|
74
|
+
};
|
|
75
|
+
const urgentOpen =() => all.find((q) => q.urgent && q.state === 'open');
|
|
76
|
+
// A question's number on its card: its place in its topic, which keeps every question it has had.
|
|
77
|
+
const numberOf = (q) => (topicOf(q.id)?.questions.indexOf(q.id) ?? -1) + 1;
|
|
78
|
+
// The questions the form shows, in order; the rest of qs fold under resolved (N).
|
|
79
|
+
const inForm = (qs) => qs.filter((q) => !isSent(q) || inPlace.has(q.id));
|
|
66
80
|
|
|
67
81
|
// docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
68
82
|
function topicBar() {
|
|
69
83
|
const list = topicRow(topics, topicId).pills;
|
|
70
84
|
if (list.length < 2) return '';
|
|
71
|
-
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
|
|
72
|
-
`<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>`;
|
|
85
|
+
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
|
|
73
86
|
}
|
|
87
|
+
// An arrival (arrive) slides in with a glow and settles (style.css .arrived).
|
|
88
|
+
const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" 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>`;
|
|
74
89
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
75
90
|
|
|
76
|
-
// Only open questions changed here since their last answer, with files attached,
|
|
77
|
-
// pre-checked and never answered, are sent; a
|
|
91
|
+
// Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
|
|
92
|
+
// suggested option pre-checked and never answered, are sent; a sent one is read-only.
|
|
78
93
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
79
94
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
80
95
|
function pending(qs) {
|
|
81
|
-
return qs.filter((q) => q.state
|
|
96
|
+
return qs.filter((q) => q.state === 'open').filter((q) => {
|
|
82
97
|
const d = getDraft(q.id);
|
|
83
98
|
if (filesOf(q).length) return true;
|
|
84
99
|
const last = q.answers[q.answers.length - 1];
|
|
85
100
|
if (!last && q.suggested) return true;
|
|
101
|
+
if (q.reopened && choiceOf(q, d) !== null) return true;
|
|
86
102
|
if (!('choice' in d) && !d.note) return false;
|
|
87
103
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
88
104
|
});
|
|
@@ -94,12 +110,14 @@
|
|
|
94
110
|
return t ? t.questions.map(byId).filter(Boolean) : [];
|
|
95
111
|
}
|
|
96
112
|
|
|
97
|
-
|
|
113
|
+
// → the fetch of the topic's resolved questions, or null when they are held. force asks again; the questions held stay
|
|
114
|
+
// drawn until the new ones land.
|
|
115
|
+
function loadResolved(force = false) {
|
|
98
116
|
const t = soloId ? null : topics.find((x) => x.id === topicId);
|
|
99
|
-
if (!t?.resolved || (resolved.topic === t.id && resolved.n === t.resolved)) return;
|
|
100
|
-
resolved = { topic: t.id, n: t.resolved, questions: [] };
|
|
117
|
+
if (!t?.resolved || (!force && resolved.topic === t.id && resolved.n === t.resolved)) return null;
|
|
118
|
+
resolved = { topic: t.id, n: t.resolved, questions: resolved.topic === t.id ? resolved.questions : [] };
|
|
101
119
|
const asked = resolved;
|
|
102
|
-
fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
120
|
+
return fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
103
121
|
if (!B.alive() || resolved !== asked) return;
|
|
104
122
|
asked.questions = body.questions || [];
|
|
105
123
|
repaint(false);
|
|
@@ -107,139 +125,31 @@
|
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
function render() {
|
|
110
|
-
clearTakeResources();
|
|
111
128
|
loadResolved();
|
|
129
|
+
drawn = drawnOf();
|
|
112
130
|
const el = $('#page');
|
|
113
131
|
const qs = visible();
|
|
114
132
|
if (!qs.length) {
|
|
115
|
-
el.innerHTML =
|
|
133
|
+
el.innerHTML = `<div class="q-empty"><svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${HubUI.TILE_PATHS.moon}</svg><p class="muted">no open questions</p></div>`;
|
|
116
134
|
$('#progress').textContent = '';
|
|
117
135
|
return;
|
|
118
136
|
}
|
|
119
137
|
const t = topics.find((x) => x.id === topicId);
|
|
120
|
-
const live = qs
|
|
121
|
-
const done = qs.filter((q) => q
|
|
138
|
+
const live = inForm(qs);
|
|
139
|
+
const done = qs.filter((q) => !live.includes(q));
|
|
140
|
+
for (const q of live) inPlace.add(q.id);
|
|
122
141
|
const head = soloId
|
|
123
142
|
? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
|
|
124
143
|
: `${topicBar()}${topicHead(t)}`;
|
|
125
144
|
el.innerHTML = `${head}<form id="qform">
|
|
126
|
-
${
|
|
127
|
-
${
|
|
145
|
+
${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span></summary>${done.map((q) => questionCard(q, { n: numberOf(q), links: links(q) })).join('')}</details>` : ''}
|
|
146
|
+
${live.map((q) => questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) })).join('')}
|
|
128
147
|
<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>
|
|
129
|
-
</form
|
|
148
|
+
</form>`;
|
|
130
149
|
BoardAttach.paintAll();
|
|
131
|
-
el.querySelectorAll('.take-shot').forEach(fitTake);
|
|
132
150
|
updateBar();
|
|
133
151
|
}
|
|
134
152
|
|
|
135
|
-
// A width sets its page's height, so a size measured at another width is measured again.
|
|
136
|
-
const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
|
|
137
|
-
const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
|
|
138
|
-
// 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
|
|
139
|
-
// at the top covers the frame's top.
|
|
140
|
-
const frameTop = (shot) => {
|
|
141
|
-
const head = $('header.top')?.getBoundingClientRect();
|
|
142
|
-
const over = head && head.top < innerHeight / 2 ? head.bottom : 0;
|
|
143
|
-
return Math.max(0, scrollY + shot.getBoundingClientRect().top - over);
|
|
144
|
-
};
|
|
145
|
-
function repaintTakes(sec) {
|
|
146
|
-
if (!sec.querySelector('.takes')) return;
|
|
147
|
-
const q = byId(sec.dataset.q);
|
|
148
|
-
const old = sec.querySelector('.takes');
|
|
149
|
-
clearTakeResources(old);
|
|
150
|
-
old.outerHTML = takes(q, choiceOf(q, getDraft(q.id)), links(q), fitOf(q));
|
|
151
|
-
sec.querySelectorAll('.take-shot').forEach(fitTake);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// A full-screen take gets the screen between the header and the Send bar.
|
|
155
|
-
const screenH = () => Math.max(320, innerHeight - ($('header.top')?.offsetHeight || 0) - ($('#qform .bar')?.offsetHeight || 0));
|
|
156
|
-
|
|
157
|
-
// A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
|
|
158
|
-
// any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
|
|
159
|
-
// height, turns its question into one full-width take under a choice row
|
|
160
|
-
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
161
|
-
function fitTake(shot) {
|
|
162
|
-
const frame = shot.querySelector('iframe');
|
|
163
|
-
const sec = shot.closest('section.q');
|
|
164
|
-
// A switch in place (inPlace) changes the take a frame shows, and so the key its size is kept under.
|
|
165
|
-
const key = () => fitKey(sec.dataset.q, shot.closest('.take').dataset.opt);
|
|
166
|
-
const full = Boolean(shot.closest('.takes-full'));
|
|
167
|
-
let watcher = null;
|
|
168
|
-
let probing = false;
|
|
169
|
-
let raf = 0;
|
|
170
|
-
const measure = () => {
|
|
171
|
-
try {
|
|
172
|
-
const doc = frame.contentDocument;
|
|
173
|
-
// The body's own height: the document's scrollHeight never drops below the frame's.
|
|
174
|
-
return doc?.body && doc.location.href !== 'about:blank' ? Math.ceil(doc.body.getBoundingClientRect().height) : null;
|
|
175
|
-
} catch { return null; }
|
|
176
|
-
};
|
|
177
|
-
const sizeNow = () => { const f = fits.get(key()); return f?.w === window.innerWidth ? { ...f } : { w: window.innerWidth }; };
|
|
178
|
-
const fillsFrame = (h) => Math.abs(h - frame.clientHeight) <= 1;
|
|
179
|
-
// A page as tall as its frame is grown once: a page sized by its content keeps its height, a full-screen one
|
|
180
|
-
// follows the frame.
|
|
181
|
-
const probe = () => {
|
|
182
|
-
probing = true;
|
|
183
|
-
shot.style.height = `${frame.clientHeight + 202}px`;
|
|
184
|
-
raf = requestAnimationFrame(() => raf = requestAnimationFrame(() => {
|
|
185
|
-
raf = 0;
|
|
186
|
-
probing = false;
|
|
187
|
-
const h = measure();
|
|
188
|
-
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
189
|
-
fits.set(key(), { ...sizeNow(), fills: fillsFrame(h) });
|
|
190
|
-
apply();
|
|
191
|
-
}));
|
|
192
|
-
};
|
|
193
|
-
const apply = () => {
|
|
194
|
-
if (probing) return;
|
|
195
|
-
const h = measure();
|
|
196
|
-
if (h === null || !shot.isConnected || !B.alive()) return;
|
|
197
|
-
const size = sizeNow();
|
|
198
|
-
if (size.fills === undefined) {
|
|
199
|
-
if (fillsFrame(h)) { probe(); return; }
|
|
200
|
-
size.fills = false;
|
|
201
|
-
}
|
|
202
|
-
// The shot's border is 1px each side, and the frame fills the rest.
|
|
203
|
-
const height = size.fills ? screenH() : h + 2;
|
|
204
|
-
if (full) size.fullH = height;
|
|
205
|
-
else { size.h = height; size.scrolls = size.fills || h > window.innerHeight * 0.8; }
|
|
206
|
-
fits.set(key(), size);
|
|
207
|
-
const q = byId(sec.dataset.q);
|
|
208
|
-
if (!full && size.scrolls && q.options.every((o) => !o.diagram && !o.practice)) {
|
|
209
|
-
repaintTakes(sec);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
shot.style.height = `${height}px`;
|
|
213
|
-
shot.classList.remove('measuring');
|
|
214
|
-
if (scrollTake === shot) { scrollTo(0, frameTop(shot)); scrollTake = null; }
|
|
215
|
-
};
|
|
216
|
-
const off = B.on(frame, 'load', () => {
|
|
217
|
-
if (measure() === null || !B.alive()) return;
|
|
218
|
-
watcher?.disconnect();
|
|
219
|
-
watcher = null;
|
|
220
|
-
apply();
|
|
221
|
-
// A page that grows after it loads, or narrows with the view, is measured again.
|
|
222
|
-
if (!shot.isConnected) return;
|
|
223
|
-
try {
|
|
224
|
-
watcher = new frame.contentWindow.ResizeObserver(apply);
|
|
225
|
-
watcher.observe(frame.contentDocument.body);
|
|
226
|
-
} catch {}
|
|
227
|
-
});
|
|
228
|
-
takeResources.set(shot, () => { off(); watcher?.disconnect(); if (raf) cancelAnimationFrame(raf); });
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function waitingTopics() {
|
|
232
|
-
const list = topicRow(topics, topicId).waiting;
|
|
233
|
-
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
234
|
-
`<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>` : '';
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function resolvedTopics() {
|
|
238
|
-
const done = topics.filter((t) => t.state === 'resolved' && t.id !== topicId);
|
|
239
|
-
return done.length ? `<details class="done"><summary>Resolved topics · ${done.length}</summary>${done.map((t) =>
|
|
240
|
-
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
241
|
-
}
|
|
242
|
-
|
|
243
153
|
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
244
154
|
function leaving() {
|
|
245
155
|
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
@@ -255,33 +165,45 @@
|
|
|
255
165
|
moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
|
|
256
166
|
}
|
|
257
167
|
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
168
|
+
// A tap's change no load has drawn in BoardUI's PENDING_LATE_MS: the board loads once and the button rests.
|
|
169
|
+
function stillWaiting() {
|
|
170
|
+
if (!B.alive()) return;
|
|
171
|
+
B.toast('Still waiting on the board');
|
|
172
|
+
shown = '';
|
|
173
|
+
load(false);
|
|
174
|
+
}
|
|
175
|
+
// A refusal rests the button with its reason on the status line, as before.
|
|
176
|
+
const refused = (e) => { $('#qstatus').textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; };
|
|
177
|
+
|
|
178
|
+
// The tapped Prototype button waits (ui.js waitOn) until the page shows the request
|
|
179
|
+
// (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
180
|
+
function askPrototype(q, button) {
|
|
261
181
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
262
182
|
const at = leaving();
|
|
263
|
-
|
|
183
|
+
$('#qstatus').textContent = '';
|
|
264
184
|
const d = getDraft(q.id);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
185
|
+
return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
186
|
+
let out;
|
|
187
|
+
try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: noteOf(q, d).trim(), choice: choiceOf(q, d) }); } catch (e) { refused(e); return; }
|
|
188
|
+
setDraft(q.id, {});
|
|
189
|
+
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
190
|
+
})(), stillWaiting));
|
|
270
191
|
}
|
|
271
192
|
|
|
272
|
-
// A
|
|
273
|
-
//
|
|
274
|
-
async function
|
|
275
|
-
if (!(await BoardDialogs.confirm('
|
|
276
|
-
const st = $('#qstatus');
|
|
193
|
+
// A sent question's Resolved badge reopens it once the owner confirms: the question opens in place with its last
|
|
194
|
+
// pick and note, and stays in the form until you leave the topic.
|
|
195
|
+
async function reopen(q, button) {
|
|
196
|
+
if (!(await BoardDialogs.confirm('Reopen this question?')) || !B.alive()) return;
|
|
277
197
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
198
|
+
await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
199
|
+
let out;
|
|
200
|
+
try { out = await send('question-reopen', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
|
|
201
|
+
if (!B.alive()) return;
|
|
202
|
+
inPlace.add(q.id);
|
|
203
|
+
sentLine = '';
|
|
204
|
+
if (!land(out.board, false)) await load(false);
|
|
205
|
+
repaint(false);
|
|
206
|
+
})(), stillWaiting));
|
|
285
207
|
}
|
|
286
208
|
|
|
287
209
|
function updateBar() {
|
|
@@ -289,7 +211,7 @@
|
|
|
289
211
|
const n = pending(qs).length;
|
|
290
212
|
const b = $('#send');
|
|
291
213
|
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
292
|
-
const live = qs
|
|
214
|
+
const live = inForm(qs);
|
|
293
215
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
294
216
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
295
217
|
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
@@ -300,10 +222,10 @@
|
|
|
300
222
|
|
|
301
223
|
function pick(hash) {
|
|
302
224
|
const u = urgentOpen();
|
|
303
|
-
if (u && (!hash || hash === u.id)) { soloId = u.id;
|
|
225
|
+
if (u && (!hash || hash === u.id)) { soloId = u.id; setTopic(topicOf(u.id)?.id ?? null); return; }
|
|
304
226
|
soloId = null;
|
|
305
|
-
const t = (hash && topicOf(hash)) ||
|
|
306
|
-
|
|
227
|
+
const t = (hash && topicOf(hash)) || topics.find((x) => x.open);
|
|
228
|
+
setTopic(t ? t.id : null);
|
|
307
229
|
}
|
|
308
230
|
|
|
309
231
|
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)])]);
|
|
@@ -312,16 +234,30 @@
|
|
|
312
234
|
let loading = null;
|
|
313
235
|
let loaded = false;
|
|
314
236
|
const load = (first) => (loading ||= fetchBoard(first).finally(() => { loading = null; }));
|
|
237
|
+
// While the board can't be reached, the page holds its sending buttons and shows the board's copy here, painted live,
|
|
238
|
+
// or with none and nothing landed the landing for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
239
|
+
let copy = null;
|
|
240
|
+
BoardUI.holdSends(B.on, root, () => root.classList.contains('offline'), () => B.toast(BoardUI.WAITING));
|
|
241
|
+
const out = (down) => {
|
|
242
|
+
root.classList.add('offline');
|
|
243
|
+
copy ||= BoardUI.boardCopy(site.url(''), document.documentElement.dataset.build);
|
|
244
|
+
copy.then((c) => {
|
|
245
|
+
if (!B.alive() || loaded) return;
|
|
246
|
+
if (!c) { $('#page').innerHTML = downLanding(down); return; }
|
|
247
|
+
land(c.board, true);
|
|
248
|
+
repaint(true);
|
|
249
|
+
});
|
|
250
|
+
};
|
|
315
251
|
async function fetchBoard(first) {
|
|
316
|
-
const page = $('#page');
|
|
317
252
|
try {
|
|
318
|
-
const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: () =>
|
|
253
|
+
const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: (ms, down) => out(down), sleep: B.sleep });
|
|
319
254
|
if (!B.alive()) return false;
|
|
255
|
+
root.classList.remove('offline');
|
|
320
256
|
if (d) land(d, first);
|
|
321
257
|
repaint(first);
|
|
322
258
|
return true;
|
|
323
259
|
} catch (e) {
|
|
324
|
-
|
|
260
|
+
out(e.down);
|
|
325
261
|
return false;
|
|
326
262
|
}
|
|
327
263
|
}
|
|
@@ -341,29 +277,75 @@
|
|
|
341
277
|
loaded = true;
|
|
342
278
|
return true;
|
|
343
279
|
}
|
|
344
|
-
// Draws what changed, unless the owner is typing a note.
|
|
280
|
+
// Draws what changed: an arrival in place (arrive), else the whole page, unless the owner is typing a note.
|
|
345
281
|
function repaint(first) {
|
|
346
282
|
const sig = sigNow();
|
|
283
|
+
if (sig === shown) return;
|
|
284
|
+
if (!first && arrive()) { shown = sig; return; }
|
|
347
285
|
const active = document.activeElement;
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
286
|
+
if (active && active.tagName === 'TEXTAREA') return;
|
|
287
|
+
shown = sig;
|
|
288
|
+
const y = scrollY;
|
|
289
|
+
render();
|
|
290
|
+
scroll(first ? 0 : y);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// What render last drew of the topic: its questions in order as they stood, and its resolved count.
|
|
294
|
+
const drawnOf = () => {
|
|
295
|
+
const t = topics.find((x) => x.id === topicId);
|
|
296
|
+
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)])) };
|
|
297
|
+
};
|
|
298
|
+
let drawn = null;
|
|
299
|
+
// A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
|
|
300
|
+
// 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
|
|
301
|
+
// one, and the topic line and the bar update. → false when anything shown changed.
|
|
302
|
+
function arrive() {
|
|
303
|
+
const form = $('#qform');
|
|
304
|
+
const now = drawnOf();
|
|
305
|
+
if (!form || !drawn || now.solo || drawn.solo || now.topic !== drawn.topic || now.resolved !== drawn.resolved
|
|
306
|
+
|| drawn.qs.some((s, i) => now.qs[i] !== s)) return false;
|
|
307
|
+
const added = visible().slice(drawn.qs.length);
|
|
308
|
+
if (added.some((q) => q.state !== 'open')) return false;
|
|
309
|
+
const page = $('#page');
|
|
310
|
+
const list = topicRow(topics, topicId).pills;
|
|
311
|
+
const bar = page.querySelector('.topics');
|
|
312
|
+
if (list.length < 2) bar?.remove();
|
|
313
|
+
else if (!bar) page.insertAdjacentHTML('afterbegin', topicBar());
|
|
314
|
+
else {
|
|
315
|
+
const pills = list.map((t) => bar.querySelector(`button[data-topic="${CSS.escape(t.id)}"]`) || fragmentOf(pillHtml(t, true)));
|
|
316
|
+
for (const b of [...bar.children]) if (!pills.includes(b)) b.remove();
|
|
317
|
+
pills.forEach((b, i) => {
|
|
318
|
+
const t = list[i];
|
|
319
|
+
b.setAttribute('aria-pressed', String(t.id === topicId));
|
|
320
|
+
b.querySelector('.n').textContent = t.state === 'resolved' ? 'resolved' : String(t.open);
|
|
321
|
+
if (bar.children[i] !== b) bar.insertBefore(b, bar.children[i] || null);
|
|
322
|
+
});
|
|
353
323
|
}
|
|
324
|
+
const head = page.querySelector('.topichead');
|
|
325
|
+
const headHtml = topicHead(topics.find((x) => x.id === topicId));
|
|
326
|
+
if (head && head.outerHTML !== headHtml) head.outerHTML = headHtml;
|
|
327
|
+
const sendBar = form.querySelector('.bar');
|
|
328
|
+
for (const q of added) {
|
|
329
|
+
inPlace.add(q.id);
|
|
330
|
+
sendBar.insertAdjacentHTML('beforebegin', questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) }));
|
|
331
|
+
}
|
|
332
|
+
BoardAttach.paintAll();
|
|
333
|
+
drawn = now;
|
|
334
|
+
updateBar();
|
|
335
|
+
return true;
|
|
354
336
|
}
|
|
337
|
+
const fragmentOf = (html) => { const box = document.createElement('div'); box.innerHTML = html; return box.firstElementChild; };
|
|
355
338
|
|
|
356
339
|
function moveOn(from, pills, what, footer = what, countOpen = true) {
|
|
357
340
|
soloId = null;
|
|
358
341
|
const next = nextQuestion(all, pills, { topic: from });
|
|
359
342
|
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
360
343
|
if (next?.topic === from) {
|
|
361
|
-
topicId = from;
|
|
362
344
|
sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
|
|
363
345
|
shown = sigNow();
|
|
364
346
|
const y = scrollY;
|
|
365
347
|
render();
|
|
366
|
-
|
|
348
|
+
scroll(y);
|
|
367
349
|
return;
|
|
368
350
|
}
|
|
369
351
|
sentLine = footer;
|
|
@@ -371,58 +353,34 @@
|
|
|
371
353
|
sentNotice.put(B.route, what);
|
|
372
354
|
if (ctx.up?.()) return;
|
|
373
355
|
sentNotice.take(B.route);
|
|
374
|
-
|
|
356
|
+
setTopic(null);
|
|
357
|
+
B.toast(what);
|
|
375
358
|
} else {
|
|
376
|
-
|
|
377
|
-
|
|
359
|
+
setTopic(next.topic);
|
|
360
|
+
setHash(next.id);
|
|
378
361
|
}
|
|
379
362
|
shown = sigNow();
|
|
380
363
|
render();
|
|
381
|
-
|
|
364
|
+
scroll(0);
|
|
382
365
|
}
|
|
383
366
|
|
|
384
367
|
B.on(root, 'click', (ev) => {
|
|
385
368
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
386
369
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
387
370
|
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
371
|
+
const reopenBadge = ev.target.closest && ev.target.closest('[data-reopen]');
|
|
372
|
+
if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
|
|
390
373
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
391
374
|
if (b) {
|
|
392
|
-
soloId = null;
|
|
375
|
+
soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
|
|
393
376
|
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
394
|
-
if (first)
|
|
395
|
-
render();
|
|
377
|
+
if (first) setHash(first);
|
|
378
|
+
render(); scroll(0);
|
|
396
379
|
}
|
|
397
380
|
});
|
|
398
|
-
// The full-width frame shows another take without reloading, where its page's switcher can
|
|
399
|
-
// (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
|
|
400
|
-
function inPlace(full, choice) {
|
|
401
|
-
const take = full.querySelector('.take');
|
|
402
|
-
const frame = take?.querySelector('iframe');
|
|
403
|
-
let sw = null;
|
|
404
|
-
try { sw = frame?.contentWindow?.PrototypeSwitcher?.current; } catch { sw = null; }
|
|
405
|
-
if (!sw?.go || !sw.takes?.includes(choice)) return false;
|
|
406
|
-
sw.go(choice);
|
|
407
|
-
take.dataset.opt = choice;
|
|
408
|
-
for (const label of full.querySelectorAll('.take-choices .opt')) {
|
|
409
|
-
if (label.querySelector('input')?.value === choice) label.setAttribute('aria-current', 'true');
|
|
410
|
-
else label.removeAttribute('aria-current');
|
|
411
|
-
}
|
|
412
|
-
return true;
|
|
413
|
-
}
|
|
414
381
|
B.on(root, 'change', (ev) => {
|
|
415
382
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
|
|
416
383
|
const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
417
|
-
const full = sec.querySelector('.takes-full');
|
|
418
|
-
if (full && inPlace(full, d.choice)) return;
|
|
419
|
-
if (full) {
|
|
420
|
-
const top = frameTop(full.querySelector('.take-shot'));
|
|
421
|
-
repaintTakes(sec);
|
|
422
|
-
scrollTake = sec.querySelector('.take-shot');
|
|
423
|
-
sec.querySelector(`input[value="${CSS.escape(d.choice)}"]`)?.focus({ preventScroll: true });
|
|
424
|
-
scrollTo(0, top);
|
|
425
|
-
}
|
|
426
384
|
});
|
|
427
385
|
B.on(root, 'input', (ev) => {
|
|
428
386
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
|
|
@@ -449,43 +407,50 @@
|
|
|
449
407
|
const d = getDraft(q.id);
|
|
450
408
|
const choice = choiceOf(q, d);
|
|
451
409
|
const files = filesOf(q);
|
|
452
|
-
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d
|
|
453
|
-
});
|
|
454
|
-
let out;
|
|
455
|
-
try { out = await BoardUI.waitOn($('#send'), send('owner-answers', { answers })); }
|
|
456
|
-
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
457
|
-
for (const q of list) {
|
|
458
|
-
setDraft(q.id, {});
|
|
459
|
-
BoardAttach.clear('q:' + q.id, site.root);
|
|
460
|
-
}
|
|
461
|
-
await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
|
|
462
|
-
const open = all.filter((q) => q.state === 'open').length;
|
|
463
|
-
return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
|
|
410
|
+
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, d).trim(), ...(files.length ? { files } : {}) };
|
|
464
411
|
});
|
|
412
|
+
// Send waits until the page has moved on (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
413
|
+
await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
|
|
414
|
+
let out;
|
|
415
|
+
try { out = await send('owner-answers', { answers }); } catch (e) { refused(e); return; }
|
|
416
|
+
for (const q of list) {
|
|
417
|
+
setDraft(q.id, {});
|
|
418
|
+
BoardAttach.clear('q:' + q.id, site.root);
|
|
419
|
+
}
|
|
420
|
+
await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
|
|
421
|
+
const open = all.filter((q) => q.state === 'open').length;
|
|
422
|
+
return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
|
|
423
|
+
});
|
|
424
|
+
})(), stillWaiting));
|
|
465
425
|
});
|
|
466
426
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
467
427
|
B.on(root, 'attach-change', updateBar);
|
|
468
428
|
// A link to another question here only moves the hash.
|
|
469
|
-
B.on(window, 'hashchange', () => {
|
|
429
|
+
if (!ctx.embedded) B.on(window, 'hashchange', () => {
|
|
470
430
|
if (!loaded) return;
|
|
471
431
|
pick(hashId());
|
|
472
432
|
sentLine = '';
|
|
473
433
|
shown = '';
|
|
474
434
|
render();
|
|
475
|
-
|
|
435
|
+
scroll(0);
|
|
476
436
|
});
|
|
477
437
|
|
|
438
|
+
// In the board's pane, the shell opens the topic of a question or topic id there, a resolved one included, and with
|
|
439
|
+
// none or one that's gone the topic the page would open (app-needs.js focusQuestion). → false before the first load.
|
|
440
|
+
if (ctx.embedded) B.openTopic = (id) => {
|
|
441
|
+
if (!loaded) return false;
|
|
442
|
+
const topic = topics.find((t) => t.id === id);
|
|
443
|
+
if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
|
|
444
|
+
sentLine = '';
|
|
445
|
+
shown = '';
|
|
446
|
+
render();
|
|
447
|
+
return true;
|
|
448
|
+
};
|
|
449
|
+
|
|
478
450
|
load(true);
|
|
479
451
|
B.every(15000, () => { if (!document.hidden) load(false); });
|
|
480
452
|
B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
|
|
481
|
-
//
|
|
453
|
+
// A pick drafted on a standalone prototype page shows when this page returns from the browser cache.
|
|
482
454
|
B.on(window, 'pageshow', (ev) => { if (ev.persisted) { shown = ''; load(false); } });
|
|
483
|
-
// A new width needs grid measurements before choosing either layout again.
|
|
484
|
-
let width = window.innerWidth;
|
|
485
|
-
B.on(window, 'resize', () => {
|
|
486
|
-
if (window.innerWidth === width) return;
|
|
487
|
-
width = window.innerWidth;
|
|
488
|
-
root.querySelectorAll('section.q').forEach(repaintTakes);
|
|
489
|
-
});
|
|
490
455
|
}),
|
|
491
|
-
}
|
|
456
|
+
};
|