@lovek4d/kickflip 0.5.3 → 0.6.1
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 +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- 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 +179 -92
- 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 +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -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 +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- 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 +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- 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 +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- 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 +2 -2
- 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 +6 -6
- 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/tailscale.mjs +1 -1
- 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,18 +1,25 @@
|
|
|
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, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
3
|
+
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
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
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 ?
|
|
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';
|
|
14
21
|
// A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
|
|
15
|
-
// phone's deck (app-needs.js) and the prototype viewer's
|
|
22
|
+
// phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
16
23
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
17
24
|
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
18
25
|
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
@@ -26,8 +33,6 @@
|
|
|
26
33
|
|
|
27
34
|
const QUESTION_STATE = {
|
|
28
35
|
open: { label: 'open', tone: 'normal' },
|
|
29
|
-
answered: { label: 'waiting for claude', tone: 'notice' },
|
|
30
|
-
replied: { label: 'claude replied', tone: 'ready' },
|
|
31
36
|
requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
|
|
32
37
|
};
|
|
33
38
|
// docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
|
|
@@ -39,8 +44,8 @@
|
|
|
39
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>';
|
|
40
45
|
function answerHistory(q, links = {}) {
|
|
41
46
|
if (!q.answers.length) return '';
|
|
42
|
-
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${
|
|
43
|
-
${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('');
|
|
44
49
|
const last = q.answers[q.answers.length - 1];
|
|
45
50
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
46
51
|
}
|
|
@@ -82,19 +87,21 @@
|
|
|
82
87
|
}
|
|
83
88
|
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
84
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>` : '';
|
|
85
|
-
// docs/adr/2026-11-
|
|
86
|
-
// A resolved question, read-only
|
|
87
|
-
// `
|
|
88
|
-
//
|
|
89
|
-
|
|
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) {
|
|
90
96
|
const last = q.answers[q.answers.length - 1];
|
|
91
97
|
const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
|
|
92
98
|
const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
|
|
93
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>
|
|
94
100
|
${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
|
|
95
101
|
return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
96
|
-
<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>
|
|
97
103
|
<h3>${linkText(q.question, links)}</h3>
|
|
104
|
+
${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
|
|
98
105
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
99
106
|
${questionLink(q.url)}
|
|
100
107
|
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
@@ -106,18 +113,19 @@
|
|
|
106
113
|
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
|
|
107
114
|
// A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
|
|
108
115
|
// round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
116
|
+
// A requested question takes the accent tint, as a sent one takes the green.
|
|
109
117
|
function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
|
|
110
|
-
if (q
|
|
118
|
+
if (isSent(q)) return resolvedCard(q, links, n);
|
|
111
119
|
const locked = q.state === 'requested';
|
|
112
120
|
const chosen = locked ? q.request.choice : choiceOf(q, draft);
|
|
113
|
-
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)}">
|
|
114
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>
|
|
115
123
|
<h3>${linkText(q.question, links)}</h3>
|
|
116
124
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
117
125
|
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
118
126
|
${confirmLine(q)}
|
|
119
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('')}
|
|
120
|
-
${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
|
|
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))}
|
|
121
129
|
${answerHistory(q, links)}
|
|
122
130
|
</section>`;
|
|
123
131
|
}
|
|
@@ -166,5 +174,5 @@
|
|
|
166
174
|
},
|
|
167
175
|
};
|
|
168
176
|
|
|
169
|
-
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
177
|
+
Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
170
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) {
|
|
@@ -171,25 +174,20 @@
|
|
|
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
|
-
//
|
|
182
|
-
//
|
|
183
|
-
function updateRow(update,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!upgrade) return '';
|
|
188
|
-
if (upgrade.state === 'updated') return button('data-app-restart', false, `Updated to v${esc(upgrade.version)}`, upgrade.error, 'Restart');
|
|
189
|
-
if (upgrade.state === 'restarting') return button('data-app-restart', true, `Restarting to v${esc(upgrade.version)}`, '', 'Restart');
|
|
190
|
-
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Updating', '', 'Update');
|
|
191
|
-
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Update');
|
|
192
|
-
return button('data-app-upgrade', false, 'Update available', '', 'Update');
|
|
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>`;
|
|
193
191
|
}
|
|
194
192
|
|
|
195
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
|
|
@@ -198,12 +196,22 @@
|
|
|
198
196
|
// update: null | 'ready' | 'reloading'.
|
|
199
197
|
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
200
198
|
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
199
|
+
// Whether the Hub row reads busy: the host is busy and no usage pause reads first.
|
|
200
|
+
const hubBusy = (data) => data?.pause?.machine?.state === 'busy' && data?.pause?.state !== 'paused';
|
|
201
|
+
// The logo's dot for the hub's state, like the blue update dot: 'down' (red) for a broken hub, 'busy' (orange), else ''.
|
|
202
|
+
const hubDot = (data) => (data?.hub?.tone === 'down' ? 'down' : hubBusy(data) ? 'busy' : '');
|
|
203
|
+
|
|
201
204
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
202
205
|
const sites = data?.sites || [];
|
|
203
206
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
204
207
|
const paused = data?.pause?.state === 'paused';
|
|
208
|
+
// A busy host holds every board's new work but shows only on the Hub row and the logo's dot, never on a board, and a pause reads before it
|
|
209
|
+
// (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
210
|
+
const busy = hubBusy(data);
|
|
211
|
+
// 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).
|
|
212
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : '');
|
|
205
213
|
const siteRow = (s) => {
|
|
206
|
-
const sub =
|
|
214
|
+
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
207
215
|
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)) });
|
|
208
216
|
};
|
|
209
217
|
const boards = sites.filter((s) => s.board);
|
|
@@ -219,7 +227,7 @@
|
|
|
219
227
|
: 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' })
|
|
220
228
|
: '';
|
|
221
229
|
const usage = sidebarUsage(data?.usage);
|
|
222
|
-
const hub = paused ? { tone: '
|
|
230
|
+
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
223
231
|
const n = data?.notifications || {};
|
|
224
232
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
225
233
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -239,7 +247,7 @@
|
|
|
239
247
|
const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
|
|
240
248
|
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>`;
|
|
241
249
|
return top
|
|
242
|
-
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update
|
|
250
|
+
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
|
|
243
251
|
+ 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('')
|
|
244
252
|
+ `</div>${turnOn}</section>`
|
|
245
253
|
+ (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>` : '')
|
|
@@ -247,5 +255,5 @@
|
|
|
247
255
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
248
256
|
}
|
|
249
257
|
|
|
250
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
258
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords, hubDot });
|
|
251
259
|
})();
|
|
@@ -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
|
-
|
|
5
|
-
const SNAPSHOT_MAX = 900000;
|
|
6
|
-
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
4
|
+
const ACTIVE_LANES = ['review', '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
|
})();
|