@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.
Files changed (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -1,41 +1,15 @@
1
- // The board's per-card patches (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md): server.mjs makes one
2
- // from the board a page holds to the board now, and the page applies it to the board it holds. Cards patch by key,
3
- // questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new order when
4
- // that changed; every other part of the board comes whole when it changed.
5
- // Needs nothing else: board/server.mjs runs this file in a vm context.
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 ||= {}, { boardPatch, applyPatch });
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
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 Pick (prototype-switcher.js) write it.
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>${esc(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
43
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : a.acted ? '' : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
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-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
86
- // A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
87
- // `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
88
- // checked among locked options, and its last note and Claude's reply. reask is a re-ask under way, which locks the badge.
89
- function resolvedCard(q, links) {
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', 'resolved', { button: true, title: 'Ask this question again', attrs: { type: 'button', 'data-reask': q.id, disabled: q.reask ? '' : null } })}</div>
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.state === 'resolved') return resolvedCard(q, links);
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 ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
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.note)}
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-phase-bars.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
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, phaseBars } = globalThis.BoardUI || {};
8
+ const { header, progressBar } = globalThis.BoardUI || {};
9
9
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 strip = s.step === 'cloning' ? { reached: 0, running: false, fill: s.percent || 0, label } : { reached: at + 1, running: true, label };
175
- return `<span class="sb-steps">${phaseBars(strip, STEP_KEYS)}</span>`;
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 New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
179
- // /_hub/sidebar.json's npm update, null | {state: available | updating | updated | restarting | failed, version, error}
180
- // (hub/upgrade.mjs; docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The hub pulls a new
181
- // version itself, so the row reads Updated to v<version> with Restart; restarting is the sidebar's own state while the hub
182
- // restarts. A reload, a pull and a restart wait (ui.js BUSY): the pill keeps its blue with a spinner in place of its word.
183
- function updateRow(update, upgrade) {
184
- const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
185
- + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
186
- if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
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 = paused && s.board ? word('wait', 'paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.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: 'wait', word: 'paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
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, data?.upgrade)}</div><div class="sb-group">`
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 last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
2
- // page may do meanwhile (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md). Needs ui.js.
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
- const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
4
+ const ACTIVE_LANES = ['review', 'progress', 'todo'];
7
5
 
8
- // What a first paint shows of board: every card as the board sent it, Done cards already slim (board/domain.mjs
9
- // slimDone), or the open lanes alone when openOnly. It keeps no version, since it isn't the board that version names,
10
- // so the first reload downloads the whole board (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
11
- const firstPaint = ({ version: _, ...board }, openOnly) => ({ ...board, items: openOnly ? board.items.filter((it) => it.lane !== 'done') : board.items });
12
-
13
- // threads maps item keys to thread messages (app.js state.threadCache). The board goes in as firstPaint, then threads of
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: {key: messages}}, or null for anything packSnapshot didn't write under this build: code
39
- // from another build may read the board's shape differently, and a paint that throws stops the live load.
40
- function unpackSnapshot(raw, build) {
41
- let s;
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.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
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, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
63
+ Object.assign(globalThis.BoardUI, { copyOf, readCopy, copyStore, pageCopy, boardCopy, preloadKeys, changedLines, updateSign });
73
64
  })();