@lovek4d/kickflip 0.5.3 → 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 +69 -37
- package/SKILL.md +19 -13
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +156 -83
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- 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-cards.js +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- 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 +25 -17
- package/board/public/ui-sidebar.js +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- 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 +1 -1
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- 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 +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/public/view-hub.js
CHANGED
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
|
|
72
72
|
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
|
|
73
73
|
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
74
|
+
${HubUI.machineRow(d.pause.machine)}
|
|
74
75
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
75
76
|
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
|
|
76
77
|
<h2 class="label">Polling status</h2>
|
|
@@ -88,7 +89,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
88
89
|
(globalThis.AppViews ||= {}).hub = {
|
|
89
90
|
className: 'hub-view view-hub',
|
|
90
91
|
title: () => 'Hub',
|
|
91
|
-
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png'
|
|
92
|
+
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
|
|
92
93
|
<main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
|
|
93
94
|
|
|
94
95
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
@@ -11,22 +11,42 @@
|
|
|
11
11
|
|
|
12
12
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
|
|
13
13
|
const { $, site } = B;
|
|
14
|
-
const { esc, markdown, linkContext, fetchJSON,
|
|
14
|
+
const { esc, markdown, linkContext, fetchJSON, downLanding, copyNote, pageCopy } = BoardUI;
|
|
15
15
|
const key = ctx.view.item || '';
|
|
16
16
|
const wave = ctx.view.wave;
|
|
17
17
|
const el = $('#plan');
|
|
18
18
|
const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
|
|
19
|
+
const paint = (body, items, note = '') => {
|
|
20
|
+
$('#ref').textContent = `${wave ? body.title : body.ref} notes`;
|
|
21
|
+
document.title = $('#ref').textContent;
|
|
22
|
+
el.innerHTML = note + markdown(body.markdown, linkContext(items, key, { base: './' }));
|
|
23
|
+
};
|
|
24
|
+
// While the board can't be reached, the notes' last answer here shows under its copy line, or with none the landing
|
|
25
|
+
// for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
26
|
+
const url = site.url(`api/notes?${wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`}`);
|
|
27
|
+
let copy = null;
|
|
28
|
+
let live = false;
|
|
29
|
+
let copied = false;
|
|
30
|
+
const waiting = (down) => {
|
|
31
|
+
copy ||= pageCopy.read(url);
|
|
32
|
+
copy.then((c) => {
|
|
33
|
+
if (!B.alive() || live || copied) return;
|
|
34
|
+
if (!c) { el.innerHTML = downLanding(down); return; }
|
|
35
|
+
copied = true;
|
|
36
|
+
paint(c.body, [], copyNote('the notes', c.savedAt));
|
|
37
|
+
});
|
|
38
|
+
};
|
|
19
39
|
try {
|
|
20
40
|
const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
|
|
21
|
-
const
|
|
22
|
-
const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
|
|
41
|
+
const r = await get(url, { onWait: (ms, down) => waiting(down) });
|
|
23
42
|
if (!B.alive()) return;
|
|
24
43
|
if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
live = true;
|
|
45
|
+
paint(r.body, (await board).items || []);
|
|
46
|
+
pageCopy.save(url, r.body);
|
|
28
47
|
} catch (e) {
|
|
29
|
-
|
|
48
|
+
if (e.down) waiting(e.down);
|
|
49
|
+
else el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
|
|
30
50
|
}
|
|
31
51
|
}),
|
|
32
52
|
};
|
|
@@ -11,17 +11,24 @@
|
|
|
11
11
|
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
12
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
13
|
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
14
|
-
// A
|
|
15
|
-
//
|
|
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).
|
|
16
20
|
// A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
|
|
17
21
|
// update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
|
|
18
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).
|
|
19
26
|
// markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
|
|
20
27
|
// and the board's URL and scroll are left alone.
|
|
21
28
|
(globalThis.AppViews ||= {}).questions = {
|
|
22
29
|
className: 'view-questions',
|
|
23
30
|
title: () => 'Waiting On You',
|
|
24
|
-
markup: ({ embedded = false } = {}) => `${embedded ? '' : 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' })}
|
|
25
32
|
<main>
|
|
26
33
|
${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
|
|
27
34
|
</main>`,
|
|
@@ -30,7 +37,7 @@
|
|
|
30
37
|
const { $, site } = B;
|
|
31
38
|
const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
|
|
32
39
|
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,
|
|
40
|
+
const { esc, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, downLanding } = BoardUI;
|
|
34
41
|
const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
35
42
|
|
|
36
43
|
let all = [];
|
|
@@ -46,6 +53,9 @@
|
|
|
46
53
|
let soloId = null;
|
|
47
54
|
let shown = '';
|
|
48
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();
|
|
49
59
|
// The footer after a prototype request: the blue spinner, then these words.
|
|
50
60
|
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
51
61
|
// The bar's line while a suggested option is still pre-checked.
|
|
@@ -57,7 +67,16 @@
|
|
|
57
67
|
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
58
68
|
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
59
69
|
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
60
|
-
|
|
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));
|
|
61
80
|
|
|
62
81
|
// docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
63
82
|
function topicBar() {
|
|
@@ -66,19 +85,20 @@
|
|
|
66
85
|
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
|
|
67
86
|
}
|
|
68
87
|
// An arrival (arrive) slides in with a glow and settles (style.css .arrived).
|
|
69
|
-
const pillHtml = (t, arrived = false) => `<button${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>`;
|
|
70
89
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
71
90
|
|
|
72
|
-
// Only open questions changed here since their last answer, with files attached,
|
|
73
|
-
// 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.
|
|
74
93
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
75
94
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
76
95
|
function pending(qs) {
|
|
77
|
-
return qs.filter((q) => q.state
|
|
96
|
+
return qs.filter((q) => q.state === 'open').filter((q) => {
|
|
78
97
|
const d = getDraft(q.id);
|
|
79
98
|
if (filesOf(q).length) return true;
|
|
80
99
|
const last = q.answers[q.answers.length - 1];
|
|
81
100
|
if (!last && q.suggested) return true;
|
|
101
|
+
if (q.reopened && choiceOf(q, d) !== null) return true;
|
|
82
102
|
if (!('choice' in d) && !d.note) return false;
|
|
83
103
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
84
104
|
});
|
|
@@ -110,37 +130,26 @@
|
|
|
110
130
|
const el = $('#page');
|
|
111
131
|
const qs = visible();
|
|
112
132
|
if (!qs.length) {
|
|
113
|
-
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>`;
|
|
114
134
|
$('#progress').textContent = '';
|
|
115
135
|
return;
|
|
116
136
|
}
|
|
117
137
|
const t = topics.find((x) => x.id === topicId);
|
|
118
|
-
const live = qs
|
|
119
|
-
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);
|
|
120
141
|
const head = soloId
|
|
121
142
|
? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
|
|
122
143
|
: `${topicBar()}${topicHead(t)}`;
|
|
123
144
|
el.innerHTML = `${head}<form id="qform">
|
|
124
|
-
${
|
|
125
|
-
${
|
|
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('')}
|
|
126
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>
|
|
127
|
-
</form
|
|
148
|
+
</form>`;
|
|
128
149
|
BoardAttach.paintAll();
|
|
129
150
|
updateBar();
|
|
130
151
|
}
|
|
131
152
|
|
|
132
|
-
function waitingTopics() {
|
|
133
|
-
const list = topicRow(topics, topicId).waiting;
|
|
134
|
-
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
135
|
-
`<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>` : '';
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function resolvedTopics() {
|
|
139
|
-
const done = topics.filter((t) => t.state === 'resolved' && t.id !== topicId);
|
|
140
|
-
return done.length ? `<details class="done"><summary>Resolved topics · ${done.length}</summary>${done.map((t) =>
|
|
141
|
-
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
153
|
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
145
154
|
function leaving() {
|
|
146
155
|
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
@@ -175,23 +184,24 @@
|
|
|
175
184
|
const d = getDraft(q.id);
|
|
176
185
|
return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
177
186
|
let out;
|
|
178
|
-
try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d
|
|
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; }
|
|
179
188
|
setDraft(q.id, {});
|
|
180
189
|
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
181
190
|
})(), stillWaiting));
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
// A
|
|
185
|
-
//
|
|
186
|
-
async function
|
|
187
|
-
if (!(await BoardDialogs.confirm('
|
|
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;
|
|
188
197
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
189
198
|
await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
|
|
190
199
|
let out;
|
|
191
|
-
try { out = await send('
|
|
200
|
+
try { out = await send('question-reopen', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
|
|
192
201
|
if (!B.alive()) return;
|
|
202
|
+
inPlace.add(q.id);
|
|
203
|
+
sentLine = '';
|
|
193
204
|
if (!land(out.board, false)) await load(false);
|
|
194
|
-
await loadResolved(true);
|
|
195
205
|
repaint(false);
|
|
196
206
|
})(), stillWaiting));
|
|
197
207
|
}
|
|
@@ -201,7 +211,7 @@
|
|
|
201
211
|
const n = pending(qs).length;
|
|
202
212
|
const b = $('#send');
|
|
203
213
|
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
204
|
-
const live = qs
|
|
214
|
+
const live = inForm(qs);
|
|
205
215
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
206
216
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
207
217
|
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
@@ -212,10 +222,10 @@
|
|
|
212
222
|
|
|
213
223
|
function pick(hash) {
|
|
214
224
|
const u = urgentOpen();
|
|
215
|
-
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; }
|
|
216
226
|
soloId = null;
|
|
217
|
-
const t = (hash && topicOf(hash)) ||
|
|
218
|
-
|
|
227
|
+
const t = (hash && topicOf(hash)) || topics.find((x) => x.open);
|
|
228
|
+
setTopic(t ? t.id : null);
|
|
219
229
|
}
|
|
220
230
|
|
|
221
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)])]);
|
|
@@ -224,16 +234,30 @@
|
|
|
224
234
|
let loading = null;
|
|
225
235
|
let loaded = false;
|
|
226
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
|
+
};
|
|
227
251
|
async function fetchBoard(first) {
|
|
228
|
-
const page = $('#page');
|
|
229
252
|
try {
|
|
230
|
-
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 });
|
|
231
254
|
if (!B.alive()) return false;
|
|
255
|
+
root.classList.remove('offline');
|
|
232
256
|
if (d) land(d, first);
|
|
233
257
|
repaint(first);
|
|
234
258
|
return true;
|
|
235
259
|
} catch (e) {
|
|
236
|
-
|
|
260
|
+
out(e.down);
|
|
237
261
|
return false;
|
|
238
262
|
}
|
|
239
263
|
}
|
|
@@ -274,7 +298,7 @@
|
|
|
274
298
|
let drawn = null;
|
|
275
299
|
// A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
|
|
276
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
|
|
277
|
-
// one, and the topic line
|
|
301
|
+
// one, and the topic line and the bar update. → false when anything shown changed.
|
|
278
302
|
function arrive() {
|
|
279
303
|
const form = $('#qform');
|
|
280
304
|
const now = drawnOf();
|
|
@@ -300,13 +324,11 @@
|
|
|
300
324
|
const head = page.querySelector('.topichead');
|
|
301
325
|
const headHtml = topicHead(topics.find((x) => x.id === topicId));
|
|
302
326
|
if (head && head.outerHTML !== headHtml) head.outerHTML = headHtml;
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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;
|
|
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
|
+
}
|
|
310
332
|
BoardAttach.paintAll();
|
|
311
333
|
drawn = now;
|
|
312
334
|
updateBar();
|
|
@@ -319,7 +341,6 @@
|
|
|
319
341
|
const next = nextQuestion(all, pills, { topic: from });
|
|
320
342
|
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
321
343
|
if (next?.topic === from) {
|
|
322
|
-
topicId = from;
|
|
323
344
|
sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
|
|
324
345
|
shown = sigNow();
|
|
325
346
|
const y = scrollY;
|
|
@@ -332,9 +353,10 @@
|
|
|
332
353
|
sentNotice.put(B.route, what);
|
|
333
354
|
if (ctx.up?.()) return;
|
|
334
355
|
sentNotice.take(B.route);
|
|
335
|
-
|
|
356
|
+
setTopic(null);
|
|
357
|
+
B.toast(what);
|
|
336
358
|
} else {
|
|
337
|
-
|
|
359
|
+
setTopic(next.topic);
|
|
338
360
|
setHash(next.id);
|
|
339
361
|
}
|
|
340
362
|
shown = sigNow();
|
|
@@ -346,11 +368,11 @@
|
|
|
346
368
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
347
369
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
348
370
|
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
349
|
-
const
|
|
350
|
-
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; }
|
|
351
373
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
352
374
|
if (b) {
|
|
353
|
-
soloId = null;
|
|
375
|
+
soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
|
|
354
376
|
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
355
377
|
if (first) setHash(first);
|
|
356
378
|
render(); scroll(0);
|
|
@@ -385,7 +407,7 @@
|
|
|
385
407
|
const d = getDraft(q.id);
|
|
386
408
|
const choice = choiceOf(q, d);
|
|
387
409
|
const files = filesOf(q);
|
|
388
|
-
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d
|
|
410
|
+
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, d).trim(), ...(files.length ? { files } : {}) };
|
|
389
411
|
});
|
|
390
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).
|
|
391
413
|
await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
|
|
@@ -403,18 +425,6 @@
|
|
|
403
425
|
});
|
|
404
426
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
405
427
|
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); }
|
|
418
428
|
// A link to another question here only moves the hash.
|
|
419
429
|
if (!ctx.embedded) B.on(window, 'hashchange', () => {
|
|
420
430
|
if (!loaded) return;
|
|
@@ -425,6 +435,18 @@
|
|
|
425
435
|
scroll(0);
|
|
426
436
|
});
|
|
427
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
|
+
|
|
428
450
|
load(true);
|
|
429
451
|
B.every(15000, () => { if (!document.hidden) load(false); });
|
|
430
452
|
B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
|