@lovek4d/kickflip 0.6.0 → 0.6.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 +15 -11
- package/SKILL.md +7 -7
- package/bin/doctor.mjs +5 -4
- package/bin/install.mjs +3 -3
- package/board/bin/board.mjs +26 -12
- package/board/domain.mjs +7 -7
- package/board/headless.mjs +9 -9
- package/board/public/app-cards.js +23 -44
- package/board/public/app-lanes.js +4 -7
- package/board/public/app-threads.js +12 -8
- package/board/public/prototype-switcher.js +7 -2
- package/board/public/shell.js +4 -3
- package/board/public/sidebar.css +5 -0
- package/board/public/sidebar.js +3 -0
- package/board/public/style.css +11 -19
- package/board/public/ui-cards.js +21 -15
- package/board/public/ui-enums.js +2 -2
- package/board/public/ui-header.js +4 -6
- package/board/public/ui-hub.js +7 -4
- package/board/public/ui-order.js +1 -1
- package/board/public/ui-page-header.js +1 -1
- package/board/public/ui-sidebar.js +11 -5
- package/board/public/ui-snapshot.js +1 -1
- package/board/public/ui.js +3 -2
- package/board/public/view-hub.js +3 -1
- package/board/public/view-questions.js +1 -1
- package/board/server.mjs +20 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +27 -25
- package/board/transcript.mjs +2 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
- package/hub/front-door.mjs +1 -1
- package/hub/hub.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/usage.mjs +3 -3
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -2
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -269,7 +269,6 @@ BoardView.part((B) => {
|
|
|
269
269
|
const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
|
|
270
270
|
const CONFIRM = {
|
|
271
271
|
resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
|
|
272
|
-
nudge: () => 'Nudge Claude to check this implementer?',
|
|
273
272
|
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
|
|
274
273
|
merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
|
|
275
274
|
: openPRs(it).length > 1
|
|
@@ -301,21 +300,16 @@ BoardView.part((B) => {
|
|
|
301
300
|
// (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
|
|
302
301
|
const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
|
|
303
302
|
// A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
|
|
304
|
-
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${
|
|
303
|
+
return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${mergeButton(it)}`)}</div>`;
|
|
305
304
|
}
|
|
306
305
|
|
|
307
306
|
// board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
307
|
+
// Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
|
|
308
|
+
// (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
|
|
308
309
|
const ACTION_BUTTONS = {
|
|
309
|
-
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">
|
|
310
|
+
[BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
|
|
310
311
|
};
|
|
311
312
|
const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
|
|
312
|
-
// docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
|
|
313
|
-
// On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
|
|
314
|
-
// the viewer on the latest round, solid orange while a round waits on the owner's pick.
|
|
315
|
-
const prototypesButton = (it) => {
|
|
316
|
-
const latest = it.prototypes?.rounds.at(-1);
|
|
317
|
-
return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
|
|
318
|
-
};
|
|
319
313
|
|
|
320
314
|
// The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
|
|
321
315
|
const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
|
|
@@ -476,7 +470,6 @@ BoardView.part((B) => {
|
|
|
476
470
|
// A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
|
|
477
471
|
async function confirmTap(b) {
|
|
478
472
|
const { key, confirm: act } = b.dataset;
|
|
479
|
-
if (b.classList.contains('nudging')) return;
|
|
480
473
|
const it = state.board.items.find((i) => i.key === key);
|
|
481
474
|
if (it && act === 'hold') { openHold(it); return; }
|
|
482
475
|
if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
|
|
@@ -484,40 +477,26 @@ BoardView.part((B) => {
|
|
|
484
477
|
// server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
|
|
485
478
|
const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
|
|
486
479
|
finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
500
|
-
try {
|
|
501
|
-
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
502
|
-
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
503
|
-
} catch (e) {
|
|
504
|
-
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
505
|
-
B.failed(e);
|
|
506
|
-
}
|
|
507
|
-
if (act === 'finish') B.syncSends(key);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
// A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
|
|
511
|
-
// changed, the builder no longer stalled or the card gone (style.css .nudging).
|
|
512
|
-
const id = `${key}:nudge`;
|
|
513
|
-
const { progress, at } = it.builder;
|
|
514
|
-
BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
|
|
515
|
-
B.render();
|
|
480
|
+
// Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
|
|
481
|
+
// anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
|
|
482
|
+
const close = act === 'finish' ? () => {
|
|
483
|
+
const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
|
|
484
|
+
if (card) BoardUI.closeDetails(card);
|
|
485
|
+
state.openItems.delete(key);
|
|
486
|
+
store.set('openItems', [...state.openItems]);
|
|
487
|
+
} : null;
|
|
488
|
+
// docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
|
|
489
|
+
// In Review, or no longer merging there, which is its refusal.
|
|
490
|
+
const pend = act === 'merge' ? `${key}:merge` : null;
|
|
491
|
+
if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
|
|
516
492
|
try {
|
|
517
|
-
const out = await B.sentHere(key, () => B.send(
|
|
518
|
-
BoardUI.pending.answered(
|
|
519
|
-
} catch
|
|
520
|
-
|
|
493
|
+
const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
|
|
494
|
+
if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
|
|
495
|
+
} catch (e) {
|
|
496
|
+
if (pend) { BoardUI.pending.drop(pend); B.render(); }
|
|
497
|
+
B.failed(e);
|
|
498
|
+
}
|
|
499
|
+
if (act === 'finish') B.syncSends(key);
|
|
521
500
|
}
|
|
522
501
|
|
|
523
502
|
Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
|
|
@@ -6,7 +6,6 @@ BoardView.part((B) => {
|
|
|
6
6
|
const { esc, escText, waitOn } = BoardUI;
|
|
7
7
|
const LANES = [
|
|
8
8
|
{ id: 'review', name: 'In Review', open: true },
|
|
9
|
-
{ id: 'stale', name: 'Stale', open: true, hideEmpty: true },
|
|
10
9
|
{ id: 'progress', name: 'In Progress', open: true },
|
|
11
10
|
{ id: 'todo', name: 'Todo', open: true },
|
|
12
11
|
{ id: 'done', name: 'Done', open: false, capped: true },
|
|
@@ -93,7 +92,6 @@ BoardView.part((B) => {
|
|
|
93
92
|
return reviewsHtml(d) + LANES.map((lane) => {
|
|
94
93
|
// Items arrive in each lane's fixed order (domain.mjs laneOrder).
|
|
95
94
|
const items = shown.filter((i) => i.lane === lane.id);
|
|
96
|
-
if (lane.hideEmpty && !items.length) return '';
|
|
97
95
|
const expanded = laneState[lane.id] ?? lane.open;
|
|
98
96
|
const open = items.filter((i) => !i.fold);
|
|
99
97
|
const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
|
|
@@ -108,19 +106,18 @@ BoardView.part((B) => {
|
|
|
108
106
|
// docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
|
|
109
107
|
// Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
|
|
110
108
|
// band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
|
|
111
|
-
// counts its wave's cards in Todo, In Progress
|
|
109
|
+
// counts its wave's cards in Todo, In Progress, In Review and Done as x/y/k/z, from every lane, with
|
|
112
110
|
// the lane's own number bold in that lane's colour (style.css: review blue, progress orange, Todo and Done gray) and a tooltip that spells the counts out.
|
|
113
111
|
// A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
|
|
114
112
|
let countsShown = null;
|
|
115
113
|
const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
|
|
116
114
|
function waveCounts(band, laneId) {
|
|
117
|
-
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane
|
|
115
|
+
const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane), new Map());
|
|
118
116
|
const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
|
|
119
|
-
const here = laneId === 'stale' ? 'progress' : laneId;
|
|
120
117
|
const id = `${laneId}/${band.slug}`;
|
|
121
118
|
const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
|
|
122
119
|
const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
|
|
123
|
-
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane ===
|
|
120
|
+
return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === laneId ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
|
|
124
121
|
}
|
|
125
122
|
function waveGroups(laneId, cards) {
|
|
126
123
|
const bands = state.board.waveBands || [];
|
|
@@ -142,7 +139,7 @@ BoardView.part((B) => {
|
|
|
142
139
|
let goalShown = null;
|
|
143
140
|
const bandStore = (slug) => FOLDS[`band:${slug}`].store;
|
|
144
141
|
const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
|
|
145
|
-
const LANE_WORDS = { progress: 'in progress', review: 'in review'
|
|
142
|
+
const LANE_WORDS = { progress: 'in progress', review: 'in review' };
|
|
146
143
|
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>';
|
|
147
144
|
|
|
148
145
|
// Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
|
|
@@ -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, quieted, sendDraft, links } = B;
|
|
4
|
-
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
|
|
4
|
+
const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine, BUTTON } = BoardUI;
|
|
5
5
|
|
|
6
6
|
const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
|
|
7
7
|
// A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
|
|
@@ -16,14 +16,18 @@ BoardView.part((B) => {
|
|
|
16
16
|
// A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
|
|
17
17
|
// action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
|
|
18
18
|
// (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
|
|
19
|
-
// onto its own line under the row.
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
19
|
+
// onto its own line under the row. Send or Reply is orange while a message clears the card's Action needed, as on a
|
|
20
|
+
// card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
|
|
21
|
+
const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
|
|
22
|
+
const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
|
|
23
|
+
: item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
|
|
23
24
|
const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
|
|
24
|
-
const sends = (kind, item) =>
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const sends = (kind, item) => {
|
|
26
|
+
const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
|
|
27
|
+
return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
|
|
28
|
+
: kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
|
|
29
|
+
: pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
|
|
30
|
+
};
|
|
27
31
|
// The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
|
|
28
32
|
function syncSends(el, key) {
|
|
29
33
|
const at = el.querySelector('form [data-sends]');
|
|
@@ -122,7 +122,12 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
122
122
|
.ps-switch .ps-text[hidden] { display:none; }
|
|
123
123
|
.ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
|
|
124
124
|
.ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
|
|
125
|
-
.ps-note[hidden] { display:none; }
|
|
125
|
+
.ps-note[hidden] { display:none; }
|
|
126
|
+
@media (min-width: 900px) {
|
|
127
|
+
.ps-pill { width:min(640px, 100%); }
|
|
128
|
+
.ps-switch .ps-text { width:min(640px, 100%); }
|
|
129
|
+
.ps-states { flex-wrap:wrap; overflow-x:visible; }
|
|
130
|
+
}`;
|
|
126
131
|
|
|
127
132
|
const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
|
|
128
133
|
|
|
@@ -165,7 +170,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
|
|
|
165
170
|
function paint() {
|
|
166
171
|
const now = state();
|
|
167
172
|
pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
|
|
168
|
-
text.hidden = !(now.kind === 'pick' || now.kind === 'change');
|
|
173
|
+
text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
|
|
169
174
|
}
|
|
170
175
|
function go(next) {
|
|
171
176
|
take = next;
|
package/board/public/shell.js
CHANGED
|
@@ -35,6 +35,10 @@ window.AppShell = (function () {
|
|
|
35
35
|
channel.port1.onmessage = done;
|
|
36
36
|
try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
|
|
37
37
|
});
|
|
38
|
+
// Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
|
|
39
|
+
// (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
|
|
40
|
+
history.scrollRestoration = 'manual';
|
|
41
|
+
addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
|
|
38
42
|
setTimeout(() => location.reload(), 0);
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -44,9 +48,6 @@ window.AppShell = (function () {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
async function start() {
|
|
47
|
-
// The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
|
|
48
|
-
// offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
|
|
49
|
-
history.scrollRestoration = 'manual';
|
|
50
51
|
// A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
|
|
51
52
|
if (outer()?.open(location.href, { frames: false })) return;
|
|
52
53
|
// The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
|
package/board/public/sidebar.css
CHANGED
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
42
42
|
html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
43
43
|
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
|
|
44
|
+
/* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
|
|
45
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
|
|
46
|
+
html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
|
|
47
|
+
content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
|
|
48
|
+
html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
|
|
44
49
|
.sidebar .sb-text { flex: 1; min-width: 0; }
|
|
45
50
|
.sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
46
51
|
.sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
|
package/board/public/sidebar.js
CHANGED
|
@@ -102,6 +102,9 @@
|
|
|
102
102
|
// A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
|
|
103
103
|
function paint() {
|
|
104
104
|
html.classList.toggle('sb-update', Boolean(update));
|
|
105
|
+
const dot = globalThis.BoardUI?.hubDot?.(data);
|
|
106
|
+
html.classList.toggle('sb-hub-busy', dot === 'busy');
|
|
107
|
+
html.classList.toggle('sb-hub-down', dot === 'down');
|
|
105
108
|
if (!data) return;
|
|
106
109
|
const scroll = aside.querySelector('.sb-scroll');
|
|
107
110
|
const top = painted ? scroll?.scrollTop : undefined;
|
package/board/public/style.css
CHANGED
|
@@ -86,6 +86,9 @@ button.chip { border: 0; }
|
|
|
86
86
|
.card-blocked, .card-action { color: var(--color-notice); }
|
|
87
87
|
.card-ready { color: var(--color-success); }
|
|
88
88
|
.card-staging { color: var(--color-notice); }
|
|
89
|
+
/* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
|
|
90
|
+
.card-stale { color: var(--muted); }
|
|
91
|
+
.card-stalled { color: var(--color-danger); }
|
|
89
92
|
.sub.next.idle { color: var(--muted); }
|
|
90
93
|
.sub.refused { color: var(--color-danger); }
|
|
91
94
|
.toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
|
|
@@ -197,7 +200,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
197
200
|
.lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
|
|
198
201
|
.lane-github .late { color: var(--color-notice); }
|
|
199
202
|
.msg.other { background: transparent; border: 1px solid var(--line); }
|
|
200
|
-
.status.stale { border-color: var(--color-danger); background: conic-gradient(var(--color-danger) 0 75%, transparent 75% 100%); }
|
|
201
203
|
.msg .reply, .msg .small, .rep, .sent { overflow-wrap: anywhere; }
|
|
202
204
|
code { overflow-wrap: anywhere; }
|
|
203
205
|
/* iOS Safari zooms the page when a field under 16px takes focus, which shifts the page width. */
|
|
@@ -376,20 +378,6 @@ button.attach:disabled { opacity: .35; }
|
|
|
376
378
|
@keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--bar-fill) 35%, transparent); } }
|
|
377
379
|
/* Every pulse beats on one clock (ui-progress-bar.js onePulse), so a card's running bar and its status dot blink together. */
|
|
378
380
|
@media (prefers-reduced-motion: no-preference) { .phase-bars i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
|
|
379
|
-
/* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
|
|
380
|
-
/* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
|
|
381
|
-
shorthand stays out so .sub's 12px wins. It sets no width: a button is its own block formatting context, so a full-width one
|
|
382
|
-
drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
|
|
383
|
-
button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
|
|
384
|
-
font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
|
|
385
|
-
button.sub.agent.stalled { color: var(--color-danger); }
|
|
386
|
-
/* A tap on it calms the line to the in-progress orange, its dot sending a ripple, until the card's next status line (app-cards.js nudge). */
|
|
387
|
-
button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
|
|
388
|
-
.nudging .working-dot { position: relative; }
|
|
389
|
-
.nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
|
|
390
|
-
@keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
|
|
391
|
-
@media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
|
|
392
|
-
.stalled .working-dot { background: currentColor; }
|
|
393
381
|
.brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
|
|
394
382
|
.brand .hub { grid-row: 1 / span 2; align-self: center; }
|
|
395
383
|
/* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
|
|
@@ -417,6 +405,9 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
|
|
|
417
405
|
has picked up are grey, after the lane colours so they win. */
|
|
418
406
|
.sub.agent.quiet, .sub.asked { color: var(--muted); }
|
|
419
407
|
.sub.agent.waiting-gray { color: var(--muted); }
|
|
408
|
+
/* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
|
|
409
|
+
.sub.agent.stale { color: var(--muted); }
|
|
410
|
+
.sub.agent.stale .working-dot { animation: none; }
|
|
420
411
|
/* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
|
|
421
412
|
.paused { color: var(--muted); font-weight: 600; }
|
|
422
413
|
.phase-bars.in-todo { --bar-fill: var(--muted); }
|
|
@@ -521,7 +512,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
521
512
|
}
|
|
522
513
|
@keyframes refresh-spin { to { transform: rotate(360deg); } }
|
|
523
514
|
/* INT-55: the header's right group (the time then ↻, settings) sits one gap apart; each control paints disabled until app-header.js wires it */
|
|
524
|
-
.top .nav { gap:
|
|
515
|
+
.top .nav { gap: 2px; }
|
|
525
516
|
.top .nav .refresh { flex-direction: row-reverse; gap: 2px; }
|
|
526
517
|
.top .nav #refresh, .top .nav a.icon { display: inline-flex; align-items: center; justify-content: center; margin: 0; padding: 4px; border: 0; line-height: 1; }
|
|
527
518
|
.top .nav #status { line-height: 1.1; white-space: nowrap; }
|
|
@@ -633,12 +624,12 @@ main > * { transition: opacity .25s ease; }
|
|
|
633
624
|
.view-questions .q { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 12px 0; }
|
|
634
625
|
.view-questions .q.urgent { border-color: var(--color-notice); }
|
|
635
626
|
/* docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
|
|
636
|
-
A requested question takes an accent tint
|
|
637
|
-
|
|
627
|
+
A requested question takes an accent tint, with its border drawn inside the card as well, so the card keeps its size.
|
|
628
|
+
A resolved question has no border or tint of its own; its Resolved badge marks it
|
|
629
|
+
(docs/adr/2026-11-06-1200-a-resolved-question-drops-its-green-border-and-tint.md). */
|
|
638
630
|
.view-questions .q.pr-busy { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
639
631
|
.view-questions .q.pr-busy .opt { opacity: .55; cursor: default; }
|
|
640
632
|
.view-questions .q.pr-busy .opt:has(input:checked) { opacity: 1; }
|
|
641
|
-
.view-questions .q.resolved { border-color: var(--color-success); background: color-mix(in srgb, var(--color-success) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--color-success); }
|
|
642
633
|
.view-questions .q.resolved .sent-line { margin: 6px 0; font-size: 13px; color: var(--color-success); }
|
|
643
634
|
.view-questions .q h3 { font-size: 15px; margin: 6px 0 4px; line-height: 1.3; }
|
|
644
635
|
.view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
|
|
@@ -953,6 +944,7 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
953
944
|
.needs-deck { display: none; }
|
|
954
945
|
/* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
|
|
955
946
|
.top .pages-switch { display: none; }
|
|
947
|
+
.top .nav a.pages-switch { display: none; }
|
|
956
948
|
/* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
|
|
957
949
|
.view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
|
|
958
950
|
body.has-sidebar { --sidebar-w: 272px; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
[PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
|
|
12
12
|
[PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
|
|
13
13
|
};
|
|
14
|
+
// docs/adr/2026-11-06-1547-a-cards-pr-badges-stay-once-added.md: a PR no longer open keeps its CI, Codex and reviewer
|
|
15
|
+
// chips at their last state, gray.
|
|
16
|
+
const keeps = (pr) => pr?.status === PR_STATE.merged || pr?.status === PR_STATE.closed;
|
|
14
17
|
// An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
|
|
15
18
|
const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
|
|
16
19
|
const prChips = (it) => (it.prs || []).map(prChip).join('');
|
|
@@ -20,8 +23,9 @@
|
|
|
20
23
|
const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
|
|
21
24
|
const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
|
|
22
25
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
23
|
-
const
|
|
24
|
-
const
|
|
26
|
+
const live = pr.state === 'OPEN';
|
|
27
|
+
const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
28
|
+
const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
|
|
25
29
|
const name = prName(pr);
|
|
26
30
|
const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
|
|
27
31
|
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
@@ -39,12 +43,13 @@
|
|
|
39
43
|
return days >= 1 ? ` ${days}d` : '';
|
|
40
44
|
};
|
|
41
45
|
function reviewChips(it) {
|
|
42
|
-
|
|
46
|
+
const live = it.pr?.state === 'OPEN';
|
|
47
|
+
if (!live && !keeps(it.pr)) return '';
|
|
43
48
|
return (it.reviewers || []).map((r) => {
|
|
44
49
|
const rv = globalThis.BoardUI.record(it.review, r);
|
|
45
50
|
const s = REVIEW[rv?.state || 'unsent'];
|
|
46
|
-
const age = reviewAge(rv).trim();
|
|
47
|
-
return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
51
|
+
const age = live ? reviewAge(rv).trim() : '';
|
|
52
|
+
return badge(live ? s.tone : 'quiet', `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
|
|
48
53
|
}).join('');
|
|
49
54
|
}
|
|
50
55
|
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
@@ -194,7 +199,7 @@
|
|
|
194
199
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
195
200
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
196
201
|
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
197
|
-
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.
|
|
202
|
+
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
198
203
|
}
|
|
199
204
|
const dim = !it.builder || asking;
|
|
200
205
|
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
@@ -221,16 +226,15 @@
|
|
|
221
226
|
const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
|
|
222
227
|
return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
|
|
223
228
|
}
|
|
224
|
-
//
|
|
225
|
-
// A
|
|
229
|
+
// docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md
|
|
230
|
+
// A stale build's status line is grey, its dot still, since the orchestrator is already told; a quiet one is grey with
|
|
231
|
+
// no dot, and one the orchestrator stopped plain.
|
|
226
232
|
function builderStatus(it) {
|
|
227
233
|
const { quiet } = it.builder;
|
|
228
|
-
const
|
|
234
|
+
const stale = it.builder.stale && !it.builder.stopped && it.lane !== 'done';
|
|
235
|
+
const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}${stale ? ' stale' : ''}`;
|
|
229
236
|
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
230
|
-
|
|
231
|
-
if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
|
|
232
|
-
const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
|
|
233
|
-
return `<button type="button" class="${classes} stalled${nudging}" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
|
|
237
|
+
return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
|
|
234
238
|
}
|
|
235
239
|
// The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
|
|
236
240
|
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
@@ -264,14 +268,16 @@
|
|
|
264
268
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
265
269
|
}
|
|
266
270
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
267
|
-
// staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md),
|
|
271
|
+
// staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), stalled in red or stale in grey
|
|
272
|
+
// (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
268
273
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
269
274
|
// docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
270
275
|
const SEP = '<span class="sep"> · </span>';
|
|
271
276
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
272
277
|
function timeLine(it, unread, bars) {
|
|
273
278
|
const iso = it.sortAt;
|
|
274
|
-
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>'
|
|
279
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>',
|
|
280
|
+
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && '<span class="card-stale">stale</span>'].filter(Boolean);
|
|
275
281
|
const news = newWords(unread, 'message');
|
|
276
282
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
277
283
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
package/board/public/ui-enums.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
|
|
4
4
|
// since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
|
|
5
5
|
(function () {
|
|
6
|
-
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge' });
|
|
7
|
-
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge', reply: 'reply' });
|
|
7
|
+
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
10
|
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
// The board header's time and the In Review header's GitHub line, with when a refresh asks for a check. Needs ui.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { since, esc } = globalThis.BoardUI;
|
|
4
|
-
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age
|
|
4
|
+
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
|
|
5
5
|
// the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
|
|
6
6
|
// /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
|
|
7
|
-
// (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy
|
|
8
|
-
// docs/adr/2026-11-06-
|
|
7
|
+
// (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy host doesn't change it
|
|
8
|
+
// at all (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
9
9
|
// link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
|
|
10
10
|
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
|
|
11
11
|
const paused = pause?.state === 'paused';
|
|
12
|
-
const busy = pause?.machine?.state === 'busy';
|
|
13
12
|
const down = link === 'offline' || watcher === 'stopped';
|
|
14
13
|
const reason = link === 'offline' ? "Offline: can't reach the host"
|
|
15
14
|
: watcher === 'stopped' ? 'Claude not listening'
|
|
16
15
|
: paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
|
|
17
|
-
: busy ? 'This Mac is busy: new work waits until it eases'
|
|
18
16
|
: link === 'reconnecting' ? 'Reconnecting…'
|
|
19
17
|
: watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
|
|
20
18
|
: watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
|
|
21
19
|
if (!at && !reason) return '';
|
|
22
20
|
const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
|
|
23
|
-
return `<span class="fresh${down ? ' down' : paused
|
|
21
|
+
return `<span class="fresh${down ? ' down' : paused ? ' hold' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
|
|
24
22
|
};
|
|
25
23
|
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
26
24
|
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
package/board/public/ui-hub.js
CHANGED
|
@@ -34,14 +34,17 @@
|
|
|
34
34
|
// hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
|
|
35
35
|
const holdBanner = (hold) => (hold ? `<div class="hold-banner"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${TILE_PATHS.moon}</svg><span>${esc(hold.text)}</span>${hold.end ? '<form method="post" action="/_hub/settings/notifications"><button class="btn quiet" name="snooze" value="off">End snooze</button></form>' : ''}</div>` : '');
|
|
36
36
|
|
|
37
|
+
// pause: shared/usage-gate.mjs usageGate's gate. Near the line (its near), the banner says agents start one at a time,
|
|
38
|
+
// with Keep going (docs/adr/2026-11-06-1249-the-pause-line-reads-every-headless-agents-usage.md).
|
|
37
39
|
function pauseBanner(pause) {
|
|
38
|
-
if (!pause || pause.state === 'normal') return '';
|
|
39
|
-
const going = pause.state === 'going';
|
|
40
|
-
const until = BoardUI.usageClock(pause.resets_at);
|
|
40
|
+
if (!pause || (pause.state === 'normal' && !pause.near)) return '';
|
|
41
|
+
const going = pause.state === 'going' || Boolean(pause.state === 'normal' && pause.near.going);
|
|
42
|
+
const until = BoardUI.usageClock(pause.state === 'normal' ? pause.near.resets_at : pause.resets_at);
|
|
41
43
|
const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
|
|
42
44
|
// Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): Resume ends it, which view-hub.js posts.
|
|
43
45
|
if (pause.manual) return `<div class="hold-banner ub-banner ub-paused" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(pause.resets_at === 'forever' ? 'Work paused: you paused the hub until you resume.' : `Work paused: you paused the hub. Resumes at ${until}.`)}</span><form method="post" action="/_hub/settings/hub-pause"><button class="btn quiet" name="pause" value="off">Resume</button></form></div>`;
|
|
44
|
-
const text = going ? `
|
|
46
|
+
const text = pause.state === 'normal' ? going ? `Approaching usage limit: agents start in parallel until ${until}` : 'Approaching usage limit, reducing parallel agents'
|
|
47
|
+
: going ? `Past the buffer: spending usage until ${until}`
|
|
45
48
|
: `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
|
|
46
49
|
return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
|
|
47
50
|
}
|
package/board/public/ui-order.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
|
|
4
4
|
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
5
|
(function () {
|
|
6
|
-
const LANE_ORDER = { review: 0,
|
|
6
|
+
const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
|
|
7
7
|
// Todo lists bumped cards first, the latest bump on top, a bumped New ask included
|
|
8
8
|
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
|
|
9
9
|
// time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
|
|
11
11
|
// The pages switch (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): a bare ? and a bare check, drawn
|
|
12
12
|
// like the bulb and gear.
|
|
13
|
-
const QUESTION = icon(18, '<path d="
|
|
13
|
+
const QUESTION = icon(18, '<path d="M6.5 8.5a5.5 5.5 0 1 1 8.2 4.8c-1.6.9-2.7 1.8-2.7 3.2V17"/><path d="M12 21h.01"/>');
|
|
14
14
|
const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
|
|
15
15
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
16
16
|
|
|
@@ -196,14 +196,20 @@
|
|
|
196
196
|
// update: null | 'ready' | 'reloading'.
|
|
197
197
|
// now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
|
|
198
198
|
// draws their boards alone: no Hub row, update or notification rows, and no New board.
|
|
199
|
+
// Whether the Hub row reads busy: the host is busy and no usage pause reads first.
|
|
200
|
+
const hubBusy = (data) => data?.pause?.machine?.state === 'busy' && data?.pause?.state !== 'paused';
|
|
201
|
+
// The logo's dot for the hub's state, like the blue update dot: 'down' (red) for a broken hub, 'busy' (orange), else ''.
|
|
202
|
+
const hubDot = (data) => (data?.hub?.tone === 'down' ? 'down' : hubBusy(data) ? 'busy' : '');
|
|
203
|
+
|
|
199
204
|
function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
|
|
200
205
|
const sites = data?.sites || [];
|
|
201
206
|
const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
|
|
202
207
|
const paused = data?.pause?.state === 'paused';
|
|
203
|
-
// A busy
|
|
204
|
-
|
|
208
|
+
// A busy host holds every board's new work but shows only on the Hub row and the logo's dot, never on a board, and a pause reads before it
|
|
209
|
+
// (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
|
|
210
|
+
const busy = hubBusy(data);
|
|
205
211
|
// A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
|
|
206
|
-
const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') :
|
|
212
|
+
const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : '');
|
|
207
213
|
const siteRow = (s) => {
|
|
208
214
|
const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
|
|
209
215
|
return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
|
|
@@ -221,7 +227,7 @@
|
|
|
221
227
|
: 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' })
|
|
222
228
|
: '';
|
|
223
229
|
const usage = sidebarUsage(data?.usage);
|
|
224
|
-
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: '
|
|
230
|
+
const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
|
|
225
231
|
const n = data?.notifications || {};
|
|
226
232
|
const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
|
|
227
233
|
const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
|
|
@@ -249,5 +255,5 @@
|
|
|
249
255
|
+ `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
|
|
250
256
|
}
|
|
251
257
|
|
|
252
|
-
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords });
|
|
258
|
+
Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords, hubDot });
|
|
253
259
|
})();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
|
|
2
2
|
// and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
|
|
3
3
|
(function () {
|
|
4
|
-
const ACTIVE_LANES = ['review', '
|
|
4
|
+
const ACTIVE_LANES = ['review', 'progress', 'todo'];
|
|
5
5
|
|
|
6
6
|
// threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
|
|
7
7
|
// threads of the cards on the board; build is the saving page's (app.html data-build).
|