@lovek4d/kickflip 0.5.0 → 0.5.3

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 (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -1,7 +1,6 @@
1
- // The + button and the sheet that sends a new task as an ask event, on every page of a board: the board view's markup
2
- // (app.js) and each other view of a board (shell.js) put BoardNewTask.markup() in their root and wire it with
3
- // BoardNewTask.wire(B) once mounted. A board's view sends through B.send, which lands the board the ask changed; any
4
- // other view sends through its site. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
1
+ // The + button and the sheet that sends a new task as an ask event, on the board's main page alone: the board view's markup
2
+ // (app.js) puts BoardNewTask.markup() in its root and app-asks.js wires it with BoardNewTask.wire(B). No other page
3
+ // opts in. The ask goes through B.send, which lands the board the ask changed. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
5
4
  globalThis.BoardNewTask = (function () {
6
5
  const markup = () => `<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
7
6
  <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
@@ -9,32 +8,28 @@ globalThis.BoardNewTask = (function () {
9
8
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
10
9
  ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
11
10
  <div class="pending-files" data-files></div>
12
- <div class="tk-seg ask-seg" role="group" aria-label="Before building"><button type="button" id="askDiscuss" aria-pressed="true">Discuss</button><button type="button" id="askQuick" aria-pressed="false">Quick build</button></div>
13
- <p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
14
- <div class="row">
15
- <span class="muted small" id="askStatus"></span>
16
- <button type="submit" class="primary">Send</button>
17
- </div>
11
+ <div class="send-actions">${BoardUI.sendButton('Quick build', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
12
+ <p class="muted small" id="askStatus" aria-live="polite"></p>
18
13
  </form>
19
14
  </dialog>`;
20
15
 
21
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;
22
19
  const { $, store, toast, quieted, sendDraft } = B;
23
- const { attachButton, waitOn } = BoardUI;
20
+ const { waitOn, keyedSend } = BoardUI;
24
21
  const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
25
22
  // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
26
23
  const sheet = $('#sheet');
27
24
  const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
28
- if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
29
25
  // Files picked before the app shell switched away from this board are still attached, so they show again.
30
26
  BoardAttach.paint('ask', B.site.root);
31
- const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
32
- if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
33
- // ⌘↵ (Ctrl+↵ off a Mac) sends the ask from its box.
27
+ // ⌘↵ sends the ask as Discuss and ⌥↵ as Quick build (ui.js keyedSend).
34
28
  B.on($('#askText'), 'keydown', (ev) => {
35
- if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
29
+ const button = keyedSend(ev, $('#askForm'));
30
+ if (!button) return;
36
31
  ev.preventDefault();
37
- $('#askForm').requestSubmit();
32
+ $('#askForm').requestSubmit(button);
38
33
  });
39
34
  $('#askText').value = store.get('draft:ask', '');
40
35
  B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
@@ -80,36 +75,20 @@ globalThis.BoardNewTask = (function () {
80
75
  B.on($('#sheetClose'), 'click', closeSheet);
81
76
  B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
82
77
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
83
- // Discuss or Quick build, above Send: Discuss, the default, has the session grill or prototype the task at once; Quick
84
- // build sends the ask with quick: true, and the session marks the card ready. Each send goes back to Discuss
85
- // (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
86
- const CHOICE_HELP = {
87
- discuss: 'Discuss: Claude grills or prototypes it first, then waits for you.',
88
- quick: 'Quick build: for changes you’re sure about; Claude marks it ready and builds.',
89
- };
90
- let quick = false;
91
- function choose(isQuick) {
92
- quick = isQuick;
93
- $('#askDiscuss').setAttribute('aria-pressed', String(!quick));
94
- $('#askQuick').setAttribute('aria-pressed', String(quick));
95
- $('#askChoiceHelp').textContent = CHOICE_HELP[quick ? 'quick' : 'discuss'];
96
- }
97
- choose(false);
98
- B.on(B.root, 'click', (ev) => {
99
- const b = ev.target.closest('#askDiscuss, #askQuick');
100
- if (b) choose(b.id === 'askQuick');
101
- });
78
+ // Discuss has the session grill or prototype the task at once; Quick build sends the ask with quick: true, and the
79
+ // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
102
80
  B.on($('#askForm'), 'submit', async (ev) => {
103
81
  ev.preventDefault();
104
82
  $('#askText').blur();
105
83
  const text = $('#askText').value.trim();
106
84
  const files = BoardAttach.files('ask', B.site.root);
85
+ const button = ev.submitter;
86
+ const quick = button.dataset.intent === 'quick';
107
87
  if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
108
88
  $('#askStatus').textContent = '';
109
89
  try {
110
- await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
90
+ await waitOn(button, sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
111
91
  BoardAttach.clear('ask', B.site.root);
112
- choose(false);
113
92
  $('#askStatus').textContent = '';
114
93
  closeSheet();
115
94
  toast('Sent');
@@ -75,7 +75,7 @@
75
75
  <h2 class="label">Polling status</h2>
76
76
  <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
77
77
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
78
- <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span></footer></div></main></div>`;
78
+ <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
79
79
  }
80
80
 
81
81
  const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
@@ -127,6 +127,21 @@
127
127
 
128
128
  const kit = { sidebar, hub, cards, sheet, toast, on, fake, esc };
129
129
 
130
+ // [element, place] for each element in stage, its place the tag and class of it and each ancestor up to the stage, with
131
+ // a count telling apart boxes that share one, so a take drawn again finds the same box.
132
+ function placesIn(stage) {
133
+ const seen = new Map();
134
+ return [...stage.querySelectorAll('*')].map((el) => {
135
+ let path = '';
136
+ for (let at = el; at && at !== stage; at = at.parentElement) path = `${at.tagName}.${at.className}/${path}`;
137
+ const n = seen.get(path) || 0;
138
+ seen.set(path, n + 1);
139
+ return [el, `${path}#${n}`];
140
+ });
141
+ }
142
+ // place → [scrollTop, scrollLeft] of each box in stage scrolled away from its start.
143
+ const scrolledBoxes = (stage) => new Map(placesIn(stage).filter(([el]) => el.scrollTop || el.scrollLeft).map(([el, place]) => [place, [el.scrollTop, el.scrollLeft]]));
144
+
130
145
  function start(data) {
131
146
  const keys = data.takes.map((t) => t.key);
132
147
  const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
@@ -158,13 +173,23 @@
158
173
  try { cleanup?.(); } catch (err) { console.error(err); }
159
174
  cleanup = null;
160
175
  ends.splice(0).forEach((end) => end());
161
- // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn.
176
+ // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn, and
177
+ // each box scrolled inside the take scrolls where it was once the new take has drawn the same box.
162
178
  const old = document.getElementById('stage');
163
179
  const stage = old.cloneNode(false);
164
180
  const y = window.scrollY;
181
+ const scrolled = scrolledBoxes(old);
165
182
  stage.style.minHeight = `${old.offsetHeight}px`;
166
183
  old.replaceWith(stage);
167
- const settle = () => { if (n !== shown) return; stage.style.minHeight = ''; window.scrollTo(0, y); };
184
+ const settle = () => {
185
+ if (n !== shown) return;
186
+ stage.style.minHeight = '';
187
+ for (const [el, place] of placesIn(stage)) {
188
+ const at = scrolled.get(place);
189
+ if (at) [el.scrollTop, el.scrollLeft] = at;
190
+ }
191
+ window.scrollTo(0, y);
192
+ };
168
193
  document.documentElement.dataset.take = key;
169
194
  const take = data.takes.find((t) => t.key === key);
170
195
  if (!take.src) {
@@ -1,16 +1,11 @@
1
- // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
1
+ // The floating ‹ take › pill of a question's full-page prototype viewer (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
2
+ // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
2
3
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
3
4
  // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
4
5
  // render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
5
- // take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's answer: a take
6
- // is pickable when an option's id equals its key. Pick sends the owner-answer event questions.html sends, with the
7
- // note typed above the pill, then moves on through BoardUI.nextQuestion (ui-questions.js).
8
- // PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
9
- // to switch its full-width frame without a reload (view-questions.js inPlace).
10
- // With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
11
- // (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
12
- // bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
13
- // to the page's height.
6
+ // take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's pick: a take is
7
+ // pickable when an option's id equals its key. Pick drafts it, with the note typed above the pill, in the {choice, note}
8
+ // questions.html keeps for that question, and goes back to the question, whose Send sends it.
14
9
  (function () {
15
10
  const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
16
11
 
@@ -20,53 +15,42 @@
20
15
  };
21
16
  const startTake = (keys, wanted, initial) => (keys.includes(wanted) ? wanted : keys.includes(initial) ? initial : keys[0]);
22
17
 
23
- // The owner's current pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
18
+ // The owner's last sent pick is the latest answer that chose an option; a note-only answer keeps the earlier pick.
24
19
  const pickedChoice = (question) => [...(question?.answers || [])].reverse().find((a) => a.choice)?.choice ?? null;
25
- // none: no question, or the take is not one of its options. pick: nothing picked yet. picked: this take is the
26
- // owner's pick. change: another take is.
27
- function pickState(question, take) {
20
+ // The pick a question shows on Waiting on you (ui-questions.js choiceOf): drafted, this device's unsent pick, which Send
21
+ // would send, else the last answer's, else the option the overnight triage suggested. A resolved question is locked at
22
+ // its last answer, and one waiting on the prototype the owner asked for at the request's pick.
23
+ const locked = (question) => question.state === 'resolved' || question.state === 'requested';
24
+ const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
25
+ : question.state === 'resolved' ? pickedChoice(question)
26
+ : drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
27
+ // none: no question, the take is not one of its options, or the question is locked and the take isn't its pick.
28
+ // pick: nothing picked yet. picked: this take is the pick. change: another take is.
29
+ function pickState(question, take, drafted = null) {
28
30
  if (!question) return { kind: 'none' };
29
31
  const option = (question.options || []).find((o) => o.id === take);
30
- const picked = pickedChoice(question);
32
+ const picked = shownPick(question, drafted);
31
33
  if (!option) return { kind: 'none', picked };
32
- if (!picked) return { kind: 'pick', option };
33
- return { kind: picked === take ? 'picked' : 'change', option, picked };
34
+ if (picked === take) return { kind: 'picked', option, picked };
35
+ if (locked(question)) return { kind: 'none', picked };
36
+ return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
34
37
  }
35
- // Matches ui-questions.js choiceLabel for a single-choice question.
36
- const answerLabel = (option) => `${/^[A-Z][0-9]?$/i.test(option.id) ? `${option.id}. ` : ''}${option.label}`;
37
- const questionUrl = (q) => (/^prototype-[\w-]+\.html(?:[?#]|$)/.test(q.url || '') ? q.url : `questions.html#${encodeURIComponent(q.id)}`);
38
- // questions.html keeps each question's unsent {choice, note} under this key; a pick sent here takes its note and clears it.
38
+ // questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
39
39
  const draftKey = (qid) => 'qdraft:' + qid;
40
40
  const readDraft = (qid) => { try { return JSON.parse(localStorage.getItem(draftKey(qid)) || '{}'); } catch { return {}; } };
41
- // In place of this page: framed in the app shell, the shell shows the URL's view in place (board/public/shell.js
42
- // AppShell.open); alone, or for a URL that isn't a view, the page loads it.
41
+ const writeDraft = (qid, draft) => { try { localStorage.setItem(draftKey(qid), JSON.stringify(draft)); } catch { /* nothing kept */ } };
42
+ // In place of this page: framed in the app shell, the shell shows the URL's view in place, going up to the question
43
+ // the viewer sits under (board/public/shell.js AppShell.open, router.js); alone, the page loads it.
43
44
  function replaceWith(url) {
44
45
  let app = null;
45
46
  try { app = window.top !== window ? window.top.AppShell : null; } catch { app = null; }
46
47
  if (!app?.open?.(String(url), { replace: true })) location.replace(url);
47
48
  }
48
49
 
49
- // Prototype pages need only claude.js and this switcher. In the shell, its BoardUI also owns the board's notice.
50
- let questionsUI = null;
51
- const sharedQuestions = (site) => (questionsUI ||= (async () => {
52
- try { if (window.top !== window && window.top.BoardUI?.nextQuestion) return window.top.BoardUI; } catch {}
53
- for (const [file, key] of [['ui.js', 'esc'], ['ui-links.js', 'linkText'], ['ui-questions.js', 'nextQuestion']]) {
54
- if (globalThis.BoardUI?.[key]) continue;
55
- await new Promise((resolve, reject) => {
56
- const script = document.createElement('script');
57
- script.src = site.url(file);
58
- script.onload = resolve;
59
- script.onerror = reject;
60
- document.head.append(script);
61
- });
62
- }
63
- return globalThis.BoardUI;
64
- })().catch((e) => { questionsUI = null; throw e; }));
65
-
66
- function pillHtml({ name, pick = { kind: 'none' }, busy = false }) {
50
+ function pillHtml({ name, pick = { kind: 'none' } }) {
67
51
  const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
68
52
  : pick.kind === 'pick' || pick.kind === 'change'
69
- ? `<button type="button" class="ps-pick" data-ps="pick"${busy ? ' disabled' : ''}>${busy ? 'Sending…' : pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
53
+ ? `<button type="button" class="ps-pick" data-ps="pick">${pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
70
54
  : '';
71
55
  return `<button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button>${control}`;
72
56
  }
@@ -82,16 +66,13 @@
82
66
  .ps-switch .pill button.on { background:#fff; color:#000; }
83
67
  .ps-name { flex:0 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
84
68
  .ps-pill .ps-pick { background:#fff; color:#000; margin-left:2px; }
85
- .ps-pill .ps-pick:disabled { opacity:.6; cursor:default; }
86
69
  .ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
87
70
  padding:7px 10px; font:400 14px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
88
71
  .ps-switch .ps-text::placeholder { color:#fffa; }
89
72
  .ps-switch .ps-text[hidden] { display:none; }
90
73
  .ps-picked { margin-left:2px; padding:7px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
91
74
  .ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
92
- .ps-note[hidden] { display:none; }
93
- .ps-embed header.top { display:none; }
94
- .ps-embed main { padding-bottom:12px !important; }`;
75
+ .ps-note[hidden] { display:none; }`;
95
76
 
96
77
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
97
78
 
@@ -101,29 +82,10 @@
101
82
  const qid = params.get('q');
102
83
  const site = globalThis.ClaudeSite;
103
84
  if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
104
- if (params.has('take')) {
105
- let only = startTake(keys, params.get('take'), initial);
106
- document.documentElement.classList.add('ps-embed');
107
- render(only);
108
- const embedded = {
109
- takes: keys,
110
- get take() { return only; },
111
- go(next) {
112
- if (!keys.includes(next) || next === only) return;
113
- only = next;
114
- const u = new URL(location.href); u.searchParams.set('take', only); history.replaceState(history.state, '', u);
115
- render(only);
116
- },
117
- };
118
- return (globalThis.PrototypeSwitcher.current = embedded);
119
- }
120
85
  let take = startTake(keys, params.get('variant'), initial);
121
86
  let question = null;
122
- let topics = [];
123
- // The board's cards, which name their own question topics (ui-questions.js cardTopics).
124
- let cards = [];
125
- // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
126
- // reload stays on the page; only the first load sends an already-picked question on to its card's thread.
87
+ // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
88
+ // page; only the first load sends an already-picked question on to its card's thread.
127
89
  let fromPush = params.get('from') === 'push';
128
90
  if (fromPush) { const u = new URL(location.href); u.searchParams.delete('from'); history.replaceState(null, '', u); }
129
91
 
@@ -135,13 +97,13 @@
135
97
  const pill = box.querySelector('.ps-pill');
136
98
  const noteEl = box.querySelector('.ps-note');
137
99
  const text = box.querySelector('.ps-text');
138
- let busy = false;
139
100
 
140
101
  const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
102
+ const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
141
103
  function paint() {
142
- const state = pickState(question, take);
143
- pill.innerHTML = pillHtml({ name: takes[take], pick: state, busy });
144
- text.hidden = !(state.kind === 'pick' || state.kind === 'change');
104
+ const now = state();
105
+ pill.innerHTML = pillHtml({ name: takes[take], pick: now });
106
+ text.hidden = !(now.kind === 'pick' || now.kind === 'change');
145
107
  }
146
108
  function go(next) {
147
109
  take = next;
@@ -153,14 +115,11 @@
153
115
 
154
116
  async function load() {
155
117
  if (!qid || !site) return;
156
- let board = null;
157
118
  try {
158
- board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
159
- topics = board.ownerTopics;
160
- cards = board.items || [];
119
+ const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
161
120
  // A resolved question is left out of /api/board, and its topic's resolved questions hold it
162
121
  // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
163
- const topic = topics.find((t) => t.questions.includes(qid));
122
+ const topic = board.ownerTopics.find((t) => t.questions.includes(qid));
164
123
  question = board.owner.find((q) => q.id === qid)
165
124
  || (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
166
125
  || null;
@@ -174,37 +133,13 @@
174
133
  if (question && !text.value) text.value = readDraft(qid).note || '';
175
134
  fromPush = false;
176
135
  paint();
177
- return board;
178
136
  }
179
137
 
180
- async function pick() {
181
- const state = pickState(question, take);
182
- if (busy || !(state.kind === 'pick' || state.kind === 'change')) return;
183
- busy = true; paint();
184
- const answered = question;
185
- let ui, pills, name;
186
- try {
187
- ui = await sharedQuestions(site);
188
- pills = ui.topicRow(topics, answered.topic).pills;
189
- const topic = topics.find((t) => t.id === answered.topic);
190
- name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
191
- await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
192
- } catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
193
- try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
194
- text.value = '';
195
- busy = false;
196
- note('Sent. Claude replies on the card.');
197
- const board = await load();
198
- const next = ui.nextQuestion(board?.owner || [], pills, answered);
199
- if (next) {
200
- const u = new URL(site.url(questionUrl(next)), location.href);
201
- if (globalThis.PageBack?.pushed) u.searchParams.set('from', 'push');
202
- replaceWith(u);
203
- } else {
204
- const route = new URL(site.url(''), location.href).pathname.replace(/^\/+|\/+$/g, '');
205
- ui.sentNotice.put(route, `Sent 1 answer${name ? ` to ${name}` : ''}.`);
206
- replaceWith(site.url('./'));
207
- }
138
+ function pick() {
139
+ const now = state();
140
+ if (!(now.kind === 'pick' || now.kind === 'change')) return;
141
+ writeDraft(qid, { ...readDraft(qid), choice: now.option.id, note: text.value.trim() });
142
+ replaceWith(site.url(`questions.html#${encodeURIComponent(qid)}`));
208
143
  }
209
144
  text.addEventListener('keydown', (e) => {
210
145
  if (e.key !== 'Enter' || !(e.metaKey || e.ctrlKey) || e.isComposing) return;
@@ -227,8 +162,8 @@
227
162
 
228
163
  go(take);
229
164
  load();
230
- return (globalThis.PrototypeSwitcher.current = { takes: keys, get take() { return take; }, go });
165
+ return { takes: keys, get take() { return take; }, go };
231
166
  }
232
167
 
233
- globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, answerLabel, pillHtml, mount };
168
+ globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, mount };
234
169
  })();
@@ -57,6 +57,10 @@ window.AppShell = (function () {
57
57
  // A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
58
58
  bootCode = data?.code || null;
59
59
  data ||= read(DATA_KEY);
60
+ // A board shared with someone else, view only (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
61
+ // the hub sends them data.shared, and style.css hides what writes under .shared-view.
62
+ const shared = data?.shared || null;
63
+ document.documentElement.classList.toggle('shared-view', Boolean(shared));
60
64
  sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
61
65
  const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
62
66
  const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
@@ -116,7 +120,9 @@ window.AppShell = (function () {
116
120
  // minute while present. While away the first one goes at once and the moon goes from this page at the same moment
117
121
  // (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
118
122
  let beatAt = 0;
123
+ // A shared person's taps are never the owner's.
119
124
  const beat = () => {
125
+ if (shared) return;
120
126
  const away = Boolean(data?.away);
121
127
  if (!away && Date.now() - beatAt < 60_000) return;
122
128
  beatAt = Date.now();
@@ -182,7 +188,7 @@ window.AppShell = (function () {
182
188
  const page = board ? null : AppViews[view.kind];
183
189
  const root = document.createElement('div');
184
190
  root.className = board ? 'view-board' : page.className;
185
- root.innerHTML = board ? BoardView.markup({ title: siteTitle }) : page.markup(view, { siteTitle });
191
+ root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
186
192
  if (current) current.root.replaceWith(root); else scrim.after(root);
187
193
  const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
188
194
  let v;
@@ -200,11 +206,7 @@ window.AppShell = (function () {
200
206
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
201
207
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
202
208
  scrollTo(0, 0);
203
- // Every other page of a board has the board's + and its sheet too; the hub's own pages aren't a board's.
204
- const plus = !board && boards().has(route);
205
- if (plus) root.insertAdjacentHTML('beforeend', BoardNewTask.markup());
206
209
  v.mount();
207
- if (plus) BoardNewTask.wire(v.B);
208
210
  if (modal) showSettings();
209
211
  }
210
212
 
@@ -58,6 +58,9 @@
58
58
  if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
59
59
  let device = null;
60
60
  let update = null;
61
+ // While Restart waits for the hub: null | {version} | {version, error}, which replaces the hub's upgrade field in the row.
62
+ let restart = null;
63
+ const upgradeShown = () => (restart ? { state: restart.error ? 'updated' : 'restarting', ...restart } : data?.upgrade);
61
64
  let updateHTML = '';
62
65
  const rail = matchMedia(RAIL);
63
66
  const html = document.documentElement;
@@ -70,7 +73,7 @@
70
73
  const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
71
74
  if (!logo) return;
72
75
  const n = othersWaiting(data, here);
73
- logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : data?.upgrade ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
76
+ logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : upgradeShown() ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
74
77
  }
75
78
  // switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
76
79
  let switching = null;
@@ -101,7 +104,7 @@
101
104
  }
102
105
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
103
106
  function paint() {
104
- html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
107
+ html.classList.toggle('sb-update', Boolean(update || upgradeShown()));
105
108
  if (!data) return;
106
109
  const scroll = aside.querySelector('.sb-scroll');
107
110
  const top = painted ? scroll?.scrollTop : undefined;
@@ -115,7 +118,7 @@
115
118
  }
116
119
  const fresh = aside.querySelector('.sb-update');
117
120
  if (held) fresh.replaceWith(held);
118
- const row = updateRow(update, data.upgrade);
121
+ const row = updateRow(update, upgradeShown());
119
122
  if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
120
123
  updateHTML = row;
121
124
  const list = aside.querySelector('.sb-scroll');
@@ -192,14 +195,50 @@
192
195
  if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
193
196
  return;
194
197
  }
198
+ const restartApp = e.target.closest?.('[data-app-restart]');
199
+ if (restartApp) { if (!restartApp.disabled) restartHub(); return; }
195
200
  const upgradeApp = e.target.closest?.('[data-app-upgrade]');
196
201
  if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
197
202
  if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
198
203
  const failed = e.target.closest?.('[data-failed-start]');
199
204
  if (failed) openFailed(failed.dataset.failedStart);
200
205
  });
201
- // Upgrade asks the hub to run kickflip update (docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). The row
202
- // waits (ui.js waitOn) until the hub answers with what it did, then while that update waits or runs (ui-sidebar.js
206
+ // Restart asks the hub to restart (POST /_hub/restart-hub), then polls until it answers with other code than it ran (its
207
+ // x-hub-code) and reloads the page (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md).
208
+ // Past RESTART_TRIES polls the row goes back to Restart with how to restart by hand.
209
+ const RESTART_TRIES = 120;
210
+ async function restartHub() {
211
+ if (restart) return;
212
+ const version = data.upgrade.version;
213
+ restart = { version };
214
+ paint();
215
+ let before, command;
216
+ try {
217
+ const response = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
218
+ const body = await response.json().catch(() => ({}));
219
+ if (!response.ok) throw new Error(body.error || 'The hub didn’t restart.');
220
+ before = response.headers.get('x-hub-code');
221
+ command = body.command;
222
+ } catch (e) {
223
+ restart = { version, error: e.message || 'The hub didn’t answer.' };
224
+ paint();
225
+ restart = null;
226
+ return;
227
+ }
228
+ for (let i = 0; i < RESTART_TRIES; i++) {
229
+ await new Promise((resolve) => setTimeout(resolve, 1000));
230
+ try {
231
+ const response = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
232
+ const code = response.headers.get('x-hub-code');
233
+ if (response.ok && code && code !== before) { reload(); return; }
234
+ } catch { /* the hub is down while it restarts */ }
235
+ }
236
+ restart = { version, error: `The hub hasn’t come back. On the host, run ${command}` };
237
+ paint();
238
+ restart = null;
239
+ }
240
+ // Update pulls again after a failed pull (docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The row
241
+ // waits (ui.js waitOn) until the hub answers with what it did, then while that pull runs (ui-sidebar.js
203
242
  // updateRow), and a refusal shows as a failed update.
204
243
  function upgrade(button) {
205
244
  BoardUI.waitOn(button, (async () => {