@lovek4d/kickflip 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +43 -43
- package/SKILL.md +12 -10
- package/bin/event-lines.mjs +1 -1
- package/bin/kickflip.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +87 -87
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +1 -1
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +5 -6
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +37 -59
- package/board/public/app-needs.js +16 -11
- package/board/public/app-reader.js +2 -2
- package/board/public/app.js +2 -2
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/prototype-kit.js +2 -5
- package/board/public/router.js +1 -1
- package/board/public/sidebar.css +2 -2
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +20 -29
- package/board/public/ui-cards.js +19 -17
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +5 -5
- package/board/public/ui-order.js +5 -6
- package/board/public/ui-questions.js +12 -10
- package/board/public/ui-threads.js +10 -7
- package/board/public/ui.js +11 -2
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -5
- package/board/public/view-settings.js +3 -3
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/usage-gate.mjs +1 -1
package/board/public/style.css
CHANGED
|
@@ -93,8 +93,10 @@ button.chip { border: 0; }
|
|
|
93
93
|
/* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
|
|
94
94
|
.card-stale { color: var(--muted); }
|
|
95
95
|
.card-stalled { color: var(--color-danger); }
|
|
96
|
-
/* A bumped
|
|
97
|
-
|
|
96
|
+
/* A bumped ready card's footer status while the host holds new work, and a paused build's (ui-cards.js timeLine);
|
|
97
|
+
a build still pausing reads orange. */
|
|
98
|
+
.card-host-held, .card-paused { color: var(--muted); }
|
|
99
|
+
.card-pausing { color: var(--color-notice); }
|
|
98
100
|
.sub.next.idle { color: var(--muted); }
|
|
99
101
|
.sub.refused { color: var(--color-danger); }
|
|
100
102
|
.toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
|
|
@@ -155,7 +157,7 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
155
157
|
.wv-link:disabled { color: var(--muted); cursor: default; }
|
|
156
158
|
.wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
|
|
157
159
|
/* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
|
|
158
|
-
.wb + .wb
|
|
160
|
+
.wb + .wb { border-top: 1px solid var(--line); }
|
|
159
161
|
.wb-head { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-height: 34px; padding: 5px 12px;
|
|
160
162
|
background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
|
|
161
163
|
.wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
|
|
@@ -173,7 +175,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
173
175
|
background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; line-height: 1.35; overflow-wrap: anywhere; cursor: default; }
|
|
174
176
|
.wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
|
|
175
177
|
.wb-done a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
|
176
|
-
.wb-loosehead { cursor: default; }
|
|
177
178
|
.wb-counts { position: relative; border: 0; background: none; padding: 4px 0; font: inherit; cursor: help; color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
178
179
|
.wb-tip { display: none; position: absolute; right: 0; top: 100%; z-index: 6; margin-top: 2px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); color: var(--text); font-weight: 400; text-align: left; }
|
|
179
180
|
.wb-counts:hover .wb-tip, .wb-counts.open .wb-tip { display: block; }
|
|
@@ -181,18 +182,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
181
182
|
.wb-counts .wv-review { color: var(--color-info); }
|
|
182
183
|
.wb-counts .wv-progress { color: var(--color-notice); }
|
|
183
184
|
.wb-counts .wv-todo, .wb-counts .wv-done { color: var(--color-quiet); }
|
|
184
|
-
.wb-lane + .wb-lane, .wb-lane + .wb-loose { border-top: 1px solid var(--line); }
|
|
185
|
-
.wb-loosehead:hover { background: var(--band); }
|
|
186
|
-
/* A wave card's step number, inside its id chip, and its ⋯ menu. */
|
|
187
|
-
.wn { color: var(--muted); font-variant-numeric: tabular-nums; padding-right: 5px; margin-right: 4px; border-right: 1px solid var(--line); }
|
|
188
|
-
.wb .item, .wb .item > summary .title-wrap { position: relative; }
|
|
189
|
-
.wb-dots[aria-expanded="true"] { color: var(--text); background: var(--chip); }
|
|
190
|
-
.wb-dots svg { display: block; }
|
|
191
|
-
.wb-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; display: grid; min-width: 160px; padding: 4px;
|
|
192
|
-
border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); }
|
|
193
|
-
.wb-menu button { border: 0; background: none; text-align: left; padding: 8px 10px; min-height: 0; border-radius: 6px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer; }
|
|
194
|
-
.wb-menu button:hover:not(:disabled) { background: var(--chip); }
|
|
195
|
-
.wb-menu button:disabled { color: var(--muted); cursor: default; }
|
|
196
185
|
.lane[data-lane="todo"] > .list:has(.wb) { overflow: visible; }
|
|
197
186
|
.nav a.icon { display: inline-flex; align-items: center; padding: 4px; color: var(--muted); }
|
|
198
187
|
.nav a.icon:hover { color: var(--text); }
|
|
@@ -218,9 +207,6 @@ code { overflow-wrap: anywhere; }
|
|
|
218
207
|
/* A file input draws its "Choose File" text at the 16px coarse-pointer size, wider than the button; iOS Safari widens the page to fit it. */
|
|
219
208
|
.attach input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; padding: 0; border: 0; font-size: 0; }
|
|
220
209
|
.row > .attach + .muted { flex: 1; min-width: 0; }
|
|
221
|
-
/* The ⌘↵ / Ctrl+↵ hint left of Send: only where there is a keyboard to press it on. */
|
|
222
|
-
.send-hint { display: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
223
|
-
@media (hover: hover) and (pointer: fine) { .send-hint { display: inline; } }
|
|
224
210
|
/* A composer's text box (BoardUI.dropZone), its attach button inside at the bottom right with the text kept clear of it:
|
|
225
211
|
while files are dragged over the composer, attach.js marks it .dragging and the box gets a dashed accent outline
|
|
226
212
|
with Drop to attach over it. */
|
|
@@ -350,7 +336,7 @@ form[data-reply] .icons { display: inline-flex; flex: none; margin-left: auto; }
|
|
|
350
336
|
/* A composer's send buttons (ui.js sendButton), each with its key as a small gray hint where there is a keyboard and
|
|
351
337
|
room. A pair takes a row of two halves; a Todo card's reply box wraps its pair under the row. */
|
|
352
338
|
.send-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
|
|
353
|
-
.send-actions button, [data-sends] button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
|
339
|
+
.send-actions button, [data-sends] button, .view-questions .bar button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
|
354
340
|
.send-actions button { min-height: 44px; }
|
|
355
341
|
form[data-reply] .row { flex-wrap: wrap; gap: 6px; }
|
|
356
342
|
form[data-reply] .row > .send-actions { flex: 0 0 100%; margin-top: 4px; }
|
|
@@ -397,6 +383,7 @@ button.attach:disabled { opacity: .35; }
|
|
|
397
383
|
.msg.builder-status .working-dot { animation: none; }
|
|
398
384
|
.msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
399
385
|
.msg.request.readied { color: var(--color-success); }
|
|
386
|
+
.msg.request.hub.fault { color: var(--color-danger); }
|
|
400
387
|
.msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
|
401
388
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
402
389
|
.msg.agent .muted { font-weight: 400; }
|
|
@@ -473,7 +460,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
473
460
|
.view-only-strip b { color: var(--accent); }
|
|
474
461
|
/* A board out of reach (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md): it reads as before,
|
|
475
462
|
undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
|
|
476
|
-
.offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-
|
|
463
|
+
.offline :is(form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
|
|
477
464
|
/* With no copy to show, the landing takes the board's place: the kickflip mark and two lines, as hub/front-door.mjs's page has them. */
|
|
478
465
|
.down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
|
|
479
466
|
.down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
|
|
@@ -653,9 +640,16 @@ main > * { transition: opacity .25s ease; }
|
|
|
653
640
|
.view-questions .opt .lbl { font-weight: 600; }
|
|
654
641
|
.view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
655
642
|
.view-questions .opt .det { color: var(--muted); font-size: 13px; }
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
.view-questions .opt .
|
|
643
|
+
/* A question's context and an option's detail take markdown at body size: headings drawn as bold lines, blocks tight
|
|
644
|
+
(docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). */
|
|
645
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) { overflow-wrap: anywhere; }
|
|
646
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
|
|
647
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
|
|
648
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(ul, ol) { padding-left: 18px; }
|
|
649
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
|
|
650
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) table { display: block; overflow-x: auto; }
|
|
651
|
+
:is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det) > :last-child { margin-bottom: 0; }
|
|
652
|
+
.view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
|
|
659
653
|
.view-questions .note { margin-top: 4px; }
|
|
660
654
|
.view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
|
|
661
655
|
.view-questions .rep { background: var(--claude); border-radius: 9px; padding: 7px 10px; margin-top: 6px; font-size: 13px; }
|
|
@@ -687,7 +681,6 @@ main > * { transition: opacity .25s ease; }
|
|
|
687
681
|
.view-questions .q.resolved .reask-line { margin-top: 10px; }
|
|
688
682
|
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg); z-index: 2;
|
|
689
683
|
display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
|
|
690
|
-
.view-questions .bar .send-hint { margin-left: auto; }
|
|
691
684
|
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
692
685
|
.view-questions .bar #qstatus { min-width: 0; }
|
|
693
686
|
.view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
|
|
@@ -777,9 +770,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
777
770
|
.sheet.keyboard-sheet { --sheet-limit: max(80px, calc(var(--vh, 100dvh) + var(--kb, 0px) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - max(12px, env(safe-area-inset-top)))); }
|
|
778
771
|
}
|
|
779
772
|
|
|
780
|
-
/* Typing, a text field focused (shell.js sets :root.typing): the
|
|
781
|
-
:root.typing .fab { opacity: .35; pointer-events: none; }
|
|
782
|
-
/* On a phone the + hides while typing, so it never sits over the keyboard's field. */
|
|
773
|
+
/* Typing, a text field focused (shell.js sets :root.typing). Only a phone changes: the + hides, so it never sits over the keyboard's field; wider screens keep it as it is. */
|
|
783
774
|
@media (max-width: 699px) {
|
|
784
775
|
:root.typing .fab { display: none; }
|
|
785
776
|
/* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
|
|
@@ -951,7 +942,7 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
951
942
|
.deck-opts button.rec { border-color: var(--accent); }
|
|
952
943
|
.deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
|
|
953
944
|
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
954
|
-
.deck-opts
|
|
945
|
+
.deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
|
|
955
946
|
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
956
947
|
.deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
957
948
|
.deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
178
178
|
// A card's progress bar (ui-progress-bar.js), In Progress's phase, or null off In Progress and on a card only a planner
|
|
179
179
|
// works on: how many phases it reached, which of them is running, whether it dims because the card waits on something
|
|
180
|
-
// other than a builder (its questions, a blocker or queue, a reopening)
|
|
180
|
+
// other than a builder (its questions, a blocker or queue, a reopening), its build is stale or paused, and its label. An install
|
|
181
181
|
// is no build: its card keeps the phase its build reached, undimmed
|
|
182
182
|
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md, docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
183
183
|
function phaseStrip(it) {
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
192
192
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
193
193
|
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
194
|
-
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
194
|
+
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet || it.builder.pause?.paused), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
195
195
|
}
|
|
196
196
|
const dim = !it.builder || asking;
|
|
197
197
|
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
@@ -228,12 +228,12 @@
|
|
|
228
228
|
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
229
229
|
return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
|
|
230
230
|
}
|
|
231
|
-
// The open card's Status row: what runs on the card, else where its lane leaves it, never the
|
|
231
|
+
// The open card's Status row: what runs on the card, else where its lane leaves it, never the item file's raw status
|
|
232
232
|
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
233
233
|
function statusWord(it) {
|
|
234
234
|
const b = it.builder;
|
|
235
235
|
if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
|
|
236
|
-
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
236
|
+
if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.pause ? (b.pause.paused ? 'paused' : 'pausing') : b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
|
|
237
237
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
238
238
|
if (it.lane === 'done') return 'done';
|
|
239
239
|
if (it.grilling || it.prototyping) return 'preheating';
|
|
@@ -274,21 +274,26 @@
|
|
|
274
274
|
const [word, tone] = PHASE_LOOK[it.phase];
|
|
275
275
|
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
276
276
|
}
|
|
277
|
+
// docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md
|
|
278
|
+
// A paused build's word and cause, which stand in for the hold or block that caused it: "pausing · blocked by #61" in
|
|
279
|
+
// orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
|
|
280
|
+
const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
|
|
281
|
+
const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
|
|
277
282
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
278
|
-
//
|
|
279
|
-
//
|
|
283
|
+
// held while host recovers on a bumped ready card a busy host holds, in grey
|
|
284
|
+
// (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
|
|
280
285
|
// in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
|
|
281
|
-
// once, as its phase, then the phase word (phaseWord), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
286
|
+
// once, as its phase, then the phase word (phaseWord), then a paused build's words (pauseWords), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
|
|
282
287
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
283
288
|
// 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).
|
|
284
289
|
const SEP = '<span class="sep"> · </span>';
|
|
285
290
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
286
291
|
function timeLine(it, unread, bars) {
|
|
287
292
|
const iso = it.sortAt;
|
|
288
|
-
const
|
|
289
|
-
|
|
293
|
+
const pause = pauseOf(it);
|
|
294
|
+
const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it), actionWords(it),
|
|
290
295
|
it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
|
|
291
|
-
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it)].filter(Boolean);
|
|
296
|
+
it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it), pauseWords(pause)].filter(Boolean);
|
|
292
297
|
const news = newWords(unread, 'message');
|
|
293
298
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
294
299
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -296,7 +301,7 @@
|
|
|
296
301
|
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
297
302
|
}
|
|
298
303
|
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
299
|
-
//
|
|
304
|
+
// Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
300
305
|
// durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
|
|
301
306
|
// at `now`, here rather than on the server, so the board's version doesn't change with the clock
|
|
302
307
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
@@ -319,13 +324,10 @@
|
|
|
319
324
|
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
320
325
|
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
321
326
|
const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
|
|
322
|
-
|
|
323
|
-
// its bump with the band's ⋯ menu.
|
|
324
|
-
function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
|
|
327
|
+
function workLines(it, { unread = 0, top = false } = {}) {
|
|
325
328
|
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);
|
|
326
329
|
// A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
|
|
327
|
-
const
|
|
328
|
-
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
330
|
+
const ids = it.kind === 'ask' ? askChip(it) : ticket;
|
|
329
331
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
330
332
|
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
331
333
|
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
@@ -343,7 +345,7 @@
|
|
|
343
345
|
// 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).
|
|
344
346
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
345
347
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
346
|
-
return `${
|
|
348
|
+
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
347
349
|
${asked}${status}${check}${eta}
|
|
348
350
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
349
351
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
package/board/public/ui-hub.js
CHANGED
|
@@ -68,10 +68,6 @@
|
|
|
68
68
|
+ `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
|
|
72
|
-
const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
|
|
73
|
-
trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
|
|
74
|
-
|
|
75
71
|
// This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
|
|
76
72
|
// while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
|
|
77
73
|
function deviceRow(state, { compact = false } = {}) {
|
|
@@ -122,5 +118,5 @@
|
|
|
122
118
|
return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
|
|
123
119
|
}
|
|
124
120
|
|
|
125
|
-
globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow,
|
|
121
|
+
globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, deviceRow, usageCard };
|
|
126
122
|
})();
|
package/board/public/ui-links.js
CHANGED
|
@@ -14,21 +14,21 @@
|
|
|
14
14
|
// from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
|
|
15
15
|
// board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
|
|
16
16
|
// than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
|
|
17
|
-
// ticket #n links from anywhere;
|
|
18
|
-
//
|
|
17
|
+
// ticket #n links from anywhere; an item file's own short id, such as B1, links only from that file's cards, since
|
|
18
|
+
// another item file's plan reuses those names.
|
|
19
19
|
function linkContext(items = [], key = null, { base = '' } = {}) {
|
|
20
|
-
const
|
|
20
|
+
const file = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
|
|
21
21
|
const byRef = new Map();
|
|
22
22
|
for (const i of items) {
|
|
23
23
|
if (i.kind === 'ask') continue;
|
|
24
24
|
for (const ref of new Set([i.ref, i.id])) {
|
|
25
|
-
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.
|
|
25
|
+
if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
|
|
26
26
|
byRef.set(ref, [...(byRef.get(ref) || []), i]);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
const refs = Object.create(null);
|
|
30
30
|
for (const [ref, list] of byRef) {
|
|
31
|
-
const pick = list.length === 1 ? list[0] : list.find((i) => i.
|
|
31
|
+
const pick = list.length === 1 ? list[0] : list.find((i) => i.file === file);
|
|
32
32
|
if (pick) refs[ref] = pick.key;
|
|
33
33
|
}
|
|
34
34
|
const teams = Object.create(null);
|
package/board/public/ui-order.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
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, bumpedAt,
|
|
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, progress: 1, todo: 2, done: 3, asks: 4 };
|
|
7
7
|
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
8
8
|
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
|
|
9
|
-
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md)
|
|
10
|
-
// the
|
|
11
|
-
// 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
|
|
9
|
+
// docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md). Todo lists the rest oldest first by sortAt, a card with no
|
|
10
|
+
// time last, the first added to its item file on top, so a New ask and every new card join the bottom and the pull takes the
|
|
12
11
|
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
13
12
|
// docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
|
|
14
|
-
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its
|
|
13
|
+
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its item file on top.
|
|
15
14
|
// Present times first, the earliest on top.
|
|
16
15
|
const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
|
|
17
16
|
function laneOrder(a, b) {
|
|
@@ -20,7 +19,7 @@
|
|
|
20
19
|
const timeless = (i) => (at(i) ? 0 : 1);
|
|
21
20
|
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
22
21
|
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
23
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) ||
|
|
22
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || age || added || a.key.localeCompare(b.key);
|
|
24
23
|
}
|
|
25
24
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
26
25
|
})();
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
|
|
2
2
|
(function () {
|
|
3
3
|
const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
|
|
4
|
+
// ui-markdown.js loads after this file, so markdown is looked up when a question draws.
|
|
5
|
+
const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
|
|
4
6
|
// A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
|
|
5
7
|
const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
|
|
6
8
|
const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
|
|
7
|
-
// suggested: the line under a suggested option
|
|
8
|
-
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
9
|
-
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${
|
|
9
|
+
// suggested: the orchestrator's blue line under a suggested option's label, as HTML.
|
|
10
|
+
const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '', links = {} }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
|
|
11
|
+
<span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${o.recommended ? '<span class="rec">recommended</span>' : ''}${suggested ? `<div class="suggested-why">Orchestrator choice: ${suggested}</div>` : ''}<div class="det">${o.detail ? markdown(o.detail, links) : ''}</div></span></label>`;
|
|
10
12
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
11
13
|
// A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
|
|
12
14
|
// suggested, pre-checked so the topic's Send sends it.
|
|
13
|
-
const choiceOf = (q, draft) => draft.choice
|
|
15
|
+
const choiceOf = (q, draft) => 'choice' in draft ? draft.choice : (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
|
|
14
16
|
// A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
|
|
15
17
|
// sends it again.
|
|
16
18
|
const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
// phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
|
|
23
25
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
|
|
24
26
|
const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
|
|
25
|
-
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`
|
|
27
|
+
const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(q.suggested.reason ?? `you answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
|
|
26
28
|
|
|
27
29
|
const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
|
|
28
30
|
// A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
|
|
@@ -83,7 +85,7 @@
|
|
|
83
85
|
|
|
84
86
|
function takes(q, chosen, links, locked = false) {
|
|
85
87
|
const name = 'c-' + q.id;
|
|
86
|
-
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
88
|
+
return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
|
|
87
89
|
}
|
|
88
90
|
// A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
|
|
89
91
|
const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
|
|
@@ -102,10 +104,10 @@
|
|
|
102
104
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
|
|
103
105
|
<h3>${linkText(q.question, links)}</h3>
|
|
104
106
|
${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
|
|
105
|
-
${q.context ? `<div class="ctx">${
|
|
107
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
106
108
|
${questionLink(q.url)}
|
|
107
109
|
${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
|
|
108
|
-
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
|
|
110
|
+
${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true, links })).join('')}
|
|
109
111
|
${said}
|
|
110
112
|
${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
|
|
111
113
|
</section>`;
|
|
@@ -121,10 +123,10 @@
|
|
|
121
123
|
return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
|
|
122
124
|
<div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
|
|
123
125
|
<h3>${linkText(q.question, links)}</h3>
|
|
124
|
-
${q.context ? `<div class="ctx">${
|
|
126
|
+
${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
125
127
|
${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
|
|
126
128
|
${confirmLine(q)}
|
|
127
|
-
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
|
|
129
|
+
${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })).join('')}
|
|
128
130
|
${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
|
|
129
131
|
${answerHistory(q, links)}
|
|
130
132
|
</section>`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// A card's thread: the orchestrator's lines (who claude, named Orchestrator),
|
|
2
|
-
// their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
1
|
+
// A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
|
|
2
|
+
// asks, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
|
|
3
3
|
(function () {
|
|
4
4
|
const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
|
|
5
5
|
const VIA = { slack: ' · Slack', github: ' · GitHub' };
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
// A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
|
|
43
43
|
: m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
44
44
|
: m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
|
|
45
|
+
// docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
|
|
46
|
+
// own line, its kind's mark and words, red for a fault.
|
|
47
|
+
: m.kind === 'hub' ? `<div class="msg request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
45
48
|
// docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
|
|
46
49
|
: m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
47
50
|
: m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
|
|
@@ -80,12 +83,12 @@
|
|
|
80
83
|
const STEP = 3;
|
|
81
84
|
const UNREAD_CAP = 10;
|
|
82
85
|
// Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
|
|
83
|
-
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
|
|
84
|
-
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
|
|
86
|
+
const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move', 'hub'];
|
|
87
|
+
const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move', 'hub']);
|
|
85
88
|
const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
|
|
86
|
-
// Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt,
|
|
87
|
-
// orchestrator's answer to a builder's ask never is.
|
|
88
|
-
const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
|
|
89
|
+
// Only Claude's and GitHub's messages and the board's fault lines are unread, as on the badge (domain.mjs othersAt,
|
|
90
|
+
// ui-cards.js unreadCount); the orchestrator's answer to a builder's ask never is.
|
|
91
|
+
const unread = (m, seenAt) => Boolean(seenAt) && (m.fault || (isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github'))) && String(m.ts) > seenAt;
|
|
89
92
|
// The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
|
|
90
93
|
function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
|
|
91
94
|
const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
|
package/board/public/ui.js
CHANGED
|
@@ -107,6 +107,9 @@
|
|
|
107
107
|
'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
|
|
108
108
|
Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
|
|
109
109
|
Resumed: mini('<path d="M7 4v16l13-8z"/>'),
|
|
110
|
+
// The board's status lines (board/hub-lines.mjs): a block's circle with a bar, a fault's warning triangle.
|
|
111
|
+
Blocked: mini('<circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/>'),
|
|
112
|
+
Fault: mini('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
|
|
110
113
|
'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
|
|
111
114
|
'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
|
|
112
115
|
Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
|
|
@@ -401,7 +404,7 @@
|
|
|
401
404
|
const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
|
|
402
405
|
// The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
|
|
403
406
|
// label and size. Reading controls, folds, links, Edit and drafts stay live.
|
|
404
|
-
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-
|
|
407
|
+
const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
|
|
405
408
|
// While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
|
|
406
409
|
// tells the tap it is waiting. on is the view's B.on.
|
|
407
410
|
function holdSends(on, root, held, onHeld) {
|
|
@@ -447,8 +450,14 @@
|
|
|
447
450
|
}
|
|
448
451
|
}
|
|
449
452
|
|
|
453
|
+
// Whether a click dismisses a modal: it landed on the backdrop element and the press that made it began there too, so a
|
|
454
|
+
// drag that starts inside (selecting text) and ends on the backdrop leaves the modal open.
|
|
455
|
+
let pressedOn = null;
|
|
456
|
+
if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => { pressedOn = e.target; }, true);
|
|
457
|
+
const onBackdrop = (ev, backdrop) => ev.target === backdrop && pressedOn === backdrop;
|
|
458
|
+
|
|
450
459
|
globalThis.BoardUI = {
|
|
451
|
-
LINK_ARROW, escText,
|
|
460
|
+
LINK_ARROW, escText, onBackdrop,
|
|
452
461
|
esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
|
|
453
462
|
attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
|
|
454
463
|
};
|