@lovek4d/kickflip 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +151 -115
- package/README.md +23 -14
- package/SKILL.md +37 -37
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +186 -69
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +57 -38
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +156 -72
- package/board/public/app-core.js +2 -4
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +11 -14
- package/board/public/app.html +2 -1
- package/board/public/app.js +53 -83
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +5 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +20 -6
- package/board/public/sidebar.css +21 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +155 -62
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +176 -71
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +26 -23
- package/board/public/ui-sidebar.js +46 -22
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +17 -11
- package/board/public/ui.js +80 -12
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +101 -50
- package/board/public/view-settings.js +167 -49
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -12
- package/board/store.mjs +13 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -14
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -12
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/repos.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// The board view's lanes and the
|
|
2
|
-
// Done's finished-wave folds, and
|
|
1
|
+
// The board view's lanes and the Auto Review lane leading them: each lane's header and cards, Todo's Queued section, Done's and Asks' Show more,
|
|
2
|
+
// Done's finished-wave folds, and Todo's wave bands with their Implement, triage and card order.
|
|
3
3
|
// Needs app-core.js and app-cards.js.
|
|
4
4
|
BoardView.part((B) => {
|
|
5
5
|
const { $, store, state } = B;
|
|
6
|
-
const { esc } = BoardUI;
|
|
6
|
+
const { esc, waitOn } = BoardUI;
|
|
7
7
|
const LANES = [
|
|
8
8
|
{ id: 'review', name: 'In Review', open: true },
|
|
9
9
|
{ id: 'stale', name: 'Stale', open: true, hideEmpty: true },
|
|
@@ -12,17 +12,44 @@ BoardView.part((B) => {
|
|
|
12
12
|
{ id: 'done', name: 'Done', open: false, capped: true },
|
|
13
13
|
{ id: 'asks', name: 'Asks', open: false, capped: true },
|
|
14
14
|
];
|
|
15
|
-
// docs/adr/
|
|
15
|
+
// docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md
|
|
16
16
|
// A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
|
|
17
|
-
// open stays shown past the cap. Show more says how many are left (docs/adr/
|
|
17
|
+
// open stays shown past the cap. Show more says how many are left (docs/adr/2026-10-29-1639-lists-never-cut-silently.md).
|
|
18
|
+
// older is how many cards wait past `cards` in the archive; the tap that would show past `cards` fetches them first.
|
|
18
19
|
const STEP = 10;
|
|
19
20
|
const moreTaps = new Map();
|
|
20
|
-
function cappedRows(laneId, cards) {
|
|
21
|
+
function cappedRows(laneId, cards, older = 0) {
|
|
21
22
|
const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
|
|
22
23
|
const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
|
|
23
|
-
const left = cards.length - shown;
|
|
24
|
+
const left = Math.max(0, cards.length - shown);
|
|
25
|
+
const n = left + (older || 0);
|
|
26
|
+
const words = `${n} older card${n === 1 ? '' : 's'}`;
|
|
27
|
+
const fetches = older !== 0 && shown + STEP > cards.length;
|
|
24
28
|
return cards.slice(0, shown).map((it) => B.card(it)).join('')
|
|
25
|
-
+ (
|
|
29
|
+
+ (n > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}"${fetches ? ' data-fetch' : ''}>Show more <span class="n">· ${words}</span></button>` : '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// docs/adr/2026-11-04-0307-the-board-keeps-the-newest-100-done-cards.md
|
|
33
|
+
// Done shows the board's Done cards, then those paged in from the archive (GET /api/done), 100 a page, newest first.
|
|
34
|
+
// The board says how many the archive holds (doneArchived), so Done's count is the true total before any page is fetched.
|
|
35
|
+
const { archived } = state;
|
|
36
|
+
const olderThanBoard = (d) => Math.max(0, d.doneArchived - archived.cards.filter((c) => !d.items.some((i) => i.key === c.key)).length);
|
|
37
|
+
function doneRows(d, open) {
|
|
38
|
+
const onBoard = new Set(d.items.map((i) => i.key));
|
|
39
|
+
const cards = [...open, ...archived.cards.filter((c) => !onBoard.has(c.key))].sort((a, b) => String(b.sortAt || '').localeCompare(String(a.sortAt || '')));
|
|
40
|
+
return cappedRows('done', cards, olderThanBoard(d));
|
|
41
|
+
}
|
|
42
|
+
function doneCount(d, items) {
|
|
43
|
+
const onBoard = new Set(d.items.map((i) => i.key));
|
|
44
|
+
const older = olderThanBoard(d);
|
|
45
|
+
return `${items.length + archived.cards.filter((c) => !onBoard.has(c.key)).length + older}`;
|
|
46
|
+
}
|
|
47
|
+
async function fetchOlder() {
|
|
48
|
+
const last = archived.cards.at(-1);
|
|
49
|
+
const r = await fetch(B.site.url(`api/done${last ? `?before=${encodeURIComponent(`${last.sortAt}|${last.key}`)}` : ''}`), { cache: 'no-store' });
|
|
50
|
+
if (!r.ok) throw new Error(`done ${r.status}`);
|
|
51
|
+
const page = await r.json();
|
|
52
|
+
archived.cards.push(...page.cards);
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
// A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
|
|
@@ -47,79 +74,160 @@ BoardView.part((B) => {
|
|
|
47
74
|
}).join('');
|
|
48
75
|
}
|
|
49
76
|
|
|
50
|
-
//
|
|
77
|
+
// docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md: the Auto Review lane leads the lanes. The PRs
|
|
78
|
+
// the owner has open are kept in memory, as cards are.
|
|
79
|
+
const openReviews = new Set();
|
|
80
|
+
const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
|
|
81
|
+
open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
|
|
82
|
+
// Opens PR number's review and its lane or fold, for a Needs you line or a push's #review=<number>.
|
|
83
|
+
function openReview(number) {
|
|
84
|
+
openReviews.add(number);
|
|
85
|
+
const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
|
|
86
|
+
if (state.board?.reviews?.earlier.some((c) => c.number === number)) store.set(FOLDS['reviews-earlier'].store, true);
|
|
87
|
+
B.render();
|
|
88
|
+
$(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// shown is the board's items less pending deletes.
|
|
51
92
|
function lanesHtml(d, shown) {
|
|
52
93
|
const laneState = store.get('lanes', {});
|
|
53
|
-
return LANES.map((lane) => {
|
|
94
|
+
return reviewsHtml(d) + LANES.map((lane) => {
|
|
54
95
|
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
55
96
|
const items = shown.filter((i) => i.lane === lane.id);
|
|
56
97
|
if (lane.hideEmpty && !items.length) return '';
|
|
57
98
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
58
99
|
const open = items.filter((i) => !i.fold);
|
|
59
|
-
const rows = (lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
|
|
100
|
+
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
|
|
60
101
|
return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
|
|
61
|
-
<summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
102
|
+
<summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
|
|
62
103
|
<div class="list">${rows || '<div class="empty">Nothing here.</div>'}
|
|
63
104
|
${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
|
|
64
105
|
</details>`;
|
|
65
106
|
}).join('');
|
|
66
107
|
}
|
|
67
108
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
109
|
+
// docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md
|
|
110
|
+
// Todo's wave bands (domain.mjs waveBandsOf), then its cards in no wave under Not in a wave. A band's open state lives
|
|
111
|
+
// in storage once the owner taps its head; until then only the first band whose next card is ready (a wave file's:
|
|
112
|
+
// any Todo card) starts open. A band's name shows its goal as a tooltip, or a popover on a long-press. menu is the
|
|
113
|
+
// card whose ⋯ menu is open, goalShown the band whose goal popover is open.
|
|
114
|
+
let menu = null;
|
|
115
|
+
let goalShown = null;
|
|
71
116
|
const triaging = new Set();
|
|
72
117
|
const triageTopic = (r) => `${r.kind}:${r.slug}`;
|
|
73
118
|
function triageLink(r) {
|
|
74
|
-
if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>
|
|
119
|
+
if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>triaging…</span>';
|
|
75
120
|
if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
|
|
76
121
|
return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
|
|
77
122
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
123
|
+
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
124
|
+
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
125
|
+
const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
|
|
126
|
+
const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
|
|
127
|
+
|
|
128
|
+
// docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md
|
|
129
|
+
// Todo's Queued section, which holds the cards waiting for an implementer slot, the next to build on top, as they
|
|
130
|
+
// arrive (domain.mjs laneOrder), then its wave bands and loose cards.
|
|
131
|
+
function todoRows(d, shown, all) {
|
|
132
|
+
const queued = all.filter((i) => i.queued);
|
|
133
|
+
const head = queued.length ? `<div class="wb-loose lane-queue"><div class="wb-head wb-loosehead"><span class="lane-name">Queued</span><span class="count">${queued.length}</span></div>${queued.map((it) => B.card(it)).join('')}</div>` : '';
|
|
134
|
+
return head + bandRows(d, shown, all.filter((i) => !i.queued));
|
|
88
135
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
136
|
+
|
|
137
|
+
function bandRows(d, shown, todo) {
|
|
138
|
+
const bands = d.waveBands || [];
|
|
139
|
+
const byKey = new Map(shown.filter((i) => !i.queued).map((i) => [i.key, i]));
|
|
140
|
+
const banded = new Set(bands.flatMap((b) => b.keys));
|
|
141
|
+
const loose = todo.filter((i) => !banded.has(i.key));
|
|
142
|
+
const first = bands.find((b) => (b.next ? b.next.ready : b.keys.some((k) => byKey.get(k)?.ready)))?.slug;
|
|
143
|
+
if (!bands.length) return loose.map((it) => B.card(it)).join('');
|
|
144
|
+
return bands.map((b) => bandHtml(b, b.keys.map((k) => byKey.get(k)).filter(Boolean), bandOpen(b, first))).join('')
|
|
145
|
+
+ (loose.length ? `<div class="wb-loose"><div class="wb-head wb-loosehead"><span class="lane-name">Not in a wave</span><span class="count">${loose.length}</span></div>${loose.map((it) => B.card(it)).join('')}</div>` : '');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function bandHtml(band, cards, open) {
|
|
149
|
+
const todo = cards.filter((i) => i.lane === 'todo');
|
|
150
|
+
const done = cards.filter((i) => i.lane === 'done');
|
|
151
|
+
const now = cards.filter((i) => LANE_WORDS[i.lane]).map((i) => `${esc(i.ref)} ${i.queued ? 'queued' : LANE_WORDS[i.lane]}`);
|
|
152
|
+
const progress = [`${band.merged} of ${band.total} merged`, ...now].join(' · ');
|
|
153
|
+
const name = `<span class="lane-name wb-name"${band.goal ? ` title="${esc(band.goal)}" data-goal="${esc(band.slug)}"` : ''}>${esc(band.title)}</span>`;
|
|
154
|
+
const notes = band.kind === 'lineup' ? `<span class="external-link"><a href="notes.html?wave=${encodeURIComponent(band.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-separator" aria-hidden="true"> · </span>` : '';
|
|
155
|
+
const next = band.next;
|
|
156
|
+
const go = !next ? '' : next.ready
|
|
157
|
+
? `<button type="button" class="wv-link" data-confirm="card-build" data-key="${esc(next.key)}">Implement</button>`
|
|
158
|
+
: `<span class="wb-next">next: ${esc(next.ref)} ${esc(next.word)}</span>`;
|
|
159
|
+
const pop = goalShown === band.slug && band.goal ? `<div class="wb-pop" role="tooltip">${esc(band.goal)}</div>` : '';
|
|
160
|
+
const head = `<div class="wb-head" data-band-toggle="${esc(band.slug)}" role="button" tabindex="0" aria-expanded="${open}">${BoardUI.WAVE_ICON}`
|
|
161
|
+
+ `<span class="wb-label">${name} <span class="count">${todo.length}</span> <span class="count">· ${progress}</span></span>`
|
|
162
|
+
+ `<span class="wb-end"><span class="wb-links">${notes}${triageLink(band)}</span>${go}</span>${pop}</div>`;
|
|
163
|
+
if (!open) return `<section class="wb" data-band="${esc(band.slug)}">${head}</section>`;
|
|
164
|
+
const doneRow = done.length ? `<div class="wb-done">${done.length} done · ${done.map((i) => `<a class="card-link" href="#item=${encodeURIComponent(i.key)}">${esc(i.ref)}</a>`).join(', ')}</div>` : '';
|
|
165
|
+
const step = (it) => (band.kind === 'lineup' ? band.phases.findIndex((p) => p.includes(it.key)) + 1 : 0);
|
|
166
|
+
const actions = (it, at) => (band.kind !== 'lineup' ? null : cardMenu(band.slug, it.key, at, todo.length));
|
|
167
|
+
return `<section class="wb" data-band="${esc(band.slug)}" data-open>${head}<div class="wb-body">${doneRow}${todo.map((it, at) => B.card(it, { step: step(it), actions: actions(it, at) })).join('')}</div></section>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A lineup card's ⋯ and, while open, its menu: Move up and Move down among the band's Todo cards. A move waits for
|
|
171
|
+
// the server (board/server.mjs lineup-move): the tapped move waits and the other is disabled meanwhile.
|
|
172
|
+
function cardMenu(slug, key, at, count) {
|
|
173
|
+
const shown = menu === key;
|
|
174
|
+
const item = (by, label) => {
|
|
175
|
+
const off = by < 0 ? at === 0 : at === count - 1;
|
|
176
|
+
return `<button type="button" role="menuitem" data-band-move="${by}" data-band="${esc(slug)}" data-key="${esc(key)}"${off ? ' disabled' : ''}>${label}</button>`;
|
|
177
|
+
};
|
|
178
|
+
return `<div class="ask-actions"><button type="button" class="ghost icon wb-dots" data-band-menu="${esc(key)}" aria-label="Card menu" aria-haspopup="menu" aria-expanded="${shown}">${DOTS}</button>`
|
|
179
|
+
+ (shown ? `<div class="wb-menu" role="menu">${item(-1, '↑ Move up')}${item(1, '↓ Move down')}</div>` : '') + '</div>';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Opens the band holding card key or named slug, for a #item= link or a Needs you line.
|
|
183
|
+
function openBand({ key, slug }) {
|
|
184
|
+
const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
|
|
185
|
+
if (band) store.set(bandStore(band.slug), true);
|
|
186
|
+
return band;
|
|
98
187
|
}
|
|
99
|
-
const showWave = (slug) => { state.waveFilter = slug; store.set('wave', slug); B.render(); };
|
|
100
188
|
|
|
101
189
|
B.on(B.root, 'toggle', (ev) => {
|
|
102
190
|
const el = ev.target;
|
|
103
191
|
if (el.matches?.('details.lane')) {
|
|
104
192
|
const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
|
|
105
193
|
} else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
|
|
194
|
+
else if (el.matches?.('details.rv-card, details.rv-line')) {
|
|
195
|
+
if (el.open) openReviews.add(Number(el.dataset.review)); else openReviews.delete(Number(el.dataset.review));
|
|
196
|
+
}
|
|
106
197
|
}, true);
|
|
107
198
|
|
|
108
199
|
B.on(B.root, 'click', async (ev) => {
|
|
200
|
+
const answer = ev.target.closest('button[data-review-answer]');
|
|
201
|
+
if (answer) {
|
|
202
|
+
// The tapped answer waits, and the review's other answer is disabled, until the server records it.
|
|
203
|
+
const pr = Number(answer.dataset.review);
|
|
204
|
+
try { await waitOn(answer, B.send('review-answer', { pr, answer: answer.dataset.reviewAnswer }), { group: answer.parentNode.querySelectorAll('button') }); }
|
|
205
|
+
catch { B.notice('Could not send. Is the host awake?'); }
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const review = ev.target.closest('a[data-review-open]');
|
|
209
|
+
if (review) { ev.preventDefault(); openReview(Number(review.dataset.reviewOpen)); return; }
|
|
109
210
|
const more = ev.target.closest('[data-lane-more]');
|
|
110
|
-
if (more) {
|
|
211
|
+
if (more) {
|
|
212
|
+
if (more.hasAttribute('data-fetch')) {
|
|
213
|
+
try { await waitOn(more, fetchOlder()); } catch { B.toast('Could not load older cards. Is the host awake?'); return; }
|
|
214
|
+
if (!B.alive()) return;
|
|
215
|
+
}
|
|
216
|
+
moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1);
|
|
217
|
+
B.render();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
111
220
|
const pb = ev.target.closest('button[data-plan]');
|
|
112
221
|
if (pb) {
|
|
113
222
|
if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
|
|
114
|
-
pb.disabled = true;
|
|
115
|
-
|
|
116
|
-
catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
|
|
223
|
+
try { await waitOn(pb, B.send('plan-waves', { keys: pb.dataset.plan.split(' ') })); pb.textContent = 'Planning… its questions appear in Needs you'; pb.disabled = true; }
|
|
224
|
+
catch { B.notice('Could not send. Is the host awake?'); }
|
|
117
225
|
return;
|
|
118
226
|
}
|
|
119
227
|
const tb = ev.target.closest('button[data-triage]');
|
|
120
228
|
if (tb) {
|
|
121
229
|
const topic = tb.dataset.triage;
|
|
122
|
-
const row = state.board.
|
|
230
|
+
const row = state.board.waveBands.find((r) => triageTopic(r) === topic);
|
|
123
231
|
const asks = row.kind === 'lineup'
|
|
124
232
|
? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
|
|
125
233
|
: `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
|
|
@@ -131,12 +239,58 @@ BoardView.part((B) => {
|
|
|
131
239
|
finally { triaging.delete(topic); B.render(); }
|
|
132
240
|
return;
|
|
133
241
|
}
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
242
|
+
const show = ev.target.closest('a[data-band-show]');
|
|
243
|
+
if (show) {
|
|
244
|
+
ev.preventDefault();
|
|
245
|
+
if (!openBand({ slug: show.dataset.bandShow })) return;
|
|
246
|
+
const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
|
|
247
|
+
B.render();
|
|
248
|
+
$(`.wb[data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// A band's ⋯ menu and its moves, its head's fold and its name's goal popover. A long-press on the name (touch only;
|
|
253
|
+
// a mouse has the tooltip) opens the popover, and the click that ends the press neither folds the band nor closes it.
|
|
254
|
+
let press = null;
|
|
255
|
+
let pressed = false;
|
|
256
|
+
const endPress = () => { press?.(); press = null; };
|
|
257
|
+
B.on(B.root, 'pointerdown', (ev) => {
|
|
258
|
+
pressed = false;
|
|
259
|
+
const name = ev.target.closest?.('[data-goal]');
|
|
260
|
+
if (!name || ev.pointerType === 'mouse') return;
|
|
261
|
+
endPress();
|
|
262
|
+
press = B.later(450, () => { press = null; pressed = true; goalShown = name.dataset.goal; B.render(); });
|
|
263
|
+
});
|
|
264
|
+
for (const type of ['pointerup', 'pointercancel']) B.on(B.root, type, endPress);
|
|
265
|
+
B.on(B.root, 'contextmenu', (ev) => { if (ev.target.closest?.('[data-goal]')) ev.preventDefault(); });
|
|
266
|
+
B.on(B.root, 'click', async (ev) => {
|
|
267
|
+
if (pressed) { pressed = false; ev.preventDefault(); return; }
|
|
268
|
+
if (goalShown) { goalShown = null; B.render(); if (ev.target.closest('.wb-head')) return; }
|
|
269
|
+
const dots = ev.target.closest('button[data-band-menu]');
|
|
270
|
+
if (dots) { ev.preventDefault(); menu = menu === dots.dataset.bandMenu ? null : dots.dataset.bandMenu; B.render(); return; }
|
|
271
|
+
const mv = ev.target.closest('button[data-band-move]');
|
|
272
|
+
if (mv) {
|
|
273
|
+
ev.preventDefault();
|
|
274
|
+
if (mv.disabled) return;
|
|
275
|
+
const { band, key } = mv.dataset;
|
|
276
|
+
try { await waitOn(mv, B.send('lineup-move', { lineup: band, key, by: Number(mv.dataset.bandMove) }), { group: mv.parentNode.querySelectorAll('button') }); }
|
|
277
|
+
catch { B.toast('Could not move it. Is the host awake?'); }
|
|
278
|
+
menu = null;
|
|
279
|
+
B.render();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (menu && !ev.target.closest('.wb-menu')) { menu = null; B.render(); if (ev.target.closest('summary')) { ev.preventDefault(); return; } }
|
|
283
|
+
const head = ev.target.closest('[data-band-toggle]');
|
|
284
|
+
if (head && !ev.target.closest('a, button')) {
|
|
285
|
+
const slug = head.dataset.bandToggle;
|
|
286
|
+
store.set(bandStore(slug), head.getAttribute('aria-expanded') !== 'true');
|
|
287
|
+
B.render();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
B.on(B.root, 'keydown', (ev) => {
|
|
291
|
+
const head = ev.target.closest?.('[data-band-toggle]');
|
|
292
|
+
if (head && ev.target === head && (ev.key === 'Enter' || ev.key === ' ')) { ev.preventDefault(); head.click(); }
|
|
139
293
|
});
|
|
140
294
|
|
|
141
|
-
Object.assign(B, { FOLDS, lanesHtml,
|
|
295
|
+
Object.assign(B, { FOLDS, lanesHtml, openReview, openBand });
|
|
142
296
|
});
|
|
@@ -23,7 +23,8 @@ BoardView.part((B) => {
|
|
|
23
23
|
w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
|
|
24
24
|
<ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
|
|
25
25
|
: line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
|
|
26
|
-
: line.
|
|
26
|
+
: line.review ? `<li><a href="${esc(line.url)}" data-review-open="${line.review}">${esc(line.title)}</a> <span class="muted small">· review held for you</span></li>`
|
|
27
|
+
: line.band ? `<li><a href="#" data-band-show="${esc(line.band)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
|
|
27
28
|
: `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
|
|
2
|
-
// board lands, flashing the lines the fresh board changed (docs/adr/
|
|
3
|
-
// the sign on a card that changed lanes or was updated (docs/adr/
|
|
2
|
+
// board lands, flashing the lines the fresh board changed (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md), and
|
|
3
|
+
// the sign on a card that changed lanes or was updated (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md).
|
|
4
4
|
// Needs app-core.js and ui-snapshot.js; app.js paints it.
|
|
5
5
|
BoardView.part((B) => {
|
|
6
6
|
const { $$, state } = B;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
|
|
2
2
|
BoardView.part((B) => {
|
|
3
3
|
const { $, store, state, toast, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
|
|
4
|
+
const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, protoNotice, doneButton, waitOn } = BoardUI;
|
|
5
5
|
|
|
6
6
|
const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
|
|
7
7
|
|
|
@@ -54,13 +54,14 @@ BoardView.part((B) => {
|
|
|
54
54
|
// The reply form is built once per card, so a refresh never drops what is being typed.
|
|
55
55
|
if (!el.querySelector('.msgs')) {
|
|
56
56
|
const draft = store.get('draft:' + key, '');
|
|
57
|
-
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"
|
|
57
|
+
el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>`)}
|
|
58
58
|
<div class="pending-files" data-files></div>
|
|
59
59
|
<div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
|
|
60
60
|
BoardAttach.paint('item:' + key, B.site.root);
|
|
61
61
|
}
|
|
62
62
|
syncDone(el, key);
|
|
63
63
|
B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
|
|
64
|
+
for (const id of edits.keys()) BoardAttach.paint('edit:' + id, B.site.root);
|
|
64
65
|
B.flashChanged?.(el);
|
|
65
66
|
// A thread's request is answered by a later thread line, so only a pending one changes the eye.
|
|
66
67
|
const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
|
|
@@ -105,10 +106,8 @@ BoardView.part((B) => {
|
|
|
105
106
|
const b = ev.target.closest('form[data-reply] button[data-quick]');
|
|
106
107
|
if (!b) return;
|
|
107
108
|
const key = b.closest('form').dataset.reply;
|
|
108
|
-
b.
|
|
109
|
-
try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
|
|
109
|
+
try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
|
|
110
110
|
catch { toast('Could not send'); }
|
|
111
|
-
b.disabled = false;
|
|
112
111
|
});
|
|
113
112
|
B.on(B.root, 'click', async (ev) => {
|
|
114
113
|
const b = ev.target.closest('form[data-reply] button[data-proto]');
|
|
@@ -121,17 +120,17 @@ BoardView.part((B) => {
|
|
|
121
120
|
const n = protoNotice(pending, latest);
|
|
122
121
|
if (!n.bump) { B.notice(n.text); return; }
|
|
123
122
|
if (!(await B.confirm(n.text))) return;
|
|
124
|
-
await B.bumpAsk(pending);
|
|
123
|
+
await B.bumpAsk(pending, b);
|
|
125
124
|
return;
|
|
126
125
|
}
|
|
127
126
|
if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
|
|
128
|
-
b.disabled = true;
|
|
129
127
|
try {
|
|
130
|
-
await B.sentHere(key, () => B.send('prototype-request', { key }));
|
|
128
|
+
await waitOn(b, B.sentHere(key, () => B.send('prototype-request', { key })));
|
|
129
|
+
b.disabled = true;
|
|
131
130
|
b.title = b.ariaLabel = 'Prototypes asked for';
|
|
132
131
|
toast('Asked Claude for prototypes');
|
|
133
132
|
loadThread(key, { force: true });
|
|
134
|
-
} catch {
|
|
133
|
+
} catch { toast('Could not send'); }
|
|
135
134
|
});
|
|
136
135
|
B.on(B.root, 'submit', async (ev) => {
|
|
137
136
|
const f = ev.target.closest('form[data-reply]');
|
|
@@ -144,14 +143,12 @@ BoardView.part((B) => {
|
|
|
144
143
|
const files = BoardAttach.files(id, B.site.root);
|
|
145
144
|
if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
|
|
146
145
|
const key = f.dataset.reply;
|
|
147
|
-
const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
|
|
148
146
|
try {
|
|
149
|
-
await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
150
|
-
() => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) })));
|
|
147
|
+
await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
|
|
148
|
+
() => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) }))));
|
|
151
149
|
BoardAttach.clear(id, B.site.root);
|
|
152
|
-
btn.disabled = false;
|
|
153
150
|
loadThread(f.dataset.reply, { force: true });
|
|
154
|
-
} catch { B.notice('Could not send. Is the host awake?');
|
|
151
|
+
} catch { B.notice('Could not send. Is the host awake?'); }
|
|
155
152
|
});
|
|
156
153
|
// Send waits while a composer's files upload, the ask's included (attach.js attach-change).
|
|
157
154
|
B.on(B.root, 'attach-change', (ev) => {
|
package/board/public/app.html
CHANGED
|
@@ -41,7 +41,9 @@ window.BoardBoot = (function () {
|
|
|
41
41
|
<script src="/_hub/app/ui.js"></script>
|
|
42
42
|
<script src="/_hub/app/ui-links.js"></script>
|
|
43
43
|
<script src="/_hub/app/ui-enums.js"></script>
|
|
44
|
+
<script src="/_hub/app/ui-phase-bars.js"></script>
|
|
44
45
|
<script src="/_hub/app/ui-order.js"></script>
|
|
46
|
+
<script src="/_hub/app/ui-patch.js"></script>
|
|
45
47
|
<script src="/_hub/app/ui-threads.js"></script>
|
|
46
48
|
<script src="/_hub/app/ui-cards.js"></script>
|
|
47
49
|
<script src="/_hub/app/ui-header.js"></script>
|
|
@@ -77,7 +79,6 @@ window.BoardBoot = (function () {
|
|
|
77
79
|
<script src="/_hub/app/view-notes.js"></script>
|
|
78
80
|
<script src="/_hub/app/view-hub.js"></script>
|
|
79
81
|
<script src="/_hub/app/view-hub-settings.js"></script>
|
|
80
|
-
<script src="/_hub/app/view-polling.js"></script>
|
|
81
82
|
<script src="/_hub/app/view-frame.js"></script>
|
|
82
83
|
<script src="/_hub/app/router.js"></script>
|
|
83
84
|
<script src="/_hub/app/shell.js"></script>
|