@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,7 +1,12 @@
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
3
  const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
4
+ const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
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;
9
+ const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
5
10
 
6
11
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
12
 
@@ -48,25 +53,43 @@ BoardView.part((B) => {
48
53
  const edits = editsIn(key);
49
54
  if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
50
55
  const view = viewOf(key);
51
- const sig = JSON.stringify([msgs, [...edits.keys()], view]);
56
+ const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
57
+ const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
58
+ const sig = JSON.stringify([lines, [...edits.keys()], view]);
52
59
  if (el.dataset.sig === sig) return;
53
60
  el.dataset.sig = sig;
54
61
  // The reply form is built once per card, so a refresh never drops what is being typed.
55
62
  if (!el.querySelector('.msgs')) {
56
63
  const draft = store.get('draft:' + key, '');
57
- el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"><textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>
64
+ 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>`)}
65
+ <div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
58
66
  <div class="pending-files" data-files></div>
59
- <div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
67
+ <div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
60
68
  BoardAttach.paint('item:' + key, B.site.root);
69
+ syncFlag(el.querySelector('form'));
61
70
  }
62
71
  syncDone(el, key);
63
- B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
72
+ B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
73
+ for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
64
74
  B.flashChanged?.(el);
65
- // A thread's request is answered by a later thread line, so only a pending one changes the eye.
66
- const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
67
- const proto = req && (req.state === 'waiting' || req.state === 'working') ? { pending: req.eventId } : null;
68
- const pb = el.querySelector('form [data-proto]');
69
- if (pb && pb.dataset.protoPending !== proto?.pending) pb.outerHTML = protoButton(key, proto);
75
+ }
76
+
77
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
78
+ // The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
79
+ // with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
80
+ const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
81
+ const HINT = 'Tap the eye or the fire to request a prototype or a grill';
82
+ const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
83
+ function syncFlag(f) {
84
+ const chosen = f.dataset.flag || null;
85
+ f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
86
+ f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
87
+ const hint = f.querySelector('[data-hint]');
88
+ if (!hint) return;
89
+ const flag = flagOf(f);
90
+ const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
91
+ hint.classList.toggle('on', Boolean(flag));
92
+ hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
70
93
  }
71
94
 
72
95
  // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
@@ -98,40 +121,25 @@ BoardView.part((B) => {
98
121
 
99
122
  B.on(B.root, 'input', (ev) => {
100
123
  const f = ev.target.closest('form[data-reply]');
101
- if (!f || ev.target.tagName !== 'TEXTAREA' || quieted('draft:' + f.dataset.reply, ev.target)) return;
124
+ if (!f || ev.target.tagName !== 'TEXTAREA') return;
125
+ syncFlag(f);
126
+ if (quieted('draft:' + f.dataset.reply, ev.target)) return;
102
127
  store.set('draft:' + f.dataset.reply, ev.target.value);
103
128
  });
104
129
  B.on(B.root, 'click', async (ev) => {
105
130
  const b = ev.target.closest('form[data-reply] button[data-quick]');
106
131
  if (!b) return;
107
132
  const key = b.closest('form').dataset.reply;
108
- b.disabled = true;
109
- try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
133
+ try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
110
134
  catch { toast('Could not send'); }
111
- b.disabled = false;
112
135
  });
113
- B.on(B.root, 'click', async (ev) => {
114
- const b = ev.target.closest('form[data-reply] button[data-proto]');
136
+ B.on(B.root, 'click', (ev) => {
137
+ const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
115
138
  if (!b) return;
116
- const key = b.dataset.proto;
117
- const pending = b.dataset.protoPending;
118
- if (pending) {
119
- let latest = null;
120
- try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
121
- const n = protoNotice(pending, latest);
122
- if (!n.bump) { B.notice(n.text); return; }
123
- if (!(await B.confirm(n.text))) return;
124
- await B.bumpAsk(pending);
125
- return;
126
- }
127
- if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
128
- b.disabled = true;
129
- try {
130
- await B.sentHere(key, () => B.send('prototype-request', { key }));
131
- b.title = b.ariaLabel = 'Prototypes asked for';
132
- toast('Asked Claude for prototypes');
133
- loadThread(key, { force: true });
134
- } catch { b.disabled = false; toast('Could not send'); }
139
+ const f = b.closest('form');
140
+ const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
141
+ if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
142
+ syncFlag(f);
135
143
  });
136
144
  B.on(B.root, 'submit', async (ev) => {
137
145
  const f = ev.target.closest('form[data-reply]');
@@ -139,24 +147,27 @@ BoardView.part((B) => {
139
147
  ev.preventDefault();
140
148
  const ta = f.querySelector('textarea');
141
149
  ta.blur();
142
- const text = ta.value.trim();
150
+ const flag = flagOf(f);
151
+ const text = flag ? slashOf(ta.value).text : ta.value.trim();
143
152
  const id = f.dataset.composer;
144
153
  const files = BoardAttach.files(id, B.site.root);
145
- if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
154
+ if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
146
155
  const key = f.dataset.reply;
147
- const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
156
+ const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
148
157
  try {
149
- await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
150
- () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) })));
158
+ await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
159
+ () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
151
160
  BoardAttach.clear(id, B.site.root);
152
- btn.disabled = false;
161
+ delete f.dataset.flag;
162
+ syncFlag(f);
153
163
  loadThread(f.dataset.reply, { force: true });
154
- } catch { B.notice('Could not send. Is the host awake?'); btn.disabled = false; }
164
+ } catch { B.notice('Could not send. Is the host awake?'); }
155
165
  });
156
166
  // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
157
167
  B.on(B.root, 'attach-change', (ev) => {
158
168
  const b = ev.target.querySelector('button[type=submit]');
159
169
  if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
170
+ if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
160
171
  });
161
172
 
162
173
  Object.assign(B, {
@@ -41,7 +41,9 @@ window.BoardBoot = (function () {
41
41
  <script src="/_hub/app/ui.js"></script>
42
42
  <script src="/_hub/app/ui-links.js"></script>
43
43
  <script src="/_hub/app/ui-enums.js"></script>
44
+ <script src="/_hub/app/ui-phase-bars.js"></script>
44
45
  <script src="/_hub/app/ui-order.js"></script>
46
+ <script src="/_hub/app/ui-patch.js"></script>
45
47
  <script src="/_hub/app/ui-threads.js"></script>
46
48
  <script src="/_hub/app/ui-cards.js"></script>
47
49
  <script src="/_hub/app/ui-header.js"></script>
@@ -62,6 +64,7 @@ window.BoardBoot = (function () {
62
64
  <script src="/_hub/app/linear-key.js"></script>
63
65
  <script src="/_hub/app/sidebar.js"></script>
64
66
  <script src="/_hub/app/app.js"></script>
67
+ <script src="/_hub/app/new-task.js"></script>
65
68
  <script src="/_hub/app/app-core.js"></script>
66
69
  <script src="/_hub/app/app-threads.js"></script>
67
70
  <script src="/_hub/app/app-cards.js"></script>
@@ -70,6 +73,7 @@ window.BoardBoot = (function () {
70
73
  <script src="/_hub/app/app-needs.js"></script>
71
74
  <script src="/_hub/app/app-header.js"></script>
72
75
  <script src="/_hub/app/app-arch.js"></script>
76
+ <script src="/_hub/app/app-reader.js"></script>
73
77
  <script src="/_hub/app/app-banner.js"></script>
74
78
  <script src="/_hub/app/app-snapshot.js"></script>
75
79
  <script src="/_hub/app/view-settings.js"></script>
@@ -77,7 +81,6 @@ window.BoardBoot = (function () {
77
81
  <script src="/_hub/app/view-notes.js"></script>
78
82
  <script src="/_hub/app/view-hub.js"></script>
79
83
  <script src="/_hub/app/view-hub-settings.js"></script>
80
- <script src="/_hub/app/view-polling.js"></script>
81
84
  <script src="/_hub/app/view-frame.js"></script>
82
85
  <script src="/_hub/app/router.js"></script>
83
86
  <script src="/_hub/app/shell.js"></script>
@@ -86,7 +86,26 @@ window.BoardView = (function () {
86
86
  }
87
87
  toasts.toast(text);
88
88
  };
89
- const B = { root, site, route, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
89
+ const store = storeFor(route);
90
+ // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
91
+ // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
92
+ const quiet = new Map();
93
+ function quieted(draftKey, ta) {
94
+ const until = quiet.get(draftKey) || 0;
95
+ if (until <= Date.now()) return false;
96
+ if (until !== Infinity) ta.value = '';
97
+ return true;
98
+ }
99
+ async function sendDraft(draftKey, ta, current, send) {
100
+ ta.blur();
101
+ quiet.set(draftKey, Infinity);
102
+ try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
103
+ store.set(draftKey, '');
104
+ const now = current();
105
+ if (now) now.value = '';
106
+ quiet.set(draftKey, Date.now() + 1000);
107
+ }
108
+ const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
90
109
  return {
91
110
  B,
92
111
  start() { alive = true; },
@@ -100,7 +119,7 @@ window.BoardView = (function () {
100
119
 
101
120
  function create(root, siteRoot, opts = {}) {
102
121
  const { B, start: begin, stop } = base(root, siteRoot);
103
- const store = storeFor(B.route);
122
+ const { store } = B;
104
123
  // board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
105
124
  // header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
106
125
  // {thread, data, text}, data being what ask-edited sends besides the text.
@@ -109,10 +128,13 @@ window.BoardView = (function () {
109
128
  fresh: null,
110
129
  openItems: new Set(store.get('openItems', [])),
111
130
  threadCache: new Map(),
112
- withdrawnAsks: new Set(),
113
131
  edits: new Map(),
114
132
  githubPending: null,
115
- waveFilter: store.get('wave', null),
133
+ // Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
134
+ mainPull: { sending: false, open: false },
135
+ // The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
136
+ // the archive holds past them, null before the first page.
137
+ archived: { cards: [], left: null },
116
138
  };
117
139
  const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
118
140
  // B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
@@ -120,7 +142,7 @@ window.BoardView = (function () {
120
142
  const keep = (fn) => { if (B.alive()) keepers.push(fn); };
121
143
  const place = places.get(B.route) || null;
122
144
  places.delete(B.route);
123
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, place, keep });
145
+ Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
124
146
 
125
147
  function mount() {
126
148
  if (B.alive()) return;
@@ -155,38 +177,40 @@ window.BoardView = (function () {
155
177
  // Renders open threads from state.threadCache; loadBoard and opening a card load them.
156
178
  function render(load = null) {
157
179
  const d = state.board;
180
+ BoardUI.pending.settle(d);
158
181
  const title = $(':scope > .top h1');
159
182
  if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
160
183
  if (d.title && document.title !== d.title) document.title = d.title;
161
184
  steady(() => {
162
185
  B.renderHeader(d);
163
186
  B.renderArch(d);
164
- B.renderWaves(d);
165
- BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
187
+ BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
166
188
  B.renderMainLog(d.main, load);
167
- const live = d.items.filter((i) => !state.withdrawnAsks.has(i.id));
168
- const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter || i.lineup?.slug === state.waveFilter) : live;
169
189
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
170
190
  for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
171
191
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
172
- setHTML($('#lanes'), B.lanesHtml(d, shown), { keep: '[data-thread]' });
192
+ const strips = B.captureStrips();
193
+ setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
173
194
  for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
195
+ B.moveStrips(d.items, strips);
174
196
  B.renderNeeds(d);
175
197
  B.renderBanner(d);
176
198
  });
177
- paintFades();
178
199
  B.flashChanged();
179
200
  B.signCards(load);
180
201
  }
181
202
 
203
+ // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
182
204
  // #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
183
- // (docs/adr/0036-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
205
+ // (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
184
206
  let focusKey = null;
185
207
  let replyKey = null;
186
208
  let hashRead = false;
187
209
  // A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
188
- const returnToPlace = () => B.restorePlace({ linked: /^#item=/.test(location.hash) });
210
+ const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
189
211
  function readHash() {
212
+ const review = /^#review=(\d+)$/.exec(location.hash);
213
+ if (review && state.board) { B.openReview(Number(review[1])); return; }
190
214
  const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
191
215
  if (!m) return;
192
216
  focusKey = decodeURIComponent(m[1]);
@@ -194,7 +218,7 @@ window.BoardView = (function () {
194
218
  replyKey = m[2] ? focusKey : null;
195
219
  state.openItems.add(focusKey);
196
220
  const it = state.board?.items.find((i) => i.key === focusKey);
197
- if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); }
221
+ if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
198
222
  if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
199
223
  }
200
224
  B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
@@ -228,13 +252,13 @@ window.BoardView = (function () {
228
252
  }
229
253
 
230
254
  // The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
231
- // spinning, and the header's time is the snapshot's until a load lands (docs/adr/0028-the-board-opens-on-the-last-visit.md).
255
+ // 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).
232
256
  // → true when it painted live (B.live), so the controls turn on at once.
233
257
  function paintSnapshot() {
234
258
  const snap = B.readSnapshot();
235
259
  if (!snap) return false;
236
260
  const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
237
- served = state.board = snap.board;
261
+ state.board = snap.board;
238
262
  for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
239
263
  state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
240
264
  B.setView(live ? 'fresh' : 'snapshot');
@@ -262,13 +286,17 @@ window.BoardView = (function () {
262
286
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
263
287
  let landed = false;
264
288
  try {
265
- const board = await (started?.board || fetch(B.site.url('api/board'), { cache: 'no-store' })
266
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
267
- land(board, load);
289
+ // A reload names the version of the board on screen: an unchanged board answers 304 with no body
290
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch from it
291
+ // (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md).
292
+ const tag = state.view === 'fresh' ? state.board.version : null;
293
+ const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
294
+ .then((r) => (r.status === 304 ? null : r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
295
+ if (board) land(board, load); else B.linkUp();
268
296
  landed = true;
269
297
  if (!preloaded) {
270
298
  preloaded = true;
271
- for (const key of preloadKeys(board.items, state.openItems)) B.loadThread(key);
299
+ for (const key of preloadKeys(state.board.items, state.openItems)) B.loadThread(key);
272
300
  }
273
301
  } catch (e) {
274
302
  B.linkDown();
@@ -278,16 +306,15 @@ window.BoardView = (function () {
278
306
  if (state.view === 'fresh') B.endFlash();
279
307
  }
280
308
 
281
- // A board from the server, a load's or an events POST's, with the taps still in flight drawn over it. One built
282
- // before the board on screen leaves it as it is, so a slow load can't undo a newer one. → whether it landed
283
- let served = null;
284
- const drawing = new Set();
285
- const drawn = () => [...drawing].reduce((board, draw) => draw(board), served);
286
- function land(board, load) {
287
- if (state.view === 'fresh' && served && board.generatedAt < served.generatedAt) return false;
309
+ // 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
310
+ // 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
311
+ // no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
312
+ function land(answer, load) {
313
+ if (state.view === 'fresh' && state.board && answer.generatedAt < state.board.generatedAt) return false;
314
+ const board = answer.since === undefined ? answer : BoardUI.applyPatch(state.view === 'fresh' ? state.board : null, answer);
315
+ if (!board) { signalled(); return false; }
288
316
  const first = state.view !== 'fresh';
289
- served = board;
290
- state.board = drawn();
317
+ state.board = board;
291
318
  B.setView('fresh');
292
319
  if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
293
320
  B.linkUp();
@@ -298,61 +325,23 @@ window.BoardView = (function () {
298
325
  B.saveSnapshotSoon();
299
326
  return true;
300
327
  }
301
- function undraw(draw) {
302
- drawing.delete(draw);
303
- state.board = drawn();
304
- render();
305
- }
306
328
 
307
- // An owner tap (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md): the events POST answers with
308
- // the board as the tap left it, which lands as a load of its own, so the tap needs no reload. draw(board) → board is
309
- // what the tap changes when its result follows from the tap alone: the page draws it before the POST and keeps it
310
- // over every board that lands until the POST settles; a failed POST takes it back off and throws.
311
- async function send(kind, data, draw = null) {
312
- if (draw) {
313
- drawing.add(draw);
314
- state.board = drawn();
315
- render();
316
- }
317
- let out;
318
- try { out = await B.site.send(kind, data); }
319
- catch (e) {
320
- if (draw && B.alive()) undraw(draw);
321
- throw e;
322
- }
323
- drawing.delete(draw);
324
- // A board newer than the POST's already holds the tap.
325
- if (B.alive() && !land(out.board, B.boardLoad = (B.boardLoad || 0) + 1) && draw) undraw(draw);
329
+ // An owner tap that saves something (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md): the events POST
330
+ // answers once the server has recorded the tap, with the board as the tap left it, which lands as a load of its own,
331
+ // so the tap needs no reload (docs/adr/2026-11-03-0500-every-owner-tap-the-server-can-apply-draws-at-once.md); it names
332
+ // the version on screen, so the answer is a patch from it. Nothing draws
333
+ // before that answer. answered() runs once it comes, before its board lands. A refusal throws, carrying the
334
+ // server's reason for failed.
335
+ async function send(kind, data, answered = null) {
336
+ const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
337
+ if (!B.alive()) return out;
338
+ answered?.();
339
+ land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
326
340
  return out;
327
341
  }
328
- // A draw that changes the item with that key, the board's order kept as the server sorts it.
329
- const drawItem = (key, change) => (board) => ({ ...board, items: board.items.map((i) => (i.key === key ? change({ ...i }) : i)).sort(BoardUI.laneOrder) });
330
-
331
- // The bump icon's fill fades in as a tap bumps a card and out as one unbumps it, on a toast's timing (toasts.js), and
332
- // keeps fading through any redraw meanwhile. selector names the icon.
333
- const fades = new Map();
334
- function paintFades() {
335
- for (const [selector, { name, ms }] of fades) {
336
- const icon = $(selector);
337
- if (!icon) continue;
338
- icon.classList.add(name);
339
- icon.style.setProperty('--bump-fade', `${ms}ms`);
340
- }
341
- }
342
- function fadeBump(selector, on) {
343
- const fade = { name: on ? 'bump-in' : 'bump-out', ms: on ? BoardToasts.enterTime() : BoardToasts.exitTime() };
344
- $(selector)?.classList.remove('bump-in', 'bump-out');
345
- fades.set(selector, fade);
346
- paintFades();
347
- B.later(fade.ms, () => {
348
- if (fades.get(selector) !== fade) return;
349
- fades.delete(selector);
350
- $(selector)?.classList.remove(fade.name);
351
- });
352
- }
353
342
 
354
343
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
355
- // 30 s poll stays as the fallback and the offline check (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
344
+ // 30 s poll stays as the fallback and the offline check (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
356
345
  let reloading = null;
357
346
  let reloadAgain = false;
358
347
  let signalMissed = false;
@@ -365,9 +354,9 @@ window.BoardView = (function () {
365
354
  });
366
355
  }
367
356
 
368
- // The toast for a tap drawn at once whose POST failed, as the page takes the drawing back off.
369
- const FAILED = 'That didn’t go through. Is the host awake?';
370
- Object.assign(B, { render, loadBoard, send, drawItem, fadeBump, reload: signalled, FAILED });
357
+ // The toast for a tap the server refused, with its reason, or that never reached it.
358
+ const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
359
+ Object.assign(B, { render, loadBoard, send, failed, reload: signalled });
371
360
  const live = paintSnapshot();
372
361
  B.headerWired();
373
362
  if (live) B.linkUp();
@@ -383,27 +372,15 @@ window.BoardView = (function () {
383
372
  }
384
373
 
385
374
  function markup({ title = '' } = {}) {
386
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true })}
375
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
387
376
  <main>
388
377
  <section id="setup" class="setup" aria-live="polite" hidden></section>
389
378
  <section id="answers" class="answer-banner" hidden></section>
390
379
  <section id="waiting" class="waiting" hidden></section>
391
- <div id="waves"></div>
392
380
  <div id="tryMain"></div>
393
381
  <div id="lanes"></div>
394
382
  </main>
395
- <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>
396
- <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
397
- <form id="askForm" data-composer="ask">
398
- <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
399
- <textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>
400
- <div class="pending-files" data-files></div>
401
- <div class="row">
402
- <span class="muted small" id="askStatus"></span>
403
- <button type="submit" class="primary">Send</button>
404
- </div>
405
- </form>
406
- </dialog>
383
+ ${BoardNewTask.markup()}
407
384
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
408
385
  <div class="sheet-body">
409
386
  <div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
@@ -426,6 +403,11 @@ window.BoardView = (function () {
426
403
  <div id="archBody" class="arch-body"></div>
427
404
  </div>
428
405
  </dialog>
406
+ <dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
407
+ <div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
408
+ <div class="reader-body" tabindex="0"></div>
409
+ <button type="button" class="reader-new" data-reader="end" hidden></button>
410
+ </dialog>
429
411
  <dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
430
412
  <div class="sheet-body">
431
413
  <div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
@@ -1,5 +1,7 @@
1
- // Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton()
2
- // and a [data-files] list. Picking files uploads them at once to the api/upload of the site named by the nearest
1
+ // docs/adr/2026-11-03-1722-every-text-box-attaches-through-attach-js.md
2
+ // Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton(), its text box in
3
+ // BoardUI.dropZone(), and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
4
+ // uploads them at once to the api/upload of the site named by the nearest
3
5
  // [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
4
6
  // files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
5
7
  // again, and a board the app shell switches away from keeps its own. site defaults to the page's own below.
@@ -65,9 +67,43 @@ window.BoardAttach = (function () {
65
67
  const input = ev.target.closest && ev.target.closest('input[data-attach]');
66
68
  const c = input && input.closest('[data-composer]');
67
69
  if (!c) return;
68
- for (const f of input.files) upload(siteOf(c), c.dataset.composer, f);
70
+ addFiles(c, input.files);
69
71
  input.value = '';
70
72
  });
73
+ // A drag of files over a composer marks it .dragging, which outlines its drop zone. dragenter and dragleave fire on
74
+ // each child it crosses, so the composer counts them, and a drop ends the drag.
75
+ const depth = new WeakMap();
76
+ const composerOf = (ev) => (ev.target.closest && ev.target.closest('[data-composer]')) || null;
77
+ const carriesFiles = (ev) => [...(ev.dataTransfer?.types || [])].includes('Files');
78
+ const setDepth = (c, n) => { depth.set(c, n); c.classList.toggle('dragging', n > 0); };
79
+ const addFiles = (c, files) => { for (const f of files) upload(siteOf(c), c.dataset.composer, f); };
80
+ document.addEventListener('dragenter', (ev) => {
81
+ const c = composerOf(ev);
82
+ if (c && carriesFiles(ev)) setDepth(c, (depth.get(c) || 0) + 1);
83
+ });
84
+ document.addEventListener('dragleave', (ev) => {
85
+ const c = composerOf(ev);
86
+ if (c && depth.get(c)) setDepth(c, depth.get(c) - 1);
87
+ });
88
+ // A file drag is taken anywhere on the page, so a drop that misses every composer does nothing in place of the
89
+ // browser opening the file over the board.
90
+ document.addEventListener('dragover', (ev) => { if (carriesFiles(ev)) ev.preventDefault(); });
91
+ document.addEventListener('drop', (ev) => {
92
+ if (!carriesFiles(ev)) return;
93
+ ev.preventDefault();
94
+ const c = composerOf(ev);
95
+ if (!c) return;
96
+ setDepth(c, 0);
97
+ addFiles(c, ev.dataTransfer.files);
98
+ });
99
+ // A paste that carries files attaches them in place of pasting; a paste of text alone pastes as usual.
100
+ document.addEventListener('paste', (ev) => {
101
+ const c = composerOf(ev);
102
+ const files = [...(ev.clipboardData?.files || [])];
103
+ if (!c || !files.length) return;
104
+ ev.preventDefault();
105
+ addFiles(c, files);
106
+ });
71
107
  document.addEventListener('click', (ev) => {
72
108
  const b = ev.target.closest && ev.target.closest('[data-unattach]');
73
109
  const c = b && b.closest('[data-composer]');