@lovek4d/kickflip 0.1.0 → 0.4.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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -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 +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="en">
2
+ <html lang="en" data-build="{{build}}">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -41,6 +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>
45
+ <script src="/_hub/app/ui-order.js"></script>
46
+ <script src="/_hub/app/ui-patch.js"></script>
44
47
  <script src="/_hub/app/ui-threads.js"></script>
45
48
  <script src="/_hub/app/ui-cards.js"></script>
46
49
  <script src="/_hub/app/ui-header.js"></script>
@@ -58,6 +61,7 @@ window.BoardBoot = (function () {
58
61
  <script src="/_hub/app/claude.js"></script>
59
62
  <script src="/_hub/app/attach.js"></script>
60
63
  <script src="/_hub/app/hub-kit.js"></script>
64
+ <script src="/_hub/app/linear-key.js"></script>
61
65
  <script src="/_hub/app/sidebar.js"></script>
62
66
  <script src="/_hub/app/app.js"></script>
63
67
  <script src="/_hub/app/app-core.js"></script>
@@ -67,6 +71,7 @@ window.BoardBoot = (function () {
67
71
  <script src="/_hub/app/app-asks.js"></script>
68
72
  <script src="/_hub/app/app-needs.js"></script>
69
73
  <script src="/_hub/app/app-header.js"></script>
74
+ <script src="/_hub/app/app-arch.js"></script>
70
75
  <script src="/_hub/app/app-banner.js"></script>
71
76
  <script src="/_hub/app/app-snapshot.js"></script>
72
77
  <script src="/_hub/app/view-settings.js"></script>
@@ -74,7 +79,6 @@ window.BoardBoot = (function () {
74
79
  <script src="/_hub/app/view-notes.js"></script>
75
80
  <script src="/_hub/app/view-hub.js"></script>
76
81
  <script src="/_hub/app/view-hub-settings.js"></script>
77
- <script src="/_hub/app/view-polling.js"></script>
78
82
  <script src="/_hub/app/view-frame.js"></script>
79
83
  <script src="/_hub/app/router.js"></script>
80
84
  <script src="/_hub/app/shell.js"></script>
@@ -3,8 +3,9 @@
3
3
  // and mount() runs the parts in load order, then renders the board from /api/board and per-item threads from
4
4
  // /api/thread. Actions go out as events through B.site.send (each wakes Claude). Parts add listeners, intervals,
5
5
  // timers, watches and unmount steps only through B.on, B.every, B.later, B.watch and B.atUnmount, so unmount() undoes
6
- // them all and no board's state outlives its view. BoardView.markup({title}) is the root's inner
7
- // HTML. BoardView.page(root, site, run) gives a board's other views (view-*.js) the same site, $ and recorders.
6
+ // them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
7
+ // BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
8
+ // (view-*.js) the same site, $ and recorders.
8
9
  // opts, both from the app shell (shell.js):
9
10
  // signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
10
11
  // live {fresh, generatedAt}
@@ -14,6 +15,9 @@
14
15
  window.BoardView = (function () {
15
16
  const parts = [];
16
17
  const part = (fn) => { parts.push(fn); };
18
+ // route → what a board's view kept as it unmounted, which its next mount in this page reads as B.place. A reload
19
+ // starts with none.
20
+ const places = new Map();
17
21
 
18
22
  // Every board shares the hub's one origin, so each keeps its keys under site:<route>:; app.html's preload reads
19
23
  // openItems by the same key. Thread and question drafts are already per card and keep their own keys.
@@ -105,13 +109,21 @@ window.BoardView = (function () {
105
109
  fresh: null,
106
110
  openItems: new Set(store.get('openItems', [])),
107
111
  threadCache: new Map(),
108
- withdrawnAsks: new Set(),
109
112
  edits: new Map(),
110
113
  githubPending: null,
111
- waveFilter: store.get('wave', null),
114
+ // Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
115
+ mainPull: { sending: false, open: false },
116
+ // The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
117
+ // the archive holds past them, null before the first page.
118
+ archived: { cards: [], left: null },
112
119
  };
113
120
  const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
114
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause });
121
+ // B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
122
+ const keepers = [];
123
+ const keep = (fn) => { if (B.alive()) keepers.push(fn); };
124
+ const place = places.get(B.route) || null;
125
+ places.delete(B.route);
126
+ Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
115
127
 
116
128
  function mount() {
117
129
  if (B.alive()) return;
@@ -119,7 +131,12 @@ window.BoardView = (function () {
119
131
  for (const p of parts) p(B);
120
132
  start(B);
121
133
  }
122
- return { B, mount, unmount: stop };
134
+ function unmount() {
135
+ if (B.alive()) places.set(B.route, Object.assign({}, ...keepers.map((fn) => fn())));
136
+ keepers.length = 0;
137
+ stop();
138
+ }
139
+ return { B, mount, unmount };
123
140
  }
124
141
 
125
142
  // A view other than the board: run(B) adds what it needs through B's recorders when the view mounts.
@@ -139,34 +156,41 @@ window.BoardView = (function () {
139
156
  const { setHTML, preloadKeys } = BoardUI;
140
157
 
141
158
  // Renders open threads from state.threadCache; loadBoard and opening a card load them.
142
- function render() {
159
+ function render(load = null) {
143
160
  const d = state.board;
144
161
  const title = $(':scope > .top h1');
145
162
  if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
146
163
  if (d.title && document.title !== d.title) document.title = d.title;
147
164
  steady(() => {
148
165
  B.renderHeader(d);
149
- B.renderWaves(d);
150
- BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
151
- const live = d.items.filter((i) => !state.withdrawnAsks.has(i.id));
152
- const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter) : live;
166
+ B.renderArch(d);
167
+ BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
168
+ B.renderMainLog(d.main, load);
153
169
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
154
170
  for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
155
171
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
156
- setHTML($('#lanes'), B.lanesHtml(d, shown), { keep: '[data-thread]' });
172
+ const strips = B.captureStrips();
173
+ setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
157
174
  for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
175
+ B.moveStrips(d.items, strips);
158
176
  B.renderNeeds(d);
159
177
  B.renderBanner(d);
160
178
  });
161
179
  B.flashChanged();
180
+ B.signCards(load);
162
181
  }
163
182
 
183
+ // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
164
184
  // #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
165
- // (docs/adr/0036-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
185
+ // (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.
166
186
  let focusKey = null;
167
187
  let replyKey = null;
168
188
  let hashRead = false;
189
+ // A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
190
+ const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
169
191
  function readHash() {
192
+ const review = /^#review=(\d+)$/.exec(location.hash);
193
+ if (review && state.board) { B.openReview(Number(review[1])); return; }
170
194
  const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
171
195
  if (!m) return;
172
196
  focusKey = decodeURIComponent(m[1]);
@@ -174,7 +198,7 @@ window.BoardView = (function () {
174
198
  replyKey = m[2] ? focusKey : null;
175
199
  state.openItems.add(focusKey);
176
200
  const it = state.board?.items.find((i) => i.key === focusKey);
177
- if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); }
201
+ if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
178
202
  if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
179
203
  }
180
204
  B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
@@ -208,7 +232,7 @@ window.BoardView = (function () {
208
232
  }
209
233
 
210
234
  // The last visit's board paints dimmed while the first load runs; it doesn't count as landed, so refresh keeps
211
- // 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).
235
+ // 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).
212
236
  // → true when it painted live (B.live), so the controls turn on at once.
213
237
  function paintSnapshot() {
214
238
  const snap = B.readSnapshot();
@@ -222,6 +246,7 @@ window.BoardView = (function () {
222
246
  hashRead = true;
223
247
  render();
224
248
  scrollToFocus(false);
249
+ returnToPlace();
225
250
  if (!live) B.holdForFlash();
226
251
  return live;
227
252
  }
@@ -235,26 +260,23 @@ window.BoardView = (function () {
235
260
  if (boot?.route && boot.route !== B.route) boot = null;
236
261
  let preloaded = false;
237
262
  async function loadBoard({ via = 'poll' } = {}) {
263
+ const load = B.boardLoad = (B.boardLoad || 0) + 1;
238
264
  const started = boot;
239
265
  boot = null;
240
266
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
241
267
  let landed = false;
242
268
  try {
243
- const board = await (started?.board || fetch(B.site.url('api/board'), { cache: 'no-store' })
244
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
245
- const first = state.view !== 'fresh';
246
- state.board = board;
247
- B.setView('fresh');
248
- if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
249
- B.linkUp();
250
- render();
251
- scrollToFocus();
252
- B.site.syncBadge();
253
- B.saveSnapshotSoon();
269
+ // A reload names the version of the board on screen: an unchanged board answers 304 with no body
270
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch from it
271
+ // (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md).
272
+ const tag = state.view === 'fresh' ? state.board.version : null;
273
+ const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
274
+ .then((r) => (r.status === 304 ? null : r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
275
+ if (board) land(board, load); else B.linkUp();
254
276
  landed = true;
255
277
  if (!preloaded) {
256
278
  preloaded = true;
257
- for (const key of preloadKeys(board.items, state.openItems)) B.loadThread(key);
279
+ for (const key of preloadKeys(state.board.items, state.openItems)) B.loadThread(key);
258
280
  }
259
281
  } catch (e) {
260
282
  B.linkDown();
@@ -264,8 +286,42 @@ window.BoardView = (function () {
264
286
  if (state.view === 'fresh') B.endFlash();
265
287
  }
266
288
 
289
+ // 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
290
+ // 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
291
+ // no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
292
+ function land(answer, load) {
293
+ if (state.view === 'fresh' && state.board && answer.generatedAt < state.board.generatedAt) return false;
294
+ const board = answer.since === undefined ? answer : BoardUI.applyPatch(state.view === 'fresh' ? state.board : null, answer);
295
+ if (!board) { signalled(); return false; }
296
+ const first = state.view !== 'fresh';
297
+ state.board = board;
298
+ B.setView('fresh');
299
+ if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
300
+ B.linkUp();
301
+ render(load);
302
+ scrollToFocus();
303
+ returnToPlace();
304
+ B.site.syncBadge();
305
+ B.saveSnapshotSoon();
306
+ return true;
307
+ }
308
+
309
+ // 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
310
+ // answers once the server has recorded the tap, with the board as the tap left it, which lands as a load of its own,
311
+ // 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
312
+ // the version on screen, so the answer is a patch from it. Nothing draws
313
+ // before that answer. answered() runs once it comes, before its board lands. A refusal throws, carrying the
314
+ // server's reason for failed.
315
+ async function send(kind, data, answered = null) {
316
+ const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
317
+ if (!B.alive()) return out;
318
+ answered?.();
319
+ land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
320
+ return out;
321
+ }
322
+
267
323
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
268
- // 30 s poll stays as the fallback and the offline check (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
324
+ // 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).
269
325
  let reloading = null;
270
326
  let reloadAgain = false;
271
327
  let signalMissed = false;
@@ -278,7 +334,9 @@ window.BoardView = (function () {
278
334
  });
279
335
  }
280
336
 
281
- Object.assign(B, { render, loadBoard });
337
+ // The toast for a tap the server refused, with its reason, or that never reached it.
338
+ const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
339
+ Object.assign(B, { render, loadBoard, send, failed, reload: signalled });
282
340
  const live = paintSnapshot();
283
341
  B.headerWired();
284
342
  if (live) B.linkUp();
@@ -294,27 +352,50 @@ window.BoardView = (function () {
294
352
  }
295
353
 
296
354
  function markup({ title = '' } = {}) {
297
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png' })}
355
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
298
356
  <main>
299
357
  <section id="setup" class="setup" aria-live="polite" hidden></section>
300
358
  <section id="answers" class="answer-banner" hidden></section>
301
359
  <section id="waiting" class="waiting" hidden></section>
302
- <div id="waves" class="waves"></div>
303
360
  <div id="tryMain"></div>
304
361
  <div id="lanes"></div>
305
362
  </main>
306
- <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="26" height="26" 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>
363
+ <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>
307
364
  <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
308
365
  <form id="askForm" data-composer="ask">
309
366
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
310
- <textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>
367
+ ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
311
368
  <div class="pending-files" data-files></div>
369
+ <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>
370
+ <p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
312
371
  <div class="row">
313
372
  <span class="muted small" id="askStatus"></span>
314
373
  <button type="submit" class="primary">Send</button>
315
374
  </div>
316
375
  </form>
317
376
  </dialog>
377
+ <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
378
+ <div class="sheet-body">
379
+ <div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
380
+ <p id="mainLogSource" class="main-log-source muted small"></p>
381
+ <pre id="mainLogLines" class="main-log-lines" tabindex="0" aria-label="Main’s build log"></pre>
382
+ <p id="mainLogReason" class="small" role="status" hidden></p>
383
+ <div class="main-log-actions"><button type="button" class="primary" id="mainLogRetry">Retry</button></div>
384
+ </div>
385
+ </dialog>
386
+ <dialog id="holdSheet" class="sheet hold-sheet" aria-labelledby="holdTitle">
387
+ <div class="sheet-body">
388
+ <div class="sheet-head"><h2 id="holdTitle" tabindex="-1"></h2><button type="button" class="ghost" id="holdClose" aria-label="Close">✕</button></div>
389
+ <p id="holdLede" class="hold-lede muted"></p>
390
+ <div id="holdPicker"></div>
391
+ </div>
392
+ </dialog>
393
+ <dialog id="archSheet" class="sheet arch-sheet" aria-labelledby="archTitle">
394
+ <div class="sheet-body">
395
+ <div class="sheet-head"><h2 id="archTitle">Architecture review</h2><button type="button" class="ghost" id="archClose" aria-label="Close sheet">✕</button></div>
396
+ <div id="archBody" class="arch-body"></div>
397
+ </div>
398
+ </dialog>
318
399
  <dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
319
400
  <div class="sheet-body">
320
401
  <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]');