@lovek4d/kickflip 0.4.0 → 0.5.3
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 +69 -48
- package/README.md +1 -1
- package/SKILL.md +22 -20
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/bin/board.mjs +108 -39
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +5 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +9 -92
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +70 -37
- package/board/public/app-core.js +24 -25
- package/board/public/app-header.js +2 -1
- package/board/public/app-lanes.js +55 -23
- package/board/public/app-needs.js +129 -4
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +6 -5
- package/board/public/app-threads.js +83 -58
- package/board/public/app.html +2 -0
- package/board/public/app.js +66 -24
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +4 -2
- package/board/public/hub.css +1 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +100 -0
- package/board/public/prototype-kit.js +28 -3
- package/board/public/prototype-switcher.js +42 -107
- package/board/public/shell.js +7 -1
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +44 -5
- package/board/public/style.css +194 -35
- package/board/public/ui-cards.js +80 -44
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +2 -2
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +63 -47
- package/board/public/ui-sidebar.js +27 -15
- package/board/public/ui-threads.js +27 -14
- package/board/public/ui.js +76 -14
- package/board/public/view-frame.js +1 -0
- package/board/public/view-hub-settings.js +11 -10
- package/board/public/view-hub.js +17 -3
- package/board/public/view-questions.js +174 -202
- package/board/public/view-settings.js +208 -70
- package/board/server.mjs +19 -18
- package/board/store.mjs +20 -13
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +24 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -14
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/fake-gh.mjs +17 -4
- package/shared/owner-check.mjs +3 -0
- package/shared/service.mjs +4 -4
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
package/board/public/app.js
CHANGED
|
@@ -27,7 +27,9 @@ window.BoardView = (function () {
|
|
|
27
27
|
try {
|
|
28
28
|
const old = [];
|
|
29
29
|
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
30
|
-
|
|
30
|
+
// Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
|
|
31
|
+
for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
|
|
32
|
+
for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
31
33
|
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
32
34
|
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
33
35
|
localStorage.removeItem(k);
|
|
@@ -86,7 +88,26 @@ window.BoardView = (function () {
|
|
|
86
88
|
}
|
|
87
89
|
toasts.toast(text);
|
|
88
90
|
};
|
|
89
|
-
const
|
|
91
|
+
const store = storeFor(route);
|
|
92
|
+
// iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
|
|
93
|
+
// So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
|
|
94
|
+
const quiet = new Map();
|
|
95
|
+
function quieted(draftKey, ta) {
|
|
96
|
+
const until = quiet.get(draftKey) || 0;
|
|
97
|
+
if (until <= Date.now()) return false;
|
|
98
|
+
if (until !== Infinity) ta.value = '';
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
async function sendDraft(draftKey, ta, current, send) {
|
|
102
|
+
ta.blur();
|
|
103
|
+
quiet.set(draftKey, Infinity);
|
|
104
|
+
try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
|
|
105
|
+
store.set(draftKey, '');
|
|
106
|
+
const now = current();
|
|
107
|
+
if (now) now.value = '';
|
|
108
|
+
quiet.set(draftKey, Date.now() + 1000);
|
|
109
|
+
}
|
|
110
|
+
const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
|
|
90
111
|
return {
|
|
91
112
|
B,
|
|
92
113
|
start() { alive = true; },
|
|
@@ -100,7 +121,7 @@ window.BoardView = (function () {
|
|
|
100
121
|
|
|
101
122
|
function create(root, siteRoot, opts = {}) {
|
|
102
123
|
const { B, start: begin, stop } = base(root, siteRoot);
|
|
103
|
-
const store =
|
|
124
|
+
const { store } = B;
|
|
104
125
|
// board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
|
|
105
126
|
// header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
|
|
106
127
|
// {thread, data, text}, data being what ask-edited sends besides the text.
|
|
@@ -112,7 +133,7 @@ window.BoardView = (function () {
|
|
|
112
133
|
edits: new Map(),
|
|
113
134
|
githubPending: null,
|
|
114
135
|
// 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: {
|
|
136
|
+
mainPull: { open: false },
|
|
116
137
|
// The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
|
|
117
138
|
// the archive holds past them, null before the first page.
|
|
118
139
|
archived: { cards: [], left: null },
|
|
@@ -158,6 +179,7 @@ window.BoardView = (function () {
|
|
|
158
179
|
// Renders open threads from state.threadCache; loadBoard and opening a card load them.
|
|
159
180
|
function render(load = null) {
|
|
160
181
|
const d = state.board;
|
|
182
|
+
BoardUI.pending.settle(d);
|
|
161
183
|
const title = $(':scope > .top h1');
|
|
162
184
|
if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
|
|
163
185
|
if (d.title && document.title !== d.title) document.title = d.title;
|
|
@@ -167,7 +189,8 @@ window.BoardView = (function () {
|
|
|
167
189
|
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
|
|
168
190
|
B.renderMainLog(d.main, load);
|
|
169
191
|
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
170
|
-
|
|
192
|
+
B.takeMarks(d.items);
|
|
193
|
+
for (const it of d.items) if (state.openItems.has(it.key)) markSeen(it);
|
|
171
194
|
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
172
195
|
const strips = B.captureStrips();
|
|
173
196
|
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
@@ -209,6 +232,15 @@ window.BoardView = (function () {
|
|
|
209
232
|
ev.preventDefault();
|
|
210
233
|
readHash(); render(); scrollToFocus();
|
|
211
234
|
});
|
|
235
|
+
// Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
|
|
236
|
+
function focusOnBoard({ key, slug }) {
|
|
237
|
+
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
238
|
+
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
239
|
+
if (key) { location.hash = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
|
|
240
|
+
render();
|
|
241
|
+
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
212
244
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
213
245
|
function scrollToFocus(final = true) {
|
|
214
246
|
if (!focusKey) return;
|
|
@@ -316,10 +348,16 @@ window.BoardView = (function () {
|
|
|
316
348
|
const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
|
|
317
349
|
if (!B.alive()) return out;
|
|
318
350
|
answered?.();
|
|
319
|
-
land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
|
|
351
|
+
if (!land(out.board, B.boardLoad = (B.boardLoad || 0) + 1)) await boardAfter(out.board.generatedAt);
|
|
320
352
|
return out;
|
|
321
353
|
}
|
|
322
354
|
|
|
355
|
+
// A board that reload raced the POST left the tap's own board unlanded (land): the tap is not drawn until a board
|
|
356
|
+
// made after it is, so send waits for one, and a tap's spinner with it (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
357
|
+
async function boardAfter(generatedAt) {
|
|
358
|
+
for (let tries = 0; tries < 3 && B.alive() && !(state.view === 'fresh' && state.board.generatedAt >= generatedAt); tries++) await (reloading || loadBoard({ via: 'signal' }));
|
|
359
|
+
}
|
|
360
|
+
|
|
323
361
|
// The server signals each change to what the board reads, through the shell, and the board reloads at once; the
|
|
324
362
|
// 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).
|
|
325
363
|
let reloading = null;
|
|
@@ -336,7 +374,14 @@ window.BoardView = (function () {
|
|
|
336
374
|
|
|
337
375
|
// The toast for a tap the server refused, with its reason, or that never reached it.
|
|
338
376
|
const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
|
|
339
|
-
|
|
377
|
+
// A tap's change no board has shown in BoardUI's PENDING_LATE_MS (ui.js pending): the board reloads once and the button rests.
|
|
378
|
+
function stillWaiting() {
|
|
379
|
+
if (!B.alive()) return;
|
|
380
|
+
B.toast('Still waiting on the board');
|
|
381
|
+
signalled();
|
|
382
|
+
render();
|
|
383
|
+
}
|
|
384
|
+
Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
340
385
|
const live = paintSnapshot();
|
|
341
386
|
B.headerWired();
|
|
342
387
|
if (live) B.linkUp();
|
|
@@ -351,29 +396,21 @@ window.BoardView = (function () {
|
|
|
351
396
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
352
397
|
}
|
|
353
398
|
|
|
354
|
-
|
|
399
|
+
// shared: the shell's data.shared for someone the board is shared with, view only, whose board opens with a strip saying so.
|
|
400
|
+
function markup({ title = '', shared = null } = {}) {
|
|
401
|
+
const strip = shared ? `<p class="view-only-strip"><b>View only.</b> Shared with you${shared.by ? ` by ${BoardUI.esc(shared.by)}` : ''}. You can look and open Visit links.</p>\n` : '';
|
|
355
402
|
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
|
|
403
|
+
<div class="split">
|
|
356
404
|
<main>
|
|
357
|
-
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
405
|
+
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
358
406
|
<section id="answers" class="answer-banner" hidden></section>
|
|
359
|
-
<section id="
|
|
407
|
+
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
360
408
|
<div id="tryMain"></div>
|
|
361
409
|
<div id="lanes"></div>
|
|
362
410
|
</main>
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
367
|
-
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
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>
|
|
371
|
-
<div class="row">
|
|
372
|
-
<span class="muted small" id="askStatus"></span>
|
|
373
|
-
<button type="submit" class="primary">Send</button>
|
|
374
|
-
</div>
|
|
375
|
-
</form>
|
|
376
|
-
</dialog>
|
|
411
|
+
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
412
|
+
</div>
|
|
413
|
+
${BoardNewTask.markup()}
|
|
377
414
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
378
415
|
<div class="sheet-body">
|
|
379
416
|
<div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
|
|
@@ -396,6 +433,11 @@ window.BoardView = (function () {
|
|
|
396
433
|
<div id="archBody" class="arch-body"></div>
|
|
397
434
|
</div>
|
|
398
435
|
</dialog>
|
|
436
|
+
<dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
|
|
437
|
+
<div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
|
|
438
|
+
<div class="reader-body" tabindex="0"></div>
|
|
439
|
+
<button type="button" class="reader-new" data-reader="end" hidden></button>
|
|
440
|
+
</dialog>
|
|
399
441
|
<dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
|
|
400
442
|
<div class="sheet-body">
|
|
401
443
|
<div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
|
package/board/public/attach.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
|
|
3
|
-
// BoardUI.
|
|
2
|
+
// Files attached to a composer: any element with data-composer="<id>" holding its text box in BoardUI.dropZone(), which
|
|
3
|
+
// puts BoardUI.attachButton() inside the box, and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
|
|
4
4
|
// uploads them at once to the api/upload of the site named by the nearest
|
|
5
5
|
// [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
|
|
6
6
|
// files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
|
package/board/public/dialogs.js
CHANGED
|
@@ -2,21 +2,18 @@
|
|
|
2
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
|
-
// inline (linear-key.js,
|
|
5
|
+
// while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
|
|
7
6
|
// docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
|
|
8
7
|
// new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
|
|
9
8
|
// {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
|
|
10
9
|
// GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
|
|
11
10
|
// 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
11
|
// 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
|
|
14
|
-
|
|
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
|
+
// as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
|
|
13
|
+
function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
|
|
17
14
|
const { esc } = BoardUI;
|
|
18
15
|
const draft = { prefix: 'INT', ...value };
|
|
19
|
-
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null
|
|
16
|
+
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
|
|
20
17
|
const works = () => Boolean(linear?.set && !linear.refused);
|
|
21
18
|
const keyWorks = () => Boolean(key?.set && !key.refused);
|
|
22
19
|
const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
|
|
@@ -26,31 +23,6 @@
|
|
|
26
23
|
const on = draft.source === 'linear' && keyWorks();
|
|
27
24
|
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
25
|
}
|
|
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
|
-
}
|
|
54
26
|
async function load() {
|
|
55
27
|
linear = { loading: true };
|
|
56
28
|
render();
|
|
@@ -114,13 +86,12 @@
|
|
|
114
86
|
if (!works()) {
|
|
115
87
|
draft.source = before;
|
|
116
88
|
render();
|
|
117
|
-
await keyed;
|
|
118
|
-
if (destroyed) return;
|
|
119
|
-
if (key) { openInline(); return; }
|
|
120
89
|
asking = true;
|
|
121
90
|
const added = await LinearKey.open();
|
|
122
91
|
asking = false;
|
|
123
92
|
if (destroyed || !added) return;
|
|
93
|
+
if (keyState) key = await keyState().catch(() => key);
|
|
94
|
+
if (destroyed) return;
|
|
124
95
|
draft.source = 'linear';
|
|
125
96
|
await load();
|
|
126
97
|
if (destroyed) return;
|
|
@@ -149,7 +120,6 @@
|
|
|
149
120
|
noGithub = button.dataset.tkSource === 'github' && !repo();
|
|
150
121
|
if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
|
|
151
122
|
if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
|
|
152
|
-
closeInline();
|
|
153
123
|
draft.source = button.dataset.tkSource;
|
|
154
124
|
render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
|
|
155
125
|
};
|
|
@@ -168,13 +138,13 @@
|
|
|
168
138
|
root.addEventListener('change', input);
|
|
169
139
|
render();
|
|
170
140
|
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
|
|
172
|
-
|
|
141
|
+
// Without an answer the row has no status word.
|
|
142
|
+
if (keyState) keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {});
|
|
173
143
|
return { read, valid, render, nameChanged() {
|
|
174
144
|
const field = root.querySelector('[data-tk="newName"]');
|
|
175
145
|
if (field && draft.newName === undefined) field.value = title();
|
|
176
146
|
},
|
|
177
|
-
destroy() { destroyed = true;
|
|
147
|
+
destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
178
148
|
}
|
|
179
149
|
function fitSheet(sheet) {
|
|
180
150
|
const vv = window.visualViewport;
|
package/board/public/hub-kit.js
CHANGED
|
@@ -113,6 +113,8 @@
|
|
|
113
113
|
const KEPT = 'kickflip.push.endpoint';
|
|
114
114
|
const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
|
|
115
115
|
const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
|
|
116
|
+
// The id the hub gave that subscription, so the Integrations list marks this device's line (view-hub.js).
|
|
117
|
+
const subscribe = (sub) => post('/_hub/push/subscribe', sub.toJSON()).then(({ id }) => { try { localStorage.setItem('kickflip.push.id', id); } catch { /* storage blocked here */ } });
|
|
116
118
|
const settle = (endpoint = null) => {
|
|
117
119
|
const old = kept();
|
|
118
120
|
if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
|
|
@@ -133,7 +135,7 @@
|
|
|
133
135
|
const sub = await reg?.pushManager.getSubscription();
|
|
134
136
|
settle(sub?.toJSON().endpoint);
|
|
135
137
|
if (sub) {
|
|
136
|
-
|
|
138
|
+
subscribe(sub).catch(() => {});
|
|
137
139
|
show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
|
|
138
140
|
} else {
|
|
139
141
|
show({ title: 'This device', sub: 'Off · Claude sessions tell you here when something needs you.', action: { label: 'Turn on', act: 'on' } });
|
|
@@ -148,7 +150,7 @@
|
|
|
148
150
|
await navigator.serviceWorker.ready;
|
|
149
151
|
const { key } = await (await fetch('/_hub/push/key')).json();
|
|
150
152
|
const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
|
|
151
|
-
await
|
|
153
|
+
await subscribe(sub);
|
|
152
154
|
await render();
|
|
153
155
|
changed();
|
|
154
156
|
} catch (err) { show({ title: 'Could not turn on notifications', sub: String(err?.message || err) }); }
|
package/board/public/hub.css
CHANGED
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
.hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
|
|
88
88
|
.hub-view .remembered p { margin:0; }
|
|
89
89
|
.hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
|
|
90
|
+
.hub-view :is(.hub-modal, .sheet) code { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
90
91
|
.hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
91
92
|
|
|
92
93
|
@media (max-width: 899px) {
|
|
@@ -1,44 +1,55 @@
|
|
|
1
|
-
// Hub's Linear key
|
|
2
|
-
// settings or New board when Linear is tapped without a working key
|
|
3
|
-
// (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A
|
|
4
|
-
// POST /_hub/settings
|
|
1
|
+
// Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
|
|
2
|
+
// control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
|
|
3
|
+
// (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A token saves on paste, change or Enter
|
|
4
|
+
// through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
|
|
5
5
|
(function () {
|
|
6
6
|
const FAILED = 'Could not save. Is the host awake?';
|
|
7
7
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
8
|
-
const works = (
|
|
8
|
+
const works = (state) => Boolean(state?.set && !state.refused);
|
|
9
|
+
const KINDS = {
|
|
10
|
+
linear: { field: 'key', input: 'linearKey', checking: 'Checking the key with Linear…', first: 'Paste a Linear API key', replace: 'Paste a new key to replace it', label: 'Linear API key',
|
|
11
|
+
note: 'Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.',
|
|
12
|
+
confirm: 'Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?' },
|
|
13
|
+
slack: { field: 'token', input: 'slackToken', checking: 'Checking the token with Slack…', first: 'Paste a Slack user token', replace: 'Paste a new token to replace it', label: 'Slack user token',
|
|
14
|
+
note: 'Stays on the host. The hub sends your reviewers their PR links, git_merged reactions and weekday digests as you, in your DMs with them.',
|
|
15
|
+
confirm: 'Reviewer links, reactions and digests stop until a token is back. Remove the Slack token?' },
|
|
16
|
+
};
|
|
9
17
|
|
|
10
|
-
function slot(
|
|
18
|
+
function slot(kind, state, { checking, flash }) {
|
|
11
19
|
const { esc, row, tile } = HubUI;
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
+
|
|
20
|
+
const k = KINDS[kind];
|
|
21
|
+
const sub = checking ? `<span class="lk-checking">${SPIN}${k.checking}</span>`
|
|
22
|
+
: `${state.down ? `<span class="st down">${esc(state.down)}</span> · ` : ''}${state.keys
|
|
23
|
+
? esc(state.sub).replace(esc(state.keys.label), `<a href="${esc(state.keys.url)}" target="_blank" rel="noopener">${esc(state.keys.label)}</a>`)
|
|
24
|
+
: esc(state.sub)}`;
|
|
25
|
+
const remove = state.set && !checking ? `<button type="button" class="btn quiet danger" data-${kind}-remove>Remove</button>` : '';
|
|
26
|
+
const placeholder = checking ? 'Checking…' : state.set ? k.replace : k.first;
|
|
27
|
+
return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', state.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(state.title), sub, wrap: !works(state), trail: remove })}`
|
|
28
|
+
+ `<form class="block lk-form"><input id="${k.input}" name="${k.field}" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="${k.label}" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
|
|
29
|
+
+ `<p class="note">${k.note}</p>`;
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
// Draws
|
|
24
|
-
// each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved
|
|
25
|
-
// → {busy(), settled(): a promise that resolves once no save is out,
|
|
26
|
-
function mount(root, { linear, saved = () => {}, worked = () => {}, busy = () => {} }) {
|
|
27
|
-
|
|
32
|
+
// Draws kind's slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
|
|
33
|
+
// each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved token the service accepted.
|
|
34
|
+
// → {busy(), settled(): a promise that resolves once no save is out, state(), set(state) unless busy, destroy()}
|
|
35
|
+
function mount(root, { kind = 'linear', state, saved = () => {}, worked = () => {}, busy = () => {} }) {
|
|
36
|
+
const k = KINDS[kind];
|
|
37
|
+
const input = `#${k.input}`;
|
|
38
|
+
let current = state, flash = null, checking = false, pending = null, destroyed = false;
|
|
28
39
|
const paint = () => {
|
|
29
|
-
const typed = root.querySelector(
|
|
30
|
-
root.innerHTML = slot(current, { checking, flash });
|
|
31
|
-
if (!checking) root.querySelector(
|
|
40
|
+
const typed = root.querySelector(input)?.value || '';
|
|
41
|
+
root.innerHTML = slot(kind, current, { checking, flash });
|
|
42
|
+
if (!checking) root.querySelector(input).value = typed;
|
|
32
43
|
};
|
|
33
44
|
function post(body) {
|
|
34
|
-
checking = body.has(
|
|
45
|
+
checking = body.has(k.field);
|
|
35
46
|
flash = null;
|
|
36
47
|
pending = (async () => {
|
|
37
48
|
try {
|
|
38
|
-
const r = await fetch(
|
|
49
|
+
const r = await fetch(`/_hub/settings/${kind}`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
|
|
39
50
|
const out = await r.json().catch(() => null);
|
|
40
51
|
if (destroyed) return;
|
|
41
|
-
if (r.ok && out) { current = out
|
|
52
|
+
if (r.ok && out) { current = out[kind]; saved(out); } else flash = { text: out?.error || FAILED, error: true };
|
|
42
53
|
} catch { flash = { text: FAILED, error: true }; }
|
|
43
54
|
const key = checking;
|
|
44
55
|
checking = false;
|
|
@@ -52,23 +63,23 @@
|
|
|
52
63
|
paint();
|
|
53
64
|
return pending;
|
|
54
65
|
}
|
|
55
|
-
const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({
|
|
66
|
+
const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ [k.field]: value.trim() })); };
|
|
56
67
|
const onPaste = (e) => {
|
|
57
68
|
const text = e.clipboardData?.getData('text');
|
|
58
|
-
if (!e.target.matches?.(
|
|
69
|
+
if (!e.target.matches?.(input) || !text) return;
|
|
59
70
|
e.preventDefault();
|
|
60
71
|
e.target.value = text.trim();
|
|
61
72
|
save(e.target.value);
|
|
62
73
|
};
|
|
63
|
-
const onChange = (e) => { if (e.target.matches?.(
|
|
74
|
+
const onChange = (e) => { if (e.target.matches?.(input)) save(e.target.value); };
|
|
64
75
|
const onSubmit = (e) => {
|
|
65
76
|
if (!e.target.matches?.('.lk-form')) return;
|
|
66
77
|
e.preventDefault();
|
|
67
|
-
save(e.target.querySelector(
|
|
78
|
+
save(e.target.querySelector(input).value);
|
|
68
79
|
};
|
|
69
80
|
const onClick = async (e) => {
|
|
70
|
-
if (!e.target.closest?.(
|
|
71
|
-
if (await BoardDialogs.confirm(
|
|
81
|
+
if (!e.target.closest?.(`[data-${kind}-remove]`) || pending) return;
|
|
82
|
+
if (await BoardDialogs.confirm(k.confirm) && !destroyed) post(new URLSearchParams({ remove: '1' }));
|
|
72
83
|
};
|
|
73
84
|
const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
|
|
74
85
|
for (const [type, fn] of listeners) root.addEventListener(type, fn);
|
|
@@ -76,7 +87,7 @@
|
|
|
76
87
|
return {
|
|
77
88
|
busy: () => Boolean(pending),
|
|
78
89
|
settled: () => pending || Promise.resolve(),
|
|
79
|
-
|
|
90
|
+
state: () => current,
|
|
80
91
|
set(next) { if (!pending) { current = next; paint(); } },
|
|
81
92
|
destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
|
|
82
93
|
};
|
|
@@ -115,7 +126,7 @@
|
|
|
115
126
|
dialog.close();
|
|
116
127
|
wrap.remove();
|
|
117
128
|
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
118
|
-
resolve(works(key?.
|
|
129
|
+
resolve(works(key?.state()));
|
|
119
130
|
}
|
|
120
131
|
function close() {
|
|
121
132
|
if (!key?.busy()) { finish(); return; }
|
|
@@ -134,7 +145,7 @@
|
|
|
134
145
|
wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
|
|
135
146
|
fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
|
|
136
147
|
if (finished) return;
|
|
137
|
-
key = mount(content, {
|
|
148
|
+
key = mount(content, { state: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
|
|
138
149
|
}).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
|
|
139
150
|
return result;
|
|
140
151
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The + button and the sheet that sends a new task as an ask event, on the board's main page alone: the board view's markup
|
|
2
|
+
// (app.js) puts BoardNewTask.markup() in its root and app-asks.js wires it with BoardNewTask.wire(B). No other page
|
|
3
|
+
// opts in. The ask goes through B.send, which lands the board the ask changed. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
|
|
4
|
+
globalThis.BoardNewTask = (function () {
|
|
5
|
+
const markup = () => `<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
|
|
6
|
+
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
7
|
+
<form id="askForm" data-composer="ask">
|
|
8
|
+
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
9
|
+
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
10
|
+
<div class="pending-files" data-files></div>
|
|
11
|
+
<div class="send-actions">${BoardUI.sendButton('Quick build', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
|
|
12
|
+
<p class="muted small" id="askStatus" aria-live="polite"></p>
|
|
13
|
+
</form>
|
|
14
|
+
</dialog>`;
|
|
15
|
+
|
|
16
|
+
function wire(B) {
|
|
17
|
+
// A shared person looks only (shell.js marks the page shared-view, and style.css hides the +), so n twice opens nothing.
|
|
18
|
+
if (document.documentElement.classList.contains('shared-view')) return;
|
|
19
|
+
const { $, store, toast, quieted, sendDraft } = B;
|
|
20
|
+
const { waitOn, keyedSend } = BoardUI;
|
|
21
|
+
const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
|
|
22
|
+
// The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
|
|
23
|
+
const sheet = $('#sheet');
|
|
24
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
25
|
+
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
26
|
+
BoardAttach.paint('ask', B.site.root);
|
|
27
|
+
// ⌘↵ sends the ask as Discuss and ⌥↵ as Quick build (ui.js keyedSend).
|
|
28
|
+
B.on($('#askText'), 'keydown', (ev) => {
|
|
29
|
+
const button = keyedSend(ev, $('#askForm'));
|
|
30
|
+
if (!button) return;
|
|
31
|
+
ev.preventDefault();
|
|
32
|
+
$('#askForm').requestSubmit(button);
|
|
33
|
+
});
|
|
34
|
+
$('#askText').value = store.get('draft:ask', '');
|
|
35
|
+
B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
|
|
36
|
+
let stopFitting = null;
|
|
37
|
+
const stopFit = () => { stopFitting?.(); stopFitting = null; };
|
|
38
|
+
B.on(sheet, 'close', stopFit);
|
|
39
|
+
B.atUnmount(stopFit);
|
|
40
|
+
function openSheet() {
|
|
41
|
+
if (sheet.open) return;
|
|
42
|
+
stopFit();
|
|
43
|
+
stopFitting = BoardDialogs.fitSheet(sheet);
|
|
44
|
+
sheet.classList.remove('closing');
|
|
45
|
+
sheet.showModal();
|
|
46
|
+
$('#askText').focus();
|
|
47
|
+
$('#compose').setAttribute('aria-expanded', 'true');
|
|
48
|
+
}
|
|
49
|
+
function closeSheet() {
|
|
50
|
+
if (!sheet.open || sheet.classList.contains('closing')) return;
|
|
51
|
+
$('#compose').setAttribute('aria-expanded', 'false');
|
|
52
|
+
if (reduceMotion.matches) { sheet.close(); return; }
|
|
53
|
+
sheet.classList.add('closing');
|
|
54
|
+
B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
|
|
55
|
+
}
|
|
56
|
+
B.on($('#compose'), 'click', openSheet);
|
|
57
|
+
// n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
|
|
58
|
+
// nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
|
|
59
|
+
// own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
|
|
60
|
+
// defaultPrevented and doesn't count.
|
|
61
|
+
const TWICE_MS = 500;
|
|
62
|
+
let lastN = -Infinity;
|
|
63
|
+
const newTaskKey = (ev) => {
|
|
64
|
+
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
65
|
+
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
66
|
+
const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
67
|
+
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
|
|
68
|
+
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
69
|
+
lastN = -Infinity;
|
|
70
|
+
ev.preventDefault();
|
|
71
|
+
openSheet();
|
|
72
|
+
};
|
|
73
|
+
B.newTaskKey = newTaskKey;
|
|
74
|
+
B.on(document, 'keydown', newTaskKey);
|
|
75
|
+
B.on($('#sheetClose'), 'click', closeSheet);
|
|
76
|
+
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
77
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
78
|
+
// Discuss has the session grill or prototype the task at once; Quick build sends the ask with quick: true, and the
|
|
79
|
+
// session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
80
|
+
B.on($('#askForm'), 'submit', async (ev) => {
|
|
81
|
+
ev.preventDefault();
|
|
82
|
+
$('#askText').blur();
|
|
83
|
+
const text = $('#askText').value.trim();
|
|
84
|
+
const files = BoardAttach.files('ask', B.site.root);
|
|
85
|
+
const button = ev.submitter;
|
|
86
|
+
const quick = button.dataset.intent === 'quick';
|
|
87
|
+
if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
|
|
88
|
+
$('#askStatus').textContent = '';
|
|
89
|
+
try {
|
|
90
|
+
await waitOn(button, sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
|
|
91
|
+
BoardAttach.clear('ask', B.site.root);
|
|
92
|
+
$('#askStatus').textContent = '';
|
|
93
|
+
closeSheet();
|
|
94
|
+
toast('Sent');
|
|
95
|
+
} catch { $('#askStatus').textContent = 'Could not send'; }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { markup, wire };
|
|
100
|
+
})();
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
<h2 class="label">Polling status</h2>
|
|
76
76
|
<div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
|
|
77
77
|
<section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
|
|
78
|
-
<div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span></footer></div></main></div>`;
|
|
78
|
+
<div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
|
|
@@ -127,6 +127,21 @@
|
|
|
127
127
|
|
|
128
128
|
const kit = { sidebar, hub, cards, sheet, toast, on, fake, esc };
|
|
129
129
|
|
|
130
|
+
// [element, place] for each element in stage, its place the tag and class of it and each ancestor up to the stage, with
|
|
131
|
+
// a count telling apart boxes that share one, so a take drawn again finds the same box.
|
|
132
|
+
function placesIn(stage) {
|
|
133
|
+
const seen = new Map();
|
|
134
|
+
return [...stage.querySelectorAll('*')].map((el) => {
|
|
135
|
+
let path = '';
|
|
136
|
+
for (let at = el; at && at !== stage; at = at.parentElement) path = `${at.tagName}.${at.className}/${path}`;
|
|
137
|
+
const n = seen.get(path) || 0;
|
|
138
|
+
seen.set(path, n + 1);
|
|
139
|
+
return [el, `${path}#${n}`];
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// place → [scrollTop, scrollLeft] of each box in stage scrolled away from its start.
|
|
143
|
+
const scrolledBoxes = (stage) => new Map(placesIn(stage).filter(([el]) => el.scrollTop || el.scrollLeft).map(([el, place]) => [place, [el.scrollTop, el.scrollLeft]]));
|
|
144
|
+
|
|
130
145
|
function start(data) {
|
|
131
146
|
const keys = data.takes.map((t) => t.key);
|
|
132
147
|
const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
|
|
@@ -158,13 +173,23 @@
|
|
|
158
173
|
try { cleanup?.(); } catch (err) { console.error(err); }
|
|
159
174
|
cleanup = null;
|
|
160
175
|
ends.splice(0).forEach((end) => end());
|
|
161
|
-
// A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn
|
|
176
|
+
// A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn, and
|
|
177
|
+
// each box scrolled inside the take scrolls where it was once the new take has drawn the same box.
|
|
162
178
|
const old = document.getElementById('stage');
|
|
163
179
|
const stage = old.cloneNode(false);
|
|
164
180
|
const y = window.scrollY;
|
|
181
|
+
const scrolled = scrolledBoxes(old);
|
|
165
182
|
stage.style.minHeight = `${old.offsetHeight}px`;
|
|
166
183
|
old.replaceWith(stage);
|
|
167
|
-
const settle = () => {
|
|
184
|
+
const settle = () => {
|
|
185
|
+
if (n !== shown) return;
|
|
186
|
+
stage.style.minHeight = '';
|
|
187
|
+
for (const [el, place] of placesIn(stage)) {
|
|
188
|
+
const at = scrolled.get(place);
|
|
189
|
+
if (at) [el.scrollTop, el.scrollLeft] = at;
|
|
190
|
+
}
|
|
191
|
+
window.scrollTo(0, y);
|
|
192
|
+
};
|
|
168
193
|
document.documentElement.dataset.take = key;
|
|
169
194
|
const take = data.takes.find((t) => t.key === key);
|
|
170
195
|
if (!take.src) {
|