@lovek4d/kickflip 0.0.0-stage → 0.3.1
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 +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), which hub.css styles
|
|
2
|
+
// under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
|
|
3
|
+
(function () {
|
|
4
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
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"/>',
|
|
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"/>',
|
|
13
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
14
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
15
|
+
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
|
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"/>',
|
|
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"/>',
|
|
19
|
+
laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
|
|
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"/>',
|
|
21
|
+
};
|
|
22
|
+
const tile = (key, color) => `<span class="icon sm solid" style="--c:${color}"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${TILE_PATHS[key]}</svg></span>`;
|
|
23
|
+
const CHEVRON = '<span class="chev" aria-hidden="true">›</span>';
|
|
24
|
+
|
|
25
|
+
function row({ icon = '', title, sub = '', wrap = false, trail = '', href, attrs = '' }) {
|
|
26
|
+
const inner = `${icon}<span class="body"><span class="text"><span class="title">${title}</span>${sub ? `<span class="sub${wrap ? ' wrap' : ''}">${sub}</span>` : ''}</span>${trail}</span>`;
|
|
27
|
+
return href ? `<a class="row" href="${esc(href)}"${attrs}>${inner}</a>` : `<div class="row"${attrs}>${inner}</div>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// flash: {text, error} or null, a form's answer shown above the view.
|
|
31
|
+
const flash = (f) => (f ? `<div class="flash${f.error ? ' error' : ''}" role="${f.error ? 'alert' : 'status'}">${esc(f.text)}</div>` : '');
|
|
32
|
+
|
|
33
|
+
// hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
|
|
34
|
+
const holdBanner = (hold) => (hold ? `<div class="hold-banner"><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">${TILE_PATHS.moon}</svg><span>${esc(hold.text)}</span>${hold.end ? '<form method="post" action="/_hub/settings/notifications"><button class="btn quiet" name="snooze" value="off">End snooze</button></form>' : ''}</div>` : '');
|
|
35
|
+
|
|
36
|
+
function pauseBanner(pause) {
|
|
37
|
+
if (!pause || pause.state === 'normal') return '';
|
|
38
|
+
const going = pause.state === 'going';
|
|
39
|
+
const until = BoardUI.usageClock(pause.resets_at);
|
|
40
|
+
const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
|
|
41
|
+
const text = going ? `Past the buffer: spending usage until ${until}`
|
|
42
|
+
: `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
|
|
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>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
|
|
47
|
+
function bufferControl(pause, builder) {
|
|
48
|
+
const at = pause.pause_at;
|
|
49
|
+
const off = at === 100;
|
|
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>`
|
|
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>`
|
|
52
|
+
+ `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
|
|
53
|
+
+ `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
|
|
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>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
58
|
+
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
59
|
+
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
60
|
+
|
|
61
|
+
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
|
|
62
|
+
// null while unknown, which keeps the row hidden.
|
|
63
|
+
function deviceRow(state, { compact = false } = {}) {
|
|
64
|
+
if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
|
|
65
|
+
const a = state.action;
|
|
66
|
+
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${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>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
|
|
71
|
+
// view-hub.js sends. While Codex isn't usable the choices are disabled, with the reason and the command to run.
|
|
72
|
+
function codexShare(codex) {
|
|
73
|
+
const off = !codex.usable;
|
|
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('');
|
|
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>`
|
|
76
|
+
+ `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
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
|
|
89
|
+
// first, then Codex's bars while share isn't Off.
|
|
90
|
+
function usageCard(data, codex, pause, claude) {
|
|
91
|
+
const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
|
|
92
|
+
const products = usageProducts(data);
|
|
93
|
+
if (claude && !products.includes('claude')) products.unshift('claude');
|
|
94
|
+
if (codex && !products.includes('codex')) products.push('codex');
|
|
95
|
+
if (!products.length) return '';
|
|
96
|
+
const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
|
|
97
|
+
const asof = (r) => (!r.at ? 'no reading' : `${r.five_hour?.state === 'stale' ? 'last seen' : 'read'} ${usageClock(r.at)}`);
|
|
98
|
+
const windows = (r) => [['5-hour', 'five_hour'], ['Week', 'seven_day']].map(([label, key]) =>
|
|
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('');
|
|
100
|
+
const blocks = products.map((p) => {
|
|
101
|
+
const r = data?.[p] || { at: null };
|
|
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>`;
|
|
104
|
+
const bars = codex.usable && codex.share !== 'off';
|
|
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>`;
|
|
106
|
+
}).join('');
|
|
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>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
|
|
112
|
+
})();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Links the refs in Claude- and owner-written text as the board renders it, so no one links a ref by hand: a card's
|
|
2
|
+
// ref opens that card, a Linear id or PR opens its page, and a bare URL becomes a link. Needs ui.js; load it before
|
|
3
|
+
// the ui-*.js files that render text.
|
|
4
|
+
(function () {
|
|
5
|
+
const { esc, record } = globalThis.BoardUI;
|
|
6
|
+
// The Linear id shape, which an internal ref shares.
|
|
7
|
+
const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
8
|
+
const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
|
|
9
|
+
// A GitHub ticket's ref (board/domain.mjs refOf).
|
|
10
|
+
const ISSUE = /^#\d+$/;
|
|
11
|
+
const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
|
|
12
|
+
|
|
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.
|
|
19
|
+
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
20
|
+
const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
21
|
+
const byRef = new Map();
|
|
22
|
+
for (const i of items) {
|
|
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
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const refs = Object.create(null);
|
|
30
|
+
for (const [ref, list] of byRef) {
|
|
31
|
+
const pick = list.length === 1 ? list[0] : list.find((i) => i.wave === wave);
|
|
32
|
+
if (pick) refs[ref] = pick.key;
|
|
33
|
+
}
|
|
34
|
+
const teams = Object.create(null);
|
|
35
|
+
const repos = Object.create(null);
|
|
36
|
+
for (const i of items) {
|
|
37
|
+
if (i.repo) repos[i.repo.split('/')[1]] = i.repo;
|
|
38
|
+
if (i.internal) continue;
|
|
39
|
+
for (const l of i.linear || []) {
|
|
40
|
+
const team = LINEAR_ID.exec(l.id || '')?.[1];
|
|
41
|
+
const prefix = LINEAR_ISSUE.exec(l.url || '')?.[0];
|
|
42
|
+
if (team && prefix && !teams[team]) teams[team] = prefix;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { refs, teams, repos, repo: items.find((i) => i.key === key)?.repo || null, base };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TOKEN = new RegExp([
|
|
49
|
+
String.raw`(https?:\/\/[^\s<>"'\u0000-\u001f]+)`,
|
|
50
|
+
String.raw`(?<![\w.\/-])(?:([A-Za-z0-9][\w.-]*)\/)?([A-Za-z0-9][\w.-]*)#(\d+)(?!\w)`,
|
|
51
|
+
String.raw`(?<![\w&#\/])#(\d+)(?!\w)`,
|
|
52
|
+
String.raw`(?<![\w-])([A-Za-z]\w*(?:-\w+)*)`,
|
|
53
|
+
].join('|'), 'g');
|
|
54
|
+
const out = (href, label) => `<a href="${esc(href)}" target="_blank" rel="noopener">${esc(label)}</a>`;
|
|
55
|
+
// A link to a page on this hub opens in place; only another origin opens a new window.
|
|
56
|
+
const opensHere = (href) => !/^[a-z][\w+.-]*:/i.test(href) || Boolean(globalThis.location?.origin && href.startsWith(location.origin + '/'));
|
|
57
|
+
const anyLink = (href, label) => (opensHere(href) ? `<a href="${esc(href)}">${esc(label)}</a>` : out(href, label));
|
|
58
|
+
const count = (s, c) => s.split(c).length - 1;
|
|
59
|
+
|
|
60
|
+
// Trailing punctuation ends a sentence, not the URL, unless it closes a bracket the URL opened.
|
|
61
|
+
function urlLink(raw) {
|
|
62
|
+
let url = raw;
|
|
63
|
+
while (/[.,;:!?)\]]$/.test(url)) {
|
|
64
|
+
const c = url.at(-1);
|
|
65
|
+
if ((c === ')' && count(url, '(') >= count(url, ')')) || (c === ']' && count(url, '[') >= count(url, ']'))) break;
|
|
66
|
+
url = url.slice(0, -1);
|
|
67
|
+
}
|
|
68
|
+
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
|
+
|
|
73
|
+
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
74
|
+
function wordLink(word, ctx) {
|
|
75
|
+
for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
|
|
76
|
+
const rest = esc(word.slice(w.length));
|
|
77
|
+
if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
|
|
78
|
+
const team = LINEAR_ID.exec(w)?.[1];
|
|
79
|
+
const prefix = team && team !== 'INT' && record(ctx.teams, team);
|
|
80
|
+
if (prefix) return out(prefix + w, w) + rest;
|
|
81
|
+
}
|
|
82
|
+
return esc(word);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A bare #n that reads as a hex colour stays text.
|
|
86
|
+
const colour = (n) => n.startsWith('0') || n.length === 6 || n.length === 8;
|
|
87
|
+
|
|
88
|
+
// Plain text in, escaped HTML out, with each ref linked.
|
|
89
|
+
function linkText(text, ctx = {}) {
|
|
90
|
+
const s = String(text ?? '');
|
|
91
|
+
let html = '';
|
|
92
|
+
let at = 0;
|
|
93
|
+
for (const m of s.matchAll(TOKEN)) {
|
|
94
|
+
const [all, url, owner, name, n, bare, word] = m;
|
|
95
|
+
html += esc(s.slice(at, m.index));
|
|
96
|
+
at = m.index + all.length;
|
|
97
|
+
if (url) html += urlLink(url);
|
|
98
|
+
else if (name) {
|
|
99
|
+
const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
|
|
100
|
+
html += repo ? out(prUrl(repo, n), 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);
|
|
102
|
+
else html += wordLink(word, ctx);
|
|
103
|
+
}
|
|
104
|
+
return html + esc(s.slice(at));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)(?:[^>"']|"[^"]*"|'[^']*')*>/g;
|
|
108
|
+
// Text inside these stays as written: a link can't nest in a link or a control, and preformatted text is code.
|
|
109
|
+
const KEEP = new Set(['a', 'button', 'label', 'option', 'pre', 'script', 'select', 'style', 'textarea']);
|
|
110
|
+
const ENTITY = /(&(?:#x[\da-f]+|#\d+|[a-z][a-z\d]*);)/i;
|
|
111
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
112
|
+
function decode(e) {
|
|
113
|
+
const n = e.slice(1, -1).toLowerCase();
|
|
114
|
+
if (n[0] !== '#') return record(NAMED, n) ?? null;
|
|
115
|
+
const code = n[1] === 'x' ? parseInt(n.slice(2), 16) : parseInt(n.slice(1), 10);
|
|
116
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : null;
|
|
117
|
+
}
|
|
118
|
+
// An HTML text run, decoded and relinked; an entity with no plain-text form is kept as written.
|
|
119
|
+
function linkRun(run, ctx) {
|
|
120
|
+
let html = '';
|
|
121
|
+
let buf = '';
|
|
122
|
+
for (const [n, part] of run.split(ENTITY).entries()) {
|
|
123
|
+
const plain = n % 2 ? decode(part) : part;
|
|
124
|
+
if (plain !== null) { buf += plain; continue; }
|
|
125
|
+
html += linkText(buf, ctx) + part;
|
|
126
|
+
buf = '';
|
|
127
|
+
}
|
|
128
|
+
return html + linkText(buf, ctx);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// An HTML fragment Claude wrote, with refs linked in its text outside links, controls and <pre>.
|
|
132
|
+
function linkHtml(html, ctx = {}) {
|
|
133
|
+
const s = String(html ?? '');
|
|
134
|
+
let res = '';
|
|
135
|
+
let at = 0;
|
|
136
|
+
let keep = 0;
|
|
137
|
+
for (const m of s.matchAll(TAG)) {
|
|
138
|
+
const run = s.slice(at, m.index);
|
|
139
|
+
res += keep ? run : linkRun(run, ctx);
|
|
140
|
+
res += m[0];
|
|
141
|
+
at = m.index + m[0].length;
|
|
142
|
+
const name = (m[2] || '').toLowerCase();
|
|
143
|
+
if (KEEP.has(name) && !m[0].endsWith('/>')) keep = Math.max(0, keep + (m[1] ? -1 : 1));
|
|
144
|
+
}
|
|
145
|
+
const tail = s.slice(at);
|
|
146
|
+
return res + (keep ? tail : linkRun(tail, ctx));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Object.assign(globalThis.BoardUI, { linkContext, linkText, linkHtml, opensHere, anyLink });
|
|
150
|
+
})();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// A small Markdown renderer for Notes (view-notes.js; docs/adr/0024-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { esc, linkHtml } = globalThis.BoardUI;
|
|
4
|
+
// Raw HTML is escaped first, so a link's href can only break out through its scheme.
|
|
5
|
+
const safeUrl = (u) => /^(https?:|mailto:)/i.test(u) || !/^[\w+.-]*:/.test(u);
|
|
6
|
+
const WIDE_CELL = 80;
|
|
7
|
+
const WIDER_CELL = 200;
|
|
8
|
+
|
|
9
|
+
function inline(s) {
|
|
10
|
+
return String(s).split(/(`[^`]*`)/).map((part, n) => (n % 2 ? `<code>${esc(part.slice(1, -1))}</code>` : esc(part)
|
|
11
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => (safeUrl(url) ? `<a href="${url}" target="_blank" rel="noopener">${label}</a>` : m))
|
|
12
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
13
|
+
.replace(/(^|[^*\w])\*(?!\s)([^*]+?)\*(?!\w)/g, '$1<em>$2</em>'))).join('');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FENCE = /^\s*```/;
|
|
17
|
+
const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
18
|
+
const RULE = /^\s*([-*_])(\s*\1){2,}\s*$/;
|
|
19
|
+
const QUOTE = /^\s*>/;
|
|
20
|
+
const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
|
|
21
|
+
const TABLE_SEP = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
|
22
|
+
const cells = (line) => line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
23
|
+
const startsTable = (lines, i) => lines[i].includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]);
|
|
24
|
+
const startsBlock = (lines, i) => FENCE.test(lines[i]) || HEADING.test(lines[i]) || RULE.test(lines[i]) || QUOTE.test(lines[i]) || ITEM.test(lines[i]) || startsTable(lines, i);
|
|
25
|
+
|
|
26
|
+
// links: a ui-links.js linkContext, to link the refs in the plan's text.
|
|
27
|
+
function markdown(src, links = null) {
|
|
28
|
+
const lines = String(src ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
29
|
+
const out = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < lines.length) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
let m;
|
|
34
|
+
if (!line.trim()) { i++; continue; }
|
|
35
|
+
if (FENCE.test(line)) {
|
|
36
|
+
const body = [];
|
|
37
|
+
for (i++; i < lines.length && !FENCE.test(lines[i]); i++) body.push(lines[i]);
|
|
38
|
+
i++;
|
|
39
|
+
out.push(`<pre><code>${esc(body.join('\n'))}</code></pre>`);
|
|
40
|
+
} else if ((m = HEADING.exec(line))) {
|
|
41
|
+
out.push(`<h${m[1].length}>${inline(m[2])}</h${m[1].length}>`);
|
|
42
|
+
i++;
|
|
43
|
+
} else if (RULE.test(line)) {
|
|
44
|
+
out.push('<hr>');
|
|
45
|
+
i++;
|
|
46
|
+
} else if (startsTable(lines, i)) {
|
|
47
|
+
const head = cells(line);
|
|
48
|
+
const rows = [];
|
|
49
|
+
for (i += 2; i < lines.length && lines[i].trim() && lines[i].includes('|'); i++) rows.push(cells(lines[i]));
|
|
50
|
+
const cell = (tag, c) => `<${tag}${c.length > WIDER_CELL ? ' class="wider"' : c.length > WIDE_CELL ? ' class="wide"' : ''}>${inline(c)}</${tag}>`;
|
|
51
|
+
out.push(`<div class="md-table"><table><thead><tr>${head.map((c) => cell('th', c)).join('')}</tr></thead>`
|
|
52
|
+
+ `<tbody>${rows.map((r) => `<tr>${r.map((c) => cell('td', c)).join('')}</tr>`).join('')}</tbody></table></div>`);
|
|
53
|
+
} else if (QUOTE.test(line)) {
|
|
54
|
+
const body = [];
|
|
55
|
+
for (; i < lines.length && QUOTE.test(lines[i]); i++) body.push(lines[i].replace(/^\s*>\s?/, ''));
|
|
56
|
+
out.push(`<blockquote>${markdown(body.join('\n'))}</blockquote>`);
|
|
57
|
+
} else if ((m = ITEM.exec(line))) {
|
|
58
|
+
const ordered = /\d/.test(m[2]);
|
|
59
|
+
const base = m[1].length;
|
|
60
|
+
const items = [];
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
const it = ITEM.exec(lines[i]);
|
|
63
|
+
if (it && (it[1].length > base || /\d/.test(it[2]) === ordered)) { items.push({ nested: it[1].length > base, text: it[3] }); i++; }
|
|
64
|
+
else if (items.length && lines[i].trim() && /^\s{2,}/.test(lines[i])) { items[items.length - 1].text += ` ${lines[i].trim()}`; i++; }
|
|
65
|
+
else break;
|
|
66
|
+
}
|
|
67
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
68
|
+
out.push(`<${tag}>${items.map((it) => `<li${it.nested ? ' class="nested"' : ''}>${inline(it.text)}</li>`).join('')}</${tag}>`);
|
|
69
|
+
} else {
|
|
70
|
+
const body = [];
|
|
71
|
+
for (; i < lines.length && lines[i].trim() && !startsBlock(lines, i); i++) body.push(lines[i].trim());
|
|
72
|
+
out.push(`<p>${inline(body.join(' '))}</p>`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const html = out.join('\n');
|
|
76
|
+
return links ? linkHtml(html, links) : html;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Object.assign(globalThis.BoardUI, { markdown });
|
|
80
|
+
})();
|
|
@@ -0,0 +1,18 @@
|
|
|
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/0140-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, 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/0047-the-plus-makes-a-new-card.md);
|
|
8
|
+
// then bumped cards, the latest bump on top (docs/adr/0019-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.
|
|
10
|
+
function laneOrder(a, b) {
|
|
11
|
+
const bumped = (i) => i.bumpedAt || '';
|
|
12
|
+
const unsorted = (i) => (i.isNew ? 0 : 1);
|
|
13
|
+
const at = (i) => String(i.sortAt || '');
|
|
14
|
+
const age = a.isNew ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
15
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
|
|
16
|
+
}
|
|
17
|
+
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
18
|
+
})();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Every page's header, for the board pages and the hub's pages alike. It needs nothing else, since board/server.mjs
|
|
2
|
+
// renders each board page's <!--header {…}--> with it and hub/hub.mjs renders the hub's headers, each in a vm context.
|
|
3
|
+
(function () {
|
|
4
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
5
|
+
const icon = (size, paths) => `<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>`;
|
|
6
|
+
const ARROW = icon(24, '<path d="M19 12H5"/><path d="M12 19l-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
|
+
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 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
|
+
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
11
|
+
|
|
12
|
+
// Where the back arrow goes (docs/adr/0048-a-questions-takes-show-inline.md): 'back', one step back in history, when
|
|
13
|
+
// the page before was on this hub; else 'href', its backHref. A page a push opened always takes its backHref. idx is
|
|
14
|
+
// the entry's index in history.state, set by an in-app router: when present, it alone says whether a step back exists.
|
|
15
|
+
function backStep({ pushed = false, referrer = '', href, depth = 1, idx }) {
|
|
16
|
+
if (pushed) return 'href';
|
|
17
|
+
if (Number.isInteger(idx)) return idx > 0 ? 'back' : 'href';
|
|
18
|
+
let ref = null;
|
|
19
|
+
try { ref = referrer ? new URL(referrer) : null; } catch { ref = null; }
|
|
20
|
+
return ref && ref.origin === new URL(href).origin && ref.href !== href && depth > 1 ? 'back' : 'href';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Runs in the page as the header's own script, so it uses nothing outside itself and step. The push mark lives in
|
|
24
|
+
// sessionStorage, since prototype-switcher.js drops from=push (board/store.mjs pushUrl) from the URL; a reload keeps it.
|
|
25
|
+
function wireBack(step) {
|
|
26
|
+
const key = 'from-push:' + location.pathname;
|
|
27
|
+
let pushed = new URLSearchParams(location.search).get('from') === 'push';
|
|
28
|
+
try {
|
|
29
|
+
if (pushed) sessionStorage.setItem(key, '1');
|
|
30
|
+
else if (performance.getEntriesByType('navigation')[0]?.type === 'navigate') sessionStorage.removeItem(key);
|
|
31
|
+
pushed = sessionStorage.getItem(key) === '1';
|
|
32
|
+
} catch { /* no storage: only this load knows */ }
|
|
33
|
+
const target = () => step({ pushed, referrer: document.referrer, href: location.href, depth: history.length, idx: history.state?.idx });
|
|
34
|
+
globalThis.PageBack = { pushed, target };
|
|
35
|
+
document.addEventListener('click', (e) => {
|
|
36
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || !e.target.closest?.('a.back') || target() !== 'back') return;
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
history.back();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 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, beside a phone-only logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
|
|
44
|
+
// page's script turns them on (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
45
|
+
// 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
|
+
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 } = {}) {
|
|
49
|
+
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
50
|
+
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
51
|
+
const first = lead === 'back'
|
|
52
|
+
? `<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>${logo('hub phone-logo')}`
|
|
53
|
+
: logo();
|
|
54
|
+
const nav = controls ? `<nav class="nav">`
|
|
55
|
+
+ `<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>`
|
|
56
|
+
+ (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
|
|
57
|
+
+ (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>` : '')
|
|
58
|
+
+ `</nav>` : trail;
|
|
59
|
+
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|
|
60
|
+
const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
|
|
61
|
+
return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}></div>${nav}</header>${script}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
|
|
65
|
+
})();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A pending prompt's Needs you line (hub/prompts.mjs shown): a permission prompt with Allow once, Always allow and
|
|
2
|
+
// Deny, or an AskUserQuestion with its options as buttons and Skip, and Send when it takes more than one tap.
|
|
3
|
+
(function () {
|
|
4
|
+
const { esc, ago } = globalThis.BoardUI;
|
|
5
|
+
|
|
6
|
+
// The answer a question prompt's picks make, {choice: 'allow', answers: {<question>: <label, or labels joined by
|
|
7
|
+
// ", ">}}, or null until every question has a pick. picks: {<question index>: [label]}.
|
|
8
|
+
function promptAnswer(p, picks = {}) {
|
|
9
|
+
const answers = Object.create(null);
|
|
10
|
+
for (const [i, q] of (p.questions || []).entries()) {
|
|
11
|
+
const got = picks[i] || [];
|
|
12
|
+
if (!got.length) return null;
|
|
13
|
+
answers[q.question] = got.join(', ');
|
|
14
|
+
}
|
|
15
|
+
return { choice: 'allow', answers };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Whether one tap on an option answers: a single question that takes one pick.
|
|
19
|
+
const oneTap = (p) => p.questions?.length === 1 && !p.questions[0].multiSelect;
|
|
20
|
+
|
|
21
|
+
// sending: true while the answer goes, which disables the line's buttons.
|
|
22
|
+
function promptItem(p, { picks = {}, sending = false } = {}) {
|
|
23
|
+
const off = sending ? ' disabled' : '';
|
|
24
|
+
const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
|
|
25
|
+
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
|
+
if (p.questions) {
|
|
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) && !sending ? '' : ' 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}>Skip</button>${status}</div></li>`;
|
|
32
|
+
}
|
|
33
|
+
const detail = p.detail ? `<pre class="prompt-detail">${esc(p.detail)}</pre>` : `<p class="prompt-what">Wants to ${esc(p.action)}</p>`;
|
|
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>${status}</div>${rule}</li>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Object.assign(globalThis.BoardUI, { promptItem, promptAnswer, oneTap });
|
|
39
|
+
})();
|