@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
package/board/public/ui-hub.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js
|
|
1
|
+
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js), which hub.css styles
|
|
2
2
|
// under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
|
|
3
3
|
(function () {
|
|
4
4
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
5
5
|
const TILE_PATHS = {
|
|
6
|
+
github: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/>',
|
|
7
|
+
codex: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3"/><path d="M13 15h4"/>',
|
|
8
|
+
linear: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
|
9
|
+
slack: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
10
|
+
tailscale: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
|
|
11
|
+
notifications: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
6
12
|
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
|
|
7
13
|
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
8
14
|
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
9
15
|
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
|
10
16
|
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
|
|
11
17
|
snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
|
|
18
|
+
coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
|
|
12
19
|
laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
|
|
13
20
|
key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
|
|
14
21
|
};
|
|
@@ -36,27 +43,28 @@
|
|
|
36
43
|
return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><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">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
// builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
|
|
47
|
+
function bufferControl(pause, builder) {
|
|
40
48
|
const at = pause.pause_at;
|
|
41
49
|
const off = at === 100;
|
|
42
|
-
return `<div class="
|
|
50
|
+
return `<div class="block"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
|
|
43
51
|
+ `<form class="segments ub-step" method="post" action="/_hub/settings/pause-at" role="group" aria-label="Pause work at"><button name="value" value="${at - 5}" aria-label="Pause 5% sooner"${at <= 50 ? ' disabled' : ''}>−</button>`
|
|
44
52
|
+ `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
|
|
45
53
|
+ `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
|
|
46
|
-
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' :
|
|
54
|
+
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
50
58
|
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
51
59
|
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
52
60
|
|
|
53
|
-
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet,
|
|
54
|
-
//
|
|
61
|
+
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
|
|
62
|
+
// while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
|
|
55
63
|
function deviceRow(state, { compact = false } = {}) {
|
|
56
|
-
if (!state) return `<div class="row" id="push" hidden>${tile('bell', '
|
|
64
|
+
if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
|
|
57
65
|
const a = state.action;
|
|
58
|
-
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.
|
|
59
|
-
return `<div class="row" id="push">${tile('bell', '
|
|
66
|
+
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.busy ? BoardUI.BUSY : ''}>${esc(a.label)}</button>` : '';
|
|
67
|
+
return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
// The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
|
|
@@ -65,14 +73,24 @@
|
|
|
65
73
|
const off = !codex.usable;
|
|
66
74
|
const buttons = codex.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}"${off ? ' disabled' : ''}>${esc(c.label)}</button>`).join('');
|
|
67
75
|
return `<div class="u-share"><form class="segments${off ? ' is-disabled' : ''}" method="post" action="/_hub/settings/codex" role="group" aria-label="Codex share"${off ? ' aria-disabled="true"' : ''}>${buttons}</form>`
|
|
68
|
-
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}
|
|
76
|
+
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}</div>`;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
//
|
|
79
|
+
// The Claude model control, drawn as Codex share's: claude is hub/views.mjs hubView's claude, and its form posts to
|
|
80
|
+
// /_hub/settings/claude-model, which view-hub.js sends.
|
|
81
|
+
function claudeModel(claude) {
|
|
82
|
+
const buttons = claude.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}">${esc(c.label)}</button>`).join('');
|
|
83
|
+
return `<div class="u-share"><form class="segments" method="post" action="/_hub/settings/claude-model" role="group" aria-label="Claude model">${buttons}</form>`
|
|
84
|
+
+ `<p class="u-share-desc">${esc(claude.description)}</p></div>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// data is the usage reading; claude, when given, opens Claude's block with the Claude model control, even without a
|
|
88
|
+
// reading; codex, when given, draws Codex's block even without a reading: the Codex share control
|
|
72
89
|
// first, then Codex's bars while share isn't Off.
|
|
73
|
-
function usageCard(data, codex, pause) {
|
|
90
|
+
function usageCard(data, codex, pause, claude) {
|
|
74
91
|
const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
|
|
75
92
|
const products = usageProducts(data);
|
|
93
|
+
if (claude && !products.includes('claude')) products.unshift('claude');
|
|
76
94
|
if (codex && !products.includes('codex')) products.push('codex');
|
|
77
95
|
if (!products.length) return '';
|
|
78
96
|
const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
|
|
@@ -81,13 +99,13 @@
|
|
|
81
99
|
`<div class="u-tone-${usageTone(r[key])}${pause?.pause_at < 100 ? ' ub-marked' : ''}"><div class="u-win"><span>${label}</span><span>${usageWords(r, key)}</span></div>${usageBar(r[key]).replace('</span>', `${pause?.pause_at < 100 ? `<span class="ub-mark" style="left:${pause.pause_at}%"></span>` : ''}</span>`)}</div>`).join('');
|
|
82
100
|
const blocks = products.map((p) => {
|
|
83
101
|
const r = data?.[p] || { at: null };
|
|
84
|
-
if (p
|
|
102
|
+
if (p === 'claude' && claude) return `<div class="block u-block">${head(usageNames.claude, asof(r))}${claudeModel(claude)}${windows(r)}</div>`;
|
|
103
|
+
if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}</div>`;
|
|
85
104
|
const bars = codex.usable && codex.share !== 'off';
|
|
86
105
|
return `<div class="block u-block">${head(usageNames.codex, codex.tag || (bars ? asof(r) : ''))}${codexShare(codex)}${bars ? `<div class="u-codex-bars">${windows(r)}</div>` : ''}</div>`;
|
|
87
106
|
}).join('');
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}</div>${note}</section>`;
|
|
107
|
+
const buffer = pause && products.includes('claude') ? bufferControl(pause, claude?.builder ?? 'Claude') : '';
|
|
108
|
+
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
|
package/board/public/ui-links.js
CHANGED
|
@@ -3,22 +3,28 @@
|
|
|
3
3
|
// the ui-*.js files that render text.
|
|
4
4
|
(function () {
|
|
5
5
|
const { esc, record } = globalThis.BoardUI;
|
|
6
|
-
// The Linear id shape
|
|
6
|
+
// The Linear id shape, which an internal ref shares.
|
|
7
7
|
const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
8
8
|
const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
|
|
9
|
+
// A GitHub ticket's ref (board/domain.mjs refOf).
|
|
10
|
+
const ISSUE = /^#\d+$/;
|
|
9
11
|
const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
|
|
10
12
|
|
|
11
|
-
// What a renderer needs to link text shown on item `key` (null for none): each ref
|
|
12
|
-
// prefix of each Linear team the board's cards link to (none on a
|
|
13
|
-
// a
|
|
14
|
-
//
|
|
15
|
-
// wave's
|
|
13
|
+
// What a renderer needs to link text shown on item `key` (null for none): each ref, and each card id that differs
|
|
14
|
+
// from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
|
|
15
|
+
// board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
|
|
16
|
+
// than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
|
|
17
|
+
// ticket #n links from anywhere; a wave's own short id, such as B1, links only from that wave's cards, since a plan
|
|
18
|
+
// for another wave reuses those names.
|
|
16
19
|
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
17
20
|
const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
18
21
|
const byRef = new Map();
|
|
19
22
|
for (const i of items) {
|
|
20
|
-
if (i.kind === 'ask'
|
|
21
|
-
|
|
23
|
+
if (i.kind === 'ask') continue;
|
|
24
|
+
for (const ref of new Set([i.ref, i.id])) {
|
|
25
|
+
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.wave !== wave)) continue;
|
|
26
|
+
byRef.set(ref, [...(byRef.get(ref) || []), i]);
|
|
27
|
+
}
|
|
22
28
|
}
|
|
23
29
|
const refs = Object.create(null);
|
|
24
30
|
for (const [ref, list] of byRef) {
|
|
@@ -62,11 +68,13 @@
|
|
|
62
68
|
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
|
+
|
|
65
73
|
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
66
74
|
function wordLink(word, ctx) {
|
|
67
75
|
for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
|
|
68
76
|
const rest = esc(word.slice(w.length));
|
|
69
|
-
if (record(ctx.refs, w)) return
|
|
77
|
+
if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
|
|
70
78
|
const team = LINEAR_ID.exec(w)?.[1];
|
|
71
79
|
const prefix = team && team !== 'INT' && record(ctx.teams, team);
|
|
72
80
|
if (prefix) return out(prefix + w, w) + rest;
|
|
@@ -90,7 +98,7 @@
|
|
|
90
98
|
else if (name) {
|
|
91
99
|
const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
|
|
92
100
|
html += repo ? out(prUrl(repo, n), all) : esc(all);
|
|
93
|
-
} else if (bare) html += ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
|
|
101
|
+
} else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
|
|
94
102
|
else html += wordLink(word, ctx);
|
|
95
103
|
}
|
|
96
104
|
return html + esc(s.slice(at));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A small Markdown renderer for Notes (view-notes.js; docs/adr/
|
|
1
|
+
// A small Markdown renderer for Notes (view-notes.js; docs/adr/2026-10-29-1639-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { esc, linkHtml } = globalThis.BoardUI;
|
|
4
4
|
// Raw HTML is escaped first, so a link's href can only break out through its scheme.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
|
|
2
|
+
// tap it draws at once by, so the card lands where the server will put it
|
|
3
|
+
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, queued, bumpedAt, sortAt, order and key.
|
|
4
|
+
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
|
+
(function () {
|
|
6
|
+
const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
|
|
7
|
+
// New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
|
|
8
|
+
// then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
|
|
9
|
+
// newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
|
|
10
|
+
// lead it, bumped first and then oldest first, so the top one is the next to build
|
|
11
|
+
// (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
|
|
12
|
+
function laneOrder(a, b) {
|
|
13
|
+
const bumped = (i) => i.bumpedAt || '';
|
|
14
|
+
const unsorted = (i) => (i.isNew ? 0 : 1);
|
|
15
|
+
const queued = (i) => (i.queued ? 1 : 0);
|
|
16
|
+
const at = (i) => String(i.sortAt || '');
|
|
17
|
+
const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
18
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
|
|
19
|
+
}
|
|
20
|
+
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
21
|
+
})();
|
|
@@ -6,9 +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"/>');
|
|
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"/>');
|
|
9
11
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
10
12
|
|
|
11
|
-
// 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
|
|
12
14
|
// the page before was on this hub; else 'href', its backHref. A page a push opened always takes its backHref. idx is
|
|
13
15
|
// the entry's index in history.state, set by an in-app router: when present, it alone says whether a step back exists.
|
|
14
16
|
function backStep({ pushed = false, referrer = '', href, depth = 1, idx }) {
|
|
@@ -39,24 +41,27 @@
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
|
|
42
|
-
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board,
|
|
43
|
-
// page's script turns them on (docs/adr/
|
|
44
|
-
// false, trail (HTML) takes their place. leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
45
|
-
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js).
|
|
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
|
|
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
|
|
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).
|
|
46
49
|
function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
|
|
47
|
-
controls = true, trail = '', time = '…', 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 } = {}) {
|
|
48
51
|
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
49
52
|
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
50
53
|
const first = lead === 'back'
|
|
51
|
-
?
|
|
54
|
+
? `${logo('hub')}<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>`
|
|
52
55
|
: logo();
|
|
53
56
|
const nav = controls ? `<nav class="nav">`
|
|
54
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
|
+
+ (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
|
|
55
59
|
+ (settingsHref ? `<a href="${esc(settingsHref)}" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>` : '')
|
|
56
60
|
+ `</nav>` : trail;
|
|
57
61
|
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|
|
58
62
|
const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
|
|
59
|
-
|
|
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}`;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
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}, or null for no bars. reached counts the phases reached, the last of them
|
|
8
|
+
// running when running is set; fill, a percent, fills the next bar that far.
|
|
9
|
+
function phaseBars(strip, phases) {
|
|
10
|
+
if (!strip) return '';
|
|
11
|
+
const { reached, running, dim, label, fill } = 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' : ''}" 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,14 +1,18 @@
|
|
|
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, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
3
|
+
const { esc, time, 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
7
|
const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}>
|
|
8
8
|
<span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${o.recommended ? '<span class="rec">Recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div></span></label>`;
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
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, which resolves it
|
|
12
|
+
// (view-questions.js); a resolved question has none.
|
|
13
|
+
const questionEye = (q) => q.state === 'resolved' ? '' : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead' });
|
|
14
|
+
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>`)}
|
|
15
|
+
<div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
|
|
12
16
|
|
|
13
17
|
const QUESTION_STATE = {
|
|
14
18
|
open: { label: 'open', tone: 'normal' },
|
|
@@ -16,10 +20,17 @@
|
|
|
16
20
|
replied: { label: 'claude replied', tone: 'ready' },
|
|
17
21
|
resolved: { label: 'resolved', tone: 'quiet' },
|
|
18
22
|
};
|
|
23
|
+
// docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
|
|
24
|
+
// A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
|
|
25
|
+
const roundChip = (r) => !r ? ''
|
|
26
|
+
: r.state === 'making' ? badge('normal', `${SPIN}Prototype round ${r.n}`)
|
|
27
|
+
: r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
|
|
28
|
+
: badge('normal', `Prototype round ${r.n}`);
|
|
29
|
+
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>';
|
|
19
30
|
function answerHistory(q, links = {}) {
|
|
20
31
|
if (!q.answers.length) return '';
|
|
21
32
|
const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${esc(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
|
|
22
|
-
${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
|
|
33
|
+
${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : a.acted ? '' : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
|
|
23
34
|
const last = q.answers[q.answers.length - 1];
|
|
24
35
|
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
25
36
|
}
|
|
@@ -34,7 +45,7 @@
|
|
|
34
45
|
: isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
|
|
35
46
|
: `<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>`;
|
|
36
47
|
|
|
37
|
-
// A question's takes show inline (docs/adr/
|
|
48
|
+
// A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
|
|
38
49
|
// it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
|
|
39
50
|
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
40
51
|
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
@@ -74,24 +85,37 @@
|
|
|
74
85
|
}
|
|
75
86
|
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
|
|
76
87
|
}
|
|
88
|
+
// A confirm links its one take alone, and says why it is asked.
|
|
89
|
+
const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
|
|
90
|
+
: `<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>` : ''}`;
|
|
77
91
|
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
|
|
78
92
|
// fit(option id) supplies the measured grid and full-width heights.
|
|
79
93
|
function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
|
|
80
94
|
const last = q.answers[q.answers.length - 1];
|
|
81
95
|
const chosen = draft.choice ?? (last ? last.choice : null);
|
|
82
|
-
const st = QUESTION_STATE[q.state];
|
|
83
96
|
return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
84
|
-
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(
|
|
97
|
+
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
|
|
85
98
|
<h3>${linkText(q.question, links)}</h3>
|
|
86
99
|
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
87
100
|
${framed(q) ? '' : questionLink(q.url)}
|
|
88
|
-
${
|
|
101
|
+
${confirmLines(q)}
|
|
102
|
+
${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'} A new answer reopens it.</div>` : ''}
|
|
89
103
|
${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
|
|
90
|
-
${noteComposer(
|
|
104
|
+
${noteComposer(q, draft.note)}
|
|
91
105
|
${answerHistory(q, links)}
|
|
92
106
|
</section>`;
|
|
93
107
|
}
|
|
94
|
-
|
|
108
|
+
// A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
|
|
109
|
+
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
|
|
110
|
+
// /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
|
|
111
|
+
// is on the board (board/server.mjs pageBoard). cards: the board's items.
|
|
112
|
+
function cardTopics(topics, cards) {
|
|
113
|
+
const byKey = new Map(cards.map((c) => [c.key, c]));
|
|
114
|
+
return topics.map((t) => {
|
|
115
|
+
const c = t.title === undefined && byKey.get(t.card);
|
|
116
|
+
return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
95
119
|
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
96
120
|
// current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
|
|
97
121
|
function topicRow(topics, current) {
|
|
@@ -126,5 +150,5 @@
|
|
|
126
150
|
},
|
|
127
151
|
};
|
|
128
152
|
|
|
129
|
-
Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, takes, topicName, topicRow, nextQuestion, sentNotice });
|
|
153
|
+
Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
|
|
130
154
|
})();
|