@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
package/board/public/ui-patch.js
CHANGED
|
@@ -1,41 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new
|
|
4
|
-
// that changed; every other part of the board comes whole when it changed.
|
|
5
|
-
//
|
|
1
|
+
// Applies the board's patches on the page (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md): board/sync.mjs
|
|
2
|
+
// patchSince makes one from the change number the page holds, and the page applies it to the board it holds. Cards
|
|
3
|
+
// patch by key, questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new
|
|
4
|
+
// order when that changed; every other part of the board comes whole when it changed. onHost rides beside the board,
|
|
5
|
+
// so it never moves the board's number.
|
|
6
6
|
(function () {
|
|
7
7
|
const LISTS = { items: 'key', owner: 'id', ownerTopics: 'id' };
|
|
8
|
-
const OWN = new Set(['version', 'generatedAt', ...Object.keys(LISTS)]);
|
|
9
|
-
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
-
|
|
11
|
-
function listPatch(from, to, id) {
|
|
12
|
-
const was = new Map(from.map((r) => [r[id], r]));
|
|
13
|
-
const now = new Set(to.map((r) => r[id]));
|
|
14
|
-
const set = to.filter((r) => !was.has(r[id]) || !same(was.get(r[id]), r));
|
|
15
|
-
const drop = from.map((r) => r[id]).filter((k) => !now.has(k));
|
|
16
|
-
if (!set.length && !drop.length && same(from.map((r) => r[id]), to.map((r) => r[id]))) return null;
|
|
17
|
-
const placed = [...from.map((r) => r[id]).filter((k) => now.has(k)), ...to.map((r) => r[id]).filter((k) => !was.has(k))];
|
|
18
|
-
const order = to.map((r) => r[id]);
|
|
19
|
-
return { set, drop, ...(same(placed, order) ? {} : { order }) };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// from and to are page boards, each with its version. → {since, version, generatedAt, set, unset, lists}
|
|
23
|
-
function boardPatch(from, to) {
|
|
24
|
-
const set = {};
|
|
25
|
-
for (const [k, v] of Object.entries(to)) if (!OWN.has(k) && (!(k in from) || !same(from[k], v))) set[k] = v;
|
|
26
|
-
const unset = Object.keys(from).filter((k) => !OWN.has(k) && !(k in to));
|
|
27
|
-
const lists = {};
|
|
28
|
-
for (const [name, id] of Object.entries(LISTS)) {
|
|
29
|
-
const p = listPatch(from[name] || [], to[name] || [], id);
|
|
30
|
-
if (p) lists[name] = p;
|
|
31
|
-
}
|
|
32
|
-
return { since: from.version, version: to.version, generatedAt: to.generatedAt, set, unset, lists };
|
|
33
|
-
}
|
|
34
8
|
|
|
35
9
|
// → a new board, leaving board as it was, or null when the patch was made against another version.
|
|
36
10
|
function applyPatch(board, patch) {
|
|
37
11
|
if (!board || board.version !== patch.since) return null;
|
|
38
|
-
const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt };
|
|
12
|
+
const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt, onHost: patch.onHost };
|
|
39
13
|
for (const k of patch.unset) delete next[k];
|
|
40
14
|
for (const [name, p] of Object.entries(patch.lists)) {
|
|
41
15
|
const id = LISTS[name];
|
|
@@ -49,5 +23,5 @@
|
|
|
49
23
|
return next;
|
|
50
24
|
}
|
|
51
25
|
|
|
52
|
-
Object.assign(globalThis.BoardUI ||= {}, {
|
|
26
|
+
Object.assign(globalThis.BoardUI ||= {}, { applyPatch });
|
|
53
27
|
})();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The progress bar every bar on the board draws from: an In Progress card's five build phases and a Todo card's planner
|
|
2
|
+
// dashes (ui-cards.js), and a starting board's three start steps (ui-sidebar.js)
|
|
3
|
+
// (docs/adr/2026-11-06-0038-a-progress-bar-pulses-one-step-at-a-time.md). It needs nothing else, since hub/hub.mjs
|
|
4
|
+
// renders the shell's first sidebar with it in a vm context. style.css draws the bars.
|
|
5
|
+
(function () {
|
|
6
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
+
// {count, done, now, percent, dim, tone, label}, or null for no bar. done counts the steps reached, filled; now is the
|
|
8
|
+
// index of the one step still running, which pulses, so no bar can draw two. percent fills the step after the done
|
|
9
|
+
// ones that far. A dim bar draws its running step filled, without the pulse. tone is a lane class, 'in-todo' drawing it in
|
|
10
|
+
// Todo's grey; with none it is In Progress's orange.
|
|
11
|
+
function progressBar(bar) {
|
|
12
|
+
if (!bar) return '';
|
|
13
|
+
const { count, done, now, percent, dim, tone, label } = bar;
|
|
14
|
+
const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
|
|
15
|
+
: i === done && percent !== undefined ? `<i class="fill" style="--p:${percent}%"></i>` : '<i class=""></i>');
|
|
16
|
+
return `<span class="phase-bars${dim ? ' dim' : ''}${tone ? ` ${tone}` : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
|
|
17
|
+
}
|
|
18
|
+
// Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
|
|
19
|
+
// card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
|
|
20
|
+
const PULSES = new Set(['pulse', 'pulse-fill', 'sb-pulse', 'reader-pulse']);
|
|
21
|
+
function onePulse(ev) {
|
|
22
|
+
if (!PULSES.has(ev.animationName)) return;
|
|
23
|
+
for (const a of ev.target.getAnimations()) if (a.animationName === ev.animationName) a.startTime = 0;
|
|
24
|
+
}
|
|
25
|
+
Object.assign(globalThis.BoardUI ||= {}, { progressBar, onePulse });
|
|
26
|
+
// hub/hub.mjs runs this with no document, only to render the sidebar.
|
|
27
|
+
globalThis.document?.addEventListener('animationstart', onePulse);
|
|
28
|
+
})();
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, time, ago, replyBody, attachments,
|
|
4
|
-
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
3
|
+
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
5
5
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
6
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
7
|
// suggested: the line under a suggested option saying where the owner chose it before, as HTML.
|
|
8
|
-
const option = (o, { type = 'radio', name, checked = false, numbered = true,
|
|
9
|
-
<span><span class="lbl">${numbered ?
|
|
8
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
|
+
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${escText(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
|
|
10
10
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
11
|
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
12
|
// suggested, pre-checked so the topic's Send sends it.
|
|
13
13
|
const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
|
+
// A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
|
|
15
|
+
// sends it again.
|
|
16
|
+
const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
|
|
17
|
+
// docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
|
|
18
|
+
// Whether the owner has resolved a question: an answer is sent, whatever Claude has done with it since, or Claude
|
|
19
|
+
// resolved it. A sent question draws as resolvedCard, locked, until its badge reopens it.
|
|
20
|
+
const isSent = (q) => q.state === 'answered' || q.state === 'replied' || q.state === 'resolved';
|
|
21
|
+
// A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
|
|
22
|
+
// phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
23
|
+
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
24
|
+
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
14
25
|
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
15
26
|
|
|
16
27
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
@@ -18,12 +29,10 @@
|
|
|
18
29
|
// requested until its prototype round arrives (view-questions.js).
|
|
19
30
|
const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
|
|
20
31
|
const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
|
|
21
|
-
<div class="note-tools">${
|
|
32
|
+
<div class="note-tools">${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
|
|
22
33
|
|
|
23
34
|
const QUESTION_STATE = {
|
|
24
35
|
open: { label: 'open', tone: 'normal' },
|
|
25
|
-
answered: { label: 'waiting for claude', tone: 'notice' },
|
|
26
|
-
replied: { label: 'claude replied', tone: 'ready' },
|
|
27
36
|
requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
|
|
28
37
|
};
|
|
29
38
|
// docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
|
|
@@ -35,12 +44,14 @@
|
|
|
35
44
|
const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
|
|
36
45
|
function answerHistory(q, links = {}) {
|
|
37
46
|
if (!q.answers.length) return '';
|
|
38
|
-
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${
|
|
39
|
-
${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` :
|
|
47
|
+
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
|
|
48
|
+
${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : ''}`).join('');
|
|
40
49
|
const last = q.answers[q.answers.length - 1];
|
|
41
50
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
42
51
|
}
|
|
43
|
-
// A prototype page in the site's public/ opens in place,
|
|
52
|
+
// A prototype page in the site's public/ is the question's full-page viewer: it opens in place, shows one take at a
|
|
53
|
+
// time, and its switcher drafts a pick (board/public/prototype-switcher.js,
|
|
54
|
+
// docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
|
|
44
55
|
const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
|
|
45
56
|
const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
|
|
46
57
|
const withParam = (url, key, value) => {
|
|
@@ -48,13 +59,11 @@
|
|
|
48
59
|
return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
|
|
49
60
|
};
|
|
50
61
|
const questionLink = (url) => !url ? ''
|
|
51
|
-
: isPrototype(url) ? `<a class="
|
|
62
|
+
: isPrototype(url) ? `<a class="button proto-view" href="${esc(url)}">View prototypes</a>`
|
|
52
63
|
: `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
|
|
53
64
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
57
|
-
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
65
|
+
// An option with `diagram` draws it with its `practice` in the question (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
66
|
+
const drawn = (q) => q.options.some((o) => o.diagram);
|
|
58
67
|
|
|
59
68
|
// rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
|
|
60
69
|
// of them; tone is a status colour's name.
|
|
@@ -72,41 +81,27 @@
|
|
|
72
81
|
}
|
|
73
82
|
const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
&& (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
|
|
77
|
-
// Grid heights decide the layout; fullH measures the chosen take at the full content width.
|
|
78
|
-
function takeBody(q, o, links, fit, full = false) {
|
|
79
|
-
if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
|
|
80
|
-
if (!isPrototype(q.url)) return '';
|
|
81
|
-
const size = fit(o.id);
|
|
82
|
-
const h = full ? size?.fullH : size?.h;
|
|
83
|
-
// Every take loads so one outside the viewport can still decide the question's layout.
|
|
84
|
-
return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
|
|
85
|
-
}
|
|
86
|
-
function takes(q, chosen, links, fit, locked = false) {
|
|
84
|
+
function takes(q, chosen, links, locked = false) {
|
|
87
85
|
const name = 'c-' + q.id;
|
|
88
|
-
|
|
89
|
-
const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
|
|
90
|
-
return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, locked, suggested: suggestedLine(q, o, links) })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
|
|
91
|
-
}
|
|
92
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
|
|
86
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
93
87
|
}
|
|
94
|
-
// A confirm
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
function resolvedCard(q, links) {
|
|
88
|
+
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
89
|
+
const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
|
|
90
|
+
// docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
|
|
91
|
+
// A sent or resolved question, read-only in a green tint: its Resolved badge reopens it (view-questions.js sends
|
|
92
|
+
// `question-reopen` once the owner confirms), how Claude resolved it and when (resolved_at), its last pick checked
|
|
93
|
+
// among locked options, and its last note and Claude's reply. reask is a re-ask under way
|
|
94
|
+
// (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
|
|
95
|
+
function resolvedCard(q, links, n) {
|
|
102
96
|
const last = q.answers[q.answers.length - 1];
|
|
103
97
|
const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
|
|
104
98
|
const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
|
|
105
99
|
const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
|
|
106
100
|
${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
|
|
107
101
|
return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
108
|
-
<div class="row" style="margin:0"><span class="muted small">${esc(q.source || '')}</span>${badge('ready', '
|
|
102
|
+
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
|
|
109
103
|
<h3>${linkText(q.question, links)}</h3>
|
|
104
|
+
${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
|
|
110
105
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
111
106
|
${questionLink(q.url)}
|
|
112
107
|
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
@@ -115,22 +110,22 @@
|
|
|
115
110
|
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
116
111
|
</section>`;
|
|
117
112
|
}
|
|
118
|
-
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext
|
|
119
|
-
// fit(option id) supplies the measured grid and full-width heights.
|
|
113
|
+
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
|
|
120
114
|
// A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
|
|
121
115
|
// round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
// A requested question takes the accent tint, as a sent one takes the green.
|
|
117
|
+
function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
|
|
118
|
+
if (isSent(q)) return resolvedCard(q, links, n);
|
|
124
119
|
const locked = q.state === 'requested';
|
|
125
120
|
const chosen = locked ? q.request.choice : choiceOf(q, draft);
|
|
126
|
-
return `<section class="q
|
|
121
|
+
return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
127
122
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
128
123
|
<h3>${linkText(q.question, links)}</h3>
|
|
129
124
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
130
|
-
${
|
|
131
|
-
${
|
|
132
|
-
${
|
|
133
|
-
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft
|
|
125
|
+
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
126
|
+
${confirmLine(q)}
|
|
127
|
+
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
128
|
+
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
|
|
134
129
|
${answerHistory(q, links)}
|
|
135
130
|
</section>`;
|
|
136
131
|
}
|
|
@@ -142,7 +137,7 @@
|
|
|
142
137
|
const byKey = new Map(cards.map((c) => [c.key, c]));
|
|
143
138
|
return topics.map((t) => {
|
|
144
139
|
const c = t.title === undefined && byKey.get(t.card);
|
|
145
|
-
return c ? { ...t, title: `${c.ref}: ${c.title}`,
|
|
140
|
+
return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
|
|
146
141
|
});
|
|
147
142
|
}
|
|
148
143
|
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
@@ -179,5 +174,5 @@
|
|
|
179
174
|
},
|
|
180
175
|
};
|
|
181
176
|
|
|
182
|
-
Object.assign(globalThis.BoardUI, { choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip,
|
|
177
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
183
178
|
})();
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The sidebar of every site (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
|
|
2
2
|
// /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself, and
|
|
3
|
-
// ui-
|
|
3
|
+
// ui-progress-bar.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
|
|
4
4
|
// shell's first sidebar with all three in a vm context.
|
|
5
5
|
// Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
|
|
6
6
|
(function () {
|
|
7
7
|
// ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
|
|
8
|
-
const { header,
|
|
8
|
+
const { header, progressBar } = globalThis.BoardUI || {};
|
|
9
9
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
10
10
|
const svg = (paths, size = 17) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
11
11
|
const ICONS = {
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP])M/i, (_, x) => `${x.toLowerCase()}m`);
|
|
33
33
|
return new Date().toDateString() === d.toDateString() ? time : `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
|
34
34
|
}
|
|
35
|
+
// A pause's end in words: until its clock, or until you resume a Forever Pause hub or Pause board
|
|
36
|
+
// (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
37
|
+
const pauseUntilWords = (end) => (end === 'forever' ? 'until you resume' : `until ${usageClock(end)}`);
|
|
35
38
|
const usageTone = (w) => !w ? 'none' : w.state === 'stale' ? 'stale' : w.used_percentage >= 90 ? 'full' : w.used_percentage >= 75 ? 'high' : 'ok';
|
|
36
39
|
const usageBar = (w) => `<span class="u-bar" aria-hidden="true"><i style="width:${w?.state === 'stale' ? 0 : Math.max(0, Math.min(100, w?.used_percentage || 0))}%"></i></span>`;
|
|
37
40
|
function usageWords(reading, key) {
|
|
@@ -69,7 +72,7 @@
|
|
|
69
72
|
const sites = data?.sites || [];
|
|
70
73
|
const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
|
|
71
74
|
const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
|
|
72
|
-
...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
75
|
+
...(data?.shared ? [] : UTILITY).map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
73
76
|
rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
|
|
74
77
|
const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
|
|
75
78
|
const taken = new Set();
|
|
@@ -171,36 +174,38 @@
|
|
|
171
174
|
function startSteps(s) {
|
|
172
175
|
const at = STEP_KEYS.indexOf(s.step);
|
|
173
176
|
const label = `${START_STEPS[s.step]}, step ${at + 1} of ${STEP_KEYS.length}`;
|
|
174
|
-
const
|
|
175
|
-
|
|
177
|
+
const count = STEP_KEYS.length;
|
|
178
|
+
const bar = s.step === 'cloning' ? { count, done: 0, percent: s.percent || 0, label } : { count, done: at + 1, now: at, label };
|
|
179
|
+
return `<span class="sb-steps">${progressBar(bar)}</span>`;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
// The
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// update
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
|
|
188
|
-
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
|
|
189
|
-
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
|
|
190
|
-
return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
|
|
182
|
+
// The update row, only while the app's code is old: the version the hub now runs, as the Hub page's footer writes it (v0.5.1), or New version
|
|
183
|
+
// when no version is known (a clone whose commit moved but not its version). update: the app's reload, null | 'ready' | 'reloading'. Nothing
|
|
184
|
+
// else draws a row: the hub pulls a new version and restarts itself, and a pull or restart that fails shows nothing
|
|
185
|
+
// (docs/adr/2026-11-06-0800-the-update-row-shows-only-new-version-and-reload.md). A reload waits (ui.js BUSY): the pill keeps
|
|
186
|
+
// its blue with a spinner in place of its word.
|
|
187
|
+
function updateRow(update, version = null) {
|
|
188
|
+
if (!update) return '';
|
|
189
|
+
return `<button type="button" class="sb-row sb-update-row" data-app-reload${update === 'reloading' ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
|
|
190
|
+
+ `<span class="sb-text"><span class="sb-title">${version ? `v${esc(version)}` : 'New version'}</span></span><span class="sb-go">Reload</span></button>`;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
194
194
|
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
195
195
|
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
196
196
|
// update: null | 'ready' | 'reloading'.
|
|
197
|
-
// now: the time a starting board's seconds count to.
|
|
197
|
+
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
198
|
+
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
198
199
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
199
200
|
const sites = data?.sites || [];
|
|
200
201
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
201
202
|
const paused = data?.pause?.state === 'paused';
|
|
203
|
+
// A busy Mac holds every board's new work, and a pause reads before it (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
|
|
204
|
+
const busy = data?.pause?.machine?.state === 'busy';
|
|
205
|
+
// A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
206
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : busy ? word('wait', 'busy') : '');
|
|
202
207
|
const siteRow = (s) => {
|
|
203
|
-
const sub =
|
|
208
|
+
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
204
209
|
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
205
210
|
};
|
|
206
211
|
const boards = sites.filter((s) => s.board);
|
|
@@ -216,7 +221,7 @@
|
|
|
216
221
|
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
217
222
|
: '';
|
|
218
223
|
const usage = sidebarUsage(data?.usage);
|
|
219
|
-
const hub = paused ? { tone: '
|
|
224
|
+
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'Mac is busy' } : data?.hub;
|
|
220
225
|
const n = data?.notifications || {};
|
|
221
226
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
222
227
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -231,15 +236,18 @@
|
|
|
231
236
|
const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
|
|
232
237
|
const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
|
|
233
238
|
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
234
|
-
|
|
235
|
-
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
236
|
-
|
|
239
|
+
const top = header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
240
|
+
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } });
|
|
241
|
+
const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
|
|
242
|
+
if (data?.shared) return `${top}<div class="sb-scroll"><section class="sb-boards" aria-label="Boards">${boardsLabel}<div class="sb-group">${boards.map(siteRow).join('')}</div></section></div>`;
|
|
243
|
+
return top
|
|
244
|
+
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
|
|
237
245
|
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
238
246
|
+ `</div>${turnOn}</section>`
|
|
239
247
|
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
240
|
-
+ `<section class="sb-boards" aria-label="Boards"
|
|
248
|
+
+ `<section class="sb-boards" aria-label="Boards">${boardsLabel}`
|
|
241
249
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
242
250
|
}
|
|
243
251
|
|
|
244
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
252
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords });
|
|
245
253
|
})();
|
|
@@ -1,73 +1,64 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
1
|
+
// The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
|
|
2
|
+
// and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
|
|
3
3
|
(function () {
|
|
4
|
-
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
5
|
-
const SNAPSHOT_MAX = 900000;
|
|
6
4
|
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
7
5
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// `keep` first and the rest while they fit under max; threads of items no longer on the board are dropped. When the
|
|
15
|
-
// board still won't fit, Done cards go too. build is the saving page's (app.html data-build). Null when open lanes alone won't fit.
|
|
16
|
-
function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
17
|
-
const headOf = (b) => `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(b)},"threads":{`;
|
|
18
|
-
let shown = firstPaint(board, false);
|
|
19
|
-
let head = headOf(shown);
|
|
20
|
-
if (head.length + 2 > max) {
|
|
21
|
-
shown = firstPaint(board, true);
|
|
22
|
-
head = headOf(shown);
|
|
23
|
-
if (head.length + 2 > max) return null;
|
|
24
|
-
}
|
|
25
|
-
const onBoard = new Set(shown.items.map((i) => i.key));
|
|
26
|
-
let out = head;
|
|
27
|
-
let n = 0;
|
|
28
|
-
for (const key of new Set([...keep, ...threads.keys()])) {
|
|
29
|
-
if (!onBoard.has(key) || !threads.has(key)) continue;
|
|
30
|
-
const part = `${n ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(threads.get(key))}`;
|
|
31
|
-
if (out.length + part.length + 2 > max) continue;
|
|
32
|
-
out += part;
|
|
33
|
-
n++;
|
|
34
|
-
}
|
|
35
|
-
return `${out}}}`;
|
|
36
|
-
}
|
|
6
|
+
// threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
|
|
7
|
+
// threads of the cards on the board; build is the saving page's (app.html data-build).
|
|
8
|
+
const copyOf = (board, threads, { build, savedAt }) => {
|
|
9
|
+
const onBoard = new Set(board.items.map((i) => i.key));
|
|
10
|
+
return { build, savedAt, board, threads: Object.fromEntries([...threads].filter(([key]) => onBoard.has(key))) };
|
|
11
|
+
};
|
|
37
12
|
|
|
38
|
-
// → {savedAt, board, threads
|
|
39
|
-
//
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
try { s = JSON.parse(raw); } catch { return null; }
|
|
43
|
-
if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
13
|
+
// → {savedAt, board, threads}, or null for anything copyOf didn't write under this build: code from another build may
|
|
14
|
+
// read the board's shape differently, and a paint that throws stops the live load.
|
|
15
|
+
function readCopy(s, build) {
|
|
16
|
+
if (!s || s.build !== build || !Array.isArray(s.board?.items) || !Number.isInteger(s.board.version) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
44
17
|
const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
|
|
45
18
|
return { savedAt: s.savedAt, board: s.board, threads };
|
|
46
19
|
}
|
|
47
20
|
|
|
21
|
+
// One transaction on a store of the copies' database: 'boards', each board's copy, or 'pages', a side page's answers
|
|
22
|
+
// (pageCopy). fn(store) → the request whose result it resolves with. Rejects where IndexedDB is missing or refuses.
|
|
23
|
+
function copyStore(name, mode, fn) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const open = indexedDB.open('kickflip-boards', 2);
|
|
26
|
+
open.onupgradeneeded = () => { for (const s of ['boards', 'pages']) if (!open.result.objectStoreNames.contains(s)) open.result.createObjectStore(s); };
|
|
27
|
+
open.onerror = () => reject(open.error);
|
|
28
|
+
open.onsuccess = () => {
|
|
29
|
+
const db = open.result;
|
|
30
|
+
const tx = db.transaction(name, mode);
|
|
31
|
+
const req = fn(tx.objectStore(name));
|
|
32
|
+
tx.oncomplete = () => { db.close(); resolve(req.result); };
|
|
33
|
+
tx.onerror = tx.onabort = () => { db.close(); reject(tx.error); };
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A side page's last answer from each URL it reads, Notes' and Settings', for showing while the board can't be reached
|
|
39
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). read(url) → {savedAt, body} or null.
|
|
40
|
+
const pageCopy = {
|
|
41
|
+
save: (url, body) => copyStore('pages', 'readwrite', (store) => store.put({ savedAt: new Date().toISOString(), body }, url)).catch(() => {}),
|
|
42
|
+
read: (url) => copyStore('pages', 'readonly', (store) => store.get(url)).then((c) => (typeof c?.savedAt === 'string' ? c : null), () => null),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The copy of the board at key, the board view's site URL: the copy build saved, or with build null any build's.
|
|
46
|
+
// → {savedAt, board, threads} or null.
|
|
47
|
+
const boardCopy = (key, build) => copyStore('boards', 'readonly', (store) => store.get(key)).then((s) => readCopy(s, build ?? s?.build), () => null);
|
|
48
|
+
|
|
48
49
|
// The cards whose threads load right after the board, so tapping one opens it filled; open cards load with the board.
|
|
49
50
|
const preloadKeys = (items, open) => {
|
|
50
51
|
const opened = new Set(open);
|
|
51
52
|
return items.filter((i) => ACTIVE_LANES.includes(i.lane) && !opened.has(i.key)).map((i) => i.key);
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
// What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
|
|
55
|
-
// and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
|
|
56
|
-
// INT-61 block dims the same list.
|
|
57
|
-
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
|
|
58
|
-
'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
|
|
59
|
-
const liveControls = (view) => {
|
|
60
|
-
const fresh = view !== 'snapshot';
|
|
61
|
-
return { read: true, ask: true, cards: fresh, send: fresh, blocked: fresh ? '' : SNAPSHOT_BLOCKED };
|
|
62
|
-
};
|
|
63
|
-
|
|
64
55
|
// before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
|
|
65
56
|
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
66
57
|
|
|
67
|
-
// What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
|
|
58
|
+
// What counts as a card's update: a new thread line, a lane, status or Next line change, or a PR or CI change
|
|
68
59
|
// (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
|
|
69
|
-
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.
|
|
60
|
+
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.lane ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
|
|
70
61
|
it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
|
|
71
62
|
|
|
72
|
-
Object.assign(globalThis.BoardUI, {
|
|
63
|
+
Object.assign(globalThis.BoardUI, { copyOf, readCopy, copyStore, pageCopy, boardCopy, preloadKeys, changedLines, updateSign });
|
|
73
64
|
})();
|