@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,151 @@
|
|
|
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, protoButton, 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, disabled = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${disabled ? ' disabled' : ''}>
|
|
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 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 (view-questions.js);
|
|
12
|
+
// once sent, the eye is off with a "prototype requested" badge right after it until the round arrives.
|
|
13
|
+
const questionEye = (q) => q.state === 'resolved' ? ''
|
|
14
|
+
: protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead', disabled: q.state === 'requested' })
|
|
15
|
+
+ (q.state === 'requested' ? badge('normal', `${SPIN}prototype requested`) : '');
|
|
16
|
+
const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}"><textarea class="note" rows="2" placeholder="Note (optional)"${q.state === 'requested' ? ' disabled' : ''}>${esc(note || '')}</textarea>
|
|
17
|
+
<div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
|
|
18
|
+
|
|
19
|
+
const QUESTION_STATE = {
|
|
20
|
+
open: { label: 'open', tone: 'normal' },
|
|
21
|
+
answered: { label: 'waiting for claude', tone: 'notice' },
|
|
22
|
+
replied: { label: 'claude replied', tone: 'ready' },
|
|
23
|
+
resolved: { label: 'resolved', tone: 'quiet' },
|
|
24
|
+
};
|
|
25
|
+
// docs/adr/0111-a-prototype-round-ends-with-a-one-take-confirm.md
|
|
26
|
+
// A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
|
|
27
|
+
const roundChip = (r) => !r ? ''
|
|
28
|
+
: r.state === 'making' ? badge('notice', `${SPIN}Prototype round ${r.n}`)
|
|
29
|
+
: r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
|
|
30
|
+
: badge('normal', `Prototype round ${r.n}`);
|
|
31
|
+
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>';
|
|
32
|
+
function answerHistory(q, links = {}) {
|
|
33
|
+
if (!q.answers.length) return '';
|
|
34
|
+
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>
|
|
35
|
+
${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('');
|
|
36
|
+
const last = q.answers[q.answers.length - 1];
|
|
37
|
+
return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
|
|
38
|
+
}
|
|
39
|
+
// A prototype page in the site's public/ opens in place, and its switcher picks a take (board/public/prototype-switcher.js).
|
|
40
|
+
const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
|
|
41
|
+
const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
|
|
42
|
+
const withParam = (url, key, value) => {
|
|
43
|
+
const at = url.includes('#') ? url.indexOf('#') : url.length;
|
|
44
|
+
return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
|
|
45
|
+
};
|
|
46
|
+
const questionLink = (url) => !url ? ''
|
|
47
|
+
: isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
|
|
48
|
+
: `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
|
|
49
|
+
|
|
50
|
+
// A question's takes show inline (docs/adr/0048-a-questions-takes-show-inline.md): an option with `diagram` draws
|
|
51
|
+
// it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
|
|
52
|
+
const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
|
|
53
|
+
const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
|
|
54
|
+
|
|
55
|
+
// rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
|
|
56
|
+
// of them; tone is a status colour's name.
|
|
57
|
+
const TONES = ['quiet', 'info', 'notice', 'danger', 'success'];
|
|
58
|
+
function diagram(rows) {
|
|
59
|
+
const label = rows.map((row) => row.map((b) => b.text).join(', ')).join(' → ');
|
|
60
|
+
return `<div class="flow" role="img" aria-label="${esc(label)}">${rows.map((row, i) => {
|
|
61
|
+
const edges = i ? rows[i - 1].flatMap((b, from) => (b.to || row.map((_, n) => n)).map((to) => [from, to])) : [];
|
|
62
|
+
const into = new Set(edges.map(([, to]) => to));
|
|
63
|
+
const conn = edges.length ? `<svg class="flow-conn" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${edges.map(([from, to]) =>
|
|
64
|
+
`<line x1="${((from + 0.5) / rows[i - 1].length) * 100}" y1="0" x2="${((to + 0.5) / row.length) * 100}" y2="100"/>`).join('')}</svg>` : '';
|
|
65
|
+
return `${conn}<div class="flow-row${row.length > 3 ? ' dense' : ''}" style="grid-template-columns:repeat(${row.length},minmax(0,1fr))">${row.map((b, n) =>
|
|
66
|
+
`<div class="flow-box${into.has(n) ? ' in' : ''}${TONES.includes(b.tone) ? ` tone-${b.tone}` : ''}">${esc(b.text)}</div>`).join('')}</div>`;
|
|
67
|
+
}).join('')}</div>`;
|
|
68
|
+
}
|
|
69
|
+
const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
|
|
70
|
+
|
|
71
|
+
const fullTakes = (q, fit) => isPrototype(q.url) && q.options.every((o) => !o.diagram && !o.practice)
|
|
72
|
+
&& (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
|
|
73
|
+
// Grid heights decide the layout; fullH measures the chosen take at the full content width.
|
|
74
|
+
function takeBody(q, o, links, fit, full = false) {
|
|
75
|
+
if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
|
|
76
|
+
if (!isPrototype(q.url)) return '';
|
|
77
|
+
const size = fit(o.id);
|
|
78
|
+
const h = full ? size?.fullH : size?.h;
|
|
79
|
+
// Every take loads so one outside the viewport can still decide the question's layout.
|
|
80
|
+
return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
|
|
81
|
+
}
|
|
82
|
+
function takes(q, chosen, links, fit) {
|
|
83
|
+
const name = 'c-' + q.id;
|
|
84
|
+
const disabled = q.state === 'requested';
|
|
85
|
+
if (fullTakes(q, fit)) {
|
|
86
|
+
const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
|
|
87
|
+
return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, disabled })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
|
|
88
|
+
}
|
|
89
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, disabled })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
|
|
90
|
+
}
|
|
91
|
+
// A confirm links its one take alone, and says why it is asked.
|
|
92
|
+
const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
|
|
93
|
+
: `<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>` : ''}`;
|
|
94
|
+
// An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
|
|
95
|
+
// fit(option id) supplies the measured grid and full-width heights.
|
|
96
|
+
// A requested question shows the note and pick its prototype was asked with, locked.
|
|
97
|
+
function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
|
|
98
|
+
const last = q.answers[q.answers.length - 1];
|
|
99
|
+
const busy = q.state === 'requested';
|
|
100
|
+
const shown = busy ? q.request : draft;
|
|
101
|
+
const chosen = shown.choice ?? (last ? last.choice : null);
|
|
102
|
+
return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}${busy ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
103
|
+
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${busy ? '' : badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
|
|
104
|
+
<h3>${linkText(q.question, links)}</h3>
|
|
105
|
+
${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
|
|
106
|
+
${framed(q) ? '' : questionLink(q.url)}
|
|
107
|
+
${confirmLines(q)}
|
|
108
|
+
${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>` : ''}
|
|
109
|
+
${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, disabled: busy })).join('')}
|
|
110
|
+
${noteComposer(q, shown.note)}
|
|
111
|
+
${answerHistory(q, links)}
|
|
112
|
+
</section>`;
|
|
113
|
+
}
|
|
114
|
+
// A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
|
|
115
|
+
const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
|
|
116
|
+
// Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
|
|
117
|
+
// current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
|
|
118
|
+
function topicRow(topics, current) {
|
|
119
|
+
const pills = topics.filter((t) => t.id === current || (t.state !== 'resolved' && t.open > 0));
|
|
120
|
+
const waiting = topics.filter((t) => t.id !== current && t.state !== 'resolved' && !(t.open > 0));
|
|
121
|
+
return { pills, waiting };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// owner is in owner.json order; topics are the pills before the answer. A topic-wide Send omits answered.id.
|
|
125
|
+
function nextQuestion(owner, topics, answered) {
|
|
126
|
+
const at = owner.findIndex((q) => q.id === answered.id);
|
|
127
|
+
const open = (q) => q.state === 'open' && q.id !== answered.id;
|
|
128
|
+
const same = [...owner.slice(at + 1), ...owner.slice(0, Math.max(at, 0))].find((q) => q.topic === answered.topic && open(q));
|
|
129
|
+
if (same) return same;
|
|
130
|
+
const ids = topics.map((t) => t.id);
|
|
131
|
+
const ti = ids.indexOf(answered.topic);
|
|
132
|
+
const order = ti < 0 ? ids : [...ids.slice(0, ti).reverse(), ...ids.slice(ti + 1)];
|
|
133
|
+
for (const id of order) {
|
|
134
|
+
const q = owner.find((q) => q.topic === id && open(q));
|
|
135
|
+
if (q) return q;
|
|
136
|
+
}
|
|
137
|
+
return owner.find(open) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The line a Send or prototype pick leaves for the board it goes back to, read once by that board's view (app.js).
|
|
141
|
+
// Kept in sessionStorage, so a board opened later in another tab shows none.
|
|
142
|
+
const noticeKey = (route) => 'sent:' + route;
|
|
143
|
+
const sentNotice = {
|
|
144
|
+
put(route, text) { try { sessionStorage.setItem(noticeKey(route), text); } catch {} },
|
|
145
|
+
take(route) {
|
|
146
|
+
try { const t = sessionStorage.getItem(noticeKey(route)) || ''; sessionStorage.removeItem(noticeKey(route)); return t; } catch { return ''; }
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, topicRow, nextQuestion, sentNotice });
|
|
151
|
+
})();
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// The sidebar of every site (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
|
|
2
|
+
// /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself:
|
|
3
|
+
// the app shell runs it through sidebar.js, and hub/hub.mjs renders the shell's first sidebar with both in a vm context.
|
|
4
|
+
// Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
|
|
5
|
+
(function () {
|
|
6
|
+
// ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
|
|
7
|
+
const { header } = globalThis.BoardUI || {};
|
|
8
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
9
|
+
const svg = (paths, size = 17) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
10
|
+
const ICONS = {
|
|
11
|
+
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"/>',
|
|
12
|
+
sliders: '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>',
|
|
13
|
+
pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
|
14
|
+
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
15
|
+
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
16
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
17
|
+
up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
|
|
18
|
+
};
|
|
19
|
+
const tile = (key, color) => `<span class="icon sm solid" style="--c:${color}" aria-hidden="true">${svg(ICONS[key])}</span>`;
|
|
20
|
+
const word = (tone, text) => `<span class="dot t-${tone}" aria-hidden="true"></span><span class="word t-${tone}">${esc(text)}</span>`;
|
|
21
|
+
const usageProducts = (data) => ['claude', 'codex'].filter((p) => data?.[p]);
|
|
22
|
+
const usageNames = { claude: 'Claude', codex: 'Codex' };
|
|
23
|
+
function usageClock(iso) {
|
|
24
|
+
const d = new Date(iso ?? '');
|
|
25
|
+
if (!Number.isFinite(d.getTime())) return '—';
|
|
26
|
+
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP])M/i, (_, x) => `${x.toLowerCase()}m`);
|
|
27
|
+
return new Date().toDateString() === d.toDateString() ? time : `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
|
|
28
|
+
}
|
|
29
|
+
const usageTone = (w) => !w ? 'none' : w.state === 'stale' ? 'stale' : w.used_percentage >= 90 ? 'full' : w.used_percentage >= 75 ? 'high' : 'ok';
|
|
30
|
+
const usageBar = (w) => `<span class="u-bar" aria-hidden="true"><i style="width:${w?.state === 'stale' ? 0 : Math.max(0, Math.min(100, w?.used_percentage || 0))}%"></i></span>`;
|
|
31
|
+
function usageWords(reading, key) {
|
|
32
|
+
const w = reading[key];
|
|
33
|
+
if (!w) return 'No reading';
|
|
34
|
+
if (w.state === 'stale') return `${Math.round(w.used_percentage)}% at ${esc(usageClock(reading.at))}`;
|
|
35
|
+
if (w.state === 'reset') return `Reset ${esc(usageClock(w.resets_at))}`;
|
|
36
|
+
return `<span class="u-num">${Math.round(w.used_percentage)}%</span> · resets ${esc(usageClock(w.resets_at))}`;
|
|
37
|
+
}
|
|
38
|
+
function sidebarUsage(data) {
|
|
39
|
+
const products = usageProducts(data);
|
|
40
|
+
return products.length ? `<span class="sb-usage">${products.map((p) => {
|
|
41
|
+
const r = data[p];
|
|
42
|
+
const w = r.five_hour;
|
|
43
|
+
const name = `<span class="u-name">${usageNames[p]}</span>`;
|
|
44
|
+
const words = !w ? `${name} · no reading yet`
|
|
45
|
+
: w.state === 'stale' ? `${name} · last seen ${esc(usageClock(r.at))}`
|
|
46
|
+
: w.state === 'reset' ? `${name} · reset ${esc(usageClock(w.resets_at))}`
|
|
47
|
+
: `${name} ${usageWords(r, 'five_hour')}`;
|
|
48
|
+
return `<span class="u-one u-tone-${usageTone(w)}"><span class="u-line">${words}</span>${usageBar(w)}</span>`;
|
|
49
|
+
}).join('')}</span>` : '';
|
|
50
|
+
}
|
|
51
|
+
// The utility rows, in the order they show.
|
|
52
|
+
const UTILITY = [
|
|
53
|
+
{ href: '/_hub/', title: 'Hub', icon: tile('pulse', '#64748b') },
|
|
54
|
+
];
|
|
55
|
+
const siteHref = (s) => `/${encodeURIComponent(s.route)}/`;
|
|
56
|
+
|
|
57
|
+
// Switch mode's keys (sidebar.js; docs/adr/0056-switch-mode-letters-come-from-names-in-join-order.md): each linked row the
|
|
58
|
+
// sidebar draws, boards first for numbering, as {href, title, board, num, letter, at}. The first nine boards take num '1'–'9'.
|
|
59
|
+
// Every row takes as letter the first a–z letter of its title not yet taken, at index `at` of the title's characters,
|
|
60
|
+
// claiming in this order: boards by `created`, then pages by `created`, a site with none after those with one, then
|
|
61
|
+
// the Hub rows. So a new board never takes another board's letter. A row with no letter left gets none.
|
|
62
|
+
function switchKeys(data) {
|
|
63
|
+
const sites = data?.sites || [];
|
|
64
|
+
const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
|
|
65
|
+
const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
|
|
66
|
+
...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
|
|
67
|
+
rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
|
|
68
|
+
const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
|
|
69
|
+
const taken = new Set();
|
|
70
|
+
for (const r of [...rows].sort((a, b) => a.rank - b.rank || when(a) - when(b))) {
|
|
71
|
+
const chars = [...r.title];
|
|
72
|
+
const at = chars.findIndex((c) => /^[a-z]$/.test(c.toLowerCase()) && !taken.has(c.toLowerCase()));
|
|
73
|
+
if (at < 0) continue;
|
|
74
|
+
r.letter = chars[at].toLowerCase();
|
|
75
|
+
r.at = at;
|
|
76
|
+
taken.add(r.letter);
|
|
77
|
+
}
|
|
78
|
+
return rows.map(({ href, title, board, num, letter, at }) => ({ href, title, board, num, letter, at }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// key: the row's switchKeys entry while switch mode shows, else undefined. The tile carries a board's number, or any
|
|
82
|
+
// other row's letter, and the title marks its letter; sidebar.css shows them in place of the glyph under .ks-on.
|
|
83
|
+
// mark: HTML after the title, such as Hub's moon.
|
|
84
|
+
function row({ href, icon, title, sub = '', extra = '', mark = '', on = false, cls = '', key }) {
|
|
85
|
+
const shown = key && (key.board ? key.num : key.letter);
|
|
86
|
+
const tileHtml = shown ? icon.replace(/<\/span>$/, `<span class="ks-key">${esc(shown)}</span></span>`) : icon;
|
|
87
|
+
const chars = [...title];
|
|
88
|
+
const titleHtml = key?.letter ? `${esc(chars.slice(0, key.at).join(''))}<span class="ks-u">${esc(chars[key.at])}</span>${esc(chars.slice(key.at + 1).join(''))}` : esc(title);
|
|
89
|
+
return `<a class="sb-row${on ? ' on' : ''}${cls ? ` ${cls}` : ''}" href="${esc(href)}"${on ? ' aria-current="page"' : ''}>${tileHtml}`
|
|
90
|
+
+ `<span class="sb-text"><span class="sb-title">${titleHtml}${mark}</span>${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What waits on the owner on every site but `here`: the count on the logo that opens the drawer.
|
|
94
|
+
function othersWaiting(data, here) {
|
|
95
|
+
return (data?.sites || []).reduce((n, s) => n + (s.route === here ? 0 : Number(s.count) || 0), 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function repoLabel(repo, home) {
|
|
99
|
+
if (!home) return repo;
|
|
100
|
+
return repo === home ? '~' : repo.startsWith(`${home}/`) ? `~${repo.slice(home.length)}` : repo;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The shortcuts sheet's rows (sidebar.js opens it on h h), grouped by where each key works. A new shortcut is added
|
|
104
|
+
// here; the code that answers its key lives with the view it acts on.
|
|
105
|
+
const SHORTCUTS = [
|
|
106
|
+
{ group: 'Anywhere', rows: [
|
|
107
|
+
{ keys: ['s', 's'], what: 'Switch boards and pages', sub: 'Then the key on a row' },
|
|
108
|
+
{ keys: ['h', 'h'], what: 'This help' },
|
|
109
|
+
{ keys: ['Esc'], what: 'Close', sub: 'The help, a sheet, the drawer or switch mode' },
|
|
110
|
+
] },
|
|
111
|
+
{ group: 'On a board', rows: [{ keys: ['n', 'n'], what: 'New card' }] },
|
|
112
|
+
{ group: 'Writing', rows: [{ keys: ['⌘', '↵'], what: 'Send', sub: 'Ctrl ↵ off a Mac' }] },
|
|
113
|
+
];
|
|
114
|
+
// The shortcuts sheet's content, inside its <dialog aria-labelledby="shortcutsTitle">.
|
|
115
|
+
function shortcuts() {
|
|
116
|
+
const keycaps = (keys) => `<span class="sc-keys">${keys.map((k) => `<kbd class="sc-key">${esc(k)}</kbd>`).join('')}</span>`;
|
|
117
|
+
const rowHtml = (r) => `<div class="sc-row"><span class="sc-what">${esc(r.what)}${r.sub ? `<span class="sc-sub">${esc(r.sub)}</span>` : ''}</span>${keycaps(r.keys)}</div>`;
|
|
118
|
+
return `<div class="sheet-body"><div class="sheet-head"><h2 id="shortcutsTitle">Keyboard shortcuts</h2></div>`
|
|
119
|
+
+ SHORTCUTS.map((g) => `<section class="sc-group" aria-label="${esc(g.group)}"><h3>${esc(g.group)}</h3>${g.rows.map(rowHtml).join('')}</section>`).join('')
|
|
120
|
+
+ '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
|
|
124
|
+
// A board's context window: its owner session's latest percent, dimmed with its age once older than ten minutes.
|
|
125
|
+
const CTX_STALE_MIN = 10;
|
|
126
|
+
function contextWords(ctx) {
|
|
127
|
+
if (!ctx) return '';
|
|
128
|
+
const minutes = Math.floor((Date.now() - Date.parse(ctx.at)) / 60_000);
|
|
129
|
+
if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx">${Math.round(ctx.percent)}% ctx</span>`;
|
|
130
|
+
const age = minutes < 60 ? `${minutes} min` : minutes < 1440 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 1440)} d`;
|
|
131
|
+
return `<span class="sb-ctx stale">${Math.round(ctx.percent)}% ctx · ${age} ago</span>`;
|
|
132
|
+
}
|
|
133
|
+
function askedWhen(iso, now) {
|
|
134
|
+
const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
|
|
135
|
+
return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : usageClock(iso);
|
|
136
|
+
}
|
|
137
|
+
// The sheet a failed start's row opens (sidebar.js), inside its <dialog aria-labelledby="failedStartTitle">: the error,
|
|
138
|
+
// what the start used and when it was asked, then Dismiss and Retry. s: a /_hub/sidebar.json starting entry. Only a
|
|
139
|
+
// New board start records a branch, and only that start can run again, so a row without one has no Retry.
|
|
140
|
+
function failedStart(s, { home, now = Date.now() } = {}) {
|
|
141
|
+
const fact = (name, value) => (value ? `<dt>${name}</dt><dd>${value}</dd>` : '');
|
|
142
|
+
return `<div class="sheet-body"><div class="sheet-head"><h2 id="failedStartTitle">${esc(s.title)} failed to start</h2><button type="button" data-failed-close aria-label="Close sheet">✕</button></div>`
|
|
143
|
+
+ `<p class="fs-error">${esc(s.error || "Its session didn't answer within 15 minutes.")}</p>`
|
|
144
|
+
+ `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
|
|
145
|
+
+ `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
|
|
146
|
+
+ '<p class="fs-refused" role="alert" hidden></p>'
|
|
147
|
+
+ `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
|
|
151
|
+
// /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
|
|
152
|
+
// (hub/upgrade.mjs; docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md).
|
|
153
|
+
function updateRow(update, upgrade) {
|
|
154
|
+
const button = (attr, disabled, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${disabled ? ' disabled' : ''}>${tile('up', 'var(--accent)')}`
|
|
155
|
+
+ `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span>${go ? `<span class="sb-go">${go}</span>` : ''}</button>`;
|
|
156
|
+
if (update) return button('data-app-reload', update === 'reloading', 'New version', '', update === 'reloading' ? 'Reloading…' : 'Reload');
|
|
157
|
+
if (!upgrade) return '';
|
|
158
|
+
if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', '');
|
|
159
|
+
if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Updating…');
|
|
160
|
+
if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
|
|
161
|
+
return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
|
|
165
|
+
// subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
|
|
166
|
+
// null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
|
|
167
|
+
// update: null | 'ready' | 'reloading'.
|
|
168
|
+
function sidebar(data, { here = '', device = null, switching = false, update = null } = {}) {
|
|
169
|
+
const sites = data?.sites || [];
|
|
170
|
+
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
171
|
+
const paused = data?.pause?.state === 'paused';
|
|
172
|
+
const siteRow = (s) => {
|
|
173
|
+
const sub = paused && s.board ? word('wait', 'Paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
174
|
+
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub || s.ctx ? `<span class="sb-status">${sub}${contextWords(s.ctx)}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
175
|
+
};
|
|
176
|
+
const boards = sites.filter((s) => s.board);
|
|
177
|
+
const pages = sites.filter((s) => !s.board);
|
|
178
|
+
const plus = `<span class="icon sb-plus" aria-hidden="true">${svg(ICONS.plus, 20)}</span>`;
|
|
179
|
+
const pending = (data?.starting || []).map((s) => (s.failed
|
|
180
|
+
? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
|
|
181
|
+
+ `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
|
|
182
|
+
: `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
|
|
183
|
+
+ `<span class="sb-text"><span class="sb-title">${s.phase === 'cloning' ? 'Cloning…' : 'Starting…'}</span><span class="sb-sub">${esc(s.title)}${s.repo ? ` · ${esc(repoLabel(s.repo, data.home))}` : ''}${s.phase === 'cloning' ? ' · Cloning from GitHub' : ' · Waiting for the board to answer'}</span></span></div>`)).join('');
|
|
184
|
+
const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
|
|
185
|
+
const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
|
|
186
|
+
: device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
|
|
187
|
+
: '';
|
|
188
|
+
const usage = sidebarUsage(data?.usage);
|
|
189
|
+
const hub = paused ? { tone: 'wait', word: 'Paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
|
|
190
|
+
const n = data?.notifications || {};
|
|
191
|
+
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
192
|
+
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
193
|
+
: n.state === 'off' ? word('needs', `Notifications ${lower(n.text || 'Off')}`) : '';
|
|
194
|
+
// A phone shows no title tooltip, so there the moon only marks the hold; the hub page's banner says it.
|
|
195
|
+
const held = esc(`Notifications ${lower(n.text)}`);
|
|
196
|
+
const moon = n.state === 'held' ? `<span class="sb-moon" role="img" aria-label="${held}" title="${held}">${svg(ICONS.moon, 13)}</span>` : '';
|
|
197
|
+
const subs = {
|
|
198
|
+
'/_hub/': (hub ? `<span class="sb-status">${word(hub.tone, hub.word)}<span>${esc(hub.detail)}</span></span>` : '')
|
|
199
|
+
+ (notify ? `<span class="sb-status">${notify}</span>` : ''),
|
|
200
|
+
};
|
|
201
|
+
const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
|
|
202
|
+
const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
|
|
203
|
+
const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
|
|
204
|
+
return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
|
|
205
|
+
leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
|
|
206
|
+
+ `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
|
|
207
|
+
+ UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
|
|
208
|
+
+ `</div>${turnOn}</section>`
|
|
209
|
+
+ (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
|
|
210
|
+
+ `<section class="sb-boards" aria-label="Boards"><div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`
|
|
211
|
+
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
|
|
215
|
+
})();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
|
|
2
|
+
// page may do meanwhile (docs/adr/0028-the-board-opens-on-the-last-visit.md). Needs ui.js.
|
|
3
|
+
(function () {
|
|
4
|
+
// Characters, one board's most; app-snapshot.js keeps every board's within three of these.
|
|
5
|
+
const SNAPSHOT_MAX = 900000;
|
|
6
|
+
const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
|
|
7
|
+
|
|
8
|
+
// threads maps item keys to thread messages (app.js state.threadCache). Threads of `keep` go in first, then the
|
|
9
|
+
// rest while they fit under max; threads of items no longer on the board are dropped. build is the saving page's
|
|
10
|
+
// (app.html data-build). Null when the board alone won't fit.
|
|
11
|
+
function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
|
|
12
|
+
const head = `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(board)},"threads":{`;
|
|
13
|
+
if (head.length + 2 > max) return null;
|
|
14
|
+
const onBoard = new Set(board.items.map((i) => i.key));
|
|
15
|
+
let out = head;
|
|
16
|
+
let n = 0;
|
|
17
|
+
for (const key of new Set([...keep, ...threads.keys()])) {
|
|
18
|
+
if (!onBoard.has(key) || !threads.has(key)) continue;
|
|
19
|
+
const part = `${n ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(threads.get(key))}`;
|
|
20
|
+
if (out.length + part.length + 2 > max) continue;
|
|
21
|
+
out += part;
|
|
22
|
+
n++;
|
|
23
|
+
}
|
|
24
|
+
return `${out}}}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write under this build: code
|
|
28
|
+
// from another build may read the board's shape differently, and a paint that throws stops the live load.
|
|
29
|
+
function unpackSnapshot(raw, build) {
|
|
30
|
+
let s;
|
|
31
|
+
try { s = JSON.parse(raw); } catch { return null; }
|
|
32
|
+
if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
|
|
33
|
+
const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
|
|
34
|
+
return { savedAt: s.savedAt, board: s.board, threads };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The cards whose threads load right after the board, so tapping one opens it filled; open cards load with the board.
|
|
38
|
+
const preloadKeys = (items, open) => {
|
|
39
|
+
const opened = new Set(open);
|
|
40
|
+
return items.filter((i) => ACTIVE_LANES.includes(i.lane) && !opened.has(i.key)).map((i) => i.key);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
|
|
44
|
+
// and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
|
|
45
|
+
// INT-61 block dims the same list.
|
|
46
|
+
const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
|
|
47
|
+
'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
|
|
48
|
+
const liveControls = (view) => {
|
|
49
|
+
const fresh = view !== 'snapshot';
|
|
50
|
+
return { read: true, ask: true, cards: fresh, send: fresh, blocked: fresh ? '' : SNAPSHOT_BLOCKED };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
|
|
54
|
+
const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
|
|
55
|
+
|
|
56
|
+
// What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
|
|
57
|
+
// (docs/adr/0135-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
|
|
58
|
+
const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
|
|
59
|
+
it.next ?? null, it.ask?.state ?? null, it.pr ? [it.pr.state, it.pr.isDraft, it.pr.reviewDecision, it.pr.checks?.state ?? null] : null]);
|
|
60
|
+
|
|
61
|
+
Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
|
|
62
|
+
})();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A card's thread: Claude's lines, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
2
|
+
(function () {
|
|
3
|
+
const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
|
|
4
|
+
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
5
|
+
// A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
|
|
6
|
+
function cardLink(link) {
|
|
7
|
+
const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
|
|
8
|
+
if (!key) return '';
|
|
9
|
+
let ask = key.startsWith('ask:');
|
|
10
|
+
try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
|
|
11
|
+
return `<a href="${esc(link)}">Open the ${ask ? 'task' : 'card'} →</a>`;
|
|
12
|
+
}
|
|
13
|
+
// A message's state comes from domain.mjs threadOf.
|
|
14
|
+
const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
|
|
15
|
+
const msgFooter = (m) => ({
|
|
16
|
+
working: () => `<div class="reply muted small">${workingLine}</div>`,
|
|
17
|
+
waiting: () => '<div class="reply muted small">Waiting for Claude…</div>',
|
|
18
|
+
}[stateOf(m)] || (() => ''))();
|
|
19
|
+
// Each reply becomes Claude's own message at the time it was written, quoting the owner's message
|
|
20
|
+
// when other messages came between them. A reply with no time follows its message.
|
|
21
|
+
const withReplies = (msgs) => {
|
|
22
|
+
const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
|
|
23
|
+
...(m.eventId && m.reply ? [{ m: { ts: m.reply.at || m.ts, who: 'claude', kind: 'reply', reply: m.reply }, of: m, at: String(m.reply.at || m.ts), i, part: 1 }] : [])]);
|
|
24
|
+
out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
|
|
25
|
+
return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
|
|
26
|
+
};
|
|
27
|
+
// A message being edited shows its edit box in place of its text; app-asks.js handles the form.
|
|
28
|
+
const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}"><textarea rows="3">${esc(text)}</textarea>
|
|
29
|
+
<div class="row"><span></span><span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
|
|
30
|
+
// batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
|
|
31
|
+
const line = (m, links, edits, batched = false) => (m.kind === 'reply'
|
|
32
|
+
? `<div class="msg claude reply-msg"><div class="who">Claude · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
|
|
33
|
+
: m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
|
|
34
|
+
: m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
35
|
+
: m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
|
|
36
|
+
: m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
|
|
37
|
+
${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
|
|
38
|
+
<div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Claude' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
|
|
39
|
+
<div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
|
|
40
|
+
${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
|
|
41
|
+
${m.eventId && !batched ? msgFooter(m) : ''}
|
|
42
|
+
</div>`);
|
|
43
|
+
|
|
44
|
+
const STEP = 3;
|
|
45
|
+
const UNREAD_CAP = 10;
|
|
46
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
|
|
47
|
+
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
48
|
+
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount).
|
|
49
|
+
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
50
|
+
const batchedAt = (lines, i) => {
|
|
51
|
+
const next = lines.slice(i + 1).find(isMessage);
|
|
52
|
+
return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
|
|
53
|
+
};
|
|
54
|
+
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
55
|
+
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
56
|
+
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
|
57
|
+
const firstUnread = lines.findIndex((m) => unread(m, seenAt));
|
|
58
|
+
const unreadSpan = firstUnread < 0 ? 0 : counted.filter((i) => i >= firstUnread).length;
|
|
59
|
+
const want = Math.max(STEP, Math.min(UNREAD_CAP, unreadSpan)) + STEP * more;
|
|
60
|
+
const pinned = lines.findIndex((m) => m.eventId && edits.has(m.eventId));
|
|
61
|
+
const start = Math.min(counted.length > want ? counted[counted.length - want] : 0, pinned < 0 ? Infinity : pinned);
|
|
62
|
+
const hidden = lines.filter((m, i) => i < start && isMessage(m)).length;
|
|
63
|
+
return { start: hidden ? start : 0, hidden };
|
|
64
|
+
}
|
|
65
|
+
const moreButton = (hidden) => `<button type="button" class="thread-more" data-thread-more>Show more <span class="n">· ${hidden} previous message${hidden === 1 ? '' : 's'}</span></button>`;
|
|
66
|
+
|
|
67
|
+
// links: the thread's ui-links.js linkContext. edits: the open edit boxes by event id, {text}. view: {seenAt, more,
|
|
68
|
+
// upto}, the card's seen mark from before it opened, its Show more taps and its newest line's ts when it first drew,
|
|
69
|
+
// draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
|
|
70
|
+
function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
|
|
71
|
+
if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
|
|
72
|
+
const lines = withReplies(msgs);
|
|
73
|
+
const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
|
|
74
|
+
const shown = lines.slice(start);
|
|
75
|
+
if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
|
|
76
|
+
return (hidden ? moreButton(hidden) : '') + shown.map((m, i) => line(m, links, edits, batchedAt(lines, start + i))).join('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
|
|
80
|
+
// docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
|
|
81
|
+
// Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
|
|
82
|
+
// (docs/adr/0136-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
|
|
83
|
+
function doneButton(it) {
|
|
84
|
+
if (!it?.finishable) return '';
|
|
85
|
+
const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
|
|
86
|
+
return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
|
|
90
|
+
})();
|