@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.
- package/CONTEXT.md +151 -115
- package/README.md +23 -14
- package/SKILL.md +37 -37
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +186 -69
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +57 -38
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +156 -72
- package/board/public/app-core.js +2 -4
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +11 -14
- package/board/public/app.html +2 -1
- package/board/public/app.js +53 -83
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +5 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +20 -6
- package/board/public/sidebar.css +21 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +155 -62
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +176 -71
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +26 -23
- package/board/public/ui-sidebar.js +46 -22
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +17 -11
- package/board/public/ui.js +80 -12
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +101 -50
- package/board/public/view-settings.js +167 -49
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -12
- package/board/store.mjs +13 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -14
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -12
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/repos.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
package/board/public/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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,
|
|
282
|
-
// before the board on screen leaves it as it is, so a slow load can't undo a newer one
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (
|
|
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
|
-
|
|
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/
|
|
308
|
-
// the board as the tap left it, which lands as a load of its own,
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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/
|
|
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
|
|
369
|
-
const
|
|
370
|
-
Object.assign(B, { render, loadBoard, send,
|
|
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>
|
package/board/public/attach.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
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]');
|
package/board/public/dialogs.js
CHANGED
|
@@ -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/
|
|
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
|
|
6
|
-
//
|
|
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/
|
|
11
|
-
|
|
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 ?
|
|
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/
|
|
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/
|
|
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 ? `✓
|
|
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
|
|
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
|
-
|
|
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(); }
|
package/board/public/hub-kit.js
CHANGED
|
@@ -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
|
|
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/
|
|
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,
|
|
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,
|
|
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;
|
package/board/public/hub.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* The hub page and settings modals (view-hub.js, view-hub-settings.js
|
|
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 .
|
|
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
|
-
.
|
|
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/
|
|
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
|
|
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
|
-
//
|
|
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
|
|