@lovek4d/kickflip 0.3.1 → 0.5.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 +170 -118
- package/README.md +23 -14
- package/SKILL.md +40 -38
- 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 +213 -74
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +4 -0
- package/board/headless.mjs +4 -0
- package/board/merge3.mjs +1 -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 +39 -103
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +166 -73
- package/board/public/app-core.js +2 -23
- 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-reader.js +174 -0
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +53 -42
- package/board/public/app.html +4 -1
- package/board/public/app.js +78 -96
- 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 +6 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/new-task.js +121 -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 +24 -6
- package/board/public/sidebar.css +22 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +245 -64
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +205 -75
- package/board/public/ui-enums.js +6 -5
- 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 +61 -29
- package/board/public/ui-sidebar.js +54 -24
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +40 -21
- package/board/public/ui.js +116 -18
- package/board/public/view-frame.js +2 -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 +142 -62
- package/board/public/view-settings.js +189 -52
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -12
- package/board/store.mjs +15 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -15
- 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/front-door.mjs +20 -0
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -14
- 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/owner-checks.mjs +1 -0
- 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/owner-check.mjs +3 -0
- package/shared/repos.mjs +1 -1
- package/shared/tailscale.mjs +1 -0
- 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
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "Open the log →"
|
|
2
|
+
// (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
|
+
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
|
+
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
|
+
// A link in it closes it first, then opens through the shell.
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as lines, what Claude says and each
|
|
7
|
+
// tool call, with a raw toggle for the file as it is; any other log reads as its text. It is re-read every 3 seconds
|
|
8
|
+
// until the run's result arrives, and follows new lines while read at its end.
|
|
9
|
+
BoardView.part((B) => {
|
|
10
|
+
const { $, state } = B;
|
|
11
|
+
const { esc, markdown, linkContext } = BoardUI;
|
|
12
|
+
const sheet = $('#readerSheet');
|
|
13
|
+
const body = sheet.querySelector('.reader-body');
|
|
14
|
+
const tools = sheet.querySelector('.reader-tools');
|
|
15
|
+
const more = sheet.querySelector('.reader-new');
|
|
16
|
+
let opened = 0;
|
|
17
|
+
let pushed = false;
|
|
18
|
+
let pending = null;
|
|
19
|
+
let log = null;
|
|
20
|
+
let stopReading = null;
|
|
21
|
+
|
|
22
|
+
// → the log's lines, each a stream event or, for a line that isn't one, its text; null when no line is an event.
|
|
23
|
+
function streamOf(text) {
|
|
24
|
+
const lines = text.split('\n');
|
|
25
|
+
if (lines.at(-1) === '') lines.pop();
|
|
26
|
+
const read = (line) => { try { const e = JSON.parse(line); return typeof e?.type === 'string' ? e : null; } catch { return null; } };
|
|
27
|
+
const events = lines.map(read);
|
|
28
|
+
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
29
|
+
}
|
|
30
|
+
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
+
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
+
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
+
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
+
// result have none; the raw view shows them.
|
|
35
|
+
function rowsOf(e) {
|
|
36
|
+
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
+
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
+
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
+
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
+
const content = e.message.content;
|
|
41
|
+
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
+
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
+
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
+
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
+
}
|
|
46
|
+
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
|
+
|
|
48
|
+
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
|
+
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
50
|
+
function drawTools() {
|
|
51
|
+
if (!log?.stream) { tools.innerHTML = ''; return; }
|
|
52
|
+
const word = log.finished ? 'finished' : log.following ? 'following' : 'paused';
|
|
53
|
+
tools.innerHTML = `<span class="reader-status${word === 'following' ? ' on' : ''}">${word}</span><button type="button" data-reader="raw" aria-pressed="${log.raw}">raw</button>`;
|
|
54
|
+
}
|
|
55
|
+
function drawMore() {
|
|
56
|
+
more.hidden = !log?.unseen;
|
|
57
|
+
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
|
+
}
|
|
59
|
+
function drawLog() {
|
|
60
|
+
const { text, stream, raw } = log;
|
|
61
|
+
body.innerHTML = !text ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
62
|
+
: !stream ? `<pre class="reader-text">${esc(text)}</pre>`
|
|
63
|
+
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
+
: stream.flatMap(rowsOf).map(rowHtml).join('');
|
|
65
|
+
drawTools();
|
|
66
|
+
drawMore();
|
|
67
|
+
}
|
|
68
|
+
function follow() {
|
|
69
|
+
log.following = true;
|
|
70
|
+
log.unseen = 0;
|
|
71
|
+
toEnd();
|
|
72
|
+
drawTools();
|
|
73
|
+
drawMore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readLog(n) {
|
|
77
|
+
let answer;
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
80
|
+
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
|
+
} catch { answer = null; }
|
|
82
|
+
if (n !== opened) return;
|
|
83
|
+
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
+
if (answer && answer.size !== log.size) {
|
|
85
|
+
const before = log.stream?.length ?? 0;
|
|
86
|
+
const stream = streamOf(answer.text);
|
|
87
|
+
Object.assign(log, { size: answer.size, text: answer.text, stream, finished: Boolean(stream?.some((e) => e.type === 'result')) });
|
|
88
|
+
if (!log.following) log.unseen += Math.max(0, (stream?.length ?? 0) - before);
|
|
89
|
+
drawLog();
|
|
90
|
+
if (log.following) toEnd();
|
|
91
|
+
}
|
|
92
|
+
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function show(title, ref) {
|
|
96
|
+
opened++;
|
|
97
|
+
stopReading?.();
|
|
98
|
+
log = null;
|
|
99
|
+
sheet.querySelector('#readerTitle').textContent = title;
|
|
100
|
+
sheet.querySelector('.reader-ref').textContent = ref;
|
|
101
|
+
tools.innerHTML = '';
|
|
102
|
+
more.hidden = true;
|
|
103
|
+
body.innerHTML = '<p class="muted">Loading…</p>';
|
|
104
|
+
sheet.showModal();
|
|
105
|
+
history.pushState({ ...history.state, idx: history.state.idx + 1, reader: true }, '', location.href);
|
|
106
|
+
pushed = true;
|
|
107
|
+
return opened;
|
|
108
|
+
}
|
|
109
|
+
function openLog(file) {
|
|
110
|
+
const n = show(`${file.split('-')[0]} log`, file);
|
|
111
|
+
log = { file, size: -1, text: '', stream: null, raw: false, following: true, unseen: 0, finished: false };
|
|
112
|
+
readLog(n);
|
|
113
|
+
}
|
|
114
|
+
async function openNotes(search) {
|
|
115
|
+
const wave = new URLSearchParams(search).has('wave');
|
|
116
|
+
const n = show('notes', '');
|
|
117
|
+
let html;
|
|
118
|
+
try {
|
|
119
|
+
const r = await fetch(B.site.url(`api/notes${search}`), { cache: 'no-store' });
|
|
120
|
+
const answer = await r.json();
|
|
121
|
+
if (!r.ok) throw new Error(answer.error || `notes ${r.status}`);
|
|
122
|
+
if (n !== opened) return;
|
|
123
|
+
sheet.querySelector('.reader-ref').textContent = `${wave ? answer.title : answer.ref} notes`;
|
|
124
|
+
html = `<article class="plan reader-md">${markdown(answer.markdown, linkContext(state.board?.items || [], answer.key ?? null))}</article>`;
|
|
125
|
+
} catch (e) { html = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`; }
|
|
126
|
+
if (n === opened) body.innerHTML = html;
|
|
127
|
+
}
|
|
128
|
+
function close() {
|
|
129
|
+
if (!sheet.open) return;
|
|
130
|
+
sheet.close();
|
|
131
|
+
opened++;
|
|
132
|
+
stopReading?.();
|
|
133
|
+
if (pushed) { pushed = false; history.back(); }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plainTap = (ev) => !ev.defaultPrevented && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey;
|
|
137
|
+
B.on(B.root, 'click', (ev) => {
|
|
138
|
+
if (!plainTap(ev)) return;
|
|
139
|
+
const a = ev.target.closest('a[href^="log.html?"], a[href^="notes.html?"]');
|
|
140
|
+
if (!a) return;
|
|
141
|
+
ev.preventDefault();
|
|
142
|
+
const href = a.getAttribute('href');
|
|
143
|
+
if (href.startsWith('log.html')) openLog(new URLSearchParams(href.slice(href.indexOf('?'))).get('file'));
|
|
144
|
+
else openNotes(href.slice(href.indexOf('?')));
|
|
145
|
+
});
|
|
146
|
+
B.on(sheet, 'click', (ev) => {
|
|
147
|
+
if (ev.target === sheet) { close(); return; }
|
|
148
|
+
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
|
+
if (act === 'close') close();
|
|
150
|
+
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
151
|
+
else if (act === 'end') follow();
|
|
152
|
+
const a = ev.target.closest('a[href]');
|
|
153
|
+
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
|
+
ev.preventDefault();
|
|
155
|
+
pending = new URL(a.getAttribute('href'), location.href).href;
|
|
156
|
+
close();
|
|
157
|
+
});
|
|
158
|
+
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); close(); });
|
|
159
|
+
B.on(body, 'scroll', () => {
|
|
160
|
+
if (!log?.stream) return;
|
|
161
|
+
log.following = atEnd();
|
|
162
|
+
if (log.following) log.unseen = 0;
|
|
163
|
+
drawTools();
|
|
164
|
+
drawMore();
|
|
165
|
+
});
|
|
166
|
+
// Back while it's open: the router has kept the board, as the entry under it holds the same URL.
|
|
167
|
+
B.on(window, 'popstate', () => {
|
|
168
|
+
if (pushed) { pushed = false; sheet.close(); opened++; stopReading?.(); }
|
|
169
|
+
if (!pending) return;
|
|
170
|
+
const href = pending;
|
|
171
|
+
pending = null;
|
|
172
|
+
if (!AppShell.open(href)) location.assign(href);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -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;
|