@lovek4d/kickflip 0.3.1 → 0.5.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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
@@ -1,18 +1,56 @@
1
1
  // Every confirmation and notice is the browser's own window.confirm or window.alert; no page imitates a native dialog.
2
- // Load before any page script that asks (docs/adr/0146-confirmations-are-the-browsers-own.md).
2
+ // Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
3
3
  (function () {
4
4
  // linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
5
- // while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
6
- // docs/adr/0117-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
5
+ // while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
6
+ // inline (linear-key.js,
7
+ // docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
7
8
  // new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
8
9
  // {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
9
10
  // GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
10
- // remote, it can't be picked and says why (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
11
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject } = {}) {
11
+ // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
12
+ // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
13
+ // as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
14
+ // form, since the key field is a form of its own; keyBusy(on) follows each save of the key
15
+ // (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
16
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
12
17
  const { esc } = BoardUI;
13
18
  const draft = { prefix: 'INT', ...value };
14
- let creating = false, createError = '', destroyed = false, asking = false, noGithub = false;
19
+ let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
15
20
  const works = () => Boolean(linear?.set && !linear.refused);
21
+ const keyWorks = () => Boolean(key?.set && !key.refused);
22
+ const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
23
+ const statusWord = () => (draft.source === 'linear' && keyWorks() ? 'linear · connected' : `${draft.source} · ${keyWord()}`);
24
+ function status() {
25
+ if (!key) return '';
26
+ const on = draft.source === 'linear' && keyWorks();
27
+ return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
28
+ }
29
+ // Hub's key field in keySlot. Once a key works, Linear is picked.
30
+ function openInline() {
31
+ if (!inline) {
32
+ keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
33
+ inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
34
+ saved: (out) => { key = out.linear; render(); },
35
+ worked: async () => {
36
+ closeInline();
37
+ draft.source = 'linear';
38
+ await load();
39
+ if (destroyed) return;
40
+ pickDefaults();
41
+ render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
42
+ } });
43
+ keySlot.hidden = false;
44
+ }
45
+ keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
46
+ }
47
+ function closeInline() {
48
+ if (!inline) return;
49
+ inline.destroy();
50
+ inline = null;
51
+ keySlot.hidden = true;
52
+ keySlot.innerHTML = '';
53
+ }
16
54
  async function load() {
17
55
  linear = { loading: true };
18
56
  render();
@@ -49,7 +87,7 @@
49
87
  const note = draft.project === '__new'
50
88
  ? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
51
89
  : `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
52
- return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? ' disabled' : ''}>${creating ? 'Creating…' : 'Create'}</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
90
+ return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
53
91
  }
54
92
  function render() {
55
93
  const off = !repo();
@@ -60,7 +98,7 @@
60
98
  else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
61
99
  else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
62
100
  else detail = linearDetail();
63
- root.innerHTML = `${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
101
+ root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
64
102
  if (draft.source === 'linear' && ready()) {
65
103
  root.querySelector('[data-tk="team"]').value = draft.team || '';
66
104
  root.querySelector('[data-tk="project"]').value = draft.project || '';
@@ -76,6 +114,9 @@
76
114
  if (!works()) {
77
115
  draft.source = before;
78
116
  render();
117
+ await keyed;
118
+ if (destroyed) return;
119
+ if (key) { openInline(); return; }
79
120
  asking = true;
80
121
  const added = await LinearKey.open();
81
122
  asking = false;
@@ -108,6 +149,7 @@
108
149
  noGithub = button.dataset.tkSource === 'github' && !repo();
109
150
  if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
110
151
  if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
152
+ closeInline();
111
153
  draft.source = button.dataset.tkSource;
112
154
  render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
113
155
  };
@@ -126,11 +168,13 @@
126
168
  root.addEventListener('change', input);
127
169
  render();
128
170
  if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
171
+ // Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
172
+ const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
129
173
  return { read, valid, render, nameChanged() {
130
174
  const field = root.querySelector('[data-tk="newName"]');
131
175
  if (field && draft.newName === undefined) field.value = title();
132
176
  },
133
- destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
177
+ destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
134
178
  }
135
179
  function fitSheet(sheet) {
136
180
  const vv = window.visualViewport;
@@ -193,16 +237,16 @@
193
237
  return d;
194
238
  };
195
239
  const fail = (e) => { if (!closed) { error.textContent = e.message; error.hidden = false; } };
196
- // A repo already on the host joins from its folder instead (docs/adr/0143-kickflip-join-adds-the-current-repository-as-a-board.md).
240
+ // A repo already on the host joins from its folder instead (docs/adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md).
197
241
  const joinHint = current ? '' : '<p class="nb-help" data-join-hint>A repo already on the host running Kickflip? Run <code>kickflip join</code> in its folder.</p>';
198
242
  function sync() { start.disabled = sending || !(draft.github || draft.remote) || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
199
243
  // A repo row's id is its GitHub owner/name, or the git link of a repo on another host, which the row names by
200
244
  // host/path and labels with its host where a GitHub row says Private or Public
201
- // (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
245
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
202
246
  const idOf = (r) => r.github || r.remote;
203
247
  const nameOf = (r) => r.github || r.name;
204
248
  const access = (r) => (r.remote ? r.host : r.private ? 'Private' : 'Public');
205
- const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ In .repos${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned in .repos'}</span></button>`;
249
+ const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ Cloned${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned yet'}</span></button>`;
206
250
  const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
207
251
  function rows(query = '') {
208
252
  if (!signedIn) { results(''); return; }
@@ -278,7 +322,7 @@
278
322
  if (!edited) draft.name = nameOf(selected).split('/').at(-1);
279
323
  error.hidden = true;
280
324
  const shared = selected.shared.map((b) => b.title).join(', ');
281
- content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned in .repos' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
325
+ content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
282
326
  if (!current) {
283
327
  content.querySelectorAll('.nb-next')[content.querySelectorAll('.nb-next').length - 1].insertAdjacentHTML('beforebegin', '<div class="nb-field tk-section" role="group" aria-labelledby="nbTicketsLabel"><span id="nbTicketsLabel">Tickets</span><span class="nb-help">Where this board’s cards get their tickets. You can change it in Board settings.</span><div data-board-tickets></div></div>');
284
328
  tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, linear, loadLinear: async () => (linear = await request('/_hub/api/linear')),
@@ -327,7 +371,8 @@
327
371
  error.hidden = true;
328
372
  try {
329
373
  const repo = draft.remote ? { remote: draft.remote } : { github: draft.github };
330
- await save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected);
374
+ // Start, or Use repo, waits (ui.js waitOn) until the hub answers; a new board's own start then shows in the sidebar.
375
+ await BoardUI.waitOn(start, save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected));
331
376
  sending = false;
332
377
  el.close();
333
378
  } catch (e) { sending = false; fail(e); sync(); }
@@ -52,6 +52,8 @@
52
52
  // the hub's answer, fail(message) a refusal. A checkbox marked data-submit saves on change, and a time input marked
53
53
  // data-autosave once its picker closes, so turning an iOS wheel doesn't save each value it passes. accepts(action, form)
54
54
  // limits it to some of those forms, since the Hub page and its hub settings each wire their own over one root.
55
+ // While a post is out its control waits (ui.js waitOn): the tapped button, or else the form's toggle or field, with
56
+ // the form's other buttons, the setting's other choices, disabled.
55
57
  // → {busy()}, true while a post is out
56
58
  function forms(B, { answer, fail, accepts = () => true }) {
57
59
  let sending = false;
@@ -65,9 +67,10 @@
65
67
  const body = new URLSearchParams(new FormData(form));
66
68
  if (e.submitter?.name) body.append(e.submitter.name, e.submitter.value);
67
69
  sending = true;
70
+ const post = fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
71
+ .then(async (r) => [r, await r.json().catch(() => null)]);
68
72
  try {
69
- const r = await fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
70
- const out = await r.json().catch(() => null);
73
+ const [r, out] = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"]), select'), post, { group: form.querySelectorAll('button') });
71
74
  if (!B.alive()) return;
72
75
  if (r.ok && out) answer(out, action); else fail(out?.error || FAILED);
73
76
  } catch {
@@ -106,7 +109,7 @@
106
109
  const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
107
110
  // The endpoint this device last gave the hub, kept in this browser, so that once the subscription goes (the
108
111
  // browser dropped or replaced it, or notifications were blocked) the page tells the hub to forget it
109
- // (docs/adr/0149-the-hub-reads-healthy-degraded-or-a-site-down.md).
112
+ // (docs/adr/2026-11-03-0501-the-hub-reads-healthy-degraded-or-a-site-down.md).
110
113
  const KEPT = 'kickflip.push.endpoint';
111
114
  const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
112
115
  const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
@@ -138,7 +141,7 @@
138
141
  }
139
142
  async function enable() {
140
143
  busy = true;
141
- show({ ...state, action: { ...state.action, disabled: true } }, on);
144
+ show({ ...state, action: { ...state.action, busy: true } }, on);
142
145
  try {
143
146
  if (await Notification.requestPermission() !== 'granted') { await render(); return; }
144
147
  const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
@@ -152,9 +155,9 @@
152
155
  finally { busy = false; }
153
156
  }
154
157
  async function test() {
155
- show({ ...state, action: { ...state.action, disabled: true } }, true);
158
+ show({ ...state, action: { ...state.action, busy: true } }, true);
156
159
  const r = await post('/_hub/push/test').catch(() => null);
157
- show({ ...state, action: { ...state.action, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
160
+ show({ ...state, action: { ...state.action, busy: false, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
158
161
  }
159
162
  B.on(B.root, 'click', (e) => {
160
163
  const act = e.target.closest?.('[data-device]')?.dataset.device;
@@ -1,4 +1,4 @@
1
- /* The hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), whose root carries .hub-view, and the hub's
1
+ /* The hub page and settings modals (view-hub.js, view-hub-settings.js), whose root carries .hub-view, and the hub's
2
2
  down page (hub/hub.mjs), whose <html> does, and the board's Hold sheet picker (ui-cards.js holdPicker), which shares the
3
3
  duration picker's rules; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
4
4
  .hub-view, since style.css uses the same short class names. The app shell links it after style.css, so a rule here
@@ -47,22 +47,7 @@
47
47
  .hub-view .chips form { display:contents; }
48
48
  .hub-view .chips form.field { display:inline-flex; gap:6px; }
49
49
  .hub-view .chips .field input { min-height:0; padding:.2rem .5rem; width:7.5rem; }
50
- .hub-view .split { display:flex; gap:8px; justify-content:flex-end; }
51
- .hub-view .day { position:relative; padding:18px 0 20px; }
52
- .hub-view .day .bar { position:relative; height:40px; border-radius:8px; overflow:hidden; background:var(--chip); }
53
- .hub-view .day .seg { position:absolute; top:0; bottom:0; display:grid; place-items:center; overflow:hidden; border-right:2px solid var(--panel);
54
- background:color-mix(in srgb, var(--muted) var(--k), var(--chip)); }
55
- .hub-view .day .seg.held { background-image:repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--text) 22%, transparent) 5px 8px); }
56
- .hub-view .day .seg.sel { box-shadow:inset 0 0 0 3px var(--text); z-index:2; }
57
- .hub-view .day .seg span { font-size:11px; font-weight:600; line-height:1.5; color:var(--text); background:color-mix(in srgb, var(--panel) 85%, transparent); border-radius:4px; padding:0 4px; }
58
- .hub-view .day .pin { position:absolute; top:2px; transform:translateX(-50%); color:var(--color-notice); font-size:11px; line-height:1; }
59
- .hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--text); z-index:3; pointer-events:none; }
60
- .hub-view .day .ticks { position:absolute; left:0; right:0; bottom:0; height:16px; font-size:10px; color:var(--muted); }
61
- .hub-view .day .ticks span { position:absolute; transform:translateX(-50%); }
62
- .hub-view .day .ticks span:first-child { transform:none; } .hub-view .day .ticks span:last-child { transform:translateX(-100%); }
63
- .hub-view .legend { display:flex; gap:4px 12px; flex-wrap:wrap; font-size:11px; color:var(--muted); }
64
- .hub-view .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:-1px; margin-right:4px; }
65
- .hub-view #day, .hub-view #window, .hub-view #quiet, .hub-view #wakes { scroll-margin-top:64px; }
50
+ .hub-view .quiet-hint { margin:6px 14px 0; color:var(--muted); font-size:12px; }
66
51
  .hub-view .field input { flex:none; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:.3rem .6rem; min-height:2.2rem; width:8.75rem; min-width:0; text-align:right; }
67
52
  .hub-view .field input[type=number] { width:5rem; }
68
53
  .hub-view .field input.toggle { width:1.4rem; height:1.4rem; min-height:0; padding:0; accent-color:var(--accent); }
@@ -102,6 +87,7 @@
102
87
  .hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
103
88
  .hub-view .remembered p { margin:0; }
104
89
  .hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
90
+ .hub-view :is(.hub-modal, .sheet) code { white-space: pre-wrap; overflow-wrap: anywhere; }
105
91
  .hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
106
92
 
107
93
  @media (max-width: 899px) {
@@ -167,4 +153,6 @@
167
153
  .hub-view .rd-fix button.link { padding: 0; font-size: inherit; }
168
154
  .hub-view .site-row > .site-link { flex:1; min-width:0; display:flex; align-items:center; gap:10px; color:inherit; }
169
155
  .hub-view .restart-btn { min-width:76px; min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
170
- .hub-view .restart-btn .try-spin { width:9px; height:9px; }
156
+ /* A waiting button keeps its own look (style.css button[aria-busy="true"]), over .btn's and a step's disabled dimming. */
157
+ .hub-view button[aria-busy="true"]:disabled { opacity:1; }
158
+ .hub-view .ub-step button[aria-busy="true"]:disabled { color:var(--text); }
@@ -1,6 +1,6 @@
1
1
  // Hub's Linear key slot. Hub settings › Linear mounts it, and the Tickets control (dialogs.js) opens it over Board
2
2
  // settings or New board when Linear is tapped without a working key
3
- // (docs/adr/0117-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
3
+ // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
4
4
  // POST /_hub/settings/linear, which checks it with Linear; linear is hub/views.mjs settingsView's `linear`.
5
5
  (function () {
6
6
  const FAILED = 'Could not save. Is the host awake?';
@@ -68,7 +68,7 @@
68
68
  };
69
69
  const onClick = async (e) => {
70
70
  if (!e.target.closest?.('[data-linear-remove]') || pending) return;
71
- if (await BoardDialogs.confirm('Boards on Linear can’t create issues or move tickets until you add a key again. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
71
+ if (await BoardDialogs.confirm('Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
72
72
  };
73
73
  const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
74
74
  for (const [type, fn] of listeners) root.addEventListener(type, fn);
@@ -82,11 +82,13 @@
82
82
  };
83
83
  }
84
84
 
85
- // Done's spinner while a save is out (style.css .settings-footer [aria-busy]).
85
+ // The footer's Done waits, disabled with a spinner in place of its word (style.css button[aria-busy="true"]), while a
86
+ // save is out; ✕, Escape and the backdrop close once it lands.
86
87
  function doneBusy(buttons, busy) {
87
88
  for (const b of buttons) {
89
+ if (!b.closest('.settings-footer')) continue;
90
+ b.disabled = busy;
88
91
  if (busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
89
- if (b.closest('.settings-footer')) b.innerHTML = `${busy ? SPIN : ''}Done`;
90
92
  }
91
93
  }
92
94
 
@@ -0,0 +1,48 @@
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>Log</title>
7
+ <link rel="stylesheet" href="colors.css">
8
+ <link rel="stylesheet" href="style.css">
9
+ </head>
10
+ <body>
11
+ <!--header {"lead":"back","title":"Log","titleId":"ref","backHref":"./","controls":false}-->
12
+ <main>
13
+ <p class="muted" id="note"></p>
14
+ <pre class="log" id="log">Loading the log…</pre>
15
+ </main>
16
+ <style>
17
+ /* The tail of a builder's log (GET /api/log), newest at the bottom. */
18
+ pre.log { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
19
+ </style>
20
+ <script>
21
+ // Shows the tail of the log named by ?file=, last lines in view, and re-reads it every 3 seconds while it grows;
22
+ // the page stays where the reader scrolled to when they've left the bottom.
23
+ (function () {
24
+ const name = new URLSearchParams(location.search).get('file') || '';
25
+ const log = document.getElementById('log');
26
+ const note = document.getElementById('note');
27
+ document.getElementById('ref').textContent = name || 'Log';
28
+ document.title = name || 'Log';
29
+ let size = -1;
30
+ async function read() {
31
+ try {
32
+ const r = await fetch('api/log?file=' + encodeURIComponent(name), { cache: 'no-store' });
33
+ const body = await r.json();
34
+ if (!r.ok) { log.textContent = body.error || 'Could not load this log.'; return; }
35
+ if (body.size === size) return;
36
+ const atEnd = size === -1 || innerHeight + scrollY >= document.documentElement.scrollHeight - 40;
37
+ size = body.size;
38
+ log.textContent = body.text || 'The log is empty so far.';
39
+ note.textContent = body.truncated ? 'Showing the last 256 KiB.' : '';
40
+ if (atEnd) scrollTo(0, document.documentElement.scrollHeight);
41
+ } catch { /* the next read tries again */ }
42
+ }
43
+ read();
44
+ setInterval(read, 3000);
45
+ })();
46
+ </script>
47
+ </body>
48
+ </html>
@@ -0,0 +1,121 @@
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.
5
+ globalThis.BoardNewTask = (function () {
6
+ 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
+ <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
8
+ <form id="askForm" data-composer="ask">
9
+ <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
10
+ ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
11
+ <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>
18
+ </form>
19
+ </dialog>`;
20
+
21
+ function wire(B) {
22
+ const { $, store, toast, quieted, sendDraft } = B;
23
+ const { attachButton, waitOn } = BoardUI;
24
+ const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
25
+ // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
26
+ const sheet = $('#sheet');
27
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
28
+ if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
29
+ // Files picked before the app shell switched away from this board are still attached, so they show again.
30
+ 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.
34
+ B.on($('#askText'), 'keydown', (ev) => {
35
+ if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
36
+ ev.preventDefault();
37
+ $('#askForm').requestSubmit();
38
+ });
39
+ $('#askText').value = store.get('draft:ask', '');
40
+ B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
41
+ let stopFitting = null;
42
+ const stopFit = () => { stopFitting?.(); stopFitting = null; };
43
+ B.on(sheet, 'close', stopFit);
44
+ B.atUnmount(stopFit);
45
+ function openSheet() {
46
+ if (sheet.open) return;
47
+ stopFit();
48
+ stopFitting = BoardDialogs.fitSheet(sheet);
49
+ sheet.classList.remove('closing');
50
+ sheet.showModal();
51
+ $('#askText').focus();
52
+ $('#compose').setAttribute('aria-expanded', 'true');
53
+ }
54
+ function closeSheet() {
55
+ if (!sheet.open || sheet.classList.contains('closing')) return;
56
+ $('#compose').setAttribute('aria-expanded', 'false');
57
+ if (reduceMotion.matches) { sheet.close(); return; }
58
+ sheet.classList.add('closing');
59
+ B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
60
+ }
61
+ B.on($('#compose'), 'click', openSheet);
62
+ // n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
63
+ // nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
64
+ // own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
65
+ // defaultPrevented and doesn't count.
66
+ const TWICE_MS = 500;
67
+ let lastN = -Infinity;
68
+ const newTaskKey = (ev) => {
69
+ if (ev.defaultPrevented) { lastN = -Infinity; return; }
70
+ if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
71
+ const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
72
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
73
+ if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
74
+ lastN = -Infinity;
75
+ ev.preventDefault();
76
+ openSheet();
77
+ };
78
+ B.newTaskKey = newTaskKey;
79
+ B.on(document, 'keydown', newTaskKey);
80
+ B.on($('#sheetClose'), 'click', closeSheet);
81
+ B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
82
+ 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
+ });
102
+ B.on($('#askForm'), 'submit', async (ev) => {
103
+ ev.preventDefault();
104
+ $('#askText').blur();
105
+ const text = $('#askText').value.trim();
106
+ const files = BoardAttach.files('ask', B.site.root);
107
+ if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
108
+ $('#askStatus').textContent = '';
109
+ try {
110
+ await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
111
+ BoardAttach.clear('ask', B.site.root);
112
+ choose(false);
113
+ $('#askStatus').textContent = '';
114
+ closeSheet();
115
+ toast('Sent');
116
+ } catch { $('#askStatus').textContent = 'Could not send'; }
117
+ });
118
+ }
119
+
120
+ return { markup, wire };
121
+ })();
@@ -1,5 +1,5 @@
1
1
  // Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
2
- // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/0060-background-sessions-prompts-come-to-the-phone.md).
2
+ // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
3
3
  // Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
4
4
  // since it covers every later call like it until the session ends. Several questions, or one taking several picks,
5
5
  // send with Send once each has a pick.
@@ -8,13 +8,14 @@ globalThis.PromptAnswers = (function () {
8
8
  // short message; after() runs once an answer has gone. → {state(p) for promptItem, shows(p)}
9
9
  function wire(B, { list, paint, notify, after = () => {} }) {
10
10
  const picks = new Map();
11
- const sending = new Set();
11
+ // Each prompt whose answer is out, to the button tapped (ui-prompts.js promptItem's sending).
12
+ const sending = new Map();
12
13
  // Answered here, hidden until the view's next load drops them.
13
14
  const answered = new Set();
14
15
  const picksOf = (id) => { if (!picks.has(id)) picks.set(id, {}); return picks.get(id); };
15
16
 
16
- async function send(p, body) {
17
- sending.add(p.id);
17
+ async function send(p, body, tapped) {
18
+ sending.set(p.id, tapped);
18
19
  paint();
19
20
  try {
20
21
  const r = await fetch(`/_hub/prompts/${encodeURIComponent(p.id)}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
@@ -40,23 +41,23 @@ globalThis.PromptAnswers = (function () {
40
41
  const got = mine[i] || [];
41
42
  const label = opt.dataset.promptOpt;
42
43
  mine[i] = q.multiSelect ? (got.includes(label) ? got.filter((l) => l !== label) : [...got, label]) : [label];
43
- if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine));
44
+ if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine), `${i}:${label}`);
44
45
  else paint();
45
46
  return;
46
47
  }
47
48
  if (ev.target.closest('button[data-prompt-send]')) {
48
49
  const body = BoardUI.promptAnswer(p, picksOf(p.id));
49
- if (body) send(p, body);
50
+ if (body) send(p, body, 'send');
50
51
  return;
51
52
  }
52
53
  const choice = ev.target.closest('button[data-prompt-choice]')?.dataset.promptChoice;
53
54
  if (!choice) return;
54
55
  if (choice === 'always' && !(await BoardDialogs.confirm(`Always allow ${p.rule} for the rest of this session?`))) return;
55
- send(p, { choice });
56
+ send(p, { choice }, choice);
56
57
  });
57
58
 
58
59
  return {
59
- state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.has(p.id) }),
60
+ state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.get(p.id) ?? null }),
60
61
  shows: (p) => !answered.has(p.id),
61
62
  };
62
63
  }
@@ -1,5 +1,5 @@
1
1
  // What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
2
- // with board.mjs prototype add (docs/adr/0089-the-board-serves-every-prototype-from-one-template.md). A take file,
2
+ // with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
3
3
  // public/prototypes/<topic>/<KEY>.js, is a module:
4
4
  // export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
5
5
  // render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
@@ -39,13 +39,13 @@
39
39
  ...(claude === null ? {} : { claude: reading(...[claude, 61].flat().slice(0, 2), 2 * 60_000, 3.4, 2.6) }),
40
40
  ...(codex === null ? {} : { codex: reading(...[codex, 40].flat().slice(0, 2), 6 * 60_000, 1.2, 4.1) }),
41
41
  }),
42
- sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
42
+ sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
43
43
  hub: () => ({
44
44
  usage: fake.usage(),
45
- hub: { tone: 'ok', word: 'Healthy', detail: '3 of 3 sites up' },
45
+ hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
46
46
  sites: fake.sites(),
47
- polling: { title: 'Checks every 15 min until 6:00pm', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
48
- settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['polling', 'Polling', '3 windows, 52 checks a day · held overnight'],
47
+ polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
48
+ settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
49
49
  ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
50
50
  }),
51
51
  items: () => [
@@ -63,7 +63,7 @@
63
63
  function hub(data = {}) {
64
64
  const d = { ...fake.hub(), header: true, ...data };
65
65
  const { row, tile, nowRow, CHEVRON } = HubUI;
66
- const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">Up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
66
+ const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
67
67
  const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
68
68
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
69
69
  const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
@@ -1,4 +1,4 @@
1
- // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/0015-prototypes-come-on-request.md).
1
+ // The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
2
2
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
3
3
  // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
4
4
  // render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
@@ -8,7 +8,7 @@
8
8
  // PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
9
9
  // to switch its full-width frame without a reload (view-questions.js inPlace).
10
10
  // With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
11
- // (docs/adr/0048-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
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
12
  // bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
13
13
  // to the page's height.
14
14
  (function () {
@@ -120,6 +120,8 @@
120
120
  let take = startTake(keys, params.get('variant'), initial);
121
121
  let question = null;
122
122
  let topics = [];
123
+ // The board's cards, which name their own question topics (ui-questions.js cardTopics).
124
+ let cards = [];
123
125
  // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
124
126
  // reload stays on the page; only the first load sends an already-picked question on to its card's thread.
125
127
  let fromPush = params.get('from') === 'push';
@@ -154,8 +156,14 @@
154
156
  let board = null;
155
157
  try {
156
158
  board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
157
- question = (board.owner || []).find((q) => q.id === qid) || null;
158
- topics = board.ownerTopics || [];
159
+ topics = board.ownerTopics;
160
+ cards = board.items || [];
161
+ // A resolved question is left out of /api/board, and its topic's resolved questions hold it
162
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
163
+ const topic = topics.find((t) => t.questions.includes(qid));
164
+ question = board.owner.find((q) => q.id === qid)
165
+ || (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
166
+ || null;
159
167
  if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
160
168
  } catch { /* keep the last question shown */ }
161
169
  if (fromPush && pickedChoice(question)) {
@@ -179,7 +187,7 @@
179
187
  ui = await sharedQuestions(site);
180
188
  pills = ui.topicRow(topics, answered.topic).pills;
181
189
  const topic = topics.find((t) => t.id === answered.topic);
182
- name = topic ? ui.topicName(topic) : '';
190
+ name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
183
191
  await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
184
192
  } catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
185
193
  try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }