@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
package/board/public/ui-cards.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-
|
|
1
|
+
// Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-phase-bars.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
|
|
3
|
+
const { esc, ago, lasted, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
// The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
|
|
6
|
+
// title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
|
|
7
|
+
// badge for each, in its order (docs/adr/2026-11-03-1904-a-card-holds-several-prs-across-repos.md).
|
|
8
|
+
const PR_LOOK = {
|
|
9
|
+
[PR_STATE.open]: { tone: 'quiet', mark: ' ✓', label: 'Open' },
|
|
10
|
+
[PR_STATE.draft]: { tone: 'notice', mark: ' ●', label: 'Draft' },
|
|
11
|
+
[PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
|
|
12
|
+
[PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
|
|
13
|
+
};
|
|
14
|
+
// An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
|
|
15
|
+
const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
|
|
16
|
+
const prChips = (it) => (it.prs || []).map(prChip).join('');
|
|
17
|
+
// A PR's short name, its repo's name and number: kickflip#6.
|
|
18
|
+
const prName = (pr) => `${(pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || ''}#${pr.number}`;
|
|
19
|
+
function prChip(pr) {
|
|
20
|
+
const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
|
|
21
|
+
const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
|
|
11
22
|
const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
|
|
12
23
|
const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
|
|
13
|
-
const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
|
|
14
24
|
const codex = pr.state === 'OPEN' && pr.codexOpen ? badge('notice', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
|
|
15
|
-
const name =
|
|
16
|
-
const label =
|
|
17
|
-
return `${badge(
|
|
25
|
+
const name = prName(pr);
|
|
26
|
+
const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
|
|
27
|
+
return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
|
|
18
28
|
}
|
|
19
29
|
const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
|
|
20
30
|
const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
|
|
@@ -38,9 +48,9 @@
|
|
|
38
48
|
}).join('');
|
|
39
49
|
}
|
|
40
50
|
const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
|
|
41
|
-
// docs/adr/
|
|
51
|
+
// docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md
|
|
42
52
|
// Action needed, last of the time line's orange words, linking to what the owner acts on
|
|
43
|
-
// (docs/adr/
|
|
53
|
+
// (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
|
|
44
54
|
function actionWords(it) {
|
|
45
55
|
if (!it.action) return '';
|
|
46
56
|
const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
|
|
@@ -52,16 +62,16 @@
|
|
|
52
62
|
return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
53
63
|
}
|
|
54
64
|
const tryWords = (kind) => kind === 'app' ? {
|
|
55
|
-
go: '
|
|
56
|
-
current: 'Nothing new to
|
|
57
|
-
busy: '
|
|
58
|
-
autoSub: '
|
|
59
|
-
saved: (on) => on ? 'Saved. Each push
|
|
65
|
+
go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
|
|
66
|
+
current: 'Nothing new to serve: Install already has this card’s latest push.',
|
|
67
|
+
busy: 'Serving now. The badge turns back into Install when the new build is ready.',
|
|
68
|
+
autoSub: 'The hub serves each card after each push, and main after each merge.',
|
|
69
|
+
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
60
70
|
} : {
|
|
61
|
-
go: 'Serve', going: '
|
|
71
|
+
go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
|
|
62
72
|
current: 'Nothing new to serve: Visit already shows this card’s latest push.',
|
|
63
73
|
busy: 'Serving now. The badge turns back into Visit when the new server is up.',
|
|
64
|
-
autoSub: '
|
|
74
|
+
autoSub: 'The hub serves each card after each push, and main after each merge.',
|
|
65
75
|
saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
|
|
66
76
|
};
|
|
67
77
|
const spinner = '<span class="try-spin" aria-hidden="true"></span>';
|
|
@@ -96,7 +106,7 @@
|
|
|
96
106
|
return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
|
|
97
107
|
}
|
|
98
108
|
// A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
|
|
99
|
-
// (docs/adr/
|
|
109
|
+
// (docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md).
|
|
100
110
|
function tryButton(it) {
|
|
101
111
|
const t = tryOf(it);
|
|
102
112
|
if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
|
|
@@ -105,49 +115,114 @@
|
|
|
105
115
|
return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
|
|
106
116
|
}
|
|
107
117
|
const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
108
|
-
|
|
118
|
+
// Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
|
|
119
|
+
// the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
|
|
120
|
+
// server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
|
|
121
|
+
// (`open`). Visit is left alone: it changes only once Main rebuilds.
|
|
122
|
+
function mainPull(pull, { sending = false, open = false } = {}) {
|
|
123
|
+
if (!pull) return '';
|
|
124
|
+
const busy = pull.running || sending;
|
|
125
|
+
const failed = !busy && pull.error;
|
|
126
|
+
const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
|
|
127
|
+
const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
|
|
128
|
+
return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
|
|
129
|
+
}
|
|
130
|
+
function mainBar(main, pulling) {
|
|
109
131
|
if (!main) return '';
|
|
110
132
|
const words = tryWords(main.kind);
|
|
111
133
|
const url = tryURL(main);
|
|
112
134
|
const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
|
|
113
135
|
: main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
|
|
114
136
|
: main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
|
|
115
|
-
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}</div><div class="meta">${chip}</div></div>`;
|
|
137
|
+
return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
|
|
116
138
|
}
|
|
117
|
-
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge,
|
|
118
|
-
//
|
|
119
|
-
|
|
139
|
+
// The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
|
|
140
|
+
// server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
|
|
141
|
+
// card with no PR, and from the tap until a board shows the card left In Review or its merge refused (ui.js pending) (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md,
|
|
142
|
+
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
|
|
143
|
+
function mergeButton(it) {
|
|
120
144
|
if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
|
|
121
|
-
const
|
|
122
|
-
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${
|
|
123
|
-
}
|
|
124
|
-
// The open card's Implement, rightmost in its action row, on
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// docs/adr/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
145
|
+
const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
|
|
146
|
+
return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
|
|
147
|
+
}
|
|
148
|
+
// The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
|
|
149
|
+
// not: it confirms, then
|
|
150
|
+
// sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
|
|
151
|
+
// mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
|
|
152
|
+
// docs/adr/2026-11-03-0219-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
|
|
153
|
+
// Implement is plain until the card is ready, then builder orange (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
154
|
+
function buildButton(it) {
|
|
155
|
+
if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
|
|
156
|
+
return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
|
|
157
|
+
}
|
|
158
|
+
// A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on, and
|
|
159
|
+
// ends with how long ago that line was set.
|
|
160
|
+
const builderLine = (b) => [b.progress || 'preheating', b.at && lasted(b.at)].filter(Boolean).join(' · ');
|
|
161
|
+
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
162
|
+
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
163
|
+
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
137
164
|
const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
166
|
+
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
167
|
+
// What a card's bars show, or null off In Progress and on a card only a planner works on: how many phases it reached,
|
|
168
|
+
// whether the last of them is running, whether it dims because the card waits on something other than a builder (its
|
|
169
|
+
// questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
|
|
170
|
+
// build reached, undimmed
|
|
171
|
+
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
|
|
172
|
+
// A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
|
|
173
|
+
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
|
|
174
|
+
function phaseStrip(it) {
|
|
175
|
+
if (it.lane === 'todo' && it.builder?.planner) {
|
|
176
|
+
const prototype = it.builder.kind === 'prototype';
|
|
177
|
+
return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
|
|
178
|
+
}
|
|
179
|
+
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
180
|
+
const all = BUILD_PHASES.length;
|
|
181
|
+
// Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
|
|
182
|
+
if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
|
|
183
|
+
const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
|
|
184
|
+
const answers = asking ? ', waiting on your answers' : '';
|
|
185
|
+
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
186
|
+
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
187
|
+
const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
188
|
+
return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Preheating, no step yet'}${answers}` };
|
|
189
|
+
}
|
|
190
|
+
const dim = !it.builder || asking;
|
|
191
|
+
if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
192
|
+
const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
|
|
193
|
+
const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
|
|
194
|
+
return { reached, running: false, dim, label: `${label}${answers}` };
|
|
195
|
+
}
|
|
196
|
+
// What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
|
|
197
|
+
// strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
|
|
198
|
+
function phaseMotion(prev, next) {
|
|
199
|
+
const had = typeof prev === 'number';
|
|
200
|
+
const has = typeof next === 'number';
|
|
201
|
+
if (had !== has) return has ? 'enter' : 'leave';
|
|
202
|
+
return has && next > prev ? 'fill' : null;
|
|
203
|
+
}
|
|
204
|
+
const phaseBars = (strip) => bars(strip, BUILD_PHASES);
|
|
205
|
+
// docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
|
|
206
|
+
// Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
|
|
207
|
+
// owner's blue (style.css).
|
|
208
|
+
const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
|
|
209
|
+
const DOT = '<span class="working-dot" aria-hidden="true"></span>';
|
|
210
|
+
// A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
|
|
211
|
+
// review with its dot once the session picks it up, each with how long ago.
|
|
212
|
+
function orchestratorStatus(it) {
|
|
213
|
+
const { since, picked } = it.orchestrator;
|
|
214
|
+
return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
|
|
215
|
+
}
|
|
216
|
+
// A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
|
|
217
|
+
function builderStatus(it) {
|
|
218
|
+
const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}`;
|
|
219
|
+
const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
|
|
220
|
+
if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
|
|
221
|
+
return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
|
|
222
|
+
}
|
|
148
223
|
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
149
224
|
// When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
|
|
150
|
-
// (docs/adr/
|
|
225
|
+
// (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md).
|
|
151
226
|
function heldUntilWords(iso, now = new Date()) {
|
|
152
227
|
const d = new Date(iso);
|
|
153
228
|
const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
@@ -164,48 +239,65 @@
|
|
|
164
239
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
165
240
|
}
|
|
166
241
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
|
|
167
|
-
// or ready, then
|
|
168
|
-
// (docs/adr/
|
|
242
|
+
// or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
243
|
+
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
244
|
+
// 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).
|
|
169
245
|
const SEP = '<span class="sep"> · </span>';
|
|
246
|
+
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
170
247
|
function timeLine(it, unread, bars) {
|
|
171
248
|
const iso = it.sortAt;
|
|
172
249
|
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
|
|
173
|
-
const
|
|
250
|
+
const news = newWords(unread, 'message');
|
|
251
|
+
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
174
252
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
175
253
|
if (!parts.length && !count && !tail) return '';
|
|
176
254
|
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
177
255
|
}
|
|
178
256
|
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
179
257
|
// Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
180
|
-
// durationPicker's markup also uses.
|
|
181
|
-
|
|
182
|
-
|
|
258
|
+
// durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
|
|
259
|
+
// at `now`, here rather than on the server, so the board's version doesn't change with the clock
|
|
260
|
+
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
261
|
+
const holdUntil = (c, now) => (c.ms === null ? 'until you unhold it' : `until ${heldUntilWords(new Date(now.getTime() + c.ms), now)}`);
|
|
262
|
+
function holdPicker(key, choices, now = new Date()) {
|
|
263
|
+
const chips = choices.map((c) => `<button type="button" class="btn quiet" data-hold-for="${esc(key)}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${holdUntil(c, now)}`)}">${esc(c.label)}</button>`).join('');
|
|
183
264
|
return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
|
|
184
265
|
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
185
266
|
}
|
|
186
267
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
187
|
-
// docs/adr/
|
|
268
|
+
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
|
|
188
269
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
270
|
+
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
271
|
+
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
272
|
+
const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
273
|
+
const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
|
|
274
|
+
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
275
|
+
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
276
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
|
|
277
|
+
// In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
|
|
278
|
+
// its bump with the band's ⋯ menu.
|
|
279
|
+
function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
|
|
192
280
|
const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
|
|
193
281
|
// A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const chips = `${
|
|
282
|
+
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
283
|
+
const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
|
|
284
|
+
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
197
285
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
198
|
-
: it.
|
|
199
|
-
: it.
|
|
286
|
+
: it.orchestrator && !it.builder ? orchestratorStatus(it)
|
|
287
|
+
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
288
|
+
: it.builder ? builderStatus(it)
|
|
289
|
+
: it.grilling || it.prototyping ? `<div class="line3 sub agent planner${tone(it)}">${DOT}${it.grilling ? 'preheating' : 'prototyping'}</div>`
|
|
200
290
|
: it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
|
|
201
291
|
: it.working ? `<div class="line3 sub agent">${workingLine}</div>`
|
|
202
|
-
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
|
|
292
|
+
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
|
|
203
293
|
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
|
|
204
|
-
|
|
205
|
-
|
|
294
|
+
// The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
|
|
295
|
+
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
296
|
+
: `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
297
|
+
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
|
|
298
|
+
${status}${check}${eta}
|
|
206
299
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
207
|
-
${
|
|
208
|
-
${timeLine(it, unread, it.lane === 'progress' && it.builder && !it.builder.planner && it.waitsOn !== 'checking' ? phaseBars(it.builder.phase) : '')}`;
|
|
300
|
+
${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
|
|
209
301
|
}
|
|
210
302
|
const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
|
|
211
303
|
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
@@ -214,5 +306,43 @@
|
|
|
214
306
|
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
|
|
215
307
|
}
|
|
216
308
|
|
|
217
|
-
|
|
309
|
+
// docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
|
|
310
|
+
// The Auto Review lane, its needs you in the sidebar's needs-you red: one card per incoming PR (board/auto-review.mjs reviewsOf), needs you, then reviewing, then
|
|
311
|
+
// posted. A tap opens the review text and the PR's links, and a review held for the owner offers Post it and Skip,
|
|
312
|
+
// whose tap waits while the server records it (app-lanes.js). Older posted reviews are one line each under Posted earlier.
|
|
313
|
+
const REVIEW_WORDS = { needs: 'needs you', reviewing: 'reviewing', posted: 'posted' };
|
|
314
|
+
const REVIEW_DOTS = { needs: 'rv-needs', reviewing: 'progress', posted: 'done' };
|
|
315
|
+
function reviewDetail(c) {
|
|
316
|
+
const text = c.text ? `<div class="rv-review">${c.state === 'needs' ? '<p class="muted small">Draft, not posted.</p>' : ''}${BoardUI.markdown(c.text)}</div>`
|
|
317
|
+
: '<p class="muted small">Nothing posted yet. Right before posting, Claude reads the PR’s reviews again and adds only new points.</p>';
|
|
318
|
+
const links = `<div class="rv-links"><a href="${esc(c.url)}" target="_blank" rel="noopener">PR #${c.number} on GitHub ${LINK_ARROW}</a>${c.reviewUrl ? `<a href="${esc(c.reviewUrl)}" target="_blank" rel="noopener">The review on GitHub ${LINK_ARROW}</a>` : ''}</div>`;
|
|
319
|
+
const acts = c.state === 'needs' ? `<div class="rv-acts"><button type="button" data-review-answer="post" data-review="${c.number}">Post it</button><button type="button" class="ghost" data-review-answer="skip" data-review="${c.number}">Skip</button></div>` : '';
|
|
320
|
+
return `<div class="rv-detail">${text}${links}${acts}</div>`;
|
|
321
|
+
}
|
|
322
|
+
const reviewChipsOf = (c) => [badge('quiet', `from ${esc(c.author)}`), ...c.touches.map((t) => badge(c.state === 'needs' ? 'danger' : 'quiet', `touches ${esc(t.ref)}`)), c.asked ? badge('normal', 'asked you') : ''].join(' ');
|
|
323
|
+
const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
|
|
324
|
+
function reviewCard(c, open) {
|
|
325
|
+
return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
|
|
326
|
+
<div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
|
|
327
|
+
<div class="line3 sub${c.state === 'reviewing' ? ' agent' : ''}">${c.state === 'reviewing' ? '<span class="working-dot" aria-hidden="true"></span>' : ''}<span class="rv-word s-${c.state}">${REVIEW_WORDS[c.state]}</span> · ${esc(reviewWhen(c))}</div>
|
|
328
|
+
<div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
|
|
329
|
+
}
|
|
330
|
+
const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
|
|
331
|
+
<div class="sub">${esc([c.author, c.state === 'posted' ? `posted ${ago(c.at)}` : 'not posted', c.open ? '' : 'closed'].filter(Boolean).join(' · '))}</div></summary>${reviewDetail(c)}</details>`;
|
|
332
|
+
// reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
|
|
333
|
+
function reviewLane(reviews, { open = {} } = {}) {
|
|
334
|
+
if (!reviews?.shown) return '';
|
|
335
|
+
const { cards, earlier } = reviews;
|
|
336
|
+
const needs = cards.filter((c) => c.state === 'needs').length;
|
|
337
|
+
const shown = (n) => open.cards?.has(n);
|
|
338
|
+
const fold = earlier.length ? `<details class="fold" data-fold="reviews-earlier"${open.earlier ? ' open' : ''}><summary><span class="lane-name">Posted earlier</span><span class="count">${earlier.length}</span></summary>
|
|
339
|
+
<div class="fold-list">${earlier.map((c) => reviewLine(c, shown(c.number))).join('')}</div></details>` : '';
|
|
340
|
+
const waiting = reviews.waiting ? `${reviews.waiting} incoming PR${reviews.waiting === 1 ? '' : 's'} wait${reviews.waiting === 1 ? 's' : ''} until the board is idle.`
|
|
341
|
+
: `No incoming PR touches this board’s work yet.${reviews.at ? ` Checked ${esc(reviews.repo)} ${ago(reviews.at)}.` : ''}`;
|
|
342
|
+
const list = cards.length ? cards.map((c) => reviewCard(c, shown(c.number))).join('') : `<div class="empty">${waiting}</div>`;
|
|
343
|
+
return `<details class="lane" data-lane="reviews"${open.lane === false ? '' : ' open'}><summary><span class="lane-name">Auto Review</span><span class="count">${cards.length}</span>${needs ? `<span class="lane-github"><span class="rv-late">${needs} needs you</span></span>` : ''}</summary>
|
|
344
|
+
<div class="list">${list}${fold}</div></details>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
|
|
218
348
|
})();
|
package/board/public/ui-enums.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// A card's button names, which board/domain.mjs sets as its actionButton and resolvedBy, the kinds of its owner
|
|
2
|
-
// action,
|
|
3
|
-
//
|
|
4
|
-
// this file in a vm context (docs/adr/
|
|
2
|
+
// action, the builder kinds the page reads, and the state of a card's PR, which domain.mjs sets as pr.status and its PR
|
|
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
|
+
// 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({
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
|
|
7
7
|
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
|
-
Object.
|
|
9
|
+
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
|
|
10
11
|
})();
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
(function () {
|
|
3
3
|
const { since, esc } = globalThis.BoardUI;
|
|
4
4
|
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
|
|
5
|
-
// /api/board's checks.watcher (docs/adr/
|
|
5
|
+
// /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): a grey asleep while
|
|
6
6
|
// the hub can wake the session, waking… once a wake is under way (waking: the page asked for one and the board hasn't
|
|
7
7
|
// said so yet), and a red not listening when nothing will wake it
|
|
8
|
-
// (docs/adr/
|
|
8
|
+
// (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
|
|
9
9
|
// 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
|
|
10
|
-
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">
|
|
10
|
+
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
|
|
11
11
|
: link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
|
|
12
|
-
: link === 'reconnecting' ? '<span class="link-state">
|
|
12
|
+
: link === 'reconnecting' ? '<span class="link-state">reconnecting…</span>'
|
|
13
13
|
: watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
|
|
14
14
|
: watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
|
|
15
15
|
: watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
|
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
// fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
|
|
20
20
|
// stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
|
|
21
21
|
// something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
|
|
22
|
-
// (docs/adr/
|
|
22
|
+
// (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
|
|
23
23
|
const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
|
|
24
24
|
const shortSince = (iso) => {
|
|
25
25
|
const s = (Date.now() - Date.parse(iso)) / 1000;
|
|
26
26
|
return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
|
|
27
27
|
};
|
|
28
|
-
// GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/
|
|
28
|
+
// GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/2026-10-29-1639-icons-are-sf-symbol-style-line-icons.md).
|
|
29
29
|
const GITHUB_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 16 16" fill="currentColor" role="img" aria-label="GitHub"><title>GitHub</title><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
|
|
30
30
|
// The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
|
|
31
|
-
// (docs/adr/
|
|
31
|
+
// (docs/adr/2026-10-29-1639-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
|
|
32
32
|
// when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
|
|
33
33
|
const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
|
|
34
34
|
const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${mark}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
|
|
39
39
|
};
|
|
40
40
|
// A board on a plain git remote has no GitHub checks, so the In Review header names its repo, host/path, instead
|
|
41
|
-
// (docs/adr/
|
|
41
|
+
// (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
|
|
42
42
|
const REMOTE_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
|
|
43
43
|
const remoteLine = (remote, { links = null } = {}) => `<span class="github remote" title="This board’s repo">${linksMark(links, REMOTE_MARK)} <span class="remote-name">${esc(remote.name)}</span></span>`;
|
|
44
44
|
// The In Review header's right end; pending is whether a check-github request is waiting.
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
|
|
55
55
|
// The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
|
|
56
|
-
// (docs/adr/
|
|
56
|
+
// (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md).
|
|
57
57
|
const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
|
|
58
58
|
const linkRow = (l) => {
|
|
59
59
|
const st = linkState(l);
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
|
|
84
84
|
// Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
|
|
85
85
|
// both turn on together once the first board load has answered or failed.
|
|
86
|
-
// Offline, refresh stays on so a tap can retry the load (docs/adr/
|
|
86
|
+
// Offline, refresh stays on so a tap can retry the load (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md).
|
|
87
87
|
const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
|
|
88
88
|
const ready = wired && boardSettled && !reloading;
|
|
89
89
|
return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
|
package/board/public/ui-hub.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js
|
|
1
|
+
// The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js), which hub.css styles
|
|
2
2
|
// under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
|
|
3
3
|
(function () {
|
|
4
4
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
@@ -58,12 +58,12 @@
|
|
|
58
58
|
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
59
59
|
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
60
60
|
|
|
61
|
-
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet,
|
|
62
|
-
//
|
|
61
|
+
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
|
|
62
|
+
// while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
|
|
63
63
|
function deviceRow(state, { compact = false } = {}) {
|
|
64
64
|
if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
|
|
65
65
|
const a = state.action;
|
|
66
|
-
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.
|
|
66
|
+
const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.busy ? BoardUI.BUSY : ''}>${esc(a.label)}</button>` : '';
|
|
67
67
|
return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A small Markdown renderer for Notes (view-notes.js; docs/adr/
|
|
1
|
+
// A small Markdown renderer for Notes (view-notes.js; docs/adr/2026-10-29-1639-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { esc, linkHtml } = globalThis.BoardUI;
|
|
4
4
|
// Raw HTML is escaped first, so a link's href can only break out through its scheme.
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
|
|
2
2
|
// tap it draws at once by, so the card lands where the server will put it
|
|
3
|
-
// (docs/adr/
|
|
3
|
+
// (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, queued, bumpedAt, sortAt, order and key.
|
|
4
4
|
// Needs nothing else: board/domain.mjs runs this file in a vm context.
|
|
5
5
|
(function () {
|
|
6
6
|
const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
|
|
7
|
-
// New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/
|
|
8
|
-
// then bumped cards, the latest bump on top (docs/adr/
|
|
9
|
-
// newest first by sortAt, a card with no time last, the last added to its wave file on top.
|
|
7
|
+
// New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
|
|
8
|
+
// then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
|
|
9
|
+
// newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
|
|
10
|
+
// lead it, bumped first and then oldest first, so the top one is the next to build
|
|
11
|
+
// (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
|
|
10
12
|
function laneOrder(a, b) {
|
|
11
13
|
const bumped = (i) => i.bumpedAt || '';
|
|
12
14
|
const unsorted = (i) => (i.isNew ? 0 : 1);
|
|
15
|
+
const queued = (i) => (i.queued ? 1 : 0);
|
|
13
16
|
const at = (i) => String(i.sortAt || '');
|
|
14
|
-
const age = a.isNew ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
15
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
|
|
17
|
+
const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
18
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
|
|
16
19
|
}
|
|
17
20
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
18
21
|
})();
|