@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
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
const ARROW = icon(24, '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>');
|
|
7
7
|
const REFRESH = icon(18, '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>');
|
|
8
8
|
const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
|
|
9
|
+
const MOON = icon(16, '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
|
|
9
10
|
const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
|
|
10
11
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
11
12
|
|
|
12
|
-
// Where the back arrow goes (docs/adr/
|
|
13
|
+
// Where the back arrow goes (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): 'back', one step back in history, when
|
|
13
14
|
// the page before was on this hub; else 'href', its backHref. A page a push opened always takes its backHref. idx is
|
|
14
15
|
// the entry's index in history.state, set by an in-app router: when present, it alone says whether a step back exists.
|
|
15
16
|
function backStep({ pushed = false, referrer = '', href, depth = 1, idx }) {
|
|
@@ -40,16 +41,17 @@
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
// lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
|
|
43
|
-
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board,
|
|
44
|
-
// page's script turns them on (docs/adr/
|
|
44
|
+
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
|
|
45
|
+
// page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
45
46
|
// false, trail (HTML) takes their place. bulb puts the architecture review's bulb between them (app-arch.js). leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
46
|
-
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js).
|
|
47
|
+
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
|
|
48
|
+
// away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
47
49
|
function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
|
|
48
|
-
controls = true, trail = '', time = '…', bulb = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
|
|
50
|
+
controls = true, trail = '', time = '…', bulb = false, moon = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
|
|
49
51
|
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
50
52
|
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
51
53
|
const first = lead === 'back'
|
|
52
|
-
?
|
|
54
|
+
? `${logo('hub')}<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>`
|
|
53
55
|
: logo();
|
|
54
56
|
const nav = controls ? `<nav class="nav">`
|
|
55
57
|
+ `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
|
|
@@ -58,7 +60,8 @@
|
|
|
58
60
|
+ `</nav>` : trail;
|
|
59
61
|
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|
|
60
62
|
const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
|
|
61
|
-
|
|
63
|
+
const away = moon ? `<span class="away-moon" role="img" aria-label="Away" aria-hidden="true">${MOON}</span>` : '';
|
|
64
|
+
return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}>${away}</div>${nav}</header>${script}`;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The board's per-card patches (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md): server.mjs makes one
|
|
2
|
+
// from the board a page holds to the board now, and the page applies it to the board it holds. Cards patch by key,
|
|
3
|
+
// questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new order when
|
|
4
|
+
// that changed; every other part of the board comes whole when it changed.
|
|
5
|
+
// Needs nothing else: board/server.mjs runs this file in a vm context.
|
|
6
|
+
(function () {
|
|
7
|
+
const LISTS = { items: 'key', owner: 'id', ownerTopics: 'id' };
|
|
8
|
+
const OWN = new Set(['version', 'generatedAt', ...Object.keys(LISTS)]);
|
|
9
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
10
|
+
|
|
11
|
+
function listPatch(from, to, id) {
|
|
12
|
+
const was = new Map(from.map((r) => [r[id], r]));
|
|
13
|
+
const now = new Set(to.map((r) => r[id]));
|
|
14
|
+
const set = to.filter((r) => !was.has(r[id]) || !same(was.get(r[id]), r));
|
|
15
|
+
const drop = from.map((r) => r[id]).filter((k) => !now.has(k));
|
|
16
|
+
if (!set.length && !drop.length && same(from.map((r) => r[id]), to.map((r) => r[id]))) return null;
|
|
17
|
+
const placed = [...from.map((r) => r[id]).filter((k) => now.has(k)), ...to.map((r) => r[id]).filter((k) => !was.has(k))];
|
|
18
|
+
const order = to.map((r) => r[id]);
|
|
19
|
+
return { set, drop, ...(same(placed, order) ? {} : { order }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// from and to are page boards, each with its version. → {since, version, generatedAt, set, unset, lists}
|
|
23
|
+
function boardPatch(from, to) {
|
|
24
|
+
const set = {};
|
|
25
|
+
for (const [k, v] of Object.entries(to)) if (!OWN.has(k) && (!(k in from) || !same(from[k], v))) set[k] = v;
|
|
26
|
+
const unset = Object.keys(from).filter((k) => !OWN.has(k) && !(k in to));
|
|
27
|
+
const lists = {};
|
|
28
|
+
for (const [name, id] of Object.entries(LISTS)) {
|
|
29
|
+
const p = listPatch(from[name] || [], to[name] || [], id);
|
|
30
|
+
if (p) lists[name] = p;
|
|
31
|
+
}
|
|
32
|
+
return { since: from.version, version: to.version, generatedAt: to.generatedAt, set, unset, lists };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// → a new board, leaving board as it was, or null when the patch was made against another version.
|
|
36
|
+
function applyPatch(board, patch) {
|
|
37
|
+
if (!board || board.version !== patch.since) return null;
|
|
38
|
+
const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt };
|
|
39
|
+
for (const k of patch.unset) delete next[k];
|
|
40
|
+
for (const [name, p] of Object.entries(patch.lists)) {
|
|
41
|
+
const id = LISTS[name];
|
|
42
|
+
const gone = new Set(p.drop);
|
|
43
|
+
const byId = new Map((board[name] || []).filter((r) => !gone.has(r[id])).map((r) => [r[id], r]));
|
|
44
|
+
const fresh = p.set.filter((r) => !byId.has(r[id]));
|
|
45
|
+
for (const r of p.set) byId.set(r[id], r);
|
|
46
|
+
const order = p.order || [...(board[name] || []).map((r) => r[id]).filter((k) => !gone.has(k)), ...fresh.map((r) => r[id])];
|
|
47
|
+
next[name] = order.map((k) => byId.get(k));
|
|
48
|
+
}
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Object.assign(globalThis.BoardUI ||= {}, { boardPatch, applyPatch });
|
|
53
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The phase bars: one bar per phase, those reached filled, a running one pulsing. An In Progress card draws the five
|
|
2
|
+
// build phases (ui-cards.js) and a starting board's sidebar row its three start steps (ui-sidebar.js)
|
|
3
|
+
// (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). It needs nothing else, since
|
|
4
|
+
// hub/hub.mjs renders the shell's first sidebar with it in a vm context. style.css draws the bars.
|
|
5
|
+
(function () {
|
|
6
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
+
// strip: {reached, running, dim, label, fill, plan}, or null for no bars. reached counts the phases reached, the last of
|
|
8
|
+
// them running when running is set; fill, a percent, fills the next bar that far; plan draws them in the planner blue.
|
|
9
|
+
function phaseBars(strip, phases) {
|
|
10
|
+
if (!strip) return '';
|
|
11
|
+
const { reached, running, dim, label, fill, plan } = strip;
|
|
12
|
+
const bar = (i) => (i === reached - 1 && running ? '<i class="now"></i>' : i < reached ? '<i class="done"></i>'
|
|
13
|
+
: i === reached && fill !== undefined ? `<i class="fill" style="--p:${fill}%"></i>` : '<i class=""></i>');
|
|
14
|
+
return `<span class="phase-bars${dim ? ' dim' : ''}${plan ? ' plan' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
|
|
15
|
+
}
|
|
16
|
+
Object.assign(globalThis.BoardUI ||= {}, { phaseBars });
|
|
17
|
+
})();
|
|
@@ -18,21 +18,21 @@
|
|
|
18
18
|
// Whether one tap on an option answers: a single question that takes one pick.
|
|
19
19
|
const oneTap = (p) => p.questions?.length === 1 && !p.questions[0].multiSelect;
|
|
20
20
|
|
|
21
|
-
// sending:
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// sending: while the answer goes, the tapped button: a choice ('allow', 'always', 'deny'), 'send', or an option as
|
|
22
|
+
// '<question index>:<label>'. It waits (ui.js BUSY) and the line's other buttons are disabled.
|
|
23
|
+
function promptItem(p, { picks = {}, sending = null } = {}) {
|
|
24
|
+
const off = (tapped) => (sending === null ? '' : sending === tapped ? BoardUI.BUSY : ' disabled');
|
|
24
25
|
const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
|
|
25
26
|
const head = `<div class="prompt-head"><b>${esc(p.questions ? 'Question' : p.tool)}</b><span class="muted small">${where}</span></div>`;
|
|
26
|
-
const status = sending ? '<span class="muted small">Sending…</span>' : '';
|
|
27
27
|
if (p.questions) {
|
|
28
28
|
const questions = p.questions.map((q, i) => `<div class="prompt-q"><p>${q.header ? `<span class="chip">${esc(q.header)}</span> ` : ''}${esc(q.question)}</p>
|
|
29
|
-
<div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
|
|
30
|
-
const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${promptAnswer(p, picks)
|
|
31
|
-
return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off}>Skip</button
|
|
29
|
+
<div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off(`${i}:${o.label}`)}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
|
|
30
|
+
const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${off('send') || (promptAnswer(p, picks) ? '' : ' disabled')}>Send</button>`;
|
|
31
|
+
return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Skip</button></div></li>`;
|
|
32
32
|
}
|
|
33
33
|
const detail = p.detail ? `<pre class="prompt-detail">${esc(p.detail)}</pre>` : `<p class="prompt-what">Wants to ${esc(p.action)}</p>`;
|
|
34
34
|
const rule = p.rule ? `<p class="muted small prompt-rule">Always allow adds <code>${esc(p.rule)}</code> until the session ends.</p>` : '';
|
|
35
|
-
return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off}>Allow once</button><button type="button" data-prompt-choice="always"${off}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off}>Deny</button
|
|
35
|
+
return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off('allow')}>Allow once</button><button type="button" data-prompt-choice="always"${off('always')}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Deny</button></div>${rule}</li>`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
Object.assign(globalThis.BoardUI, { promptItem, promptAnswer, oneTap });
|
|
@@ -1,31 +1,35 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, time, replyBody, attachments, attachButton, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
3
|
+
const { esc, time, ago, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
4
|
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
|
|
5
5
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
6
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// suggested: the line under a suggested option saying where the owner chose it before, as HTML.
|
|
8
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false, locked = false, suggested = '' }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
|
+
<span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
|
|
10
|
+
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
|
+
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
|
+
// suggested, pre-checked so the topic's Send sends it.
|
|
13
|
+
const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
|
+
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
9
15
|
|
|
10
16
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
11
|
-
// A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone
|
|
12
|
-
//
|
|
13
|
-
const questionEye = (q) => q.
|
|
14
|
-
|
|
15
|
-
+ (q.state === 'requested' ? badge('normal', `${SPIN}prototype requested`) : '');
|
|
16
|
-
const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}"><textarea class="note" rows="2" placeholder="Note (optional)"${q.state === 'requested' ? ' disabled' : ''}>${esc(note || '')}</textarea>
|
|
17
|
+
// A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
|
|
18
|
+
// requested until its prototype round arrives (view-questions.js).
|
|
19
|
+
const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
|
|
20
|
+
const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
|
|
17
21
|
<div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
|
|
18
22
|
|
|
19
23
|
const QUESTION_STATE = {
|
|
20
24
|
open: { label: 'open', tone: 'normal' },
|
|
21
25
|
answered: { label: 'waiting for claude', tone: 'notice' },
|
|
22
26
|
replied: { label: 'claude replied', tone: 'ready' },
|
|
23
|
-
|
|
27
|
+
requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
|
|
24
28
|
};
|
|
25
|
-
// docs/adr/
|
|
29
|
+
// docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
|
|
26
30
|
// A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
|
|
27
31
|
const roundChip = (r) => !r ? ''
|
|
28
|
-
: r.state === 'making' ? badge('
|
|
32
|
+
: r.state === 'making' ? badge('normal', `${SPIN}Prototype round ${r.n}`)
|
|
29
33
|
: r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
|
|
30
34
|
: badge('normal', `Prototype round ${r.n}`);
|
|
31
35
|
const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
|
|
@@ -47,7 +51,7 @@
|
|
|
47
51
|
: isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
|
|
48
52
|
: `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
|
|
49
53
|
|
|
50
|
-
// A question's takes show inline (docs/adr/
|
|
54
|
+
// A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
|
|
51
55
|
// it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
|
|
52
56
|
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
53
57
|
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
@@ -79,40 +83,68 @@
|
|
|
79
83
|
// Every take loads so one outside the viewport can still decide the question's layout.
|
|
80
84
|
return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
|
|
81
85
|
}
|
|
82
|
-
function takes(q, chosen, links, fit) {
|
|
86
|
+
function takes(q, chosen, links, fit, locked = false) {
|
|
83
87
|
const name = 'c-' + q.id;
|
|
84
|
-
const disabled = q.state === 'requested';
|
|
85
88
|
if (fullTakes(q, fit)) {
|
|
86
89
|
const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
|
|
87
|
-
return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown,
|
|
90
|
+
return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, locked, suggested: suggestedLine(q, o, links) })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
|
|
88
91
|
}
|
|
89
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id,
|
|
92
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
|
|
90
93
|
}
|
|
91
94
|
// A confirm links its one take alone, and says why it is asked.
|
|
92
|
-
const confirmLines = (q) => !q.confirm_of
|
|
95
|
+
const confirmLines = (q) => !q.confirm_of ? ''
|
|
93
96
|
: `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
|
|
97
|
+
// docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
|
|
98
|
+
// A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
|
|
99
|
+
// `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
|
|
100
|
+
// checked among locked options, and its last note and Claude's reply. reask is a re-ask under way, which locks the badge.
|
|
101
|
+
function resolvedCard(q, links) {
|
|
102
|
+
const last = q.answers[q.answers.length - 1];
|
|
103
|
+
const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
|
|
104
|
+
const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
|
|
105
|
+
const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
|
|
106
|
+
${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
|
|
107
|
+
return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
108
|
+
<div class="row" style="margin:0"><span class="muted small">${esc(q.source || '')}</span>${badge('ready', 'resolved', { button: true, title: 'Ask this question again', attrs: { type: 'button', 'data-reask': q.id, disabled: q.reask ? '' : null } })}</div>
|
|
109
|
+
<h3>${linkText(q.question, links)}</h3>
|
|
110
|
+
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
111
|
+
${questionLink(q.url)}
|
|
112
|
+
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
113
|
+
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
|
|
114
|
+
${said}
|
|
115
|
+
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
116
|
+
</section>`;
|
|
117
|
+
}
|
|
94
118
|
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
|
|
95
119
|
// fit(option id) supplies the measured grid and full-width heights.
|
|
96
|
-
// A
|
|
120
|
+
// A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
|
|
121
|
+
// round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
97
122
|
function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${busy ? '' : badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
|
|
123
|
+
if (q.state === 'resolved') return resolvedCard(q, links);
|
|
124
|
+
const locked = q.state === 'requested';
|
|
125
|
+
const chosen = locked ? q.request.choice : choiceOf(q, draft);
|
|
126
|
+
return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
127
|
+
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
104
128
|
<h3>${linkText(q.question, links)}</h3>
|
|
105
129
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
106
130
|
${framed(q) ? '' : questionLink(q.url)}
|
|
107
131
|
${confirmLines(q)}
|
|
108
|
-
${q
|
|
109
|
-
${
|
|
110
|
-
${noteComposer(q, shown.note)}
|
|
132
|
+
${inline(q) ? takes(q, chosen, links, fit, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
133
|
+
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
|
|
111
134
|
${answerHistory(q, links)}
|
|
112
135
|
</section>`;
|
|
113
136
|
}
|
|
114
137
|
// A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
|
|
115
138
|
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
|
|
139
|
+
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
140
|
+
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
141
|
+
function cardTopics(topics, cards) {
|
|
142
|
+
const byKey = new Map(cards.map((c) => [c.key, c]));
|
|
143
|
+
return topics.map((t) => {
|
|
144
|
+
const c = t.title === undefined && byKey.get(t.card);
|
|
145
|
+
return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
116
148
|
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
117
149
|
// current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
|
|
118
150
|
function topicRow(topics, current) {
|
|
@@ -147,5 +179,5 @@
|
|
|
147
179
|
},
|
|
148
180
|
};
|
|
149
181
|
|
|
150
|
-
Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, topicRow, nextQuestion, sentNotice });
|
|
182
|
+
Object.assign(globalThis.BoardUI, { choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
151
183
|
})();
|
|
@@ -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 = {
|
|
@@ -13,6 +14,11 @@
|
|
|
13
14
|
pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
|
14
15
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
15
16
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
17
|
+
// A starting board's step icons, one per START_STEPS key. No open icon set (Lucide, Tabler) has a skateboard, so landing's
|
|
18
|
+
// deck with upturned ends over two wheels is drawn here.
|
|
19
|
+
cloning: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/>',
|
|
20
|
+
starting: '<path d="m5 7 5 5-5 5M12 18h7"/>',
|
|
21
|
+
landing: '<path d="M2.5 9c0 1.7 1.2 3 3 3h13c1.8 0 3-1.3 3-3"/><circle cx="7.5" cy="16.5" r="2"/><circle cx="16.5" cy="16.5" r="2"/>',
|
|
16
22
|
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
17
23
|
up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
|
|
18
24
|
};
|
|
@@ -54,7 +60,7 @@
|
|
|
54
60
|
];
|
|
55
61
|
const siteHref = (s) => `/${encodeURIComponent(s.route)}/`;
|
|
56
62
|
|
|
57
|
-
// Switch mode's keys (sidebar.js; docs/adr/
|
|
63
|
+
// 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
64
|
// sidebar draws, boards first for numbering, as {href, title, board, num, letter, at}. The first nine boards take num '1'–'9'.
|
|
59
65
|
// 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
66
|
// claiming in this order: boards by `created`, then pages by `created`, a site with none after those with one, then
|
|
@@ -81,13 +87,13 @@
|
|
|
81
87
|
// key: the row's switchKeys entry while switch mode shows, else undefined. The tile carries a board's number, or any
|
|
82
88
|
// other row's letter, and the title marks its letter; sidebar.css shows them in place of the glyph under .ks-on.
|
|
83
89
|
// mark: HTML after the title, such as Hub's moon.
|
|
84
|
-
function row({ href, icon, title, sub = '', extra = '', mark = '', on = false, cls = '', key }) {
|
|
90
|
+
function row({ href, icon, title, sub = '', extra = '', mark = '', ctx = '', on = false, cls = '', key }) {
|
|
85
91
|
const shown = key && (key.board ? key.num : key.letter);
|
|
86
92
|
const tileHtml = shown ? icon.replace(/<\/span>$/, `<span class="ks-key">${esc(shown)}</span></span>`) : icon;
|
|
87
93
|
const chars = [...title];
|
|
88
94
|
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
95
|
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>`;
|
|
96
|
+
+ `<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
97
|
}
|
|
92
98
|
|
|
93
99
|
// What waits on the owner on every site but `here`: the count on the logo that opens the drawer.
|
|
@@ -121,14 +127,18 @@
|
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
|
|
124
|
-
// A board's context window: its owner session's latest percent
|
|
130
|
+
// A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
|
|
131
|
+
// reading is older than ten minutes, with that age in its tooltip.
|
|
125
132
|
const CTX_STALE_MIN = 10;
|
|
133
|
+
const CTX_FULL_PERCENT = 90;
|
|
126
134
|
function contextWords(ctx) {
|
|
127
135
|
if (!ctx) return '';
|
|
136
|
+
const percent = Math.round(ctx.percent);
|
|
128
137
|
const minutes = Math.floor((Date.now() - Date.parse(ctx.at)) / 60_000);
|
|
129
|
-
|
|
138
|
+
const full = percent >= CTX_FULL_PERCENT ? ' full' : '';
|
|
139
|
+
if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx${full}" title="${percent}% of its context window">${percent}% ctx</span>`;
|
|
130
140
|
const age = minutes < 60 ? `${minutes} min` : minutes < 1440 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 1440)} d`;
|
|
131
|
-
return `<span class="sb-ctx stale"
|
|
141
|
+
return `<span class="sb-ctx${full} stale" title="${percent}% of its context window, read ${age} ago">${percent}% ctx</span>`;
|
|
132
142
|
}
|
|
133
143
|
function askedWhen(iso, now) {
|
|
134
144
|
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
@@ -144,19 +154,38 @@
|
|
|
144
154
|
+ `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
|
|
145
155
|
+ `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
|
|
146
156
|
+ '<p class="fs-refused" role="alert" hidden></p>'
|
|
147
|
-
+ `<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>`;
|
|
157
|
+
+ `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch && !s.joined ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
|
|
161
|
+
// s.step is cloning, starting (the orchestrator's session, until it joins the hub) or landing (the joined board, until it
|
|
162
|
+
// listens), since s.stepAt.
|
|
163
|
+
// Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
|
|
164
|
+
const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
|
|
165
|
+
const STEP_KEYS = Object.keys(START_STEPS);
|
|
166
|
+
// The lower row: the step, cloning with its percent, and its seconds. A host clock ahead of this device's counts from 0s.
|
|
167
|
+
function startCaption(s, now = Date.now()) {
|
|
168
|
+
const seconds = Math.max(0, Math.floor((now - Date.parse(s.stepAt)) / 1000));
|
|
169
|
+
return `${s.step === 'cloning' ? `cloning (${s.percent || 0}%)` : START_STEPS[s.step]} · ${seconds}s`;
|
|
170
|
+
}
|
|
171
|
+
function startSteps(s) {
|
|
172
|
+
const at = STEP_KEYS.indexOf(s.step);
|
|
173
|
+
const label = `${START_STEPS[s.step]}, step ${at + 1} of ${STEP_KEYS.length}`;
|
|
174
|
+
const strip = s.step === 'cloning' ? { reached: 0, running: false, fill: s.percent || 0, label } : { reached: at + 1, running: true, label };
|
|
175
|
+
return `<span class="sb-steps">${phaseBars(strip, STEP_KEYS)}</span>`;
|
|
148
176
|
}
|
|
149
177
|
|
|
150
178
|
// The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
|
|
151
179
|
// /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
|
|
152
|
-
// (hub/upgrade.mjs; docs/adr/
|
|
180
|
+
// (hub/upgrade.mjs; docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). A reload, and an asked
|
|
181
|
+
// update until it runs out, waits (ui.js BUSY): its pill keeps its blue with a spinner in place of its word.
|
|
153
182
|
function updateRow(update, upgrade) {
|
|
154
|
-
const button = (attr,
|
|
155
|
-
+ `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span
|
|
156
|
-
if (update) return button('data-app-reload', update === 'reloading', 'New version', '',
|
|
183
|
+
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)')}`
|
|
184
|
+
+ `<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>`;
|
|
185
|
+
if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
|
|
157
186
|
if (!upgrade) return '';
|
|
158
|
-
if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', '');
|
|
159
|
-
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', '
|
|
187
|
+
if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
|
|
188
|
+
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
|
|
160
189
|
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
|
|
161
190
|
return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
|
|
162
191
|
}
|
|
@@ -165,13 +194,14 @@
|
|
|
165
194
|
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
166
195
|
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
167
196
|
// update: null | 'ready' | 'reloading'.
|
|
168
|
-
|
|
197
|
+
// now: the time a starting board's seconds count to.
|
|
198
|
+
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
169
199
|
const sites = data?.sites || [];
|
|
170
200
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
171
201
|
const paused = data?.pause?.state === 'paused';
|
|
172
202
|
const siteRow = (s) => {
|
|
173
|
-
const sub = paused && s.board ? word('wait', '
|
|
174
|
-
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub
|
|
203
|
+
const sub = paused && s.board ? word('wait', 'paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
204
|
+
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)) });
|
|
175
205
|
};
|
|
176
206
|
const boards = sites.filter((s) => s.board);
|
|
177
207
|
const pages = sites.filter((s) => !s.board);
|
|
@@ -179,14 +209,14 @@
|
|
|
179
209
|
const pending = (data?.starting || []).map((s) => (s.failed
|
|
180
210
|
? `<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}`
|
|
181
211
|
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
|
|
182
|
-
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${
|
|
183
|
-
+ `<span class="sb-text"><span class="sb-title">${s.
|
|
212
|
+
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}"><span class="icon sb-plus sb-start-icon" aria-hidden="true">${svg(ICONS[s.step], 20)}</span>`
|
|
213
|
+
+ `<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('');
|
|
184
214
|
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>`;
|
|
185
215
|
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' })
|
|
186
216
|
: 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' })
|
|
187
217
|
: '';
|
|
188
218
|
const usage = sidebarUsage(data?.usage);
|
|
189
|
-
const hub = paused ? { tone: 'wait', word: '
|
|
219
|
+
const hub = paused ? { tone: 'wait', word: 'paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
|
|
190
220
|
const n = data?.notifications || {};
|
|
191
221
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
192
222
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -211,5 +241,5 @@
|
|
|
211
241
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
212
242
|
}
|
|
213
243
|
|
|
214
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
244
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
215
245
|
})();
|
|
@@ -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
4
|
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
5
5
|
const SNAPSHOT_MAX = 900000;
|
|
6
6
|
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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.
|
|
11
16
|
function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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()])) {
|
|
@@ -54,9 +65,9 @@
|
|
|
54
65
|
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
55
66
|
|
|
56
67
|
// What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
|
|
57
|
-
// (docs/adr/
|
|
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.
|
|
58
69
|
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
|
|
59
|
-
it.next ?? null, it.ask?.state ?? null, it.
|
|
70
|
+
it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
|
|
60
71
|
|
|
61
72
|
Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
|
|
62
73
|
})();
|