@lovek4d/kickflip 0.0.0-stage → 0.1.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 +414 -0
- package/LICENSE +57 -0
- package/README.md +23 -2
- package/SKILL.md +121 -0
- package/bin/doctor.mjs +3 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +125 -0
- package/bin/statusline.mjs +4 -0
- package/bin/wait-events.mjs +3 -0
- package/bin/web-ts.mjs +2 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +207 -0
- package/board/bin/builder-stop-hook.mjs +5 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +3 -0
- package/board/public/app-asks.js +169 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +200 -0
- package/board/public/app-core.js +96 -0
- package/board/public/app-header.js +108 -0
- package/board/public/app-lanes.js +92 -0
- package/board/public/app-needs.js +30 -0
- package/board/public/app-snapshot.js +97 -0
- package/board/public/app-threads.js +165 -0
- package/board/public/app.html +83 -0
- package/board/public/app.js +327 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +360 -0
- package/board/public/hub-kit.js +151 -0
- package/board/public/hub.css +164 -0
- package/board/public/kickflip.png +0 -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 +223 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +199 -0
- package/board/public/sidebar.js +287 -0
- package/board/public/style.css +570 -0
- package/board/public/toasts.js +179 -0
- package/board/public/ui-cards.js +153 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +84 -0
- package/board/public/ui-hub.js +94 -0
- package/board/public/ui-links.js +142 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-page-header.js +63 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +130 -0
- package/board/public/ui-sidebar.js +177 -0
- package/board/public/ui-snapshot.js +56 -0
- package/board/public/ui-threads.js +81 -0
- package/board/public/ui.js +235 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +266 -0
- package/board/public/view-hub.js +99 -0
- package/board/public/view-notes.js +29 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +391 -0
- package/board/public/view-settings.js +328 -0
- package/board/server.mjs +12 -0
- package/board/store.mjs +7 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +14 -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 +21 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +5 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +17 -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/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/repos.mjs +2 -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 +1 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/try.mjs +3 -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 +13 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +67 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,142 @@
|
|
|
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 store.mjs accepts for a follow-up, which an internal ref shares.
|
|
7
|
+
const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
8
|
+
const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
|
|
9
|
+
const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
|
|
10
|
+
|
|
11
|
+
// What a renderer needs to link text shown on item `key` (null for none): each ref to its card's key, the issue url
|
|
12
|
+
// prefix of each Linear team the board's cards link to (none on a board without a workspace), that item's repo for
|
|
13
|
+
// a bare #n, and `base`, which a page other than the board sets to './' so a card link opens the board. A ref shaped
|
|
14
|
+
// like a Linear id, such as INT-<n>, links from anywhere; a wave's own short id, such as B1, links only from that
|
|
15
|
+
// wave's cards, since a plan for another wave reuses those names.
|
|
16
|
+
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
17
|
+
const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
18
|
+
const byRef = new Map();
|
|
19
|
+
for (const i of items) {
|
|
20
|
+
if (i.kind === 'ask' || !i.ref || (!LINEAR_ID.test(i.ref) && i.wave !== wave)) continue;
|
|
21
|
+
byRef.set(i.ref, [...(byRef.get(i.ref) || []), i]);
|
|
22
|
+
}
|
|
23
|
+
const refs = Object.create(null);
|
|
24
|
+
for (const [ref, list] of byRef) {
|
|
25
|
+
const pick = list.length === 1 ? list[0] : list.find((i) => i.wave === wave);
|
|
26
|
+
if (pick) refs[ref] = pick.key;
|
|
27
|
+
}
|
|
28
|
+
const teams = Object.create(null);
|
|
29
|
+
const repos = Object.create(null);
|
|
30
|
+
for (const i of items) {
|
|
31
|
+
if (i.repo) repos[i.repo.split('/')[1]] = i.repo;
|
|
32
|
+
if (i.internal) continue;
|
|
33
|
+
for (const l of i.linear || []) {
|
|
34
|
+
const team = LINEAR_ID.exec(l.id || '')?.[1];
|
|
35
|
+
const prefix = LINEAR_ISSUE.exec(l.url || '')?.[0];
|
|
36
|
+
if (team && prefix && !teams[team]) teams[team] = prefix;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { refs, teams, repos, repo: items.find((i) => i.key === key)?.repo || null, base };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TOKEN = new RegExp([
|
|
43
|
+
String.raw`(https?:\/\/[^\s<>"'\u0000-\u001f]+)`,
|
|
44
|
+
String.raw`(?<![\w.\/-])(?:([A-Za-z0-9][\w.-]*)\/)?([A-Za-z0-9][\w.-]*)#(\d+)(?!\w)`,
|
|
45
|
+
String.raw`(?<![\w&#\/])#(\d+)(?!\w)`,
|
|
46
|
+
String.raw`(?<![\w-])([A-Za-z]\w*(?:-\w+)*)`,
|
|
47
|
+
].join('|'), 'g');
|
|
48
|
+
const out = (href, label) => `<a href="${esc(href)}" target="_blank" rel="noopener">${esc(label)}</a>`;
|
|
49
|
+
// A link to a page on this hub opens in place; only another origin opens a new window.
|
|
50
|
+
const opensHere = (href) => !/^[a-z][\w+.-]*:/i.test(href) || Boolean(globalThis.location?.origin && href.startsWith(location.origin + '/'));
|
|
51
|
+
const anyLink = (href, label) => (opensHere(href) ? `<a href="${esc(href)}">${esc(label)}</a>` : out(href, label));
|
|
52
|
+
const count = (s, c) => s.split(c).length - 1;
|
|
53
|
+
|
|
54
|
+
// Trailing punctuation ends a sentence, not the URL, unless it closes a bracket the URL opened.
|
|
55
|
+
function urlLink(raw) {
|
|
56
|
+
let url = raw;
|
|
57
|
+
while (/[.,;:!?)\]]$/.test(url)) {
|
|
58
|
+
const c = url.at(-1);
|
|
59
|
+
if ((c === ')' && count(url, '(') >= count(url, ')')) || (c === ']' && count(url, '[') >= count(url, ']'))) break;
|
|
60
|
+
url = url.slice(0, -1);
|
|
61
|
+
}
|
|
62
|
+
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
66
|
+
function wordLink(word, ctx) {
|
|
67
|
+
for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
|
|
68
|
+
const rest = esc(word.slice(w.length));
|
|
69
|
+
if (record(ctx.refs, w)) return `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, w))}`)}">${esc(w)}</a>${rest}`;
|
|
70
|
+
const team = LINEAR_ID.exec(w)?.[1];
|
|
71
|
+
const prefix = team && team !== 'INT' && record(ctx.teams, team);
|
|
72
|
+
if (prefix) return out(prefix + w, w) + rest;
|
|
73
|
+
}
|
|
74
|
+
return esc(word);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A bare #n that reads as a hex colour stays text.
|
|
78
|
+
const colour = (n) => n.startsWith('0') || n.length === 6 || n.length === 8;
|
|
79
|
+
|
|
80
|
+
// Plain text in, escaped HTML out, with each ref linked.
|
|
81
|
+
function linkText(text, ctx = {}) {
|
|
82
|
+
const s = String(text ?? '');
|
|
83
|
+
let html = '';
|
|
84
|
+
let at = 0;
|
|
85
|
+
for (const m of s.matchAll(TOKEN)) {
|
|
86
|
+
const [all, url, owner, name, n, bare, word] = m;
|
|
87
|
+
html += esc(s.slice(at, m.index));
|
|
88
|
+
at = m.index + all.length;
|
|
89
|
+
if (url) html += urlLink(url);
|
|
90
|
+
else if (name) {
|
|
91
|
+
const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
|
|
92
|
+
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);
|
|
94
|
+
else html += wordLink(word, ctx);
|
|
95
|
+
}
|
|
96
|
+
return html + esc(s.slice(at));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)(?:[^>"']|"[^"]*"|'[^']*')*>/g;
|
|
100
|
+
// Text inside these stays as written: a link can't nest in a link or a control, and preformatted text is code.
|
|
101
|
+
const KEEP = new Set(['a', 'button', 'label', 'option', 'pre', 'script', 'select', 'style', 'textarea']);
|
|
102
|
+
const ENTITY = /(&(?:#x[\da-f]+|#\d+|[a-z][a-z\d]*);)/i;
|
|
103
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
104
|
+
function decode(e) {
|
|
105
|
+
const n = e.slice(1, -1).toLowerCase();
|
|
106
|
+
if (n[0] !== '#') return record(NAMED, n) ?? null;
|
|
107
|
+
const code = n[1] === 'x' ? parseInt(n.slice(2), 16) : parseInt(n.slice(1), 10);
|
|
108
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : null;
|
|
109
|
+
}
|
|
110
|
+
// An HTML text run, decoded and relinked; an entity with no plain-text form is kept as written.
|
|
111
|
+
function linkRun(run, ctx) {
|
|
112
|
+
let html = '';
|
|
113
|
+
let buf = '';
|
|
114
|
+
for (const [n, part] of run.split(ENTITY).entries()) {
|
|
115
|
+
const plain = n % 2 ? decode(part) : part;
|
|
116
|
+
if (plain !== null) { buf += plain; continue; }
|
|
117
|
+
html += linkText(buf, ctx) + part;
|
|
118
|
+
buf = '';
|
|
119
|
+
}
|
|
120
|
+
return html + linkText(buf, ctx);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// An HTML fragment Claude wrote, with refs linked in its text outside links, controls and <pre>.
|
|
124
|
+
function linkHtml(html, ctx = {}) {
|
|
125
|
+
const s = String(html ?? '');
|
|
126
|
+
let res = '';
|
|
127
|
+
let at = 0;
|
|
128
|
+
let keep = 0;
|
|
129
|
+
for (const m of s.matchAll(TAG)) {
|
|
130
|
+
const run = s.slice(at, m.index);
|
|
131
|
+
res += keep ? run : linkRun(run, ctx);
|
|
132
|
+
res += m[0];
|
|
133
|
+
at = m.index + m[0].length;
|
|
134
|
+
const name = (m[2] || '').toLowerCase();
|
|
135
|
+
if (KEEP.has(name) && !m[0].endsWith('/>')) keep = Math.max(0, keep + (m[1] ? -1 : 1));
|
|
136
|
+
}
|
|
137
|
+
const tail = s.slice(at);
|
|
138
|
+
return res + (keep ? tail : linkRun(tail, ctx));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
Object.assign(globalThis.BoardUI, { linkContext, linkText, linkHtml, opensHere, anyLink });
|
|
142
|
+
})();
|
|
@@ -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,63 @@
|
|
|
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 id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
10
|
+
|
|
11
|
+
// Where the back arrow goes (docs/adr/0048-a-questions-takes-show-inline.md): 'back', one step back in history, when
|
|
12
|
+
// the page before was on this hub; else 'href', its backHref. A page a push opened always takes its backHref. idx is
|
|
13
|
+
// the entry's index in history.state, set by an in-app router: when present, it alone says whether a step back exists.
|
|
14
|
+
function backStep({ pushed = false, referrer = '', href, depth = 1, idx }) {
|
|
15
|
+
if (pushed) return 'href';
|
|
16
|
+
if (Number.isInteger(idx)) return idx > 0 ? 'back' : 'href';
|
|
17
|
+
let ref = null;
|
|
18
|
+
try { ref = referrer ? new URL(referrer) : null; } catch { ref = null; }
|
|
19
|
+
return ref && ref.origin === new URL(href).origin && ref.href !== href && depth > 1 ? 'back' : 'href';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Runs in the page as the header's own script, so it uses nothing outside itself and step. The push mark lives in
|
|
23
|
+
// sessionStorage, since prototype-switcher.js drops from=push (board/store.mjs pushUrl) from the URL; a reload keeps it.
|
|
24
|
+
function wireBack(step) {
|
|
25
|
+
const key = 'from-push:' + location.pathname;
|
|
26
|
+
let pushed = new URLSearchParams(location.search).get('from') === 'push';
|
|
27
|
+
try {
|
|
28
|
+
if (pushed) sessionStorage.setItem(key, '1');
|
|
29
|
+
else if (performance.getEntriesByType('navigation')[0]?.type === 'navigate') sessionStorage.removeItem(key);
|
|
30
|
+
pushed = sessionStorage.getItem(key) === '1';
|
|
31
|
+
} catch { /* no storage: only this load knows */ }
|
|
32
|
+
const target = () => step({ pushed, referrer: document.referrer, href: location.href, depth: history.length, idx: history.state?.idx });
|
|
33
|
+
globalThis.PageBack = { pushed, target };
|
|
34
|
+
document.addEventListener('click', (e) => {
|
|
35
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || !e.target.closest?.('a.back') || target() !== 'back') return;
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
history.back();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 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, beside a phone-only logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
|
|
43
|
+
// page's script turns them on (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
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).
|
|
46
|
+
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 } = {}) {
|
|
48
|
+
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
49
|
+
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
50
|
+
const first = lead === 'back'
|
|
51
|
+
? `<a class="back" href="${esc(backHref)}"${id(backId)}${attrs({ 'aria-label': backLabel })}>${ARROW}</a>${logo('hub phone-logo')}`
|
|
52
|
+
: logo();
|
|
53
|
+
const nav = controls ? `<nav class="nav">`
|
|
54
|
+
+ `<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>`
|
|
55
|
+
+ (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
|
+
+ `</nav>` : trail;
|
|
57
|
+
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|
|
58
|
+
const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
|
|
59
|
+
return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}></div>${nav}</header>${script}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
|
|
63
|
+
})();
|
|
@@ -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
|
+
})();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { esc, time, replyBody, attachments, attachButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
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
|
+
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
|
+
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
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
|
+
<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
|
+
|
|
10
|
+
const noteComposer = (id, note) => `<div class="composer" data-composer="${esc(id)}"><textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>
|
|
11
|
+
<div class="note-tools"><div class="pending-files" data-files></div>${attachButton()}</div></div>`;
|
|
12
|
+
|
|
13
|
+
const QUESTION_STATE = {
|
|
14
|
+
open: { label: 'open', tone: 'normal' },
|
|
15
|
+
answered: { label: 'waiting for claude', tone: 'notice' },
|
|
16
|
+
replied: { label: 'claude replied', tone: 'ready' },
|
|
17
|
+
resolved: { label: 'resolved', tone: 'quiet' },
|
|
18
|
+
};
|
|
19
|
+
function answerHistory(q, links = {}) {
|
|
20
|
+
if (!q.answers.length) return '';
|
|
21
|
+
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('');
|
|
23
|
+
const last = q.answers[q.answers.length - 1];
|
|
24
|
+
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
25
|
+
}
|
|
26
|
+
// A prototype page in the site's public/ opens in place, and its switcher picks a take (board/public/prototype-switcher.js).
|
|
27
|
+
const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
|
|
28
|
+
const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
|
|
29
|
+
const withParam = (url, key, value) => {
|
|
30
|
+
const at = url.includes('#') ? url.indexOf('#') : url.length;
|
|
31
|
+
return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
|
|
32
|
+
};
|
|
33
|
+
const questionLink = (url) => !url ? ''
|
|
34
|
+
: isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
|
|
35
|
+
: `<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
|
+
|
|
37
|
+
// A question's takes show inline (docs/adr/0048-a-questions-takes-show-inline.md): an option with `diagram` draws
|
|
38
|
+
// it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
|
|
39
|
+
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
40
|
+
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
41
|
+
|
|
42
|
+
// rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
|
|
43
|
+
// of them; tone is a status colour's name.
|
|
44
|
+
const TONES = ['quiet', 'info', 'notice', 'danger', 'success'];
|
|
45
|
+
function diagram(rows) {
|
|
46
|
+
const label = rows.map((row) => row.map((b) => b.text).join(', ')).join(' → ');
|
|
47
|
+
return `<div class="flow" role="img" aria-label="${esc(label)}">${rows.map((row, i) => {
|
|
48
|
+
const edges = i ? rows[i - 1].flatMap((b, from) => (b.to || row.map((_, n) => n)).map((to) => [from, to])) : [];
|
|
49
|
+
const into = new Set(edges.map(([, to]) => to));
|
|
50
|
+
const conn = edges.length ? `<svg class="flow-conn" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges.map(([from, to]) =>
|
|
51
|
+
`<line x1="${((from + 0.5) / rows[i - 1].length) * 100}" y1="0" x2="${((to + 0.5) / row.length) * 100}" y2="100"/>`).join('')}</svg>` : '';
|
|
52
|
+
return `${conn}<div class="flow-row${row.length > 3 ? ' dense' : ''}" style="grid-template-columns:repeat(${row.length},minmax(0,1fr))">${row.map((b, n) =>
|
|
53
|
+
`<div class="flow-box${into.has(n) ? ' in' : ''}${TONES.includes(b.tone) ? ` tone-${b.tone}` : ''}">${esc(b.text)}</div>`).join('')}</div>`;
|
|
54
|
+
}).join('')}</div>`;
|
|
55
|
+
}
|
|
56
|
+
const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
|
|
57
|
+
|
|
58
|
+
const fullTakes = (q, fit) => isPrototype(q.url) && q.options.every((o) => !o.diagram && !o.practice)
|
|
59
|
+
&& (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
|
|
60
|
+
// Grid heights decide the layout; fullH measures the chosen take at the full content width.
|
|
61
|
+
function takeBody(q, o, links, fit, full = false) {
|
|
62
|
+
if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
|
|
63
|
+
if (!isPrototype(q.url)) return '';
|
|
64
|
+
const size = fit(o.id);
|
|
65
|
+
const h = full ? size?.fullH : size?.h;
|
|
66
|
+
// Every take loads so one outside the viewport can still decide the question's layout.
|
|
67
|
+
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>`;
|
|
68
|
+
}
|
|
69
|
+
function takes(q, chosen, links, fit) {
|
|
70
|
+
const name = 'c-' + q.id;
|
|
71
|
+
if (fullTakes(q, fit)) {
|
|
72
|
+
const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
|
|
73
|
+
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 })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
|
|
74
|
+
}
|
|
75
|
+
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
|
+
}
|
|
77
|
+
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
|
|
78
|
+
// fit(option id) supplies the measured grid and full-width heights.
|
|
79
|
+
function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
|
|
80
|
+
const last = q.answers[q.answers.length - 1];
|
|
81
|
+
const chosen = draft.choice ?? (last ? last.choice : null);
|
|
82
|
+
const st = QUESTION_STATE[q.state];
|
|
83
|
+
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(st.tone, esc(st.label))}</div>
|
|
85
|
+
<h3>${linkText(q.question, links)}</h3>
|
|
86
|
+
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
87
|
+
${framed(q) ? '' : questionLink(q.url)}
|
|
88
|
+
${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)} A new answer reopens it.</div>` : ''}
|
|
89
|
+
${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
|
|
90
|
+
${noteComposer('q:' + q.id, draft.note)}
|
|
91
|
+
${answerHistory(q, links)}
|
|
92
|
+
</section>`;
|
|
93
|
+
}
|
|
94
|
+
const topicName = (t) => (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title;
|
|
95
|
+
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
96
|
+
// current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
|
|
97
|
+
function topicRow(topics, current) {
|
|
98
|
+
const pills = topics.filter((t) => t.id === current || (t.state !== 'resolved' && t.open > 0));
|
|
99
|
+
const waiting = topics.filter((t) => t.id !== current && t.state !== 'resolved' && !(t.open > 0));
|
|
100
|
+
return { pills, waiting };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// owner is in owner.json order; topics are the pills before the answer. A topic-wide Send omits answered.id.
|
|
104
|
+
function nextQuestion(owner, topics, answered) {
|
|
105
|
+
const at = owner.findIndex((q) => q.id === answered.id);
|
|
106
|
+
const open = (q) => q.state === 'open' && q.id !== answered.id;
|
|
107
|
+
const same = [...owner.slice(at + 1), ...owner.slice(0, Math.max(at, 0))].find((q) => q.topic === answered.topic && open(q));
|
|
108
|
+
if (same) return same;
|
|
109
|
+
const ids = topics.map((t) => t.id);
|
|
110
|
+
const ti = ids.indexOf(answered.topic);
|
|
111
|
+
const order = ti < 0 ? ids : [...ids.slice(0, ti).reverse(), ...ids.slice(ti + 1)];
|
|
112
|
+
for (const id of order) {
|
|
113
|
+
const q = owner.find((q) => q.topic === id && open(q));
|
|
114
|
+
if (q) return q;
|
|
115
|
+
}
|
|
116
|
+
return owner.find(open) || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The line a Send or prototype pick leaves for the board it goes back to, read once by that board's view (app.js).
|
|
120
|
+
// Kept in sessionStorage, so a board opened later in another tab shows none.
|
|
121
|
+
const noticeKey = (route) => 'sent:' + route;
|
|
122
|
+
const sentNotice = {
|
|
123
|
+
put(route, text) { try { sessionStorage.setItem(noticeKey(route), text); } catch {} },
|
|
124
|
+
take(route) {
|
|
125
|
+
try { const t = sessionStorage.getItem(noticeKey(route)) || ''; sessionStorage.removeItem(noticeKey(route)); return t; } catch { return ''; }
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, takes, topicName, topicRow, nextQuestion, sentNotice });
|
|
130
|
+
})();
|