@lovek4d/kickflip 0.3.1 → 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 (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  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 +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -109,10 +109,13 @@ window.BoardView = (function () {
109
109
  fresh: null,
110
110
  openItems: new Set(store.get('openItems', [])),
111
111
  threadCache: new Map(),
112
- withdrawnAsks: new Set(),
113
112
  edits: new Map(),
114
113
  githubPending: null,
115
- 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 },
116
119
  };
117
120
  const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
118
121
  // B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
@@ -120,7 +123,7 @@ window.BoardView = (function () {
120
123
  const keep = (fn) => { if (B.alive()) keepers.push(fn); };
121
124
  const place = places.get(B.route) || null;
122
125
  places.delete(B.route);
123
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, place, keep });
126
+ Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, away: opts.away, place, keep });
124
127
 
125
128
  function mount() {
126
129
  if (B.alive()) return;
@@ -161,32 +164,33 @@ window.BoardView = (function () {
161
164
  steady(() => {
162
165
  B.renderHeader(d);
163
166
  B.renderArch(d);
164
- B.renderWaves(d);
165
- BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
167
+ BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
166
168
  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
169
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
170
170
  for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
171
171
  // 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]' });
172
+ const strips = B.captureStrips();
173
+ setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
173
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);
174
176
  B.renderNeeds(d);
175
177
  B.renderBanner(d);
176
178
  });
177
- paintFades();
178
179
  B.flashChanged();
179
180
  B.signCards(load);
180
181
  }
181
182
 
183
+ // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
182
184
  // #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.
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.
184
186
  let focusKey = null;
185
187
  let replyKey = null;
186
188
  let hashRead = false;
187
189
  // 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) });
190
+ const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
189
191
  function readHash() {
192
+ const review = /^#review=(\d+)$/.exec(location.hash);
193
+ if (review && state.board) { B.openReview(Number(review[1])); return; }
190
194
  const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
191
195
  if (!m) return;
192
196
  focusKey = decodeURIComponent(m[1]);
@@ -194,7 +198,7 @@ window.BoardView = (function () {
194
198
  replyKey = m[2] ? focusKey : null;
195
199
  state.openItems.add(focusKey);
196
200
  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); }
201
+ if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
198
202
  if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
199
203
  }
200
204
  B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
@@ -228,13 +232,13 @@ window.BoardView = (function () {
228
232
  }
229
233
 
230
234
  // 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).
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).
232
236
  // → true when it painted live (B.live), so the controls turn on at once.
233
237
  function paintSnapshot() {
234
238
  const snap = B.readSnapshot();
235
239
  if (!snap) return false;
236
240
  const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
237
- served = state.board = snap.board;
241
+ state.board = snap.board;
238
242
  for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
239
243
  state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
240
244
  B.setView(live ? 'fresh' : 'snapshot');
@@ -262,13 +266,17 @@ window.BoardView = (function () {
262
266
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
263
267
  let landed = false;
264
268
  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);
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();
268
276
  landed = true;
269
277
  if (!preloaded) {
270
278
  preloaded = true;
271
- 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);
272
280
  }
273
281
  } catch (e) {
274
282
  B.linkDown();
@@ -278,16 +286,15 @@ window.BoardView = (function () {
278
286
  if (state.view === 'fresh') B.endFlash();
279
287
  }
280
288
 
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;
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; }
288
296
  const first = state.view !== 'fresh';
289
- served = board;
290
- state.board = drawn();
297
+ state.board = board;
291
298
  B.setView('fresh');
292
299
  if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
293
300
  B.linkUp();
@@ -298,61 +305,23 @@ window.BoardView = (function () {
298
305
  B.saveSnapshotSoon();
299
306
  return true;
300
307
  }
301
- function undraw(draw) {
302
- drawing.delete(draw);
303
- state.board = drawn();
304
- render();
305
- }
306
308
 
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);
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);
326
320
  return out;
327
321
  }
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
322
 
354
323
  // 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).
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).
356
325
  let reloading = null;
357
326
  let reloadAgain = false;
358
327
  let signalMissed = false;
@@ -365,9 +334,9 @@ window.BoardView = (function () {
365
334
  });
366
335
  }
367
336
 
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 });
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 });
371
340
  const live = paintSnapshot();
372
341
  B.headerWired();
373
342
  if (live) B.linkUp();
@@ -383,12 +352,11 @@ window.BoardView = (function () {
383
352
  }
384
353
 
385
354
  function markup({ title = '' } = {}) {
386
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true })}
355
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
387
356
  <main>
388
357
  <section id="setup" class="setup" aria-live="polite" hidden></section>
389
358
  <section id="answers" class="answer-banner" hidden></section>
390
359
  <section id="waiting" class="waiting" hidden></section>
391
- <div id="waves"></div>
392
360
  <div id="tryMain"></div>
393
361
  <div id="lanes"></div>
394
362
  </main>
@@ -396,8 +364,10 @@ window.BoardView = (function () {
396
364
  <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
397
365
  <form id="askForm" data-composer="ask">
398
366
  <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>
367
+ ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
400
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>
401
371
  <div class="row">
402
372
  <span class="muted small" id="askStatus"></span>
403
373
  <button type="submit" class="primary">Send</button>
@@ -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]');
@@ -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); }
@@ -167,4 +152,6 @@
167
152
  .hub-view .rd-fix button.link { padding: 0; font-size: inherit; }
168
153
  .hub-view .site-row > .site-link { flex:1; min-width:0; display:flex; align-items:center; gap:10px; color:inherit; }
169
154
  .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; }
155
+ /* A waiting button keeps its own look (style.css button[aria-busy="true"]), over .btn's and a step's disabled dimming. */
156
+ .hub-view button[aria-busy="true"]:disabled { opacity:1; }
157
+ .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