@lovek4d/kickflip 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -1,42 +1,36 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
- const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, boltButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
3
+ const { $, store, state, quieted, sendDraft, links } = B;
4
+ const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
5
5
 
6
- const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
7
- // A /prototype or /grill word in a message, the last one winning, and the message without it.
8
- const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
6
+ const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
7
+ // A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
8
+ const SLASH = /(^|\s)\/(grill|prototype|visual)(?=\s|$)/g;
9
9
  const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
10
10
 
11
11
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
12
12
 
13
13
  // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
14
14
  // docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
15
+ // docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
15
16
  // A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
16
- // action: builder orange once the card is ready, quiet before. Any other card's box sends with Send, carrying no intent.
17
- // The pair wraps onto its own line under the row.
18
- const sendsOf = (item) => (item?.lane !== 'todo' ? 'one' : item.ready ? 'todo-ready' : 'todo');
19
- const sends = (kind) => (kind === 'one'
20
- ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
21
- : `<span class="send-actions" data-sends="${kind}">${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}</span>`);
22
- // The board, not the thread, decides the Done button and the send buttons, so they follow each board load even when
23
- // the thread is unchanged.
24
- function syncDone(el, key) {
17
+ // action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
18
+ // (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
19
+ // onto its own line under the row.
20
+ const REVIEW = new Set(['review', 'stale']);
21
+ const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
22
+ : REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
23
+ const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
24
+ const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
25
+ : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
26
+ : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
27
+ // The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
28
+ function syncSends(el, key) {
25
29
  const at = el.querySelector('form [data-sends]');
26
30
  if (!at) return;
27
31
  const item = state.board?.items.find((i) => i.key === key);
28
32
  const kind = sendsOf(item);
29
- const todo = item?.lane === 'todo';
30
- if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
31
- const tools = el.querySelector('form .tools');
32
- const bolt = tools.querySelector('[data-bolt]');
33
- if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
34
- else if (!todo) bolt?.remove();
35
- const html = doneButton(item);
36
- const current = el.querySelector('form [data-confirm="finish"]');
37
- if (current?.outerHTML === html) return;
38
- current?.remove();
39
- if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
33
+ if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind, item)); at.remove(); }
40
34
  }
41
35
 
42
36
  // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
@@ -67,7 +61,7 @@ BoardView.part((B) => {
67
61
  function renderThread(key, msgs) {
68
62
  const el = $(`[data-thread="${CSS.escape(key)}"]`);
69
63
  if (!el) return;
70
- syncDone(el, key);
64
+ syncSends(el, key);
71
65
  const edits = editsIn(key);
72
66
  if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
73
67
  const view = viewOf(key);
@@ -79,21 +73,24 @@ BoardView.part((B) => {
79
73
  // The reply form is built once per card, so a refresh never drops what is being typed.
80
74
  if (!el.querySelector('.msgs')) {
81
75
  const draft = store.get('draft:' + key, '');
82
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
76
+ // The row: What's left? at the left, the eye, fire, image and attach at its right, and the send buttons
77
+ // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
78
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`, { attach: false })}
83
79
  <div class="pending-files" data-files></div>
84
- <div class="row"><span class="tools">${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button>${sends('one')}</div></form>`;
80
+ <div class="row"><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><span class="icons">${protoButton(key)}${grillButton(key)}${visualButton(key)}${attachButton()}</span>${sends('one')}</div></form>`;
85
81
  BoardAttach.paint('item:' + key, B.site.root);
86
82
  }
87
- syncDone(el, key);
83
+ syncSends(el, key);
88
84
  B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
89
85
  for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
90
86
  B.flashChanged?.(el);
91
87
  }
92
88
 
93
89
  // docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
94
- // The composer's eye asks for a prototype and its fire for a grill: a tap sends the request at once, with whatever is
95
- // typed, as /prototype or /grill in the text does on Reply. Nothing typed sends the bare request.
96
- const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
90
+ // The composer's eye asks for a prototype, its fire for a grill and its image for a visual: a tap sends the request at
91
+ // once, with whatever is typed, as /prototype, /grill or /visual in the text does on Reply. Nothing typed sends the
92
+ // bare request (docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
93
+ const PLACEHOLDER = 'Reply in this thread… (/prototype, /grill or /visual asks for one)';
97
94
 
98
95
  // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
99
96
  // starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
@@ -114,7 +111,7 @@ BoardView.part((B) => {
114
111
  loadedAt.set(key, Date.now());
115
112
  state.threadCache.set(key, msgs);
116
113
  renderThread(key, msgs);
117
- B.saveSnapshotSoon?.();
114
+ B.saveCopySoon?.();
118
115
  })
119
116
  .catch(() => {})
120
117
  .finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
@@ -136,14 +133,6 @@ BoardView.part((B) => {
136
133
  try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
137
134
  catch (e) { B.failed(e); }
138
135
  });
139
- // docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
140
- B.on(B.root, 'click', async (ev) => {
141
- const b = ev.target.closest('form[data-reply] button[data-bolt]');
142
- if (!b) return;
143
- const key = b.dataset.bolt;
144
- try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
145
- catch { toast('Could not send'); }
146
- });
147
136
  // Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
148
137
  async function post(f, flag, button) {
149
138
  const ta = f.querySelector('textarea');
@@ -151,7 +140,8 @@ BoardView.part((B) => {
151
140
  const text = flag ? slashOf(ta.value).text : ta.value.trim();
152
141
  const id = f.dataset.composer;
153
142
  const files = BoardAttach.files(id, B.site.root);
154
- // An empty box still sends Implement, the card's build action.
143
+ // An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
144
+ // starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
155
145
  const intent = flag ? null : button.dataset.intent || null;
156
146
  if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
157
147
  const key = f.dataset.reply;
@@ -166,24 +156,26 @@ BoardView.part((B) => {
166
156
  try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
167
157
  }
168
158
  B.on(B.root, 'click', (ev) => {
169
- const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
170
- if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
159
+ const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill], form[data-reply] button[data-visual]');
160
+ if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : b.hasAttribute('data-grill') ? 'grill' : 'visual', b);
171
161
  });
172
162
  B.on(B.root, 'submit', (ev) => {
173
163
  const f = ev.target.closest('form[data-reply]');
174
164
  if (!f) return;
175
165
  ev.preventDefault();
166
+ // Done on ⌥↵ (app-asks.js) confirms and closes the card as its tap does (app-cards.js).
167
+ if (ev.submitter?.dataset.confirm) { B.confirmTap(ev.submitter); return; }
176
168
  post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
177
169
  });
178
170
  // A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
179
171
  B.on(B.root, 'attach-change', (ev) => {
180
172
  for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
181
- if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
173
+ if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill], button[data-visual]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
182
174
  });
183
175
 
184
176
  Object.assign(B, {
185
177
  thread, renderThread, loadThread,
186
178
  repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
187
- syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
179
+ syncSends: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncSends(el, key); },
188
180
  });
189
181
  });
@@ -6,7 +6,8 @@
6
6
  <title>Claude</title>
7
7
  <script>
8
8
  // The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
9
- // below; app.js loadBoard takes the loads over when it mounts that board. A board's other views load their own, and a
9
+ // below; app.js loadBoard takes the loads over when it mounts that board. With a copy of the board saved under this
10
+ // build (app-snapshot.js copyVersion), it asks only for the changes since the copy's number. A board's other views load their own, and a
10
11
  // site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
11
12
  // after one waits for it to load.
12
13
  window.BoardBoot = (function () {
@@ -16,12 +17,16 @@ window.BoardBoot = (function () {
16
17
  if (!route || route === '_hub') return null;
17
18
  try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
18
19
  const base = '/' + encodeURIComponent(route) + '/';
19
- const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))); p.catch(() => {}); return p; };
20
+ // A failed answer rides on the error, for the board view's BoardUI.downOf.
21
+ const unanswered = (r) => Object.assign(new Error(String(r.status)), { answer: r });
22
+ const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
20
23
  let open = [];
21
24
  try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
22
25
  const focus = /^#item=([^&]+)/.exec(location.hash);
23
26
  if (focus) open.push(decodeURIComponent(focus[1]));
24
- return { route, board: get('api/board'), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
27
+ let since = null;
28
+ try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
29
+ return { route, board: get('api/board' + (since === null ? '' : '?since=' + since)), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
25
30
  })();
26
31
  </script>
27
32
  <script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
@@ -41,7 +46,7 @@ window.BoardBoot = (function () {
41
46
  <script src="/_hub/app/ui.js"></script>
42
47
  <script src="/_hub/app/ui-links.js"></script>
43
48
  <script src="/_hub/app/ui-enums.js"></script>
44
- <script src="/_hub/app/ui-phase-bars.js"></script>
49
+ <script src="/_hub/app/ui-progress-bar.js"></script>
45
50
  <script src="/_hub/app/ui-order.js"></script>
46
51
  <script src="/_hub/app/ui-patch.js"></script>
47
52
  <script src="/_hub/app/ui-threads.js"></script>
@@ -6,11 +6,8 @@
6
6
  // them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
7
7
  // BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
8
8
  // (view-*.js) the same site, $ and recorders.
9
- // opts, both from the app shell (shell.js):
9
+ // opts, from the app shell (shell.js):
10
10
  // signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
11
- // live {fresh, generatedAt}
12
- // this board was left fresh and hasn't changed since, so a stored last visit whose board has
13
- // that generatedAt paints live, with the header time `fresh`, rather than dimmed
14
11
  // Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
15
12
  window.BoardView = (function () {
16
13
  const parts = [];
@@ -29,6 +26,8 @@ window.BoardView = (function () {
29
26
  for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
30
27
  // Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
31
28
  for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
29
+ // The saved copy moved to IndexedDB (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
30
+ for (const k of old.filter((k) => k === 'boardSnapshots' || k.startsWith('boardSnapshot:'))) localStorage.removeItem(k);
32
31
  for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
33
32
  if (localStorage.getItem(keyOf(k)) != null) continue;
34
33
  localStorage.setItem(keyOf(k), localStorage.getItem(k));
@@ -129,6 +128,8 @@ window.BoardView = (function () {
129
128
  board: null,
130
129
  fresh: null,
131
130
  openItems: new Set(store.get('openItems', [])),
131
+ // The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
132
+ restored: new Set(store.get('openItems', [])),
132
133
  threadCache: new Map(),
133
134
  edits: new Map(),
134
135
  githubPending: null,
@@ -144,7 +145,8 @@ window.BoardView = (function () {
144
145
  const keep = (fn) => { if (B.alive()) keepers.push(fn); };
145
146
  const place = places.get(B.route) || null;
146
147
  places.delete(B.route);
147
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
148
+ Object.assign(B, { store, state, signal, pause: opts.pause, away: opts.away, place, keep });
149
+ BoardUI.usagePause = opts.pause;
148
150
 
149
151
  function mount() {
150
152
  if (B.alive()) return;
@@ -220,6 +222,7 @@ window.BoardView = (function () {
220
222
  focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
221
223
  replyKey = m[2] ? focusKey : null;
222
224
  state.openItems.add(focusKey);
225
+ state.restored.delete(focusKey);
223
226
  const it = state.board?.items.find((i) => i.key === focusKey);
224
227
  if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
225
228
  if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
@@ -263,24 +266,24 @@ window.BoardView = (function () {
263
266
  ta.focus({ preventScroll: true });
264
267
  }
265
268
 
266
- // The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
267
- // spinning, and the header's time is the snapshot's until a load lands (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
268
- // → true when it painted live (B.live), so the controls turn on at once.
269
- function paintSnapshot() {
270
- const snap = B.readSnapshot();
271
- if (!snap) return false;
272
- const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
273
- state.board = snap.board;
274
- for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
275
- state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
276
- B.setView(live ? 'fresh' : 'snapshot');
269
+ // The saved copy paints live before the first load, which asks only for the changes since its number; the lines
270
+ // that catch-up changes flash (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). A board that landed
271
+ // meanwhile stays.
272
+ // old: a copy another build saved, whose board the next load asks for whole.
273
+ function paintCopy(copy, { old = false } = {}) {
274
+ if (!copy || !B.alive() || state.view === 'fresh') return;
275
+ state.oldCopy = old;
276
+ state.board = copy.board;
277
+ for (const [key, msgs] of Object.entries(copy.threads)) state.threadCache.set(key, msgs);
278
+ state.fresh = { at: copy.savedAt, sig: null };
279
+ B.setView('fresh');
277
280
  readHash();
278
281
  hashRead = true;
279
282
  render();
280
283
  scrollToFocus(false);
281
284
  returnToPlace();
282
- if (!live) B.holdForFlash();
283
- return live;
285
+ B.holdForFlash();
286
+ B.linkUp();
284
287
  }
285
288
 
286
289
  // The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
@@ -291,6 +294,10 @@ window.BoardView = (function () {
291
294
  globalThis.BoardBoot = null;
292
295
  if (boot?.route && boot.route !== B.route) boot = null;
293
296
  let preloaded = false;
297
+ // A load nothing answers gives up after this, so an asleep host reads as out of reach.
298
+ const LOAD_TIMEOUT = 20000;
299
+ // A failed answer, carried to showDown for BoardUI.downOf.
300
+ const unanswered = (r) => Object.assign(new Error(`board ${r.status}`), { answer: r });
294
301
  async function loadBoard({ via = 'poll' } = {}) {
295
302
  const load = B.boardLoad = (B.boardLoad || 0) + 1;
296
303
  const started = boot;
@@ -298,12 +305,15 @@ window.BoardView = (function () {
298
305
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
299
306
  let landed = false;
300
307
  try {
301
- // A reload names the version of the board on screen: an unchanged board answers 304 with no body
302
- // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch from it
303
- // (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md).
304
- const tag = state.view === 'fresh' ? state.board.version : null;
305
- const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
306
- .then((r) => (r.status === 304 ? null : r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
308
+ // A reload names the change number of the board on screen: an unchanged board answers 304 with no body
309
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch of what
310
+ // changed after it (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
311
+ const tag = state.view === 'fresh' && !state.oldCopy ? state.board.version : null;
312
+ const timeout = new AbortController();
313
+ const stopTimer = B.later(LOAD_TIMEOUT, () => timeout.abort());
314
+ const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', signal: timeout.signal, ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
315
+ .then((r) => (r.status === 304 ? null : r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r))))
316
+ .finally(stopTimer));
307
317
  if (board) land(board, load); else B.linkUp();
308
318
  landed = true;
309
319
  if (!preloaded) {
@@ -312,12 +322,36 @@ window.BoardView = (function () {
312
322
  }
313
323
  } catch (e) {
314
324
  B.linkDown();
325
+ if (state.view !== 'fresh' && B.alive()) await showDown(BoardUI.downOf(e.answer || null));
315
326
  }
316
327
  await Promise.all(threads);
317
328
  if (landed) B.landed(via);
318
329
  if (state.view === 'fresh') B.endFlash();
319
330
  }
320
331
 
332
+ // A board out of reach with nothing on screen paints the copy another build saved, which the first paint skipped,
333
+ // guarded: a paint that throws leaves the landing, the kickflip mark and down's two lines in the board's place, and
334
+ // the landing retries every 5 s while the board can come back by itself
335
+ // (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
336
+ let triedOldCopy = false;
337
+ async function showDown(down) {
338
+ if (!triedOldCopy) {
339
+ triedOldCopy = true;
340
+ const old = await B.readCopy({ anyBuild: true });
341
+ try { paintCopy(old, { old: true }); } catch {
342
+ state.board = null;
343
+ B.setView(null);
344
+ }
345
+ if (!B.alive()) return;
346
+ if (state.view === 'fresh') { B.linkDown(); return; }
347
+ }
348
+ state.down = down;
349
+ $('#downLanding').innerHTML = BoardUI.downLanding(down);
350
+ $('#downLanding').hidden = false;
351
+ B.root.classList.add('down');
352
+ }
353
+ B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
354
+
321
355
  // A board from the server, a load's or an events POST's, or a patch from the board on screen (ui-patch.js). One built
322
356
  // before the board on screen leaves it as it is, so a slow load can't undo a newer one; a newer patch from a board
323
357
  // no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
@@ -327,6 +361,12 @@ window.BoardView = (function () {
327
361
  if (!board) { signalled(); return false; }
328
362
  const first = state.view !== 'fresh';
329
363
  state.board = board;
364
+ state.oldCopy = false;
365
+ if (state.down) {
366
+ state.down = null;
367
+ $('#downLanding').hidden = true;
368
+ B.root.classList.remove('down');
369
+ }
330
370
  B.setView('fresh');
331
371
  if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
332
372
  B.linkUp();
@@ -334,7 +374,7 @@ window.BoardView = (function () {
334
374
  scrollToFocus();
335
375
  returnToPlace();
336
376
  B.site.syncBadge();
337
- B.saveSnapshotSoon();
377
+ B.saveCopySoon();
338
378
  return true;
339
379
  }
340
380
 
@@ -382,24 +422,28 @@ window.BoardView = (function () {
382
422
  render();
383
423
  }
384
424
  Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
385
- const live = paintSnapshot();
386
425
  B.headerWired();
387
- if (live) B.linkUp();
388
426
  const sent = BoardUI.sentNotice.take(B.route);
389
427
  if (sent) B.toast(sent);
390
428
  B.on(document, 'visibilitychange', () => {
391
429
  if (document.hidden) return;
392
430
  if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
393
431
  });
394
- loadBoard();
432
+ B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
395
433
  B.signal?.(signalled);
396
434
  B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
397
435
  }
398
436
 
399
- // shared: the shell's data.shared for someone the board is shared with, view only, whose board opens with a strip saying so.
437
+ // shared: {by, full} for someone the board is shared with, whose board opens with a strip saying what they can do there
438
+ // (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md). → the strip's inside
439
+ function sharedStrip({ by, full }) {
440
+ const from = `Shared with you${by ? ` by ${BoardUI.esc(by)}` : ''}.`;
441
+ return full ? `<b>Full.</b> ${from} You can answer and act. Merge and Board settings are ${by ? `${BoardUI.esc(by)}’s` : 'the owner’s'}.`
442
+ : `<b>View only.</b> ${from} You can look and open Visit links.`;
443
+ }
400
444
  function markup({ title = '', shared = null } = {}) {
401
- const strip = shared ? `<p class="view-only-strip"><b>View only.</b> Shared with you${shared.by ? ` by ${BoardUI.esc(shared.by)}` : ''}. You can look and open Visit links.</p>\n` : '';
402
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
445
+ const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
446
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
403
447
  <div class="split">
404
448
  <main>
405
449
  ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
@@ -407,6 +451,8 @@ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
407
451
  <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
408
452
  <div id="tryMain"></div>
409
453
  <div id="lanes"></div>
454
+ <div id="downLanding" hidden></div>
455
+ ${BoardUI.boardFoot()}
410
456
  </main>
411
457
  <aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
412
458
  </div>
@@ -446,5 +492,5 @@ ${BoardNewTask.markup()}
446
492
  </dialog>`;
447
493
  }
448
494
 
449
- return { part, create, page, markup };
495
+ return { part, create, page, markup, sharedStrip };
450
496
  })();
@@ -1,4 +1,4 @@
1
- // What the hub page and settings modals share, each through the view's B
1
+ // What the hub page and settings modals share, Board Settings' duration picker included, each through the view's B
2
2
  // (app.js BoardView.page), so unmounting the view stops it all: loading and refreshing the view's data, posting its
3
3
  // forms for JSON, and this device's notifications row.
4
4
  (function () {
@@ -169,5 +169,65 @@
169
169
  return { state: (line) => (state && on ? { ...state, sub: `On · ${line}` } : state), busy: () => busy, ready };
170
170
  }
171
171
 
172
- globalThis.HubKit = { live, forms, device };
172
+ // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the server.
173
+ const localInput = (d) => {
174
+ const pad = (n) => String(n).padStart(2, '0');
175
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
176
+ };
177
+ function customBounds(input, reset = false) {
178
+ const now = Date.now();
179
+ input.min = localInput(new Date(now + 60_000));
180
+ input.max = localInput(new Date(now + 30 * 86_400_000));
181
+ if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
182
+ }
183
+
184
+ // The one duration picker Snooze, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
185
+ // `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
186
+ // replaces it with when it ends and a button that posts `name`=off.
187
+ function durationPicker({ id, title, icon, action, name, choices, on, button }) {
188
+ const { esc } = HubUI;
189
+ if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
190
+ if (!choices) return '';
191
+ const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
192
+ return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
193
+ }
194
+
195
+ // Wires every duration picker under B.root: Custom opens its picker, and closing it submits its form with the time
196
+ // chosen. → painted(), which a view calls after drawing a picker to set Custom's bounds and suggested time.
197
+ function durationPickers(B) {
198
+ let openPicker = null;
199
+ B.on(B.root, 'click', (e) => {
200
+ const input = e.target.closest?.('[data-dur-at]');
201
+ if (!input) return;
202
+ openPicker = input;
203
+ customBounds(input, input.value === input.defaultValue);
204
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
205
+ });
206
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
207
+ B.on(B.root, 'pointerdown', (e) => {
208
+ const input = e.target.closest?.('.dur-choices button') && e.target.closest('.dur-chips')?.querySelector('[data-dur-at]');
209
+ if (!input) return;
210
+ openPicker = null;
211
+ input.value = input.defaultValue;
212
+ });
213
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
214
+ const saveCustom = (input) => {
215
+ if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
216
+ const at = new Date(input.value);
217
+ if (Number.isNaN(at.getTime())) return;
218
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
219
+ input.form.requestSubmit();
220
+ openPicker = null;
221
+ input.defaultValue = input.value;
222
+ };
223
+ B.on(B.root, 'change', (e) => {
224
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
225
+ });
226
+ B.on(B.root, 'focusout', (e) => {
227
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
228
+ });
229
+ return { painted: () => { for (const input of B.root.querySelectorAll('[data-dur-at]')) customBounds(input, true); } };
230
+ }
231
+
232
+ globalThis.HubKit = { live, forms, device, durationPicker, durationPickers };
173
233
  })();
@@ -135,6 +135,8 @@
135
135
  .hub-view .lk-form input:focus { outline:2px solid var(--accent-soft); border-color:var(--accent); }
136
136
 
137
137
  .hub-view .ub-banner > button { flex: none; min-height: 32px; }
138
+ .hub-view .hold-banner.ub-paused { border-color: var(--line); background: var(--panel); }
139
+ .hub-view .hold-banner.ub-paused > svg { color: var(--muted); }
138
140
  .hub-view .hold-banner.ub-going { border-color: color-mix(in srgb, var(--color-danger) 45%, transparent); background: color-mix(in srgb, var(--color-danger) 10%, var(--panel)); }
139
141
  .hub-view .hold-banner.ub-going > svg { color: var(--color-danger); }
140
142
 
@@ -8,14 +8,16 @@ globalThis.BoardNewTask = (function () {
8
8
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
9
9
  ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
10
10
  <div class="pending-files" data-files></div>
11
- <div class="send-actions">${BoardUI.sendButton('Quick build', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
11
+ <div class="send-actions">${BoardUI.sendButton('Implement', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
12
12
  <p class="muted small" id="askStatus" aria-live="polite"></p>
13
13
  </form>
14
14
  </dialog>`;
15
15
 
16
16
  function wire(B) {
17
- // A shared person looks only (shell.js marks the page shared-view, and style.css hides the +), so n twice opens nothing.
18
- if (document.documentElement.classList.contains('shared-view')) return;
17
+ // A View person looks only (shell.js marks the page shared-view without shared-full, and style.css hides the +), so
18
+ // n twice opens nothing.
19
+ const html = document.documentElement.classList;
20
+ if (html.contains('shared-view') && !html.contains('shared-full')) return;
19
21
  const { $, store, toast, quieted, sendDraft } = B;
20
22
  const { waitOn, keyedSend } = BoardUI;
21
23
  const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
@@ -24,7 +26,7 @@ globalThis.BoardNewTask = (function () {
24
26
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
25
27
  // Files picked before the app shell switched away from this board are still attached, so they show again.
26
28
  BoardAttach.paint('ask', B.site.root);
27
- // ⌘↵ sends the ask as Discuss and ⌥↵ as Quick build (ui.js keyedSend).
29
+ // ⌘↵ sends the ask as Discuss and ⌥↵ as Implement (ui.js keyedSend).
28
30
  B.on($('#askText'), 'keydown', (ev) => {
29
31
  const button = keyedSend(ev, $('#askForm'));
30
32
  if (!button) return;
@@ -64,7 +66,7 @@ globalThis.BoardNewTask = (function () {
64
66
  if (ev.defaultPrevented) { lastN = -Infinity; return; }
65
67
  if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
66
68
  const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
67
- if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
69
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('down')) { lastN = -Infinity; return; }
68
70
  if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
69
71
  lastN = -Infinity;
70
72
  ev.preventDefault();
@@ -75,7 +77,7 @@ globalThis.BoardNewTask = (function () {
75
77
  B.on($('#sheetClose'), 'click', closeSheet);
76
78
  B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
77
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
78
- // Discuss has the session grill or prototype the task at once; Quick build sends the ask with quick: true, and the
80
+ // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
79
81
  // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
80
82
  B.on($('#askForm'), 'submit', async (ev) => {
81
83
  ev.preventDefault();
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Progress bars</title>
7
+ <link rel="stylesheet" href="colors.css">
8
+ <link rel="stylesheet" href="style.css">
9
+ <style>
10
+ /* The progress-bar review (progress-review.js): the board's lanes, each card one bar or status line drawn by the real
11
+ renderers, its colour the lane's --lane, which the lane's colour pick sets; the open card's editor beneath. */
12
+ .pr-lanes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
13
+ @media (max-width: 799px) { .pr-lanes { grid-template-columns: minmax(0, 1fr); } }
14
+ .pr-lane > h2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 0 6px; font-size: 13px; color: var(--lane); border-bottom: 2px solid var(--lane); padding-bottom: 4px; }
15
+ .pr-lane input[type="color"] { width: 32px; height: 24px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: none; }
16
+ .pr-lane .sub.agent:not(.waiting-gray, .quiet) { color: var(--lane); }
17
+ .pr-lane .phase-bars:not(.dim) { --bar-fill: var(--lane); }
18
+ .pr-lane .item[data-open] > summary { outline: 2px solid var(--accent); outline-offset: -2px; }
19
+ .pr-lane .card-time .sb-steps .phase-bars { gap: 2px; }
20
+ .pr-editor { margin-top: 16px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
21
+ .pr-editor h2 { margin: 0 0 8px; font-size: 15px; }
22
+ .pr-editor label { display: flex; align-items: center; gap: 8px; margin: 6px 0; }
23
+ .pr-editor :where(input, select) { font-size: 16px; }
24
+ .pr-editor ol { margin: 8px 0 0; padding: 0; list-style: none; }
25
+ .pr-editor li { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
26
+ .pr-editor li.over { outline: 2px dashed var(--accent); }
27
+ .pr-editor .grip { cursor: grab; color: var(--muted); }
28
+ .pr-send { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <!--header {"lead":"back","title":"Progress bars","backHref":"./","controls":false}-->
33
+ <main>
34
+ <p class="muted">Every progress bar and status line the board draws, in the lane it shows in. Tap one to rename it, move it to another lane or rename and reorder its steps; pick each lane's colour; then Send.</p>
35
+ <div id="review"></div>
36
+ </main>
37
+ <script src="ui.js"></script>
38
+ <script src="ui-links.js"></script>
39
+ <script src="ui-enums.js"></script>
40
+ <script src="ui-progress-bar.js"></script>
41
+ <script src="ui-cards.js"></script>
42
+ <script src="ui-sidebar.js"></script>
43
+ <script src="claude.js"></script>
44
+ <script src="progress-review.js"></script>
45
+ </body>
46
+ </html>