@lovek4d/kickflip 0.3.1 → 0.5.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 +170 -118
- package/README.md +23 -14
- package/SKILL.md +40 -38
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +213 -74
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +4 -0
- package/board/headless.mjs +4 -0
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +39 -103
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +166 -73
- package/board/public/app-core.js +2 -23
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +53 -42
- package/board/public/app.html +4 -1
- package/board/public/app.js +78 -96
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +6 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/new-task.js +121 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +24 -6
- package/board/public/sidebar.css +22 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +245 -64
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +205 -75
- package/board/public/ui-enums.js +6 -5
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +61 -29
- package/board/public/ui-sidebar.js +54 -24
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +40 -21
- package/board/public/ui.js +116 -18
- package/board/public/view-frame.js +2 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +142 -62
- package/board/public/view-settings.js +189 -52
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -12
- package/board/store.mjs +15 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -15
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -14
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/owner-check.mjs +3 -0
- package/shared/repos.mjs +1 -1
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
package/board/public/dialogs.js
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
// Every confirmation and notice is the browser's own window.confirm or window.alert; no page imitates a native dialog.
|
|
2
|
-
// Load before any page script that asks (docs/adr/
|
|
2
|
+
// Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
|
|
3
3
|
(function () {
|
|
4
4
|
// linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
|
|
5
|
-
// while Hub's key works; tapping it without one opens Hub's key modal over the page
|
|
6
|
-
//
|
|
5
|
+
// while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
|
|
6
|
+
// inline (linear-key.js,
|
|
7
|
+
// docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
|
|
7
8
|
// new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
|
|
8
9
|
// {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
|
|
9
10
|
// GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
|
|
10
|
-
// remote, it can't be picked and says why (docs/adr/
|
|
11
|
-
|
|
11
|
+
// remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
12
|
+
// With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
|
|
13
|
+
// as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
|
|
14
|
+
// form, since the key field is a form of its own; keyBusy(on) follows each save of the key
|
|
15
|
+
// (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
|
|
16
|
+
function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
|
|
12
17
|
const { esc } = BoardUI;
|
|
13
18
|
const draft = { prefix: 'INT', ...value };
|
|
14
|
-
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false;
|
|
19
|
+
let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
|
|
15
20
|
const works = () => Boolean(linear?.set && !linear.refused);
|
|
21
|
+
const keyWorks = () => Boolean(key?.set && !key.refused);
|
|
22
|
+
const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
|
|
23
|
+
const statusWord = () => (draft.source === 'linear' && keyWorks() ? 'linear · connected' : `${draft.source} · ${keyWord()}`);
|
|
24
|
+
function status() {
|
|
25
|
+
if (!key) return '';
|
|
26
|
+
const on = draft.source === 'linear' && keyWorks();
|
|
27
|
+
return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
|
|
28
|
+
}
|
|
29
|
+
// Hub's key field in keySlot. Once a key works, Linear is picked.
|
|
30
|
+
function openInline() {
|
|
31
|
+
if (!inline) {
|
|
32
|
+
keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
|
|
33
|
+
inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
|
|
34
|
+
saved: (out) => { key = out.linear; render(); },
|
|
35
|
+
worked: async () => {
|
|
36
|
+
closeInline();
|
|
37
|
+
draft.source = 'linear';
|
|
38
|
+
await load();
|
|
39
|
+
if (destroyed) return;
|
|
40
|
+
pickDefaults();
|
|
41
|
+
render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
|
|
42
|
+
} });
|
|
43
|
+
keySlot.hidden = false;
|
|
44
|
+
}
|
|
45
|
+
keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
|
|
46
|
+
}
|
|
47
|
+
function closeInline() {
|
|
48
|
+
if (!inline) return;
|
|
49
|
+
inline.destroy();
|
|
50
|
+
inline = null;
|
|
51
|
+
keySlot.hidden = true;
|
|
52
|
+
keySlot.innerHTML = '';
|
|
53
|
+
}
|
|
16
54
|
async function load() {
|
|
17
55
|
linear = { loading: true };
|
|
18
56
|
render();
|
|
@@ -49,7 +87,7 @@
|
|
|
49
87
|
const note = draft.project === '__new'
|
|
50
88
|
? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
|
|
51
89
|
: `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
|
|
52
|
-
return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ?
|
|
90
|
+
return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
|
|
53
91
|
}
|
|
54
92
|
function render() {
|
|
55
93
|
const off = !repo();
|
|
@@ -60,7 +98,7 @@
|
|
|
60
98
|
else if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
|
|
61
99
|
else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A new card opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
|
|
62
100
|
else detail = linearDetail();
|
|
63
|
-
root.innerHTML = `${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
|
|
101
|
+
root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
|
|
64
102
|
if (draft.source === 'linear' && ready()) {
|
|
65
103
|
root.querySelector('[data-tk="team"]').value = draft.team || '';
|
|
66
104
|
root.querySelector('[data-tk="project"]').value = draft.project || '';
|
|
@@ -76,6 +114,9 @@
|
|
|
76
114
|
if (!works()) {
|
|
77
115
|
draft.source = before;
|
|
78
116
|
render();
|
|
117
|
+
await keyed;
|
|
118
|
+
if (destroyed) return;
|
|
119
|
+
if (key) { openInline(); return; }
|
|
79
120
|
asking = true;
|
|
80
121
|
const added = await LinearKey.open();
|
|
81
122
|
asking = false;
|
|
@@ -108,6 +149,7 @@
|
|
|
108
149
|
noGithub = button.dataset.tkSource === 'github' && !repo();
|
|
109
150
|
if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
|
|
110
151
|
if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
|
|
152
|
+
closeInline();
|
|
111
153
|
draft.source = button.dataset.tkSource;
|
|
112
154
|
render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
|
|
113
155
|
};
|
|
@@ -126,11 +168,13 @@
|
|
|
126
168
|
root.addEventListener('change', input);
|
|
127
169
|
render();
|
|
128
170
|
if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
|
|
171
|
+
// Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
|
|
172
|
+
const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
|
|
129
173
|
return { read, valid, render, nameChanged() {
|
|
130
174
|
const field = root.querySelector('[data-tk="newName"]');
|
|
131
175
|
if (field && draft.newName === undefined) field.value = title();
|
|
132
176
|
},
|
|
133
|
-
destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
177
|
+
destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
|
|
134
178
|
}
|
|
135
179
|
function fitSheet(sheet) {
|
|
136
180
|
const vv = window.visualViewport;
|
|
@@ -193,16 +237,16 @@
|
|
|
193
237
|
return d;
|
|
194
238
|
};
|
|
195
239
|
const fail = (e) => { if (!closed) { error.textContent = e.message; error.hidden = false; } };
|
|
196
|
-
// A repo already on the host joins from its folder instead (docs/adr/
|
|
240
|
+
// A repo already on the host joins from its folder instead (docs/adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md).
|
|
197
241
|
const joinHint = current ? '' : '<p class="nb-help" data-join-hint>A repo already on the host running Kickflip? Run <code>kickflip join</code> in its folder.</p>';
|
|
198
242
|
function sync() { start.disabled = sending || !(draft.github || draft.remote) || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
|
|
199
243
|
// A repo row's id is its GitHub owner/name, or the git link of a repo on another host, which the row names by
|
|
200
244
|
// host/path and labels with its host where a GitHub row says Private or Public
|
|
201
|
-
// (docs/adr/
|
|
245
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
202
246
|
const idOf = (r) => r.github || r.remote;
|
|
203
247
|
const nameOf = (r) => r.github || r.name;
|
|
204
248
|
const access = (r) => (r.remote ? r.host : r.private ? 'Private' : 'Public');
|
|
205
|
-
const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓
|
|
249
|
+
const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ Cloned${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned yet'}</span></button>`;
|
|
206
250
|
const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
|
|
207
251
|
function rows(query = '') {
|
|
208
252
|
if (!signedIn) { results(''); return; }
|
|
@@ -278,7 +322,7 @@
|
|
|
278
322
|
if (!edited) draft.name = nameOf(selected).split('/').at(-1);
|
|
279
323
|
error.hidden = true;
|
|
280
324
|
const shared = selected.shared.map((b) => b.title).join(', ');
|
|
281
|
-
content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned
|
|
325
|
+
content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
|
|
282
326
|
if (!current) {
|
|
283
327
|
content.querySelectorAll('.nb-next')[content.querySelectorAll('.nb-next').length - 1].insertAdjacentHTML('beforebegin', '<div class="nb-field tk-section" role="group" aria-labelledby="nbTicketsLabel"><span id="nbTicketsLabel">Tickets</span><span class="nb-help">Where this board’s cards get their tickets. You can change it in Board settings.</span><div data-board-tickets></div></div>');
|
|
284
328
|
tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, linear, loadLinear: async () => (linear = await request('/_hub/api/linear')),
|
|
@@ -327,7 +371,8 @@
|
|
|
327
371
|
error.hidden = true;
|
|
328
372
|
try {
|
|
329
373
|
const repo = draft.remote ? { remote: draft.remote } : { github: draft.github };
|
|
330
|
-
|
|
374
|
+
// Start, or Use repo, waits (ui.js waitOn) until the hub answers; a new board's own start then shows in the sidebar.
|
|
375
|
+
await BoardUI.waitOn(start, save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected));
|
|
331
376
|
sending = false;
|
|
332
377
|
el.close();
|
|
333
378
|
} catch (e) { sending = false; fail(e); sync(); }
|
package/board/public/hub-kit.js
CHANGED
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
// the hub's answer, fail(message) a refusal. A checkbox marked data-submit saves on change, and a time input marked
|
|
53
53
|
// data-autosave once its picker closes, so turning an iOS wheel doesn't save each value it passes. accepts(action, form)
|
|
54
54
|
// limits it to some of those forms, since the Hub page and its hub settings each wire their own over one root.
|
|
55
|
+
// While a post is out its control waits (ui.js waitOn): the tapped button, or else the form's toggle or field, with
|
|
56
|
+
// the form's other buttons, the setting's other choices, disabled.
|
|
55
57
|
// → {busy()}, true while a post is out
|
|
56
58
|
function forms(B, { answer, fail, accepts = () => true }) {
|
|
57
59
|
let sending = false;
|
|
@@ -65,9 +67,10 @@
|
|
|
65
67
|
const body = new URLSearchParams(new FormData(form));
|
|
66
68
|
if (e.submitter?.name) body.append(e.submitter.name, e.submitter.value);
|
|
67
69
|
sending = true;
|
|
70
|
+
const post = fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
|
|
71
|
+
.then(async (r) => [r, await r.json().catch(() => null)]);
|
|
68
72
|
try {
|
|
69
|
-
const r = await
|
|
70
|
-
const out = await r.json().catch(() => null);
|
|
73
|
+
const [r, out] = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"]), select'), post, { group: form.querySelectorAll('button') });
|
|
71
74
|
if (!B.alive()) return;
|
|
72
75
|
if (r.ok && out) answer(out, action); else fail(out?.error || FAILED);
|
|
73
76
|
} catch {
|
|
@@ -106,7 +109,7 @@
|
|
|
106
109
|
const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
|
|
107
110
|
// The endpoint this device last gave the hub, kept in this browser, so that once the subscription goes (the
|
|
108
111
|
// browser dropped or replaced it, or notifications were blocked) the page tells the hub to forget it
|
|
109
|
-
// (docs/adr/
|
|
112
|
+
// (docs/adr/2026-11-03-0501-the-hub-reads-healthy-degraded-or-a-site-down.md).
|
|
110
113
|
const KEPT = 'kickflip.push.endpoint';
|
|
111
114
|
const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
|
|
112
115
|
const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
|
|
@@ -138,7 +141,7 @@
|
|
|
138
141
|
}
|
|
139
142
|
async function enable() {
|
|
140
143
|
busy = true;
|
|
141
|
-
show({ ...state, action: { ...state.action,
|
|
144
|
+
show({ ...state, action: { ...state.action, busy: true } }, on);
|
|
142
145
|
try {
|
|
143
146
|
if (await Notification.requestPermission() !== 'granted') { await render(); return; }
|
|
144
147
|
const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
|
@@ -152,9 +155,9 @@
|
|
|
152
155
|
finally { busy = false; }
|
|
153
156
|
}
|
|
154
157
|
async function test() {
|
|
155
|
-
show({ ...state, action: { ...state.action,
|
|
158
|
+
show({ ...state, action: { ...state.action, busy: true } }, true);
|
|
156
159
|
const r = await post('/_hub/push/test').catch(() => null);
|
|
157
|
-
show({ ...state, action: { ...state.action, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
|
|
160
|
+
show({ ...state, action: { ...state.action, busy: false, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
|
|
158
161
|
}
|
|
159
162
|
B.on(B.root, 'click', (e) => {
|
|
160
163
|
const act = e.target.closest?.('[data-device]')?.dataset.device;
|
package/board/public/hub.css
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* The hub page and settings modals (view-hub.js, view-hub-settings.js
|
|
1
|
+
/* The hub page and settings modals (view-hub.js, view-hub-settings.js), whose root carries .hub-view, and the hub's
|
|
2
2
|
down page (hub/hub.mjs), whose <html> does, and the board's Hold sheet picker (ui-cards.js holdPicker), which shares the
|
|
3
3
|
duration picker's rules; served as /_hub/app/hub.css and /_hub/hub.css. Every rule is under
|
|
4
4
|
.hub-view, since style.css uses the same short class names. The app shell links it after style.css, so a rule here
|
|
@@ -47,22 +47,7 @@
|
|
|
47
47
|
.hub-view .chips form { display:contents; }
|
|
48
48
|
.hub-view .chips form.field { display:inline-flex; gap:6px; }
|
|
49
49
|
.hub-view .chips .field input { min-height:0; padding:.2rem .5rem; width:7.5rem; }
|
|
50
|
-
.hub-view .
|
|
51
|
-
.hub-view .day { position:relative; padding:18px 0 20px; }
|
|
52
|
-
.hub-view .day .bar { position:relative; height:40px; border-radius:8px; overflow:hidden; background:var(--chip); }
|
|
53
|
-
.hub-view .day .seg { position:absolute; top:0; bottom:0; display:grid; place-items:center; overflow:hidden; border-right:2px solid var(--panel);
|
|
54
|
-
background:color-mix(in srgb, var(--muted) var(--k), var(--chip)); }
|
|
55
|
-
.hub-view .day .seg.held { background-image:repeating-linear-gradient(135deg, transparent 0 5px, color-mix(in srgb, var(--text) 22%, transparent) 5px 8px); }
|
|
56
|
-
.hub-view .day .seg.sel { box-shadow:inset 0 0 0 3px var(--text); z-index:2; }
|
|
57
|
-
.hub-view .day .seg span { font-size:11px; font-weight:600; line-height:1.5; color:var(--text); background:color-mix(in srgb, var(--panel) 85%, transparent); border-radius:4px; padding:0 4px; }
|
|
58
|
-
.hub-view .day .pin { position:absolute; top:2px; transform:translateX(-50%); color:var(--color-notice); font-size:11px; line-height:1; }
|
|
59
|
-
.hub-view .day .now { position:absolute; top:14px; height:48px; width:2px; margin-left:-1px; background:var(--text); z-index:3; pointer-events:none; }
|
|
60
|
-
.hub-view .day .ticks { position:absolute; left:0; right:0; bottom:0; height:16px; font-size:10px; color:var(--muted); }
|
|
61
|
-
.hub-view .day .ticks span { position:absolute; transform:translateX(-50%); }
|
|
62
|
-
.hub-view .day .ticks span:first-child { transform:none; } .hub-view .day .ticks span:last-child { transform:translateX(-100%); }
|
|
63
|
-
.hub-view .legend { display:flex; gap:4px 12px; flex-wrap:wrap; font-size:11px; color:var(--muted); }
|
|
64
|
-
.hub-view .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; vertical-align:-1px; margin-right:4px; }
|
|
65
|
-
.hub-view #day, .hub-view #window, .hub-view #quiet, .hub-view #wakes { scroll-margin-top:64px; }
|
|
50
|
+
.hub-view .quiet-hint { margin:6px 14px 0; color:var(--muted); font-size:12px; }
|
|
66
51
|
.hub-view .field input { flex:none; font:inherit; font-size:16px; color:var(--text); background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:.3rem .6rem; min-height:2.2rem; width:8.75rem; min-width:0; text-align:right; }
|
|
67
52
|
.hub-view .field input[type=number] { width:5rem; }
|
|
68
53
|
.hub-view .field input.toggle { width:1.4rem; height:1.4rem; min-height:0; padding:0; accent-color:var(--accent); }
|
|
@@ -102,6 +87,7 @@
|
|
|
102
87
|
.hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
|
|
103
88
|
.hub-view .remembered p { margin:0; }
|
|
104
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; }
|
|
105
91
|
.hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
106
92
|
|
|
107
93
|
@media (max-width: 899px) {
|
|
@@ -167,4 +153,6 @@
|
|
|
167
153
|
.hub-view .rd-fix button.link { padding: 0; font-size: inherit; }
|
|
168
154
|
.hub-view .site-row > .site-link { flex:1; min-width:0; display:flex; align-items:center; gap:10px; color:inherit; }
|
|
169
155
|
.hub-view .restart-btn { min-width:76px; min-height:34px; display:inline-flex; align-items:center; justify-content:center; gap:6px; }
|
|
170
|
-
.
|
|
156
|
+
/* A waiting button keeps its own look (style.css button[aria-busy="true"]), over .btn's and a step's disabled dimming. */
|
|
157
|
+
.hub-view button[aria-busy="true"]:disabled { opacity:1; }
|
|
158
|
+
.hub-view .ub-step button[aria-busy="true"]:disabled { color:var(--text); }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Hub's Linear key slot. Hub settings › Linear mounts it, and the Tickets control (dialogs.js) opens it over Board
|
|
2
2
|
// settings or New board when Linear is tapped without a working key
|
|
3
|
-
// (docs/adr/
|
|
3
|
+
// (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
|
|
4
4
|
// POST /_hub/settings/linear, which checks it with Linear; linear is hub/views.mjs settingsView's `linear`.
|
|
5
5
|
(function () {
|
|
6
6
|
const FAILED = 'Could not save. Is the host awake?';
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
};
|
|
69
69
|
const onClick = async (e) => {
|
|
70
70
|
if (!e.target.closest?.('[data-linear-remove]') || pending) return;
|
|
71
|
-
if (await BoardDialogs.confirm('Boards on Linear
|
|
71
|
+
if (await BoardDialogs.confirm('Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
|
|
72
72
|
};
|
|
73
73
|
const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
|
|
74
74
|
for (const [type, fn] of listeners) root.addEventListener(type, fn);
|
|
@@ -82,11 +82,13 @@
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
//
|
|
85
|
+
// The footer's Done waits, disabled with a spinner in place of its word (style.css button[aria-busy="true"]), while a
|
|
86
|
+
// save is out; ✕, Escape and the backdrop close once it lands.
|
|
86
87
|
function doneBusy(buttons, busy) {
|
|
87
88
|
for (const b of buttons) {
|
|
89
|
+
if (!b.closest('.settings-footer')) continue;
|
|
90
|
+
b.disabled = busy;
|
|
88
91
|
if (busy) b.setAttribute('aria-busy', 'true'); else b.removeAttribute('aria-busy');
|
|
89
|
-
if (b.closest('.settings-footer')) b.innerHTML = `${busy ? SPIN : ''}Done`;
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>Log</title>
|
|
7
|
+
<link rel="stylesheet" href="colors.css">
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<!--header {"lead":"back","title":"Log","titleId":"ref","backHref":"./","controls":false}-->
|
|
12
|
+
<main>
|
|
13
|
+
<p class="muted" id="note"></p>
|
|
14
|
+
<pre class="log" id="log">Loading the log…</pre>
|
|
15
|
+
</main>
|
|
16
|
+
<style>
|
|
17
|
+
/* The tail of a builder's log (GET /api/log), newest at the bottom. */
|
|
18
|
+
pre.log { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
19
|
+
</style>
|
|
20
|
+
<script>
|
|
21
|
+
// Shows the tail of the log named by ?file=, last lines in view, and re-reads it every 3 seconds while it grows;
|
|
22
|
+
// the page stays where the reader scrolled to when they've left the bottom.
|
|
23
|
+
(function () {
|
|
24
|
+
const name = new URLSearchParams(location.search).get('file') || '';
|
|
25
|
+
const log = document.getElementById('log');
|
|
26
|
+
const note = document.getElementById('note');
|
|
27
|
+
document.getElementById('ref').textContent = name || 'Log';
|
|
28
|
+
document.title = name || 'Log';
|
|
29
|
+
let size = -1;
|
|
30
|
+
async function read() {
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch('api/log?file=' + encodeURIComponent(name), { cache: 'no-store' });
|
|
33
|
+
const body = await r.json();
|
|
34
|
+
if (!r.ok) { log.textContent = body.error || 'Could not load this log.'; return; }
|
|
35
|
+
if (body.size === size) return;
|
|
36
|
+
const atEnd = size === -1 || innerHeight + scrollY >= document.documentElement.scrollHeight - 40;
|
|
37
|
+
size = body.size;
|
|
38
|
+
log.textContent = body.text || 'The log is empty so far.';
|
|
39
|
+
note.textContent = body.truncated ? 'Showing the last 256 KiB.' : '';
|
|
40
|
+
if (atEnd) scrollTo(0, document.documentElement.scrollHeight);
|
|
41
|
+
} catch { /* the next read tries again */ }
|
|
42
|
+
}
|
|
43
|
+
read();
|
|
44
|
+
setInterval(read, 3000);
|
|
45
|
+
})();
|
|
46
|
+
</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The + button and the sheet that sends a new task as an ask event, on every page of a board: the board view's markup
|
|
2
|
+
// (app.js) and each other view of a board (shell.js) put BoardNewTask.markup() in their root and wire it with
|
|
3
|
+
// BoardNewTask.wire(B) once mounted. A board's view sends through B.send, which lands the board the ask changed; any
|
|
4
|
+
// other view sends through its site. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
|
|
5
|
+
globalThis.BoardNewTask = (function () {
|
|
6
|
+
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>
|
|
7
|
+
<dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
|
|
8
|
+
<form id="askForm" data-composer="ask">
|
|
9
|
+
<div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
|
|
10
|
+
${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
|
|
11
|
+
<div class="pending-files" data-files></div>
|
|
12
|
+
<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>
|
|
13
|
+
<p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
|
|
14
|
+
<div class="row">
|
|
15
|
+
<span class="muted small" id="askStatus"></span>
|
|
16
|
+
<button type="submit" class="primary">Send</button>
|
|
17
|
+
</div>
|
|
18
|
+
</form>
|
|
19
|
+
</dialog>`;
|
|
20
|
+
|
|
21
|
+
function wire(B) {
|
|
22
|
+
const { $, store, toast, quieted, sendDraft } = B;
|
|
23
|
+
const { attachButton, waitOn } = BoardUI;
|
|
24
|
+
const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
|
|
25
|
+
// The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
|
|
26
|
+
const sheet = $('#sheet');
|
|
27
|
+
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
|
|
28
|
+
if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
|
|
29
|
+
// Files picked before the app shell switched away from this board are still attached, so they show again.
|
|
30
|
+
BoardAttach.paint('ask', B.site.root);
|
|
31
|
+
const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
32
|
+
if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
|
|
33
|
+
// ⌘↵ (Ctrl+↵ off a Mac) sends the ask from its box.
|
|
34
|
+
B.on($('#askText'), 'keydown', (ev) => {
|
|
35
|
+
if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
|
|
36
|
+
ev.preventDefault();
|
|
37
|
+
$('#askForm').requestSubmit();
|
|
38
|
+
});
|
|
39
|
+
$('#askText').value = store.get('draft:ask', '');
|
|
40
|
+
B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
|
|
41
|
+
let stopFitting = null;
|
|
42
|
+
const stopFit = () => { stopFitting?.(); stopFitting = null; };
|
|
43
|
+
B.on(sheet, 'close', stopFit);
|
|
44
|
+
B.atUnmount(stopFit);
|
|
45
|
+
function openSheet() {
|
|
46
|
+
if (sheet.open) return;
|
|
47
|
+
stopFit();
|
|
48
|
+
stopFitting = BoardDialogs.fitSheet(sheet);
|
|
49
|
+
sheet.classList.remove('closing');
|
|
50
|
+
sheet.showModal();
|
|
51
|
+
$('#askText').focus();
|
|
52
|
+
$('#compose').setAttribute('aria-expanded', 'true');
|
|
53
|
+
}
|
|
54
|
+
function closeSheet() {
|
|
55
|
+
if (!sheet.open || sheet.classList.contains('closing')) return;
|
|
56
|
+
$('#compose').setAttribute('aria-expanded', 'false');
|
|
57
|
+
if (reduceMotion.matches) { sheet.close(); return; }
|
|
58
|
+
sheet.classList.add('closing');
|
|
59
|
+
B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
|
|
60
|
+
}
|
|
61
|
+
B.on($('#compose'), 'click', openSheet);
|
|
62
|
+
// n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
|
|
63
|
+
// nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
|
|
64
|
+
// own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
|
|
65
|
+
// defaultPrevented and doesn't count.
|
|
66
|
+
const TWICE_MS = 500;
|
|
67
|
+
let lastN = -Infinity;
|
|
68
|
+
const newTaskKey = (ev) => {
|
|
69
|
+
if (ev.defaultPrevented) { lastN = -Infinity; return; }
|
|
70
|
+
if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
|
|
71
|
+
const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
|
|
72
|
+
if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
|
|
73
|
+
if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
|
|
74
|
+
lastN = -Infinity;
|
|
75
|
+
ev.preventDefault();
|
|
76
|
+
openSheet();
|
|
77
|
+
};
|
|
78
|
+
B.newTaskKey = newTaskKey;
|
|
79
|
+
B.on(document, 'keydown', newTaskKey);
|
|
80
|
+
B.on($('#sheetClose'), 'click', closeSheet);
|
|
81
|
+
B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
|
|
82
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
83
|
+
// Discuss or Quick build, above Send: Discuss, the default, has the session grill or prototype the task at once; Quick
|
|
84
|
+
// build sends the ask with quick: true, and the session marks the card ready. Each send goes back to Discuss
|
|
85
|
+
// (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
86
|
+
const CHOICE_HELP = {
|
|
87
|
+
discuss: 'Discuss: Claude grills or prototypes it first, then waits for you.',
|
|
88
|
+
quick: 'Quick build: for changes you’re sure about; Claude marks it ready and builds.',
|
|
89
|
+
};
|
|
90
|
+
let quick = false;
|
|
91
|
+
function choose(isQuick) {
|
|
92
|
+
quick = isQuick;
|
|
93
|
+
$('#askDiscuss').setAttribute('aria-pressed', String(!quick));
|
|
94
|
+
$('#askQuick').setAttribute('aria-pressed', String(quick));
|
|
95
|
+
$('#askChoiceHelp').textContent = CHOICE_HELP[quick ? 'quick' : 'discuss'];
|
|
96
|
+
}
|
|
97
|
+
choose(false);
|
|
98
|
+
B.on(B.root, 'click', (ev) => {
|
|
99
|
+
const b = ev.target.closest('#askDiscuss, #askQuick');
|
|
100
|
+
if (b) choose(b.id === 'askQuick');
|
|
101
|
+
});
|
|
102
|
+
B.on($('#askForm'), 'submit', async (ev) => {
|
|
103
|
+
ev.preventDefault();
|
|
104
|
+
$('#askText').blur();
|
|
105
|
+
const text = $('#askText').value.trim();
|
|
106
|
+
const files = BoardAttach.files('ask', B.site.root);
|
|
107
|
+
if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
|
|
108
|
+
$('#askStatus').textContent = '';
|
|
109
|
+
try {
|
|
110
|
+
await waitOn($('#askForm button[type="submit"]'), sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
|
|
111
|
+
BoardAttach.clear('ask', B.site.root);
|
|
112
|
+
choose(false);
|
|
113
|
+
$('#askStatus').textContent = '';
|
|
114
|
+
closeSheet();
|
|
115
|
+
toast('Sent');
|
|
116
|
+
} catch { $('#askStatus').textContent = 'Could not send'; }
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { markup, wire };
|
|
121
|
+
})();
|
package/board/public/prompts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
|
|
2
|
-
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/
|
|
2
|
+
// through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
|
|
3
3
|
// Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
|
|
4
4
|
// since it covers every later call like it until the session ends. Several questions, or one taking several picks,
|
|
5
5
|
// send with Send once each has a pick.
|
|
@@ -8,13 +8,14 @@ globalThis.PromptAnswers = (function () {
|
|
|
8
8
|
// short message; after() runs once an answer has gone. → {state(p) for promptItem, shows(p)}
|
|
9
9
|
function wire(B, { list, paint, notify, after = () => {} }) {
|
|
10
10
|
const picks = new Map();
|
|
11
|
-
|
|
11
|
+
// Each prompt whose answer is out, to the button tapped (ui-prompts.js promptItem's sending).
|
|
12
|
+
const sending = new Map();
|
|
12
13
|
// Answered here, hidden until the view's next load drops them.
|
|
13
14
|
const answered = new Set();
|
|
14
15
|
const picksOf = (id) => { if (!picks.has(id)) picks.set(id, {}); return picks.get(id); };
|
|
15
16
|
|
|
16
|
-
async function send(p, body) {
|
|
17
|
-
sending.
|
|
17
|
+
async function send(p, body, tapped) {
|
|
18
|
+
sending.set(p.id, tapped);
|
|
18
19
|
paint();
|
|
19
20
|
try {
|
|
20
21
|
const r = await fetch(`/_hub/prompts/${encodeURIComponent(p.id)}/answer`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
@@ -40,23 +41,23 @@ globalThis.PromptAnswers = (function () {
|
|
|
40
41
|
const got = mine[i] || [];
|
|
41
42
|
const label = opt.dataset.promptOpt;
|
|
42
43
|
mine[i] = q.multiSelect ? (got.includes(label) ? got.filter((l) => l !== label) : [...got, label]) : [label];
|
|
43
|
-
if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine));
|
|
44
|
+
if (BoardUI.oneTap(p)) send(p, BoardUI.promptAnswer(p, mine), `${i}:${label}`);
|
|
44
45
|
else paint();
|
|
45
46
|
return;
|
|
46
47
|
}
|
|
47
48
|
if (ev.target.closest('button[data-prompt-send]')) {
|
|
48
49
|
const body = BoardUI.promptAnswer(p, picksOf(p.id));
|
|
49
|
-
if (body) send(p, body);
|
|
50
|
+
if (body) send(p, body, 'send');
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
52
53
|
const choice = ev.target.closest('button[data-prompt-choice]')?.dataset.promptChoice;
|
|
53
54
|
if (!choice) return;
|
|
54
55
|
if (choice === 'always' && !(await BoardDialogs.confirm(`Always allow ${p.rule} for the rest of this session?`))) return;
|
|
55
|
-
send(p, { choice });
|
|
56
|
+
send(p, { choice }, choice);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
return {
|
|
59
|
-
state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.
|
|
60
|
+
state: (p) => ({ picks: picks.get(p.id) || {}, sending: sending.get(p.id) ?? null }),
|
|
60
61
|
shows: (p) => !answered.has(p.id),
|
|
61
62
|
};
|
|
62
63
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// What a prototype's take files get, on the page board/server.mjs serves from prototype.html for each topic recorded
|
|
2
|
-
// with board.mjs prototype add (docs/adr/
|
|
2
|
+
// with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
|
|
3
3
|
// public/prototypes/<topic>/<KEY>.js, is a module:
|
|
4
4
|
// export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
|
|
5
5
|
// render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
|
|
@@ -39,13 +39,13 @@
|
|
|
39
39
|
...(claude === null ? {} : { claude: reading(...[claude, 61].flat().slice(0, 2), 2 * 60_000, 3.4, 2.6) }),
|
|
40
40
|
...(codex === null ? {} : { codex: reading(...[codex, 40].flat().slice(0, 2), 6 * 60_000, 1.2, 4.1) }),
|
|
41
41
|
}),
|
|
42
|
-
sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: '
|
|
42
|
+
sidebar: () => ({ total: 1, sites: fake.sites(), hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' }, notifications: { state: 'on' }, usage: fake.usage() }),
|
|
43
43
|
hub: () => ({
|
|
44
44
|
usage: fake.usage(),
|
|
45
|
-
hub: { tone: 'ok', word: '
|
|
45
|
+
hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
|
|
46
46
|
sites: fake.sites(),
|
|
47
|
-
polling: { title: 'Checks every
|
|
48
|
-
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['
|
|
47
|
+
polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
|
|
48
|
+
settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
|
|
49
49
|
['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
|
|
50
50
|
}),
|
|
51
51
|
items: () => [
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
function hub(data = {}) {
|
|
64
64
|
const d = { ...fake.hub(), header: true, ...data };
|
|
65
65
|
const { row, tile, nowRow, CHEVRON } = HubUI;
|
|
66
|
-
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">
|
|
66
|
+
const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
|
|
67
67
|
const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
|
|
68
68
|
const usage = d.usageCard ?? HubUI.usageCard(d.usage);
|
|
69
69
|
const header = d.header ? BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', logoHref: '/_hub/', settingsHref: null }) : '';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The floating ‹ take › pill every prototype page switches its takes with (docs/adr/
|
|
1
|
+
// The floating ‹ take › pill every prototype page switches its takes with (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
|
|
2
2
|
// Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
|
|
3
3
|
// const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
|
|
4
4
|
// render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// PrototypeSwitcher.current is the mounted switcher, whose go(key) shows another take in place; Waiting on you uses it
|
|
9
9
|
// to switch its full-width frame without a reload (view-questions.js inPlace).
|
|
10
10
|
// With ?take=<key> the page shows that take alone, with no header or pill, as questions.html frames it
|
|
11
|
-
// (docs/adr/
|
|
11
|
+
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md); a page hides its own extras under html.ps-embed. There main's
|
|
12
12
|
// bottom padding, kept clear for the pill, shrinks to 12px whatever the page sets, since questions.html sizes the frame
|
|
13
13
|
// to the page's height.
|
|
14
14
|
(function () {
|
|
@@ -120,6 +120,8 @@
|
|
|
120
120
|
let take = startTake(keys, params.get('variant'), initial);
|
|
121
121
|
let question = null;
|
|
122
122
|
let topics = [];
|
|
123
|
+
// The board's cards, which name their own question topics (ui-questions.js cardTopics).
|
|
124
|
+
let cards = [];
|
|
123
125
|
// A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a pick made here or a
|
|
124
126
|
// reload stays on the page; only the first load sends an already-picked question on to its card's thread.
|
|
125
127
|
let fromPush = params.get('from') === 'push';
|
|
@@ -154,8 +156,14 @@
|
|
|
154
156
|
let board = null;
|
|
155
157
|
try {
|
|
156
158
|
board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
+
topics = board.ownerTopics;
|
|
160
|
+
cards = board.items || [];
|
|
161
|
+
// A resolved question is left out of /api/board, and its topic's resolved questions hold it
|
|
162
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
163
|
+
const topic = topics.find((t) => t.questions.includes(qid));
|
|
164
|
+
question = board.owner.find((q) => q.id === qid)
|
|
165
|
+
|| (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
|
|
166
|
+
|| null;
|
|
159
167
|
if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
|
|
160
168
|
} catch { /* keep the last question shown */ }
|
|
161
169
|
if (fromPush && pickedChoice(question)) {
|
|
@@ -179,7 +187,7 @@
|
|
|
179
187
|
ui = await sharedQuestions(site);
|
|
180
188
|
pills = ui.topicRow(topics, answered.topic).pills;
|
|
181
189
|
const topic = topics.find((t) => t.id === answered.topic);
|
|
182
|
-
name = topic ? ui.topicName(topic) : '';
|
|
190
|
+
name = topic ? ui.topicName(ui.cardTopics([topic], cards)[0]) : '';
|
|
183
191
|
await site.send('owner-answer', { id: answered.id, question: answered.question, choice: state.option.id, label: answerLabel(state.option), note: text.value.trim() });
|
|
184
192
|
} catch { busy = false; paint(); note('Could not send. Is the host awake?'); return; }
|
|
185
193
|
try { localStorage.removeItem(draftKey(qid)); } catch { /* nothing kept */ }
|