@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- 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-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- 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 +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- 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 +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- 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/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -1,40 +1,52 @@
|
|
|
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,
|
|
3
|
+
const { $, store, state, quieted, sendDraft, links } = B;
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = 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
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
// docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
|
|
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
|
|
16
|
+
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
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.
|
|
20
|
+
const REVIEW = new Set(['review', 'stale']);
|
|
21
|
+
const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
22
|
+
: REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
|
|
23
|
+
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
24
|
+
const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
|
|
25
|
+
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
|
|
26
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
|
|
27
|
+
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
28
|
+
function syncSends(el, key) {
|
|
29
|
+
const at = el.querySelector('form [data-sends]');
|
|
30
|
+
if (!at) return;
|
|
31
|
+
const item = state.board?.items.find((i) => i.key === key);
|
|
32
|
+
const kind = sendsOf(item);
|
|
33
|
+
if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind, item)); at.remove(); }
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
// Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
|
|
25
|
-
// thread draws, so its unread messages start from its
|
|
26
|
-
//
|
|
37
|
+
// thread draws, so its unread messages start from its read mark from before it opened (B.seenBefore). A board left
|
|
38
|
+
// and opened again in this page keeps them, so its open threads show what they showed.
|
|
27
39
|
const kept = B.place?.threads;
|
|
28
|
-
const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
|
|
29
40
|
const moreTaps = new Map(kept?.moreTaps);
|
|
30
41
|
const firstDrawn = new Map(kept?.firstDrawn);
|
|
31
|
-
B.keep(() => ({ threads: {
|
|
42
|
+
B.keep(() => ({ threads: { moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
|
|
43
|
+
const seenAt = (key) => (B.seenBefore.has(key) ? B.seenBefore.get(key) : B.seen[key] || null);
|
|
32
44
|
const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
|
|
33
|
-
const viewOf = (key) => ({ seenAt:
|
|
45
|
+
const viewOf = (key) => ({ seenAt: seenAt(key), more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
|
|
34
46
|
B.on(B.root, 'toggle', (ev) => {
|
|
35
47
|
const el = ev.target;
|
|
36
48
|
if (!el.matches?.('details.item') || el.open) return;
|
|
37
|
-
seenBefore.
|
|
49
|
+
B.seenBefore.delete(el.dataset.key);
|
|
38
50
|
moreTaps.delete(el.dataset.key);
|
|
39
51
|
firstDrawn.delete(el.dataset.key);
|
|
40
52
|
}, true);
|
|
@@ -49,11 +61,11 @@ BoardView.part((B) => {
|
|
|
49
61
|
function renderThread(key, msgs) {
|
|
50
62
|
const el = $(`[data-thread="${CSS.escape(key)}"]`);
|
|
51
63
|
if (!el) return;
|
|
52
|
-
|
|
64
|
+
syncSends(el, key);
|
|
53
65
|
const edits = editsIn(key);
|
|
54
66
|
if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
|
|
55
67
|
const view = viewOf(key);
|
|
56
|
-
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions,
|
|
68
|
+
const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenAt(key));
|
|
57
69
|
const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
|
|
58
70
|
const sig = JSON.stringify([lines, [...edits.keys()], view]);
|
|
59
71
|
if (el.dataset.sig === sig) return;
|
|
@@ -61,36 +73,24 @@ BoardView.part((B) => {
|
|
|
61
73
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
62
74
|
if (!el.querySelector('.msgs')) {
|
|
63
75
|
const draft = store.get('draft:' + key, '');
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
// The row: What's left? at the left, the eye, fire, image and attach at its right, and the send buttons
|
|
77
|
+
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
78
|
+
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 })}
|
|
66
79
|
<div class="pending-files" data-files></div>
|
|
67
|
-
<div class="row"><
|
|
80
|
+
<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>`;
|
|
68
81
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
69
|
-
syncFlag(el.querySelector('form'));
|
|
70
82
|
}
|
|
71
|
-
|
|
83
|
+
syncSends(el, key);
|
|
72
84
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
|
|
73
85
|
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
74
86
|
B.flashChanged?.(el);
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
// docs/adr/2026-11-
|
|
78
|
-
// The composer's eye asks for a prototype
|
|
79
|
-
// with whatever is typed, as /prototype or /
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
|
|
83
|
-
function syncFlag(f) {
|
|
84
|
-
const chosen = f.dataset.flag || null;
|
|
85
|
-
f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
|
|
86
|
-
f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
|
|
87
|
-
const hint = f.querySelector('[data-hint]');
|
|
88
|
-
if (!hint) return;
|
|
89
|
-
const flag = flagOf(f);
|
|
90
|
-
const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
|
|
91
|
-
hint.classList.toggle('on', Boolean(flag));
|
|
92
|
-
hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
|
|
93
|
-
}
|
|
89
|
+
// docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
|
|
90
|
+
// 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
|
|
91
|
+
// once, with whatever is typed, as /prototype, /grill or /visual in the text does on Reply. Nothing typed sends the
|
|
92
|
+
// bare request (docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
|
|
93
|
+
const PLACEHOLDER = 'Reply in this thread… (/prototype, /grill or /visual asks for one)';
|
|
94
94
|
|
|
95
95
|
// A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
|
|
96
96
|
// starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
|
|
@@ -111,7 +111,7 @@ BoardView.part((B) => {
|
|
|
111
111
|
loadedAt.set(key, Date.now());
|
|
112
112
|
state.threadCache.set(key, msgs);
|
|
113
113
|
renderThread(key, msgs);
|
|
114
|
-
B.
|
|
114
|
+
B.saveCopySoon?.();
|
|
115
115
|
})
|
|
116
116
|
.catch(() => {})
|
|
117
117
|
.finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
|
|
@@ -122,7 +122,6 @@ BoardView.part((B) => {
|
|
|
122
122
|
B.on(B.root, 'input', (ev) => {
|
|
123
123
|
const f = ev.target.closest('form[data-reply]');
|
|
124
124
|
if (!f || ev.target.tagName !== 'TEXTAREA') return;
|
|
125
|
-
syncFlag(f);
|
|
126
125
|
if (quieted('draft:' + f.dataset.reply, ev.target)) return;
|
|
127
126
|
store.set('draft:' + f.dataset.reply, ev.target.value);
|
|
128
127
|
});
|
|
@@ -130,49 +129,53 @@ BoardView.part((B) => {
|
|
|
130
129
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
131
130
|
if (!b) return;
|
|
132
131
|
const key = b.closest('form').dataset.reply;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
B.on(B.root, 'click', (ev) => {
|
|
137
|
-
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
|
|
138
|
-
if (!b) return;
|
|
139
|
-
const f = b.closest('form');
|
|
140
|
-
const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
|
|
141
|
-
if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
|
|
142
|
-
syncFlag(f);
|
|
132
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
133
|
+
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)); }
|
|
134
|
+
catch (e) { B.failed(e); }
|
|
143
135
|
});
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!f) return;
|
|
147
|
-
ev.preventDefault();
|
|
136
|
+
// Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
|
|
137
|
+
async function post(f, flag, button) {
|
|
148
138
|
const ta = f.querySelector('textarea');
|
|
149
139
|
ta.blur();
|
|
150
|
-
const flag = flagOf(f);
|
|
151
140
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
152
141
|
const id = f.dataset.composer;
|
|
153
142
|
const files = BoardAttach.files(id, B.site.root);
|
|
154
|
-
|
|
143
|
+
// An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
|
|
144
|
+
// starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
145
|
+
const intent = flag ? null : button.dataset.intent || null;
|
|
146
|
+
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
155
147
|
const key = f.dataset.reply;
|
|
156
|
-
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}), ...(intent ? { intent } : {}) };
|
|
149
|
+
// Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
|
|
150
|
+
const sending = (async () => {
|
|
151
|
+
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
152
|
+
() => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data)));
|
|
160
153
|
BoardAttach.clear(id, B.site.root);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
154
|
+
await loadThread(key, { force: true });
|
|
155
|
+
})();
|
|
156
|
+
try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
|
|
157
|
+
}
|
|
158
|
+
B.on(B.root, 'click', (ev) => {
|
|
159
|
+
const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill], form[data-reply] button[data-visual]');
|
|
160
|
+
if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : b.hasAttribute('data-grill') ? 'grill' : 'visual', b);
|
|
161
|
+
});
|
|
162
|
+
B.on(B.root, 'submit', (ev) => {
|
|
163
|
+
const f = ev.target.closest('form[data-reply]');
|
|
164
|
+
if (!f) return;
|
|
165
|
+
ev.preventDefault();
|
|
166
|
+
// Done on ⌥↵ (app-asks.js) confirms and closes the card as its tap does (app-cards.js).
|
|
167
|
+
if (ev.submitter?.dataset.confirm) { B.confirmTap(ev.submitter); return; }
|
|
168
|
+
post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
|
|
165
169
|
});
|
|
166
|
-
//
|
|
170
|
+
// A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
|
|
167
171
|
B.on(B.root, 'attach-change', (ev) => {
|
|
168
|
-
const b
|
|
169
|
-
if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
170
|
-
if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
|
|
172
|
+
for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
|
|
173
|
+
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);
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
Object.assign(B, {
|
|
174
177
|
thread, renderThread, loadThread,
|
|
175
178
|
repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
|
|
176
|
-
|
|
179
|
+
syncSends: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncSends(el, key); },
|
|
177
180
|
});
|
|
178
181
|
});
|
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 = [];
|
|
@@ -27,7 +24,11 @@ window.BoardView = (function () {
|
|
|
27
24
|
try {
|
|
28
25
|
const old = [];
|
|
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.
|
|
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);
|
|
31
|
+
for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
|
|
31
32
|
if (localStorage.getItem(keyOf(k)) != null) continue;
|
|
32
33
|
localStorage.setItem(keyOf(k), localStorage.getItem(k));
|
|
33
34
|
localStorage.removeItem(k);
|
|
@@ -127,11 +128,13 @@ window.BoardView = (function () {
|
|
|
127
128
|
board: null,
|
|
128
129
|
fresh: null,
|
|
129
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', [])),
|
|
130
133
|
threadCache: new Map(),
|
|
131
134
|
edits: new Map(),
|
|
132
135
|
githubPending: null,
|
|
133
136
|
// Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
|
|
134
|
-
mainPull: {
|
|
137
|
+
mainPull: { open: false },
|
|
135
138
|
// The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
|
|
136
139
|
// the archive holds past them, null before the first page.
|
|
137
140
|
archived: { cards: [], left: null },
|
|
@@ -142,7 +145,8 @@ window.BoardView = (function () {
|
|
|
142
145
|
const keep = (fn) => { if (B.alive()) keepers.push(fn); };
|
|
143
146
|
const place = places.get(B.route) || null;
|
|
144
147
|
places.delete(B.route);
|
|
145
|
-
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;
|
|
146
150
|
|
|
147
151
|
function mount() {
|
|
148
152
|
if (B.alive()) return;
|
|
@@ -187,7 +191,8 @@ window.BoardView = (function () {
|
|
|
187
191
|
BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
|
|
188
192
|
B.renderMainLog(d.main, load);
|
|
189
193
|
for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
|
|
190
|
-
|
|
194
|
+
B.takeMarks(d.items);
|
|
195
|
+
for (const it of d.items) if (state.openItems.has(it.key)) markSeen(it);
|
|
191
196
|
// An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
|
|
192
197
|
const strips = B.captureStrips();
|
|
193
198
|
setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
|
|
@@ -217,6 +222,7 @@ window.BoardView = (function () {
|
|
|
217
222
|
focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
|
|
218
223
|
replyKey = m[2] ? focusKey : null;
|
|
219
224
|
state.openItems.add(focusKey);
|
|
225
|
+
state.restored.delete(focusKey);
|
|
220
226
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
221
227
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
222
228
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
@@ -229,6 +235,15 @@ window.BoardView = (function () {
|
|
|
229
235
|
ev.preventDefault();
|
|
230
236
|
readHash(); render(); scrollToFocus();
|
|
231
237
|
});
|
|
238
|
+
// Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
|
|
239
|
+
function focusOnBoard({ key, slug }) {
|
|
240
|
+
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
241
|
+
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
242
|
+
if (key) { location.hash = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
|
|
243
|
+
render();
|
|
244
|
+
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
232
247
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
|
233
248
|
function scrollToFocus(final = true) {
|
|
234
249
|
if (!focusKey) return;
|
|
@@ -251,24 +266,24 @@ window.BoardView = (function () {
|
|
|
251
266
|
ta.focus({ preventScroll: true });
|
|
252
267
|
}
|
|
253
268
|
|
|
254
|
-
// The
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (!
|
|
260
|
-
|
|
261
|
-
state.board =
|
|
262
|
-
for (const [key, msgs] of Object.entries(
|
|
263
|
-
state.fresh =
|
|
264
|
-
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');
|
|
265
280
|
readHash();
|
|
266
281
|
hashRead = true;
|
|
267
282
|
render();
|
|
268
283
|
scrollToFocus(false);
|
|
269
284
|
returnToPlace();
|
|
270
|
-
|
|
271
|
-
|
|
285
|
+
B.holdForFlash();
|
|
286
|
+
B.linkUp();
|
|
272
287
|
}
|
|
273
288
|
|
|
274
289
|
// The first load takes over app.html's BoardBoot fetches, so only the first view mounted takes them, and only for
|
|
@@ -279,6 +294,10 @@ window.BoardView = (function () {
|
|
|
279
294
|
globalThis.BoardBoot = null;
|
|
280
295
|
if (boot?.route && boot.route !== B.route) boot = null;
|
|
281
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 });
|
|
282
301
|
async function loadBoard({ via = 'poll' } = {}) {
|
|
283
302
|
const load = B.boardLoad = (B.boardLoad || 0) + 1;
|
|
284
303
|
const started = boot;
|
|
@@ -286,12 +305,15 @@ window.BoardView = (function () {
|
|
|
286
305
|
const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
|
|
287
306
|
let landed = false;
|
|
288
307
|
try {
|
|
289
|
-
// A reload names the
|
|
290
|
-
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md), and a changed one a patch
|
|
291
|
-
// (docs/adr/2026-11-
|
|
292
|
-
const tag = state.view === 'fresh' ? state.board.version : null;
|
|
293
|
-
const
|
|
294
|
-
|
|
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));
|
|
295
317
|
if (board) land(board, load); else B.linkUp();
|
|
296
318
|
landed = true;
|
|
297
319
|
if (!preloaded) {
|
|
@@ -300,12 +322,36 @@ window.BoardView = (function () {
|
|
|
300
322
|
}
|
|
301
323
|
} catch (e) {
|
|
302
324
|
B.linkDown();
|
|
325
|
+
if (state.view !== 'fresh' && B.alive()) await showDown(BoardUI.downOf(e.answer || null));
|
|
303
326
|
}
|
|
304
327
|
await Promise.all(threads);
|
|
305
328
|
if (landed) B.landed(via);
|
|
306
329
|
if (state.view === 'fresh') B.endFlash();
|
|
307
330
|
}
|
|
308
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
|
+
|
|
309
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
|
|
310
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
|
|
311
357
|
// no longer on screen, which another answer replaced meanwhile, reloads instead. → whether it landed
|
|
@@ -315,6 +361,12 @@ window.BoardView = (function () {
|
|
|
315
361
|
if (!board) { signalled(); return false; }
|
|
316
362
|
const first = state.view !== 'fresh';
|
|
317
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
|
+
}
|
|
318
370
|
B.setView('fresh');
|
|
319
371
|
if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
|
|
320
372
|
B.linkUp();
|
|
@@ -322,7 +374,7 @@ window.BoardView = (function () {
|
|
|
322
374
|
scrollToFocus();
|
|
323
375
|
returnToPlace();
|
|
324
376
|
B.site.syncBadge();
|
|
325
|
-
B.
|
|
377
|
+
B.saveCopySoon();
|
|
326
378
|
return true;
|
|
327
379
|
}
|
|
328
380
|
|
|
@@ -336,10 +388,16 @@ window.BoardView = (function () {
|
|
|
336
388
|
const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
|
|
337
389
|
if (!B.alive()) return out;
|
|
338
390
|
answered?.();
|
|
339
|
-
land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
|
|
391
|
+
if (!land(out.board, B.boardLoad = (B.boardLoad || 0) + 1)) await boardAfter(out.board.generatedAt);
|
|
340
392
|
return out;
|
|
341
393
|
}
|
|
342
394
|
|
|
395
|
+
// A board that reload raced the POST left the tap's own board unlanded (land): the tap is not drawn until a board
|
|
396
|
+
// 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).
|
|
397
|
+
async function boardAfter(generatedAt) {
|
|
398
|
+
for (let tries = 0; tries < 3 && B.alive() && !(state.view === 'fresh' && state.board.generatedAt >= generatedAt); tries++) await (reloading || loadBoard({ via: 'signal' }));
|
|
399
|
+
}
|
|
400
|
+
|
|
343
401
|
// The server signals each change to what the board reads, through the shell, and the board reloads at once; the
|
|
344
402
|
// 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).
|
|
345
403
|
let reloading = null;
|
|
@@ -356,30 +414,48 @@ window.BoardView = (function () {
|
|
|
356
414
|
|
|
357
415
|
// The toast for a tap the server refused, with its reason, or that never reached it.
|
|
358
416
|
const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
|
|
359
|
-
|
|
360
|
-
|
|
417
|
+
// 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.
|
|
418
|
+
function stillWaiting() {
|
|
419
|
+
if (!B.alive()) return;
|
|
420
|
+
B.toast('Still waiting on the board');
|
|
421
|
+
signalled();
|
|
422
|
+
render();
|
|
423
|
+
}
|
|
424
|
+
Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
|
|
361
425
|
B.headerWired();
|
|
362
|
-
if (live) B.linkUp();
|
|
363
426
|
const sent = BoardUI.sentNotice.take(B.route);
|
|
364
427
|
if (sent) B.toast(sent);
|
|
365
428
|
B.on(document, 'visibilitychange', () => {
|
|
366
429
|
if (document.hidden) return;
|
|
367
430
|
if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
|
|
368
431
|
});
|
|
369
|
-
loadBoard();
|
|
432
|
+
B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
|
|
370
433
|
B.signal?.(signalled);
|
|
371
434
|
B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
|
|
372
435
|
}
|
|
373
436
|
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
}
|
|
444
|
+
function markup({ title = '', shared = null } = {}) {
|
|
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 })}
|
|
447
|
+
<div class="split">
|
|
376
448
|
<main>
|
|
377
|
-
<section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
449
|
+
${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
|
|
378
450
|
<section id="answers" class="answer-banner" hidden></section>
|
|
379
|
-
<section id="
|
|
451
|
+
<section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
|
|
380
452
|
<div id="tryMain"></div>
|
|
381
453
|
<div id="lanes"></div>
|
|
454
|
+
<div id="downLanding" hidden></div>
|
|
455
|
+
${BoardUI.boardFoot()}
|
|
382
456
|
</main>
|
|
457
|
+
<aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
|
|
458
|
+
</div>
|
|
383
459
|
${BoardNewTask.markup()}
|
|
384
460
|
<dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
|
|
385
461
|
<div class="sheet-body">
|
|
@@ -416,5 +492,5 @@ ${BoardNewTask.markup()}
|
|
|
416
492
|
</dialog>`;
|
|
417
493
|
}
|
|
418
494
|
|
|
419
|
-
return { part, create, page, markup };
|
|
495
|
+
return { part, create, page, markup, sharedStrip };
|
|
420
496
|
})();
|
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
|