@lovek4d/kickflip 0.5.3 → 0.6.1
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 +82 -46
- package/SKILL.md +21 -15
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +12 -12
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +179 -92
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +40 -47
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +19 -45
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +42 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +130 -31
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +65 -24
- package/board/public/sidebar.css +6 -0
- package/board/public/sidebar.js +19 -69
- package/board/public/style.css +116 -73
- package/board/public/ui-cards.js +78 -57
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +23 -16
- package/board/public/ui-hub.js +19 -5
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +31 -23
- package/board/public/ui-snapshot.js +44 -53
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +94 -18
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +5 -2
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -2
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +2 -2
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +6 -6
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,72 +1,41 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The board view's saved copy: reading it from IndexedDB and saving it there with its change number
|
|
2
|
+
// (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md), flashing the lines the catch-up changed, and the sign
|
|
3
|
+
// on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
|
|
4
4
|
// Needs app-core.js and ui-snapshot.js; app.js paints it.
|
|
5
5
|
BoardView.part((B) => {
|
|
6
6
|
const { $$, state } = B;
|
|
7
|
-
const {
|
|
8
|
-
// The hub serves every site from one origin, so each board keeps its own key.
|
|
9
|
-
const KEY =
|
|
10
|
-
// Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
|
|
11
|
-
// dropping first, so they leave room in the browser's 5 MB of storage for the drafts, which would otherwise fail to
|
|
12
|
-
// save.
|
|
13
|
-
const BUDGET = 3 * SNAPSHOT_MAX;
|
|
14
|
-
const ORDER = 'boardSnapshots';
|
|
7
|
+
const { copyOf, boardCopy, copyStore, preloadKeys, changedLines, updateSign } = BoardUI;
|
|
8
|
+
// The hub serves every site from one origin, so each board keeps its own key in the one store.
|
|
9
|
+
const KEY = B.site.url('');
|
|
15
10
|
const BUILD = document.documentElement.dataset.build;
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try { order = JSON.parse(localStorage.getItem(ORDER) || '[]'); } catch {}
|
|
26
|
-
const stored = [];
|
|
27
|
-
for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith('boardSnapshot:') && k !== KEY) stored.push(k); }
|
|
28
|
-
const recent = [...order.filter((k) => stored.includes(k)), ...stored.filter((k) => !order.includes(k))];
|
|
29
|
-
let used = raw.length;
|
|
30
|
-
const kept = [KEY];
|
|
31
|
-
for (const k of recent) {
|
|
32
|
-
const size = localStorage.getItem(k)?.length || 0;
|
|
33
|
-
if (used + size <= BUDGET) { used += size; kept.push(k); } else localStorage.removeItem(k);
|
|
34
|
-
}
|
|
35
|
-
localStorage.setItem(ORDER, JSON.stringify(kept));
|
|
36
|
-
}
|
|
12
|
+
const copies = (mode, fn) => copyStore('boards', mode, fn);
|
|
13
|
+
|
|
14
|
+
// → {savedAt, board, threads} or null. anyBuild reads a copy another build saved too, which only a board out of reach
|
|
15
|
+
// paints, guarded (app.js showDown).
|
|
16
|
+
const readCopy = ({ anyBuild = false } = {}) => boardCopy(KEY, anyBuild ? null : BUILD);
|
|
17
|
+
|
|
18
|
+
// Saves the board on screen. app.html's boot reads copyVersion before any script loads, so its first board request
|
|
19
|
+
// asks since the copy's number; it names no number when the save failed.
|
|
37
20
|
let saveTimer = null;
|
|
38
|
-
function
|
|
21
|
+
async function saveCopy() {
|
|
39
22
|
saveTimer?.();
|
|
40
23
|
saveTimer = null;
|
|
41
24
|
if (state.view !== 'fresh' || !state.board) return;
|
|
42
|
-
const
|
|
43
|
-
build: BUILD,
|
|
44
|
-
savedAt: state.board.generatedAt || new Date().toISOString(),
|
|
45
|
-
keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
|
|
46
|
-
});
|
|
25
|
+
const copy = copyOf(state.board, state.threadCache, { build: BUILD, savedAt: state.board.generatedAt || new Date().toISOString() });
|
|
47
26
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} catch { try { localStorage.removeItem(KEY); } catch {} }
|
|
27
|
+
await copies('readwrite', (store) => store.put(copy, KEY));
|
|
28
|
+
B.store.set('copyVersion', { build: BUILD, version: copy.board.version });
|
|
29
|
+
} catch { B.store.set('copyVersion', null); }
|
|
52
30
|
}
|
|
53
|
-
const
|
|
54
|
-
B.on(window, 'pagehide', () => { if (saveTimer)
|
|
55
|
-
|
|
56
|
-
B.atUnmount(saveSnapshot);
|
|
31
|
+
const saveCopySoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveCopy); };
|
|
32
|
+
B.on(window, 'pagehide', () => { if (saveTimer) saveCopy(); });
|
|
33
|
+
B.atUnmount(saveCopy);
|
|
57
34
|
|
|
58
|
-
// view is
|
|
35
|
+
// view is null until a board paints, then 'fresh'; a guarded paint that failed sets it back to null.
|
|
59
36
|
function setView(view) {
|
|
60
37
|
state.view = view;
|
|
61
|
-
B.root.dataset.board = view;
|
|
62
|
-
}
|
|
63
|
-
for (const type of ['click', 'submit']) {
|
|
64
|
-
B.on(B.root, type, (ev) => {
|
|
65
|
-
const { blocked } = liveControls(state.view);
|
|
66
|
-
if (!blocked) return;
|
|
67
|
-
const control = type === 'submit' ? ev.target.querySelector?.('button[type=submit]') : ev.target.closest?.(blocked);
|
|
68
|
-
if (control?.matches(blocked)) { ev.preventDefault(); ev.stopImmediatePropagation(); }
|
|
69
|
-
}, true);
|
|
38
|
+
if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
|
|
70
39
|
}
|
|
71
40
|
|
|
72
41
|
// The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
|
|
@@ -79,31 +48,39 @@ BoardView.part((B) => {
|
|
|
79
48
|
}
|
|
80
49
|
const texts = (els) => Object.fromEntries([...els].map(([id, el]) => [id, el.textContent.replace(/\s+/g, ' ').trim()]));
|
|
81
50
|
let before = null;
|
|
51
|
+
// The one highlight: every changed line, moved card and updated card takes the full tint of style.css .tint. Re-adding
|
|
52
|
+
// the class after a layout read restarts the animation, from the delay's point.
|
|
53
|
+
function highlight(el, elapsed = 0) {
|
|
54
|
+
el.classList.remove('tint');
|
|
55
|
+
el.style.setProperty('--sign-delay', `-${elapsed}ms`);
|
|
56
|
+
void el.offsetWidth;
|
|
57
|
+
el.classList.add('tint');
|
|
58
|
+
}
|
|
82
59
|
const holdForFlash = () => { before = texts(lines()); };
|
|
83
60
|
function flashChanged() {
|
|
84
61
|
if (!before || state.view !== 'fresh') return;
|
|
85
62
|
const els = lines();
|
|
86
63
|
const after = texts(els);
|
|
87
64
|
for (const id of changedLines(before, after)) {
|
|
88
|
-
els.get(id)
|
|
65
|
+
highlight(els.get(id));
|
|
89
66
|
before[id] = after[id];
|
|
90
67
|
}
|
|
91
68
|
}
|
|
92
69
|
const endFlash = () => { before = null; };
|
|
93
70
|
|
|
94
71
|
// Since the page last drew it, a last visit's board included, a card that changed lanes, a work card that takes
|
|
95
|
-
// the place of a drawn New task (its fromAsk),
|
|
96
|
-
//
|
|
97
|
-
// card's next change unsigned until the first board load started after send settles.
|
|
72
|
+
// the place of a drawn New task (its fromAsk), a work card or New task that joins Todo at the bottom, or any other
|
|
73
|
+
// card whose updateSign changed takes the same tint. It does not show for a change the owner sent from this page:
|
|
74
|
+
// sentHere(key, send) holds the card's next change unsigned until the first board load started after send settles.
|
|
98
75
|
let drawn = new Map();
|
|
99
76
|
const ownChanges = new Map();
|
|
100
77
|
async function sentHere(key, send) {
|
|
101
78
|
ownChanges.set(key, Infinity);
|
|
102
79
|
try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
|
|
103
80
|
}
|
|
104
|
-
//
|
|
81
|
+
// The tint's style.css animation length. A lane repaint resets a card's classes, so a tint still running is put back
|
|
105
82
|
// as far into its animation as it was.
|
|
106
|
-
const
|
|
83
|
+
const TINT_MS = 2400;
|
|
107
84
|
const running = new Map();
|
|
108
85
|
function signCards(load = null) {
|
|
109
86
|
const now = Date.now();
|
|
@@ -114,19 +91,15 @@ BoardView.part((B) => {
|
|
|
114
91
|
const updated = Boolean(was) && was.sign !== updateSign(it);
|
|
115
92
|
const own = ownChanges.get(it.key);
|
|
116
93
|
if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
|
|
117
|
-
if ((moved || updated) && state.view === 'fresh') running.set(it.key,
|
|
94
|
+
if ((moved || updated) && state.view === 'fresh') running.set(it.key, now);
|
|
118
95
|
}
|
|
119
96
|
drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
|
|
120
|
-
for (const [key,
|
|
121
|
-
const card = now - at <
|
|
97
|
+
for (const [key, at] of running) {
|
|
98
|
+
const card = now - at < TINT_MS && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
122
99
|
if (!card) { running.delete(key); continue; }
|
|
123
|
-
|
|
124
|
-
card.classList.remove('flash', 'tint');
|
|
125
|
-
card.style.setProperty('--sign-delay', `-${now - at}ms`);
|
|
126
|
-
void card.offsetWidth;
|
|
127
|
-
card.classList.add(sign);
|
|
100
|
+
highlight(card, now - at);
|
|
128
101
|
}
|
|
129
102
|
}
|
|
130
103
|
|
|
131
|
-
Object.assign(B, {
|
|
104
|
+
Object.assign(B, { readCopy, saveCopySoon, setView, holdForFlash, flashChanged, endFlash, highlight, sentHere, signCards });
|
|
132
105
|
});
|
|
@@ -1,42 +1,40 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
|
-
const { $, store, state,
|
|
4
|
-
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton,
|
|
3
|
+
const { $, store, state, quieted, sendDraft, links } = B;
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine, BUTTON } = BoardUI;
|
|
5
5
|
|
|
6
|
-
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
|
|
7
|
-
// A /prototype or /
|
|
8
|
-
const SLASH = /(^|\s)\/(grill|prototype)(?=\s|$)/g;
|
|
6
|
+
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
|
|
7
|
+
// A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
|
|
8
|
+
const SLASH = /(^|\s)\/(grill|prototype|visual)(?=\s|$)/g;
|
|
9
9
|
const slashOf = (value) => ({ flag: [...value.matchAll(SLASH)].pop()?.[2] ?? null, text: value.replace(SLASH, '$1').replace(/ {2,}/g, ' ').trim() });
|
|
10
10
|
|
|
11
11
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
12
12
|
|
|
13
13
|
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
|
|
14
14
|
// docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
|
|
15
|
+
// docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
|
|
15
16
|
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
16
|
-
// action: builder orange once the card is ready, quiet before.
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
// action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
|
|
18
|
+
// (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
|
|
19
|
+
// onto its own line under the row. Send or Reply is orange while a message clears the card's Action needed, as on a
|
|
20
|
+
// card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
|
|
21
|
+
const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
|
|
22
|
+
const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
23
|
+
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
24
|
+
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
25
|
+
const sends = (kind, item) => {
|
|
26
|
+
const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
|
|
27
|
+
return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
|
|
28
|
+
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
|
|
29
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
|
|
30
|
+
};
|
|
31
|
+
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
32
|
+
function syncSends(el, key) {
|
|
25
33
|
const at = el.querySelector('form [data-sends]');
|
|
26
34
|
if (!at) return;
|
|
27
35
|
const item = state.board?.items.find((i) => i.key === key);
|
|
28
36
|
const kind = sendsOf(item);
|
|
29
|
-
|
|
30
|
-
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
|
|
31
|
-
const tools = el.querySelector('form .tools');
|
|
32
|
-
const bolt = tools.querySelector('[data-bolt]');
|
|
33
|
-
if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
|
|
34
|
-
else if (!todo) bolt?.remove();
|
|
35
|
-
const html = doneButton(item);
|
|
36
|
-
const current = el.querySelector('form [data-confirm="finish"]');
|
|
37
|
-
if (current?.outerHTML === html) return;
|
|
38
|
-
current?.remove();
|
|
39
|
-
if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
|
|
37
|
+
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind, item)); at.remove(); }
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
@@ -67,7 +65,7 @@ BoardView.part((B) => {
|
|
|
67
65
|
function renderThread(key, msgs) {
|
|
68
66
|
const el = $(`[data-thread="${CSS.escape(key)}"]`);
|
|
69
67
|
if (!el) return;
|
|
70
|
-
|
|
68
|
+
syncSends(el, key);
|
|
71
69
|
const edits = editsIn(key);
|
|
72
70
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
73
71
|
const view = viewOf(key);
|
|
@@ -79,21 +77,24 @@ BoardView.part((B) => {
|
|
|
79
77
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
80
78
|
if (!el.querySelector('.msgs')) {
|
|
81
79
|
const draft = store.get('draft:' + key, '');
|
|
82
|
-
|
|
80
|
+
// The row: What's left? at the left, the eye, fire, image and attach at its right, and the send buttons
|
|
81
|
+
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
82
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`, { attach: false })}
|
|
83
83
|
<div class="pending-files" data-files></div>
|
|
84
|
-
<div class="row"><
|
|
84
|
+
<div class="row"><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><span class="icons">${protoButton(key)}${grillButton(key)}${visualButton(key)}${attachButton()}</span>${sends('one')}</div></form>`;
|
|
85
85
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
86
86
|
}
|
|
87
|
-
|
|
87
|
+
syncSends(el, key);
|
|
88
88
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
89
89
|
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
90
90
|
B.flashChanged?.(el);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
|
|
94
|
-
// The composer's eye asks for a prototype and its
|
|
95
|
-
// typed, as /prototype or /
|
|
96
|
-
|
|
94
|
+
// The composer's eye asks for a prototype, its fire for a grill and its image for a visual: a tap sends the request at
|
|
95
|
+
// once, with whatever is typed, as /prototype, /grill or /visual in the text does on Reply. Nothing typed sends the
|
|
96
|
+
// bare request (docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
97
|
+
const PLACEHOLDER = 'Reply in this thread… (/prototype, /grill or /visual asks for one)';
|
|
97
98
|
|
|
98
99
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
99
100
|
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
@@ -114,7 +115,7 @@ BoardView.part((B) => {
|
|
|
114
115
|
loadedAt.set(key, Date.now());
|
|
115
116
|
state.threadCache.set(key, msgs);
|
|
116
117
|
renderThread(key, msgs);
|
|
117
|
-
B.
|
|
118
|
+
B.saveCopySoon?.();
|
|
118
119
|
})
|
|
119
120
|
.catch(() => {})
|
|
120
121
|
.finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
|
|
@@ -136,14 +137,6 @@ BoardView.part((B) => {
|
|
|
136
137
|
try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
|
|
137
138
|
catch (e) { B.failed(e); }
|
|
138
139
|
});
|
|
139
|
-
// docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
|
|
140
|
-
B.on(B.root, 'click', async (ev) => {
|
|
141
|
-
const b = ev.target.closest('form[data-reply] button[data-bolt]');
|
|
142
|
-
if (!b) return;
|
|
143
|
-
const key = b.dataset.bolt;
|
|
144
|
-
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
|
|
145
|
-
catch { toast('Could not send'); }
|
|
146
|
-
});
|
|
147
140
|
// Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
|
|
148
141
|
async function post(f, flag, button) {
|
|
149
142
|
const ta = f.querySelector('textarea');
|
|
@@ -151,7 +144,8 @@ BoardView.part((B) => {
|
|
|
151
144
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
152
145
|
const id = f.dataset.composer;
|
|
153
146
|
const files = BoardAttach.files(id, B.site.root);
|
|
154
|
-
// An empty box still sends Implement, the card's build action
|
|
147
|
+
// An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
|
|
148
|
+
// starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
155
149
|
const intent = flag ? null : button.dataset.intent || null;
|
|
156
150
|
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
157
151
|
const key = f.dataset.reply;
|
|
@@ -166,24 +160,26 @@ BoardView.part((B) => {
|
|
|
166
160
|
try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
|
|
167
161
|
}
|
|
168
162
|
B.on(B.root, 'click', (ev) => {
|
|
169
|
-
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
170
|
-
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
|
|
163
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill], form[data-reply] button[data-visual]');
|
|
164
|
+
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : b.hasAttribute('data-grill') ? 'grill' : 'visual', b);
|
|
171
165
|
});
|
|
172
166
|
B.on(B.root, 'submit', (ev) => {
|
|
173
167
|
const f = ev.target.closest('form[data-reply]');
|
|
174
168
|
if (!f) return;
|
|
175
169
|
ev.preventDefault();
|
|
170
|
+
// Done on ⌥↵ (app-asks.js) confirms and closes the card as its tap does (app-cards.js).
|
|
171
|
+
if (ev.submitter?.dataset.confirm) { B.confirmTap(ev.submitter); return; }
|
|
176
172
|
post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
|
|
177
173
|
});
|
|
178
174
|
// A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
|
|
179
175
|
B.on(B.root, 'attach-change', (ev) => {
|
|
180
176
|
for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
181
|
-
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
177
|
+
if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill], button[data-visual]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
182
178
|
});
|
|
183
179
|
|
|
184
180
|
Object.assign(B, {
|
|
185
181
|
thread, renderThread, loadThread,
|
|
186
182
|
repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
|
|
187
|
-
|
|
183
|
+
syncSends: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncSends(el, key); },
|
|
188
184
|
});
|
|
189
185
|
});
|
package/board/public/app.html
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
<title>Claude</title>
|
|
7
7
|
<script>
|
|
8
8
|
// The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
|
|
9
|
-
// below; app.js loadBoard takes the loads over when it mounts that board.
|
|
9
|
+
// below; app.js loadBoard takes the loads over when it mounts that board. With a copy of the board saved under this
|
|
10
|
+
// build (app-snapshot.js copyVersion), it asks only for the changes since the copy's number. A board's other views load their own, and a
|
|
10
11
|
// site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
|
|
11
12
|
// after one waits for it to load.
|
|
12
13
|
window.BoardBoot = (function () {
|
|
@@ -16,12 +17,16 @@ window.BoardBoot = (function () {
|
|
|
16
17
|
if (!route || route === '_hub') return null;
|
|
17
18
|
try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
|
|
18
19
|
const base = '/' + encodeURIComponent(route) + '/';
|
|
19
|
-
|
|
20
|
+
// A failed answer rides on the error, for the board view's BoardUI.downOf.
|
|
21
|
+
const unanswered = (r) => Object.assign(new Error(String(r.status)), { answer: r });
|
|
22
|
+
const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
|
|
20
23
|
let open = [];
|
|
21
24
|
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
22
25
|
const focus = /^#item=([^&]+)/.exec(location.hash);
|
|
23
26
|
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
24
|
-
|
|
27
|
+
let since = null;
|
|
28
|
+
try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
|
|
29
|
+
return { route, board: get('api/board' + (since === null ? '' : '?since=' + since)), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
|
|
25
30
|
})();
|
|
26
31
|
</script>
|
|
27
32
|
<script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
|
|
@@ -41,7 +46,7 @@ window.BoardBoot = (function () {
|
|
|
41
46
|
<script src="/_hub/app/ui.js"></script>
|
|
42
47
|
<script src="/_hub/app/ui-links.js"></script>
|
|
43
48
|
<script src="/_hub/app/ui-enums.js"></script>
|
|
44
|
-
<script src="/_hub/app/ui-
|
|
49
|
+
<script src="/_hub/app/ui-progress-bar.js"></script>
|
|
45
50
|
<script src="/_hub/app/ui-order.js"></script>
|
|
46
51
|
<script src="/_hub/app/ui-patch.js"></script>
|
|
47
52
|
<script src="/_hub/app/ui-threads.js"></script>
|
package/board/public/app.js
CHANGED
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
// them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
|
|
7
7
|
// BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
|
|
8
8
|
// (view-*.js) the same site, $ and recorders.
|
|
9
|
-
// opts,
|
|
9
|
+
// opts, from the app shell (shell.js):
|
|
10
10
|
// signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
|
|
11
|
-
// live {fresh, generatedAt}
|
|
12
|
-
// this board was left fresh and hasn't changed since, so a stored last visit whose board has
|
|
13
|
-
// that generatedAt paints live, with the header time `fresh`, rather than dimmed
|
|
14
11
|
// Load this after ui-*.js, attach.js and toasts.js, and before the app-*.js files.
|
|
15
12
|
window.BoardView = (function () {
|
|
16
13
|
const parts = [];
|
|
@@ -29,6 +26,8 @@ window.BoardView = (function () {
|
|
|
29
26
|
for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
|
|
30
27
|
// 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
28
|
for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
|
|
29
|
+
// The saved copy moved to IndexedDB (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
|
|
30
|
+
for (const k of old.filter((k) => k === 'boardSnapshots' || k.startsWith('boardSnapshot:'))) localStorage.removeItem(k);
|
|
32
31
|
for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
33
32
|
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
34
33
|
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
@@ -129,6 +128,8 @@ window.BoardView = (function () {
|
|
|
129
128
|
board: null,
|
|
130
129
|
fresh: null,
|
|
131
130
|
openItems: new Set(store.get('openItems', [])),
|
|
131
|
+
// The cards open when the page loaded: Done and Asks hide those past their cap until the owner opens one this visit.
|
|
132
|
+
restored: new Set(store.get('openItems', [])),
|
|
132
133
|
threadCache: new Map(),
|
|
133
134
|
edits: new Map(),
|
|
134
135
|
githubPending: null,
|
|
@@ -144,7 +145,8 @@ window.BoardView = (function () {
|
|
|
144
145
|
const keep = (fn) => { if (B.alive()) keepers.push(fn); };
|
|
145
146
|
const place = places.get(B.route) || null;
|
|
146
147
|
places.delete(B.route);
|
|
147
|
-
Object.assign(B, { store, state, signal,
|
|
148
|
+
Object.assign(B, { store, state, signal, pause: opts.pause, away: opts.away, place, keep });
|
|
149
|
+
BoardUI.usagePause = opts.pause;
|
|
148
150
|
|
|
149
151
|
function mount() {
|
|
150
152
|
if (B.alive()) return;
|
|
@@ -220,6 +222,7 @@ window.BoardView = (function () {
|
|
|
220
222
|
focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
|
|
221
223
|
replyKey = m[2] ? focusKey : null;
|
|
222
224
|
state.openItems.add(focusKey);
|
|
225
|
+
state.restored.delete(focusKey);
|
|
223
226
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
224
227
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
225
228
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
@@ -263,24 +266,24 @@ window.BoardView = (function () {
|
|
|
263
266
|
ta.focus({ preventScroll: true });
|
|
264
267
|
}
|
|
265
268
|
|
|
266
|
-
// The
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (!
|
|
272
|
-
|
|
273
|
-
state.board =
|
|
274
|
-
for (const [key, msgs] of Object.entries(
|
|
275
|
-
state.fresh =
|
|
276
|
-
B.setView(
|
|
269
|
+
// The saved copy paints live before the first load, which asks only for the changes since its number; the lines
|
|
270
|
+
// that catch-up changes flash (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). A board that landed
|
|
271
|
+
// meanwhile stays.
|
|
272
|
+
// old: a copy another build saved, whose board the next load asks for whole.
|
|
273
|
+
function paintCopy(copy, { old = false } = {}) {
|
|
274
|
+
if (!copy || !B.alive() || state.view === 'fresh') return;
|
|
275
|
+
state.oldCopy = old;
|
|
276
|
+
state.board = copy.board;
|
|
277
|
+
for (const [key, msgs] of Object.entries(copy.threads)) state.threadCache.set(key, msgs);
|
|
278
|
+
state.fresh = { at: copy.savedAt, sig: null };
|
|
279
|
+
B.setView('fresh');
|
|
277
280
|
readHash();
|
|
278
281
|
hashRead = true;
|
|
279
282
|
render();
|
|
280
283
|
scrollToFocus(false);
|
|
281
284
|
returnToPlace();
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
B.holdForFlash();
|
|
286
|
+
B.linkUp();
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
// The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
|
|
@@ -291,6 +294,10 @@ window.BoardView = (function () {
|
|
|
291
294
|
globalThis.BoardBoot = null;
|
|
292
295
|
if (boot?.route && boot.route !== B.route) boot = null;
|
|
293
296
|
let preloaded = false;
|
|
297
|
+
// A load nothing answers gives up after this, so an asleep host reads as out of reach.
|
|
298
|
+
const LOAD_TIMEOUT = 20000;
|
|
299
|
+
// A failed answer, carried to showDown for BoardUI.downOf.
|
|
300
|
+
const unanswered = (r) => Object.assign(new Error(`board ${r.status}`), { answer: r });
|
|
294
301
|
async function loadBoard({ via = 'poll' } = {}) {
|
|
295
302
|
const load = B.boardLoad = (B.boardLoad || 0) + 1;
|
|
296
303
|
const started = boot;
|
|
@@ -298,12 +305,15 @@ window.BoardView = (function () {
|
|
|
298
305
|
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
299
306
|
let landed = false;
|
|
300
307
|
try {
|
|
301
|
-
// A reload names the
|
|
302
|
-
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch
|
|
303
|
-
// (docs/adr/2026-11-
|
|
304
|
-
const tag = state.view === 'fresh' ? state.board.version : null;
|
|
305
|
-
const
|
|
306
|
-
|
|
308
|
+
// A reload names the change number of the board on screen: an unchanged board answers 304 with no body
|
|
309
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch of what
|
|
310
|
+
// changed after it (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
|
|
311
|
+
const tag = state.view === 'fresh' && !state.oldCopy ? state.board.version : null;
|
|
312
|
+
const timeout = new AbortController();
|
|
313
|
+
const stopTimer = B.later(LOAD_TIMEOUT, () => timeout.abort());
|
|
314
|
+
const board = await (started?.board || fetch(B.site.url('api/board' + (tag ? '?since=' + encodeURIComponent(tag) : '')), { cache: 'no-store', signal: timeout.signal, ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) })
|
|
315
|
+
.then((r) => (r.status === 304 ? null : r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r))))
|
|
316
|
+
.finally(stopTimer));
|
|
307
317
|
if (board) land(board, load); else B.linkUp();
|
|
308
318
|
landed = true;
|
|
309
319
|
if (!preloaded) {
|
|
@@ -312,12 +322,36 @@ window.BoardView = (function () {
|
|
|
312
322
|
}
|
|
313
323
|
} catch (e) {
|
|
314
324
|
B.linkDown();
|
|
325
|
+
if (state.view !== 'fresh' && B.alive()) await showDown(BoardUI.downOf(e.answer || null));
|
|
315
326
|
}
|
|
316
327
|
await Promise.all(threads);
|
|
317
328
|
if (landed) B.landed(via);
|
|
318
329
|
if (state.view === 'fresh') B.endFlash();
|
|
319
330
|
}
|
|
320
331
|
|
|
332
|
+
// A board out of reach with nothing on screen paints the copy another build saved, which the first paint skipped,
|
|
333
|
+
// guarded: a paint that throws leaves the landing, the kickflip mark and down's two lines in the board's place, and
|
|
334
|
+
// the landing retries every 5 s while the board can come back by itself
|
|
335
|
+
// (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
336
|
+
let triedOldCopy = false;
|
|
337
|
+
async function showDown(down) {
|
|
338
|
+
if (!triedOldCopy) {
|
|
339
|
+
triedOldCopy = true;
|
|
340
|
+
const old = await B.readCopy({ anyBuild: true });
|
|
341
|
+
try { paintCopy(old, { old: true }); } catch {
|
|
342
|
+
state.board = null;
|
|
343
|
+
B.setView(null);
|
|
344
|
+
}
|
|
345
|
+
if (!B.alive()) return;
|
|
346
|
+
if (state.view === 'fresh') { B.linkDown(); return; }
|
|
347
|
+
}
|
|
348
|
+
state.down = down;
|
|
349
|
+
$('#downLanding').innerHTML = BoardUI.downLanding(down);
|
|
350
|
+
$('#downLanding').hidden = false;
|
|
351
|
+
B.root.classList.add('down');
|
|
352
|
+
}
|
|
353
|
+
B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
|
|
354
|
+
|
|
321
355
|
// 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
|
|
322
356
|
// 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
|
|
323
357
|
// no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
|
|
@@ -327,6 +361,12 @@ window.BoardView = (function () {
|
|
|
327
361
|
if (!board) { signalled(); return false; }
|
|
328
362
|
const first = state.view !== 'fresh';
|
|
329
363
|
state.board = board;
|
|
364
|
+
state.oldCopy = false;
|
|
365
|
+
if (state.down) {
|
|
366
|
+
state.down = null;
|
|
367
|
+
$('#downLanding').hidden = true;
|
|
368
|
+
B.root.classList.remove('down');
|
|
369
|
+
}
|
|
330
370
|
B.setView('fresh');
|
|
331
371
|
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
332
372
|
B.linkUp();
|
|
@@ -334,7 +374,7 @@ window.BoardView = (function () {
|
|
|
334
374
|
scrollToFocus();
|
|
335
375
|
returnToPlace();
|
|
336
376
|
B.site.syncBadge();
|
|
337
|
-
B.
|
|
377
|
+
B.saveCopySoon();
|
|
338
378
|
return true;
|
|
339
379
|
}
|
|
340
380
|
|
|
@@ -382,24 +422,28 @@ window.BoardView = (function () {
|
|
|
382
422
|
render();
|
|
383
423
|
}
|
|
384
424
|
Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
385
|
-
const live = paintSnapshot();
|
|
386
425
|
B.headerWired();
|
|
387
|
-
if (live) B.linkUp();
|
|
388
426
|
const sent = BoardUI.sentNotice.take(B.route);
|
|
389
427
|
if (sent) B.toast(sent);
|
|
390
428
|
B.on(document, 'visibilitychange', () => {
|
|
391
429
|
if (document.hidden) return;
|
|
392
430
|
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
393
431
|
});
|
|
394
|
-
loadBoard();
|
|
432
|
+
B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
|
|
395
433
|
B.signal?.(signalled);
|
|
396
434
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
397
435
|
}
|
|
398
436
|
|
|
399
|
-
// shared:
|
|
437
|
+
// shared: {by, full} for someone the board is shared with, whose board opens with a strip saying what they can do there
|
|
438
|
+
// (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md). → the strip's inside
|
|
439
|
+
function sharedStrip({ by, full }) {
|
|
440
|
+
const from = `Shared with you${by ? ` by ${BoardUI.esc(by)}` : ''}.`;
|
|
441
|
+
return full ? `<b>Full.</b> ${from} You can answer and act. Merge and Board settings are ${by ? `${BoardUI.esc(by)}’s` : 'the owner’s'}.`
|
|
442
|
+
: `<b>View only.</b> ${from} You can look and open Visit links.`;
|
|
443
|
+
}
|
|
400
444
|
function markup({ title = '', shared = null } = {}) {
|
|
401
|
-
const strip = shared ? `<p class="view-only-strip"
|
|
402
|
-
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png',
|
|
445
|
+
const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
|
|
446
|
+
return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
|
|
403
447
|
<div class="split">
|
|
404
448
|
<main>
|
|
405
449
|
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
@@ -407,6 +451,8 @@ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
|
407
451
|
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
408
452
|
<div id="tryMain"></div>
|
|
409
453
|
<div id="lanes"></div>
|
|
454
|
+
<div id="downLanding" hidden></div>
|
|
455
|
+
${BoardUI.boardFoot()}
|
|
410
456
|
</main>
|
|
411
457
|
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
412
458
|
</div>
|
|
@@ -446,5 +492,5 @@ ${BoardNewTask.markup()}
|
|
|
446
492
|
</dialog>`;
|
|
447
493
|
}
|
|
448
494
|
|
|
449
|
-
return { part, create, page, markup };
|
|
495
|
+
return { part, create, page, markup, sharedStrip };
|
|
450
496
|
})();
|