@lovek4d/kickflip 0.5.0 → 0.6.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 +105 -68
- package/README.md +1 -1
- package/SKILL.md +27 -21
- package/bin/kickflip.mjs +27 -18
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -0
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +199 -79
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -4
- package/board/on-host.mjs +1 -1
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-asks.js +6 -6
- package/board/public/app-banner.js +7 -13
- package/board/public/app-cards.js +75 -37
- package/board/public/app-core.js +26 -8
- package/board/public/app-header.js +8 -8
- package/board/public/app-lanes.js +67 -58
- package/board/public/app-needs.js +140 -10
- package/board/public/app-reader.js +1 -1
- package/board/public/app-snapshot.js +46 -72
- package/board/public/app-threads.js +78 -75
- package/board/public/app.html +9 -4
- package/board/public/app.js +114 -38
- package/board/public/attach.js +2 -2
- package/board/public/dialogs.js +9 -39
- package/board/public/hub-kit.js +66 -4
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +46 -35
- package/board/public/new-task.js +20 -39
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +137 -7
- package/board/public/prototype-switcher.js +152 -123
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +68 -26
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -30
- package/board/public/style.css +205 -83
- package/board/public/ui-cards.js +91 -65
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +27 -18
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-order.js +12 -10
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +47 -52
- package/board/public/ui-sidebar.js +34 -26
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +59 -30
- package/board/public/ui.js +125 -18
- package/board/public/view-hub-settings.js +22 -67
- package/board/public/view-hub.js +19 -4
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +205 -240
- package/board/public/view-settings.js +313 -76
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -20
- package/board/stop.mjs +1 -0
- package/board/store.mjs +29 -15
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/transcript.mjs +2 -0
- package/board/worktree.mjs +25 -20
- package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
- package/hooks-plugin/hooks/hooks.json +9 -0
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +5 -5
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -2
- package/hub/live.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/presence.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +1 -2
- package/hub/review-send.mjs +1 -0
- package/hub/secrets.mjs +2 -0
- package/hub/self-restart.mjs +2 -0
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -0
- package/hub/slack.mjs +2 -2
- package/hub/stream.mjs +10 -13
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +2 -1
- package/shared/audit.mjs +4 -0
- package/shared/card-prs.mjs +1 -1
- package/shared/fake-gh.mjs +17 -4
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/service.mjs +4 -4
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
package/board/public/ui-cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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-
|
|
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-progress-bar.js.
|
|
2
2
|
(function () {
|
|
3
|
-
const { esc, ago, lasted, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW,
|
|
3
|
+
const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = globalThis.BoardUI;
|
|
4
4
|
const EXTERNAL = { target: '_blank', rel: 'noopener' };
|
|
5
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
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
|
|
@@ -56,10 +56,17 @@
|
|
|
56
56
|
const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
|
|
57
57
|
return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
|
|
58
58
|
}
|
|
59
|
-
// Each unfinished dep, linking to its card: "blocked by #53, #54"
|
|
59
|
+
// Each unfinished dep, linking to its card: "blocked by #53, #54", or "waits on #53" on a bumped card not held, which
|
|
60
|
+
// the pull takes once nothing blocks it (docs/adr/2026-11-05-0802-todo-has-no-queue.md); then its condition, "blocked
|
|
61
|
+
// until <condition>" ("and until <condition>" after deps), then ", <reason>"
|
|
62
|
+
// (docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md).
|
|
60
63
|
function blockedWords(it) {
|
|
61
64
|
const deps = it.blockedBy || [];
|
|
62
|
-
|
|
65
|
+
const waits = it.bumpedAt && !it.held;
|
|
66
|
+
const refs = deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
67
|
+
const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
|
|
68
|
+
const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
|
|
69
|
+
return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
|
|
63
70
|
}
|
|
64
71
|
const tryWords = (kind) => kind === 'app' ? {
|
|
65
72
|
go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
|
|
@@ -112,16 +119,17 @@
|
|
|
112
119
|
if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
|
|
113
120
|
const words = tryWords(t.kind);
|
|
114
121
|
const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
|
|
115
|
-
|
|
122
|
+
const attrs = BoardUI.pending.has(`${it.key}:try`) ? BoardUI.BUSY : reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : '';
|
|
123
|
+
return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
|
|
116
124
|
}
|
|
117
125
|
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>';
|
|
118
126
|
// 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
127
|
// 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
128
|
// server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
|
|
121
129
|
// (`open`). Visit is left alone: it changes only once Main rebuilds.
|
|
122
|
-
function mainPull(pull, {
|
|
130
|
+
function mainPull(pull, { open = false } = {}) {
|
|
123
131
|
if (!pull) return '';
|
|
124
|
-
const busy = pull.running ||
|
|
132
|
+
const busy = pull.running || BoardUI.pending.has('main:pull');
|
|
125
133
|
const failed = !busy && pull.error;
|
|
126
134
|
const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
|
|
127
135
|
const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
|
|
@@ -140,60 +148,61 @@
|
|
|
140
148
|
// server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
|
|
141
149
|
// 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
150
|
// docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
|
|
151
|
+
// A blocked card's Merge shows disabled, its tooltip naming what blocks it
|
|
152
|
+
// (docs/adr/2026-11-06-0102-a-block-past-todo-bumps-a-build-and-holds-a-land.md).
|
|
143
153
|
function mergeButton(it) {
|
|
154
|
+
if (it?.mergeBlocked) return `<button class="primary" disabled title="${esc(it.mergeBlocked)}">Merge</button>`;
|
|
144
155
|
if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
|
|
145
156
|
const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
|
|
146
|
-
|
|
157
|
+
// A merging card has no Action needed left, so resolvedBy is null by then: merging keeps the colour resolvedBy gave it.
|
|
158
|
+
return `<button class="${it.merging || it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
|
|
147
159
|
}
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// docs/adr/2026-11-
|
|
153
|
-
|
|
154
|
-
|
|
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(' · ');
|
|
160
|
+
// A builder reads "starting" until its first status line, from the Implement tap or the session's dispatch on, and
|
|
161
|
+
// ends with how long ago that line was set. One whose process is gone leads with stopped; one whose live process
|
|
162
|
+
// went quiet keeps its words and only turns grey (domain.mjs builderOf; docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
163
|
+
// One the orchestrator stopped reads the stop's words and how long ago it stopped
|
|
164
|
+
// (docs/adr/2026-11-05-1809-the-orchestrator-stops-the-boards-own-runs.md).
|
|
165
|
+
const builderLine = (b) => (b.stopped ? `${b.stopped} · ${lasted(b.silentSince)}`
|
|
166
|
+
: [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · '));
|
|
161
167
|
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
162
168
|
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
163
169
|
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
164
170
|
const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
|
|
165
171
|
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
166
172
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
167
|
-
//
|
|
168
|
-
//
|
|
173
|
+
// A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner works on: how many
|
|
174
|
+
// phases it reached, which of them is running, whether it dims because the card waits on something other than a builder (its
|
|
169
175
|
// 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
176
|
// build reached, undimmed
|
|
171
177
|
// (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
|
|
173
|
-
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
|
|
178
|
+
// A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
|
|
179
|
+
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
|
|
180
|
+
// docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
|
|
174
181
|
function phaseStrip(it) {
|
|
175
182
|
if (it.lane === 'todo' && it.builder?.planner) {
|
|
176
183
|
const prototype = it.builder.kind === 'prototype';
|
|
177
|
-
|
|
184
|
+
const done = prototype ? 2 : 1;
|
|
185
|
+
return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
|
|
178
186
|
}
|
|
179
187
|
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
180
188
|
const all = BUILD_PHASES.length;
|
|
181
189
|
// Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
|
|
182
|
-
|
|
190
|
+
const checking = BUILD_PHASES.indexOf('checking');
|
|
191
|
+
if (it.waitsOn === 'checking') return { count: all, done: checking + 1, now: checking, label: `Orchestrator review, ${stepWords('checking')}` };
|
|
183
192
|
const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
|
|
184
193
|
const answers = asking ? ', waiting on your answers' : '';
|
|
185
194
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
186
195
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
187
|
-
const
|
|
188
|
-
return {
|
|
196
|
+
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
197
|
+
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
189
198
|
}
|
|
190
199
|
const dim = !it.builder || asking;
|
|
191
|
-
if (it.reopened) return {
|
|
192
|
-
const
|
|
193
|
-
const label =
|
|
194
|
-
return {
|
|
200
|
+
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
201
|
+
const done = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
|
|
202
|
+
const label = done ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
|
|
203
|
+
return { count: all, done, dim, label: `${label}${answers}` };
|
|
195
204
|
}
|
|
196
|
-
// What the bars do when a card's
|
|
205
|
+
// What the bars do when a card's done count goes from prev to next across redraws, null or undefined meaning no
|
|
197
206
|
// strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
|
|
198
207
|
function phaseMotion(prev, next) {
|
|
199
208
|
const had = typeof prev === 'number';
|
|
@@ -201,24 +210,40 @@
|
|
|
201
210
|
if (had !== has) return has ? 'enter' : 'leave';
|
|
202
211
|
return has && next > prev ? 'fill' : null;
|
|
203
212
|
}
|
|
204
|
-
|
|
205
|
-
// docs/adr/2026-11-
|
|
206
|
-
|
|
207
|
-
// owner's blue (style.css).
|
|
208
|
-
const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
|
|
213
|
+
// A run's status line takes its card's lane colour (style.css): grey in Todo, the phase-bar orange in In Progress, blue in
|
|
214
|
+
// In Review (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). A Done card's line keeps the plain orange.
|
|
215
|
+
const tone = (it) => (['todo', 'progress', 'review'].includes(it.lane) ? ` in-${it.lane}` : '');
|
|
209
216
|
const DOT = '<span class="working-dot" aria-hidden="true"></span>';
|
|
210
217
|
// A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
|
|
211
218
|
// review with its dot once the session picks it up, each with how long ago.
|
|
212
219
|
function orchestratorStatus(it) {
|
|
213
220
|
const { since, picked } = it.orchestrator;
|
|
214
|
-
|
|
221
|
+
const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
|
|
222
|
+
return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
|
|
215
223
|
}
|
|
216
|
-
// A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
|
|
224
|
+
// A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
|
|
225
|
+
// A quiet one is grey, with no dot, and one the orchestrator stopped plain, since the orchestrator already knows.
|
|
217
226
|
function builderStatus(it) {
|
|
218
|
-
const
|
|
219
|
-
const
|
|
227
|
+
const { quiet } = it.builder;
|
|
228
|
+
const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
|
|
229
|
+
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
230
|
+
const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
|
|
220
231
|
if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
|
|
221
|
-
|
|
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>`;
|
|
234
|
+
}
|
|
235
|
+
// 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
|
+
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
237
|
+
function statusWord(it) {
|
|
238
|
+
const b = it.builder;
|
|
239
|
+
if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
|
|
240
|
+
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
241
|
+
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
242
|
+
if (it.lane === 'done') return 'done';
|
|
243
|
+
if (it.grilling || it.prototyping) return 'preheating';
|
|
244
|
+
if (it.lane === 'todo') return it.staging ? 'staging' : it.ready ? 'ready' : 'to do';
|
|
245
|
+
if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
|
|
246
|
+
return 'in review';
|
|
222
247
|
}
|
|
223
248
|
const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
|
|
224
249
|
// When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
|
|
@@ -238,15 +263,15 @@
|
|
|
238
263
|
: `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
|
|
239
264
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
240
265
|
}
|
|
241
|
-
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
|
|
242
|
-
// or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
266
|
+
// 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), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
243
268
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
244
269
|
// 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).
|
|
245
270
|
const SEP = '<span class="sep"> · </span>';
|
|
246
271
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
247
272
|
function timeLine(it, unread, bars) {
|
|
248
273
|
const iso = it.sortAt;
|
|
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);
|
|
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>'].filter(Boolean);
|
|
250
275
|
const news = newWords(unread, 'message');
|
|
251
276
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
252
277
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -273,37 +298,38 @@
|
|
|
273
298
|
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
299
|
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
275
300
|
: 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' &&
|
|
301
|
+
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && thinkingLine()].filter(Boolean).join(' · ');
|
|
277
302
|
// 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
303
|
// its bump with the band's ⋯ menu.
|
|
279
304
|
function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
|
|
280
305
|
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);
|
|
281
|
-
// A card
|
|
306
|
+
// A work card always shows its ticket; New is only on an ask (askChip).
|
|
282
307
|
const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
|
|
283
|
-
const ids = it.kind === 'ask' ? askChip(it) :
|
|
308
|
+
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
284
309
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
285
310
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
286
|
-
: it.orchestrator && !it.builder ? orchestratorStatus(it)
|
|
311
|
+
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
287
312
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
288
313
|
: it.builder ? builderStatus(it)
|
|
289
|
-
: it.grilling || it.prototyping ?
|
|
290
|
-
: it.refusal ? `<div class="line3 sub refused">${
|
|
291
|
-
: it.
|
|
292
|
-
: it.
|
|
293
|
-
|
|
314
|
+
: it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
|
|
315
|
+
: it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
|
|
316
|
+
: it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
|
|
317
|
+
: it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
|
|
318
|
+
: it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${escText(it.next)}</div>` : '';
|
|
319
|
+
const eta = it.eta && it.builder ? `<div class="line3 sub eta">${escText(etaLine(it.eta))}</div>` : '';
|
|
294
320
|
// 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">${
|
|
296
|
-
: `<div class="line3 sub check">${
|
|
297
|
-
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${
|
|
321
|
+
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
322
|
+
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
323
|
+
return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
298
324
|
${status}${check}${eta}
|
|
299
325
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
300
|
-
${timeLine(it, unread,
|
|
326
|
+
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
|
301
327
|
}
|
|
302
328
|
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>';
|
|
303
329
|
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
304
330
|
function planButton(items) {
|
|
305
|
-
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.
|
|
306
|
-
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
|
|
331
|
+
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
|
|
332
|
+
return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
|
|
307
333
|
}
|
|
308
334
|
|
|
309
335
|
// docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
|
|
@@ -323,11 +349,11 @@
|
|
|
323
349
|
const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
|
|
324
350
|
function reviewCard(c, open) {
|
|
325
351
|
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}`)} ${
|
|
352
|
+
<div class="line1 title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
|
|
327
353
|
<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
354
|
<div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
|
|
329
355
|
}
|
|
330
|
-
const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${
|
|
356
|
+
const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
|
|
331
357
|
<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
358
|
// reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
|
|
333
359
|
function reviewLane(reviews, { open = {} } = {}) {
|
|
@@ -344,5 +370,5 @@
|
|
|
344
370
|
<div class="list">${list}${fold}</div></details>`;
|
|
345
371
|
}
|
|
346
372
|
|
|
347
|
-
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton,
|
|
373
|
+
Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
|
|
348
374
|
})();
|
package/board/public/ui-enums.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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',
|
|
6
|
+
const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', 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
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
@@ -1,19 +1,27 @@
|
|
|
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),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 } = {}) =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
4
|
+
// The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age, or busy while the Mac is busy;
|
|
5
|
+
// the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
|
|
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 Mac (pause.machine,
|
|
8
|
+
// docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md) turns it orange too and reads busy in place of the age.
|
|
9
|
+
// link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
|
|
10
|
+
const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
|
|
11
|
+
const paused = pause?.state === 'paused';
|
|
12
|
+
const busy = pause?.machine?.state === 'busy';
|
|
13
|
+
const down = link === 'offline' || watcher === 'stopped';
|
|
14
|
+
const reason = link === 'offline' ? "Offline: can't reach the host"
|
|
15
|
+
: watcher === 'stopped' ? 'Claude not listening'
|
|
16
|
+
: paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
|
|
17
|
+
: busy ? 'This Mac is busy: new work waits until it eases'
|
|
18
|
+
: link === 'reconnecting' ? 'Reconnecting…'
|
|
19
|
+
: watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
|
|
20
|
+
: watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
|
|
21
|
+
if (!at && !reason) return '';
|
|
22
|
+
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 || busy ? ' hold' : ''}" title="${esc(title)}">${busy ? 'busy' : at ? shortSince(at) : ''}</span>`;
|
|
24
|
+
};
|
|
17
25
|
// What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
|
|
18
26
|
const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
|
|
19
27
|
// fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
|
|
@@ -65,13 +73,13 @@
|
|
|
65
73
|
? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
|
|
66
74
|
: '<p class="muted">The hub doesn\'t list this board yet. It picks a board up from the manifest on its next check.</p>');
|
|
67
75
|
|
|
68
|
-
// The checklist a new board shows at the top until its session answered the
|
|
76
|
+
// The checklist a new board shows at the top until its session answered the ping and GitHub checks reach it; setup
|
|
69
77
|
// is /api/board's, null once both pass. Cards can start meanwhile.
|
|
70
78
|
const setupCard = (setup) => {
|
|
71
79
|
if (!setup) return '';
|
|
72
80
|
const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
|
|
73
81
|
return `<h2>Connecting this board</h2><ul class="setup-steps">
|
|
74
|
-
${step(setup.events, 'Claude answered the
|
|
82
|
+
${step(setup.events, 'Claude answered the ping', 'Waiting for Claude to answer the ping')}
|
|
75
83
|
${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
|
|
76
84
|
</ul>`;
|
|
77
85
|
};
|
|
@@ -83,10 +91,11 @@
|
|
|
83
91
|
|
|
84
92
|
// Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
|
|
85
93
|
// 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/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md)
|
|
87
|
-
|
|
94
|
+
// 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),
|
|
95
|
+
// and Settings opens its read-only copy (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
96
|
+
const headerControls = ({ wired = false, boardSettled = false, reloading = false } = {}) => {
|
|
88
97
|
const ready = wired && boardSettled && !reloading;
|
|
89
|
-
return { refresh: ready, settings: ready
|
|
98
|
+
return { refresh: ready, settings: ready, spin: !boardSettled || reloading };
|
|
90
99
|
};
|
|
91
100
|
|
|
92
101
|
Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
|
package/board/public/ui-hub.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
|
|
16
16
|
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
|
|
17
17
|
snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
|
|
18
|
+
pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
|
18
19
|
coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
|
|
19
20
|
laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
|
|
20
21
|
key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
|
|
@@ -38,11 +39,21 @@
|
|
|
38
39
|
const going = pause.state === 'going';
|
|
39
40
|
const until = BoardUI.usageClock(pause.resets_at);
|
|
40
41
|
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
|
+
// 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
|
+
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>`;
|
|
41
44
|
const text = going ? `Past the buffer: spending usage until ${until}`
|
|
42
45
|
: `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}.`;
|
|
43
46
|
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>`;
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
// Health's Machine row: the gate's machine (shared/machine-gate.mjs), busy with its reason or normal, in words only
|
|
50
|
+
// (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
|
|
51
|
+
const machineRow = (machine) => {
|
|
52
|
+
const busy = machine.state === 'busy';
|
|
53
|
+
return row({ icon: tile('power', busy ? '#f59e0b' : '#22c55e'), title: 'Machine', wrap: true,
|
|
54
|
+
sub: busy ? `<span class="st wait">busy</span> · ${esc(machine.reason)}` : '<span class="st ok">normal</span>' });
|
|
55
|
+
};
|
|
56
|
+
|
|
46
57
|
// builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
|
|
47
58
|
function bufferControl(pause, builder) {
|
|
48
59
|
const at = pause.pause_at;
|
|
@@ -108,5 +119,5 @@
|
|
|
108
119
|
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
|
|
122
|
+
globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, nowRow, deviceRow, usageCard };
|
|
112
123
|
})();
|
package/board/public/ui-links.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ref opens that card, a Linear id or PR opens its page, and a bare URL becomes a link. Needs ui.js; load it before
|
|
3
3
|
// the ui-*.js files that render text.
|
|
4
4
|
(function () {
|
|
5
|
-
const { esc, record } = globalThis.BoardUI;
|
|
5
|
+
const { esc, escText, record } = globalThis.BoardUI;
|
|
6
6
|
// The Linear id shape, which an internal ref shares.
|
|
7
7
|
const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
|
|
8
8
|
const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
|
|
@@ -73,13 +73,13 @@
|
|
|
73
73
|
// The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
|
|
74
74
|
function wordLink(word, ctx) {
|
|
75
75
|
for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
|
|
76
|
-
const rest =
|
|
76
|
+
const rest = escText(word.slice(w.length));
|
|
77
77
|
if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
|
|
78
78
|
const team = LINEAR_ID.exec(w)?.[1];
|
|
79
79
|
const prefix = team && team !== 'INT' && record(ctx.teams, team);
|
|
80
80
|
if (prefix) return out(prefix + w, w) + rest;
|
|
81
81
|
}
|
|
82
|
-
return
|
|
82
|
+
return escText(word);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// A bare #n that reads as a hex colour stays text.
|
|
@@ -92,16 +92,16 @@
|
|
|
92
92
|
let at = 0;
|
|
93
93
|
for (const m of s.matchAll(TOKEN)) {
|
|
94
94
|
const [all, url, owner, name, n, bare, word] = m;
|
|
95
|
-
html +=
|
|
95
|
+
html += escText(s.slice(at, m.index));
|
|
96
96
|
at = m.index + all.length;
|
|
97
97
|
if (url) html += urlLink(url);
|
|
98
98
|
else if (name) {
|
|
99
99
|
const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
|
|
100
|
-
html += repo ? out(prUrl(repo, n), all) :
|
|
101
|
-
} else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) :
|
|
100
|
+
html += repo ? out(prUrl(repo, n), all) : escText(all);
|
|
101
|
+
} else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : escText(all);
|
|
102
102
|
else html += wordLink(word, ctx);
|
|
103
103
|
}
|
|
104
|
-
return html +
|
|
104
|
+
return html + escText(s.slice(at));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)(?:[^>"']|"[^"]*"|'[^']*')*>/g;
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
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/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew,
|
|
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
6
|
const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// Todo lists bumped cards first, the latest bump on top, a bumped New ask included
|
|
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
|
+
// 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
|
+
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
11
|
+
// docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
|
|
12
|
+
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
|
|
12
13
|
function laneOrder(a, b) {
|
|
13
14
|
const bumped = (i) => i.bumpedAt || '';
|
|
14
|
-
const unsorted = (i) => (i.isNew ? 0 : 1);
|
|
15
|
-
const queued = (i) => (i.queued ? 1 : 0);
|
|
16
15
|
const at = (i) => String(i.sortAt || '');
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
const oldestFirst = a.lane === 'todo';
|
|
17
|
+
const timeless = (i) => (at(i) ? 0 : 1);
|
|
18
|
+
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
19
|
+
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
20
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || bumped(b).localeCompare(bumped(a)) || age || added || a.key.localeCompare(b.key);
|
|
19
21
|
}
|
|
20
22
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
21
23
|
})();
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
|
|
9
9
|
const MOON = icon(16, '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
|
|
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
|
+
// 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
|
+
// like the bulb and gear.
|
|
13
|
+
const QUESTION = icon(18, '<path d="M8 8a4 4 0 1 1 5.5 3.7c-.9.4-1.5 1.2-1.5 2.2V15"/><path d="M12 19.5h.01"/>');
|
|
14
|
+
const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
|
|
11
15
|
const id = (v) => (v ? ` id="${esc(v)}"` : '');
|
|
12
16
|
|
|
13
17
|
// Where the back arrow goes (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): 'back', one step back in history, when
|
|
@@ -41,13 +45,15 @@
|
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
// lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
|
|
44
|
-
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh
|
|
48
|
+
// or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh) paint disabled with refresh spinning until the
|
|
45
49
|
// page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
|
|
46
|
-
// false, trail (HTML) takes their place.
|
|
50
|
+
// false, trail (HTML) takes their place. pages is the switch between a board's two pages below 900px
|
|
51
|
+
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): 'questions' puts the ? after the controls, with
|
|
52
|
+
// the open-question count in its badge (app-needs.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
|
|
47
53
|
// title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
|
|
48
54
|
// away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
|
|
49
55
|
function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
|
|
50
|
-
controls = true, trail = '', time = '…',
|
|
56
|
+
controls = true, trail = '', time = '…', pages = null, moon = false, logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
|
|
51
57
|
const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
|
|
52
58
|
const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
|
|
53
59
|
const first = lead === 'back'
|
|
@@ -55,14 +61,20 @@
|
|
|
55
61
|
: logo();
|
|
56
62
|
const nav = controls ? `<nav class="nav">`
|
|
57
63
|
+ `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
|
|
58
|
-
+ (
|
|
59
|
-
+ (
|
|
60
|
-
+ `</nav>` : trail;
|
|
64
|
+
+ (pages === 'questions' ? `<a href="questions.html" id="questionsLink" class="ghost icon pages-switch" aria-label="Open questions" title="Open questions">${QUESTION}<span class="q-badge" hidden></span></a>` : '')
|
|
65
|
+
+ `</nav>` : trail + (pages === 'board' ? `<a class="ghost icon pages-switch back" href="./" aria-label="Back to the board" title="Back to the board">${CHECK}</a>` : '');
|
|
61
66
|
const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
|
|
62
67
|
const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
|
|
63
68
|
const away = moon ? `<span class="away-moon" role="img" aria-label="Away" aria-hidden="true">${MOON}</span>` : '';
|
|
64
69
|
return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}>${away}</div>${nav}</header>${script}`;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
// The foot of the board, under the lanes: the architecture review's bulb (app-arch.js) and the gear, Board Settings,
|
|
73
|
+
// which paints disabled until app-header.js turns it on with the header's controls.
|
|
74
|
+
const boardFoot = () => '<div class="board-foot">'
|
|
75
|
+
+ `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>`
|
|
76
|
+
+ `<a href="settings.html" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>`
|
|
77
|
+
+ '</div>';
|
|
78
|
+
|
|
79
|
+
Object.assign(globalThis.BoardUI ||= {}, { header, backStep, boardFoot });
|
|
68
80
|
})();
|