@lovek4d/kickflip 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- 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 +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- 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 +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// The sidebar of every site (docs/adr/
|
|
2
|
-
// /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself
|
|
3
|
-
// the app shell runs it through sidebar.js, and hub/hub.mjs renders the
|
|
1
|
+
// The sidebar of every site (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
|
|
2
|
+
// /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself, and
|
|
3
|
+
// ui-phase-bars.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
|
|
4
|
+
// shell's first sidebar with all three in a vm context.
|
|
4
5
|
// Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
|
|
5
6
|
(function () {
|
|
6
7
|
// ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
|
|
7
|
-
const { header } = globalThis.BoardUI || {};
|
|
8
|
+
const { header, phaseBars } = globalThis.BoardUI || {};
|
|
8
9
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
9
10
|
const svg = (paths, size = 17) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
10
11
|
const ICONS = {
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
];
|
|
55
56
|
const siteHref = (s) => `/${encodeURIComponent(s.route)}/`;
|
|
56
57
|
|
|
57
|
-
// Switch mode's keys (sidebar.js; docs/adr/
|
|
58
|
+
// Switch mode's keys (sidebar.js; docs/adr/2026-10-30-1102-switch-mode-letters-come-from-names-in-join-order.md): each linked row the
|
|
58
59
|
// sidebar draws, boards first for numbering, as {href, title, board, num, letter, at}. The first nine boards take num '1'–'9'.
|
|
59
60
|
// Every row takes as letter the first a–z letter of its title not yet taken, at index `at` of the title's characters,
|
|
60
61
|
// claiming in this order: boards by `created`, then pages by `created`, a site with none after those with one, then
|
|
@@ -81,13 +82,13 @@
|
|
|
81
82
|
// key: the row's switchKeys entry while switch mode shows, else undefined. The tile carries a board's number, or any
|
|
82
83
|
// other row's letter, and the title marks its letter; sidebar.css shows them in place of the glyph under .ks-on.
|
|
83
84
|
// mark: HTML after the title, such as Hub's moon.
|
|
84
|
-
function row({ href, icon, title, sub = '', extra = '', mark = '', on = false, cls = '', key }) {
|
|
85
|
+
function row({ href, icon, title, sub = '', extra = '', mark = '', ctx = '', on = false, cls = '', key }) {
|
|
85
86
|
const shown = key && (key.board ? key.num : key.letter);
|
|
86
87
|
const tileHtml = shown ? icon.replace(/<\/span>$/, `<span class="ks-key">${esc(shown)}</span></span>`) : icon;
|
|
87
88
|
const chars = [...title];
|
|
88
89
|
const titleHtml = key?.letter ? `${esc(chars.slice(0, key.at).join(''))}<span class="ks-u">${esc(chars[key.at])}</span>${esc(chars.slice(key.at + 1).join(''))}` : esc(title);
|
|
89
90
|
return `<a class="sb-row${on ? ' on' : ''}${cls ? ` ${cls}` : ''}" href="${esc(href)}"${on ? ' aria-current="page"' : ''}>${tileHtml}`
|
|
90
|
-
+ `<span class="sb-text"><span class="sb-title">${titleHtml}${mark}</span>${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
|
|
91
|
+
+ `<span class="sb-text">${ctx ? `<span class="sb-head"><span class="sb-title">${titleHtml}${mark}</span>${ctx}</span>` : `<span class="sb-title">${titleHtml}${mark}</span>`}${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
// What waits on the owner on every site but `here`: the count on the logo that opens the drawer.
|
|
@@ -120,35 +121,96 @@
|
|
|
120
121
|
+ '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
|
|
125
|
+
// A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
|
|
126
|
+
// reading is older than ten minutes, with that age in its tooltip.
|
|
127
|
+
const CTX_STALE_MIN = 10;
|
|
128
|
+
const CTX_FULL_PERCENT = 90;
|
|
129
|
+
function contextWords(ctx) {
|
|
130
|
+
if (!ctx) return '';
|
|
131
|
+
const percent = Math.round(ctx.percent);
|
|
132
|
+
const minutes = Math.floor((Date.now() - Date.parse(ctx.at)) / 60_000);
|
|
133
|
+
const full = percent >= CTX_FULL_PERCENT ? ' full' : '';
|
|
134
|
+
if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx${full}" title="${percent}% of its context window">${percent}% ctx</span>`;
|
|
135
|
+
const age = minutes < 60 ? `${minutes} min` : minutes < 1440 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 1440)} d`;
|
|
136
|
+
return `<span class="sb-ctx${full} stale" title="${percent}% of its context window, read ${age} ago">${percent}% ctx</span>`;
|
|
137
|
+
}
|
|
138
|
+
function askedWhen(iso, now) {
|
|
139
|
+
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
140
|
+
return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : usageClock(iso);
|
|
141
|
+
}
|
|
142
|
+
// The sheet a failed start's row opens (sidebar.js), inside its <dialog aria-labelledby="failedStartTitle">: the error,
|
|
143
|
+
// what the start used and when it was asked, then Dismiss and Retry. s: a /_hub/sidebar.json starting entry. Only a
|
|
144
|
+
// New board start records a branch, and only that start can run again, so a row without one has no Retry.
|
|
145
|
+
function failedStart(s, { home, now = Date.now() } = {}) {
|
|
146
|
+
const fact = (name, value) => (value ? `<dt>${name}</dt><dd>${value}</dd>` : '');
|
|
147
|
+
return `<div class="sheet-body"><div class="sheet-head"><h2 id="failedStartTitle">${esc(s.title)} failed to start</h2><button type="button" data-failed-close aria-label="Close sheet">✕</button></div>`
|
|
148
|
+
+ `<p class="fs-error">${esc(s.error || "Its session didn't answer within 15 minutes.")}</p>`
|
|
149
|
+
+ `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
|
|
150
|
+
+ `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
|
|
151
|
+
+ '<p class="fs-refused" role="alert" hidden></p>'
|
|
152
|
+
+ `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
|
|
156
|
+
// s.step is cloning, starting (the orchestrator's session) or landing (the session joining the hub), since s.stepAt.
|
|
157
|
+
// Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
|
|
158
|
+
const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
|
|
159
|
+
const STEP_KEYS = Object.keys(START_STEPS);
|
|
160
|
+
// The lower row: the step, cloning with its percent, and its seconds. A host clock ahead of this device's counts from 0s.
|
|
161
|
+
function startCaption(s, now = Date.now()) {
|
|
162
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(s.stepAt)) / 1000));
|
|
163
|
+
return `${s.step === 'cloning' ? `cloning (${s.percent || 0}%)` : START_STEPS[s.step]} · ${seconds}s`;
|
|
164
|
+
}
|
|
165
|
+
function startSteps(s) {
|
|
166
|
+
const at = STEP_KEYS.indexOf(s.step);
|
|
167
|
+
const label = `${START_STEPS[s.step]}, step ${at + 1} of ${STEP_KEYS.length}`;
|
|
168
|
+
const strip = s.step === 'cloning' ? { reached: 0, running: false, fill: s.percent || 0, label } : { reached: at + 1, running: true, label };
|
|
169
|
+
return `<span class="sb-steps">${phaseBars(strip, STEP_KEYS)}</span>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
|
|
173
|
+
// /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
|
|
174
|
+
// (hub/upgrade.mjs; docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). A reload, and an asked
|
|
175
|
+
// update until it runs out, waits (ui.js BUSY): its pill keeps its blue with a spinner in place of its word.
|
|
176
|
+
function updateRow(update, upgrade) {
|
|
177
|
+
const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
|
|
178
|
+
+ `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
|
|
179
|
+
if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
|
|
180
|
+
if (!upgrade) return '';
|
|
181
|
+
if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
|
|
182
|
+
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
|
|
183
|
+
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
|
|
184
|
+
return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
|
|
126
185
|
}
|
|
127
186
|
|
|
128
187
|
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
129
188
|
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
130
189
|
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
131
190
|
// update: null | 'ready' | 'reloading'.
|
|
132
|
-
|
|
191
|
+
// now: the time a starting board's seconds count to.
|
|
192
|
+
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
133
193
|
const sites = data?.sites || [];
|
|
134
194
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
135
195
|
const paused = data?.pause?.state === 'paused';
|
|
136
196
|
const siteRow = (s) => {
|
|
137
|
-
const sub = paused && s.board ? word('wait', '
|
|
138
|
-
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
197
|
+
const sub = paused && s.board ? word('wait', 'paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
198
|
+
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
139
199
|
};
|
|
140
200
|
const boards = sites.filter((s) => s.board);
|
|
141
201
|
const pages = sites.filter((s) => !s.board);
|
|
142
202
|
const plus = `<span class="icon sb-plus" aria-hidden="true">${svg(ICONS.plus, 20)}</span>`;
|
|
143
|
-
const pending = (data?.starting || []).map((s) =>
|
|
144
|
-
|
|
145
|
-
|
|
203
|
+
const pending = (data?.starting || []).map((s) => (s.failed
|
|
204
|
+
? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
|
|
205
|
+
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
|
|
206
|
+
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
|
|
207
|
+
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span>${startSteps(s)}<span class="sb-sub sb-step">${esc(startCaption(s, now))}</span></span></div>`)).join('');
|
|
146
208
|
const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
|
|
147
|
-
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', '
|
|
148
|
-
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', '
|
|
209
|
+
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
|
|
210
|
+
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
149
211
|
: '';
|
|
150
212
|
const usage = sidebarUsage(data?.usage);
|
|
151
|
-
const hub = paused ? { tone: 'wait', word: '
|
|
213
|
+
const hub = paused ? { tone: 'wait', word: 'paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
|
|
152
214
|
const n = data?.notifications || {};
|
|
153
215
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
154
216
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -165,7 +227,7 @@
|
|
|
165
227
|
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
166
228
|
return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
167
229
|
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
168
|
-
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
|
|
230
|
+
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
|
|
169
231
|
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
170
232
|
+ `</div>${turnOn}</section>`
|
|
171
233
|
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
@@ -173,5 +235,5 @@
|
|
|
173
235
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
174
236
|
}
|
|
175
237
|
|
|
176
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
238
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
177
239
|
})();
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
// The last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
|
|
2
|
-
// page may do meanwhile (docs/adr/
|
|
2
|
+
// page may do meanwhile (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md). Needs ui.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const VERSION = 1;
|
|
5
4
|
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
6
5
|
const SNAPSHOT_MAX = 900000;
|
|
7
6
|
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
8
7
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
// What a first paint shows of board: every card as the board sent it, Done cards already slim (board/domain.mjs
|
|
9
|
+
// slimDone), or the open lanes alone when openOnly. It keeps no version, since it isn't the board that version names,
|
|
10
|
+
// so the first reload downloads the whole board (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
11
|
+
const firstPaint = ({ version: _, ...board }, openOnly) => ({ ...board, items: openOnly ? board.items.filter((it) => it.lane !== 'done') : board.items });
|
|
12
|
+
|
|
13
|
+
// threads maps item keys to thread messages (app.js state.threadCache). The board goes in as firstPaint, then threads of
|
|
14
|
+
// `keep` first and the rest while they fit under max; threads of items no longer on the board are dropped. When the
|
|
15
|
+
// board still won't fit, Done cards go too. build is the saving page's (app.html data-build). Null when open lanes alone won't fit.
|
|
16
|
+
function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
17
|
+
const headOf = (b) => `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(b)},"threads":{`;
|
|
18
|
+
let shown = firstPaint(board, false);
|
|
19
|
+
let head = headOf(shown);
|
|
20
|
+
if (head.length + 2 > max) {
|
|
21
|
+
shown = firstPaint(board, true);
|
|
22
|
+
head = headOf(shown);
|
|
23
|
+
if (head.length + 2 > max) return null;
|
|
24
|
+
}
|
|
25
|
+
const onBoard = new Set(shown.items.map((i) => i.key));
|
|
15
26
|
let out = head;
|
|
16
27
|
let n = 0;
|
|
17
28
|
for (const key of new Set([...keep, ...threads.keys()])) {
|
|
@@ -24,11 +35,12 @@
|
|
|
24
35
|
return `${out}}}`;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
|
-
// → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write
|
|
28
|
-
|
|
38
|
+
// → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write under this build: code
|
|
39
|
+
// from another build may read the board's shape differently, and a paint that throws stops the live load.
|
|
40
|
+
function unpackSnapshot(raw, build) {
|
|
29
41
|
let s;
|
|
30
42
|
try { s = JSON.parse(raw); } catch { return null; }
|
|
31
|
-
if (s?.
|
|
43
|
+
if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
32
44
|
const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
|
|
33
45
|
return { savedAt: s.savedAt, board: s.board, threads };
|
|
34
46
|
}
|
|
@@ -42,7 +54,7 @@
|
|
|
42
54
|
// What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
|
|
43
55
|
// and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
|
|
44
56
|
// INT-61 block dims the same list.
|
|
45
|
-
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-
|
|
57
|
+
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
|
|
46
58
|
'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
|
|
47
59
|
const liveControls = (view) => {
|
|
48
60
|
const fresh = view !== 'snapshot';
|
|
@@ -52,5 +64,10 @@
|
|
|
52
64
|
// before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
|
|
53
65
|
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
54
66
|
|
|
55
|
-
|
|
67
|
+
// What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
|
|
68
|
+
// (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
|
|
69
|
+
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
|
|
70
|
+
it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
|
|
71
|
+
|
|
72
|
+
Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
|
|
56
73
|
})();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// A card's thread:
|
|
1
|
+
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
|
|
2
|
+
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
2
3
|
(function () {
|
|
3
|
-
const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
|
|
4
|
+
const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
|
|
4
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
5
6
|
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
6
7
|
function cardLink(link) {
|
|
@@ -24,32 +25,45 @@
|
|
|
24
25
|
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
25
26
|
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
|
|
26
27
|
};
|
|
27
|
-
// A message being edited shows its edit box in place of its text; app-asks.js handles the form.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
// A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
|
|
29
|
+
// files it adds to the message, under composer id edit:<event id>.
|
|
30
|
+
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
|
|
31
|
+
<div class="pending-files" data-files></div>
|
|
32
|
+
<div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
|
|
33
|
+
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
|
|
34
|
+
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
35
|
+
? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
32
36
|
: m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
|
|
33
|
-
: m.
|
|
34
|
-
: m.kind === '
|
|
37
|
+
: m.request ? `<div class="msg request">${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
38
|
+
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
39
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
40
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
35
41
|
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
36
|
-
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? '
|
|
42
|
+
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
37
43
|
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
44
|
+
${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log →</a></div>` : ''}
|
|
38
45
|
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
39
|
-
${m.eventId ? msgFooter(m) : ''}
|
|
46
|
+
${m.eventId && !batched ? msgFooter(m) : ''}
|
|
40
47
|
</div>`);
|
|
41
48
|
|
|
42
49
|
const STEP = 3;
|
|
43
|
-
const
|
|
50
|
+
const UNREAD_CAP = 10;
|
|
51
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
|
|
44
52
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
45
|
-
//
|
|
46
|
-
|
|
53
|
+
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
|
|
54
|
+
// orchestrator's answer to a builder's ask never is.
|
|
55
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
56
|
+
const batchedAt = (lines, i) => {
|
|
57
|
+
const next = lines.slice(i + 1).find(isMessage);
|
|
58
|
+
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
59
|
+
};
|
|
60
|
+
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
47
61
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
48
62
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
49
63
|
const firstUnread = lines.findIndex((m) => unread(m, seenAt));
|
|
50
64
|
const unreadSpan = firstUnread < 0 ? 0 : counted.filter((i) => i >= firstUnread).length;
|
|
51
|
-
const want = Math.max(STEP, unreadSpan) + STEP * more;
|
|
52
|
-
const pinned = lines.findIndex((m) => m.eventId &&
|
|
65
|
+
const want = Math.max(STEP, Math.min(UNREAD_CAP, unreadSpan)) + STEP * more;
|
|
66
|
+
const pinned = lines.findIndex((m) => m.eventId && edits.has(m.eventId));
|
|
53
67
|
const start = Math.min(counted.length > want ? counted[counted.length - want] : 0, pinned < 0 ? Infinity : pinned);
|
|
54
68
|
const hidden = lines.filter((m, i) => i < start && isMessage(m)).length;
|
|
55
69
|
return { start: hidden ? start : 0, hidden };
|
|
@@ -65,16 +79,17 @@
|
|
|
65
79
|
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
66
80
|
const shown = lines.slice(start);
|
|
67
81
|
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
68
|
-
return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
|
|
82
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m, i) => line(m, links, edits, batchedAt(lines, start + i))).join('');
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
// The reply form's Done
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
85
|
+
// The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
|
|
86
|
+
// docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md
|
|
87
|
+
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
88
|
+
// (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
89
|
+
function doneButton(it) {
|
|
75
90
|
if (!it?.finishable) return '';
|
|
76
|
-
const
|
|
77
|
-
return `<button type="button"${
|
|
91
|
+
const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
|
|
92
|
+
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
78
93
|
}
|
|
79
94
|
|
|
80
95
|
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
|
package/board/public/ui.js
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// string and touches no DOM or storage: ui.test.mjs runs these files in node:vm, where neither exists. Load this first.
|
|
3
3
|
// fetchJSON is the side pages' one way to read the board's API.
|
|
4
4
|
(function () {
|
|
5
|
-
// A bare ↗ can draw as an emoji tile on iOS; U+FE0E asks for its text glyph.
|
|
6
|
-
const LINK_ARROW = '↗\uFE0E';
|
|
7
5
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
8
6
|
const record = (records, key) => (Object.hasOwn(records || {}, key) ? records[key] : undefined);
|
|
9
7
|
const time = (iso) => new Date(iso).toLocaleString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
@@ -26,6 +24,14 @@
|
|
|
26
24
|
const day = d.toDateString() === now.toDateString() ? '' : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, `;
|
|
27
25
|
return `ETA about ${day}${clock(d)}`;
|
|
28
26
|
}
|
|
27
|
+
// A card's last line: the time only today, with the day before today, and with the year too past 365 days.
|
|
28
|
+
function cardTime(iso, now = new Date()) {
|
|
29
|
+
const d = new Date(iso);
|
|
30
|
+
const at = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
|
31
|
+
if (d.toDateString() === now.toDateString()) return at;
|
|
32
|
+
const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
|
|
33
|
+
return `${day}, ${at}`;
|
|
34
|
+
}
|
|
29
35
|
const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
|
|
30
36
|
|
|
31
37
|
// Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
|
|
@@ -43,14 +49,18 @@
|
|
|
43
49
|
}
|
|
44
50
|
const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
|
|
45
51
|
const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
|
|
46
|
-
|
|
52
|
+
// A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
|
|
53
|
+
const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
|
|
54
|
+
const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
55
|
+
// An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
|
|
56
|
+
const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
|
|
47
57
|
const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
|
|
48
58
|
const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
|
|
49
59
|
const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
|
|
50
|
-
// Asks Claude for throwaway prototypes of one item (docs/adr/
|
|
51
|
-
function protoButton(id, state = null) {
|
|
60
|
+
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
|
|
61
|
+
function protoButton(id, state = null, { label = 'Ask for prototypes' } = {}) {
|
|
52
62
|
if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
|
|
53
|
-
return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="
|
|
63
|
+
return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
|
|
54
64
|
}
|
|
55
65
|
// latestBump is the data.id of the newest ask-bump event.
|
|
56
66
|
const protoNotice = (eventId, latestBump) => (latestBump === eventId
|
|
@@ -61,13 +71,17 @@
|
|
|
61
71
|
// Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
|
|
62
72
|
const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
|
|
63
73
|
const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
|
|
64
|
-
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
74
|
+
// A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
|
|
75
|
+
// bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
|
|
76
|
+
// it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
77
|
+
// docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
78
|
+
const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
|
|
79
|
+
const cardBumpButton = (key, bumped) => (bumped
|
|
80
|
+
? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
|
|
81
|
+
: `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
|
|
82
|
+
const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
|
|
69
83
|
// html is markup the caller has escaped; href, title and attrs values are escaped here.
|
|
70
|
-
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
|
|
84
|
+
const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
|
|
71
85
|
function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
|
|
72
86
|
if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
|
|
73
87
|
const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
@@ -200,21 +214,87 @@
|
|
|
200
214
|
}
|
|
201
215
|
lastHtml.set(el, html);
|
|
202
216
|
if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
|
|
217
|
+
for (const w of waits) { const b = waitingButton(w); if (b && !b.classList.contains(w.mark)) markWaiting(b, w); }
|
|
203
218
|
return true;
|
|
204
219
|
}
|
|
205
220
|
|
|
221
|
+
// Closes <details> d at once: its toggle event comes a task later, and a setHTML before it would reopen it.
|
|
222
|
+
function closeDetails(d) {
|
|
223
|
+
openDetails.delete(detailsKey(d));
|
|
224
|
+
d.open = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A tap that saves something waits for the server (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md,
|
|
228
|
+
// docs/adr/2026-11-03-1915-every-pending-control-waits-in-one-style.md): waitOn(control, promise, {group}) disables
|
|
229
|
+
// control until promise settles and marks it tap-waiting and aria-busy. style.css button[aria-busy="true"] keeps a
|
|
230
|
+
// button's colours and width and shows a spinner in its text colour in place of its content, or pulses a bump icon
|
|
231
|
+
// orange. A toggle, select or field keeps its face, with a spinner beside it: after its caption in a .tk-field, else
|
|
232
|
+
// just before it. group: the other choices of the same setting, disabled (tap-blocked) with no spinner meanwhile.
|
|
233
|
+
// A repaint meanwhile (setHTML) patches each back to its markup, so setHTML marks it again, found by its id and data
|
|
234
|
+
// attributes when the repaint replaced it. Settling restores the disabled and aria-busy its latest markup had. → promise
|
|
235
|
+
// Work that outlives its request (a restart, an update, a merge) draws its button with BUSY in its markup until the
|
|
236
|
+
// state the page draws from says the work is done.
|
|
237
|
+
const BUSY = ' disabled aria-busy="true"';
|
|
238
|
+
const waits = new Set();
|
|
239
|
+
function markWaiting(b, w) {
|
|
240
|
+
w.el = b;
|
|
241
|
+
w.disabled = b.disabled;
|
|
242
|
+
w.busy = b.getAttribute('aria-busy');
|
|
243
|
+
b.disabled = true;
|
|
244
|
+
b.classList.add(w.mark);
|
|
245
|
+
if (w.mark !== 'tap-waiting') return;
|
|
246
|
+
b.setAttribute('aria-busy', 'true');
|
|
247
|
+
if (b.tagName === 'BUTTON') return;
|
|
248
|
+
w.spin?.remove();
|
|
249
|
+
w.spin = document.createElement('span');
|
|
250
|
+
w.spin.setAttribute('class', 'try-spin tap-beside');
|
|
251
|
+
w.spin.setAttribute('aria-hidden', 'true');
|
|
252
|
+
const caption = b.closest('.tk-field')?.firstElementChild;
|
|
253
|
+
if (caption && caption !== b) caption.appendChild(w.spin);
|
|
254
|
+
else b.before(w.spin);
|
|
255
|
+
}
|
|
256
|
+
const waitingButton = (w) => (w.el.isConnected && w.el.matches(w.selector) ? w.el
|
|
257
|
+
: w.named ? document.body.querySelector(w.selector) : null);
|
|
258
|
+
function waitFor(el, mark) {
|
|
259
|
+
const named = [...el.attributes].filter(({ name }) => name === 'id' || name.startsWith('data-'));
|
|
260
|
+
const w = { mark, selector: el.tagName.toLowerCase() + named.map(({ name, value }) => (value ? `[${name}="${CSS.escape(value)}"]` : `[${name}]`)).join(''), named: named.length > 0 };
|
|
261
|
+
markWaiting(el, w);
|
|
262
|
+
waits.add(w);
|
|
263
|
+
return w;
|
|
264
|
+
}
|
|
265
|
+
function waitOn(control, promise, { group = [] } = {}) {
|
|
266
|
+
const mine = [waitFor(control, 'tap-waiting'), ...[...group].filter((el) => el !== control).map((el) => waitFor(el, 'tap-blocked'))];
|
|
267
|
+
const done = () => {
|
|
268
|
+
for (const w of mine) {
|
|
269
|
+
waits.delete(w);
|
|
270
|
+
w.spin?.remove();
|
|
271
|
+
// setHTML keeps w.el the node it last marked; one a plain innerHTML replaced is gone with its mark.
|
|
272
|
+
const b = w.el;
|
|
273
|
+
if (!b.classList.contains(w.mark)) continue;
|
|
274
|
+
b.classList.remove(w.mark);
|
|
275
|
+
if (w.mark === 'tap-waiting') { if (w.busy === null) b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', w.busy); }
|
|
276
|
+
b.disabled = w.disabled;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
promise.then(done, done);
|
|
280
|
+
return promise;
|
|
281
|
+
}
|
|
282
|
+
|
|
206
283
|
// While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
|
|
207
284
|
// on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
|
|
208
285
|
// then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
|
|
286
|
+
// tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
|
|
287
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
209
288
|
const RESTARTING = 'Board restarting…';
|
|
210
289
|
const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
|
|
211
|
-
async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
290
|
+
async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
212
291
|
let waited = 0;
|
|
213
292
|
let delay = 500;
|
|
214
293
|
for (;;) {
|
|
215
294
|
let failure;
|
|
216
295
|
try {
|
|
217
|
-
const r = await get(url, { cache: 'no-store' });
|
|
296
|
+
const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
|
|
297
|
+
if (r.status === 304) return { ok: true, status: 304, body: null };
|
|
218
298
|
const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
|
|
219
299
|
if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
|
|
220
300
|
failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
|
|
@@ -229,7 +309,7 @@
|
|
|
229
309
|
|
|
230
310
|
globalThis.BoardUI = {
|
|
231
311
|
LINK_ARROW,
|
|
232
|
-
esc, record, time, ago, since, etaLine, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
|
|
233
|
-
attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
312
|
+
esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
|
|
313
|
+
attachments, attachButton, dropZone, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
|
|
234
314
|
};
|
|
235
315
|
})();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Any other page on the hub as a view of the app shell (shell.js): another site, a prototype, or a board page that isn't
|
|
2
|
-
// a view of its own, in a same-origin frame beside the board header (docs/adr/
|
|
2
|
+
// a view of its own, in a same-origin frame beside the board header (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md). The
|
|
3
3
|
// header's title follows the page's, and Open alone ↗ loads the page by itself (?alone=1, hub/shell.mjs pageOf). A
|
|
4
4
|
// page that draws the board header itself, such as a prototype or another site's page, hides this one; its logo then
|
|
5
5
|
// toggles the sidebar as this one's would, and hides while the rail shows. The page's own navigations stay in the frame
|