@lovek4d/kickflip 0.6.1 → 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 +60 -48
- package/SKILL.md +20 -14
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +118 -104
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +2 -2
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +3 -3
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +17 -9
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -61
- package/board/public/app-needs.js +116 -30
- package/board/public/app-reader.js +54 -44
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +23 -13
- 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/progress-review.js +4 -4
- package/board/public/prototype-kit.js +27 -16
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +35 -7
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +4 -4
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +74 -36
- package/board/public/ui-cards.js +56 -37
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +6 -6
- package/board/public/ui-order.js +8 -6
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +30 -13
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +20 -19
- package/board/public/ui.js +15 -4
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +2 -2
- package/board/public/view-questions.js +35 -6
- package/board/public/view-settings.js +63 -31
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- 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/home.mjs +1 -1
- 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/upgrade.mjs +1 -1
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
package/board/public/style.css
CHANGED
|
@@ -84,11 +84,19 @@ button.chip { border: 0; }
|
|
|
84
84
|
.sub.next { color: var(--color-notice); }
|
|
85
85
|
.card-hold { color: var(--color-quiet); }
|
|
86
86
|
.card-blocked, .card-action { color: var(--color-notice); }
|
|
87
|
-
.card
|
|
88
|
-
.card-
|
|
87
|
+
/* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
|
|
88
|
+
.card-phase.ph-quiet { color: var(--color-quiet); }
|
|
89
|
+
.card-phase.ph-info { color: var(--color-info); }
|
|
90
|
+
.card-phase.ph-notice { color: var(--color-notice); }
|
|
91
|
+
.card-phase.ph-success { color: var(--color-success); }
|
|
92
|
+
.card-phase.ph-merged { color: var(--color-merged); }
|
|
89
93
|
/* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
|
|
90
94
|
.card-stale { color: var(--muted); }
|
|
91
95
|
.card-stalled { color: var(--color-danger); }
|
|
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); }
|
|
92
100
|
.sub.next.idle { color: var(--muted); }
|
|
93
101
|
.sub.refused { color: var(--color-danger); }
|
|
94
102
|
.toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
|
|
@@ -149,7 +157,7 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
149
157
|
.wv-link:disabled { color: var(--muted); cursor: default; }
|
|
150
158
|
.wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
|
|
151
159
|
/* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
|
|
152
|
-
.wb + .wb
|
|
160
|
+
.wb + .wb { border-top: 1px solid var(--line); }
|
|
153
161
|
.wb-head { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 8px; min-height: 34px; padding: 5px 12px;
|
|
154
162
|
background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
|
|
155
163
|
.wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
|
|
@@ -167,7 +175,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
167
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; }
|
|
168
176
|
.wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
|
|
169
177
|
.wb-done a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
|
170
|
-
.wb-loosehead { cursor: default; }
|
|
171
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; }
|
|
172
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; }
|
|
173
180
|
.wb-counts:hover .wb-tip, .wb-counts.open .wb-tip { display: block; }
|
|
@@ -175,18 +182,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
|
|
|
175
182
|
.wb-counts .wv-review { color: var(--color-info); }
|
|
176
183
|
.wb-counts .wv-progress { color: var(--color-notice); }
|
|
177
184
|
.wb-counts .wv-todo, .wb-counts .wv-done { color: var(--color-quiet); }
|
|
178
|
-
.wb-lane + .wb-lane, .wb-lane + .wb-loose { border-top: 1px solid var(--line); }
|
|
179
|
-
.wb-loosehead:hover { background: var(--band); }
|
|
180
|
-
/* A wave card's step number, inside its id chip, and its ⋯ menu. */
|
|
181
|
-
.wn { color: var(--muted); font-variant-numeric: tabular-nums; padding-right: 5px; margin-right: 4px; border-right: 1px solid var(--line); }
|
|
182
|
-
.wb .item, .wb .item > summary .title-wrap { position: relative; }
|
|
183
|
-
.wb-dots[aria-expanded="true"] { color: var(--text); background: var(--chip); }
|
|
184
|
-
.wb-dots svg { display: block; }
|
|
185
|
-
.wb-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; display: grid; min-width: 160px; padding: 4px;
|
|
186
|
-
border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 18px rgba(0,0,0,.18); }
|
|
187
|
-
.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; }
|
|
188
|
-
.wb-menu button:hover:not(:disabled) { background: var(--chip); }
|
|
189
|
-
.wb-menu button:disabled { color: var(--muted); cursor: default; }
|
|
190
185
|
.lane[data-lane="todo"] > .list:has(.wb) { overflow: visible; }
|
|
191
186
|
.nav a.icon { display: inline-flex; align-items: center; padding: 4px; color: var(--muted); }
|
|
192
187
|
.nav a.icon:hover { color: var(--text); }
|
|
@@ -212,9 +207,6 @@ code { overflow-wrap: anywhere; }
|
|
|
212
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. */
|
|
213
208
|
.attach input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; padding: 0; border: 0; font-size: 0; }
|
|
214
209
|
.row > .attach + .muted { flex: 1; min-width: 0; }
|
|
215
|
-
/* The ⌘↵ / Ctrl+↵ hint left of Send: only where there is a keyboard to press it on. */
|
|
216
|
-
.send-hint { display: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
217
|
-
@media (hover: hover) and (pointer: fine) { .send-hint { display: inline; } }
|
|
218
210
|
/* A composer's text box (BoardUI.dropZone), its attach button inside at the bottom right with the text kept clear of it:
|
|
219
211
|
while files are dragged over the composer, attach.js marks it .dragging and the box gets a dashed accent outline
|
|
220
212
|
with Drop to attach over it. */
|
|
@@ -344,7 +336,7 @@ form[data-reply] .icons { display: inline-flex; flex: none; margin-left: auto; }
|
|
|
344
336
|
/* A composer's send buttons (ui.js sendButton), each with its key as a small gray hint where there is a keyboard and
|
|
345
337
|
room. A pair takes a row of two halves; a Todo card's reply box wraps its pair under the row. */
|
|
346
338
|
.send-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
|
|
347
|
-
.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; }
|
|
348
340
|
.send-actions button { min-height: 44px; }
|
|
349
341
|
form[data-reply] .row { flex-wrap: wrap; gap: 6px; }
|
|
350
342
|
form[data-reply] .row > .send-actions { flex: 0 0 100%; margin-top: 4px; }
|
|
@@ -383,10 +375,15 @@ button.attach:disabled { opacity: .35; }
|
|
|
383
375
|
/* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
|
|
384
376
|
.thinking { color: var(--accent); font-size: 12px; font-weight: 600; }
|
|
385
377
|
.thinking .working-dot { background: currentColor; }
|
|
378
|
+
/* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
|
|
379
|
+
.pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
|
|
380
|
+
.pickup-wait .working-dot { background: currentColor; animation: none; }
|
|
386
381
|
.msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
|
|
387
382
|
.msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
|
|
388
383
|
.msg.builder-status .working-dot { animation: none; }
|
|
389
384
|
.msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
385
|
+
.msg.request.readied { color: var(--color-success); }
|
|
386
|
+
.msg.request.hub.fault { color: var(--color-danger); }
|
|
390
387
|
.msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
|
|
391
388
|
.msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
|
|
392
389
|
.msg.agent .muted { font-weight: 400; }
|
|
@@ -410,7 +407,6 @@ button.attach:disabled { opacity: .35; }
|
|
|
410
407
|
.sub.agent.stale .working-dot { animation: none; }
|
|
411
408
|
/* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
|
|
412
409
|
.paused { color: var(--muted); font-weight: 600; }
|
|
413
|
-
.phase-bars.in-todo { --bar-fill: var(--muted); }
|
|
414
410
|
.msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
|
|
415
411
|
.msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
|
|
416
412
|
.msg.calls summary::-webkit-details-marker { display: none; }
|
|
@@ -464,7 +460,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
|
|
|
464
460
|
.view-only-strip b { color: var(--accent); }
|
|
465
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,
|
|
466
462
|
undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
|
|
467
|
-
.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; }
|
|
468
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. */
|
|
469
465
|
.down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
|
|
470
466
|
.down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
|
|
@@ -583,7 +579,7 @@ main > * { transition: opacity .25s ease; }
|
|
|
583
579
|
.view-settings .who.new { border-color: var(--accent); }
|
|
584
580
|
.view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
|
|
585
581
|
.view-settings .add input { flex: 1; min-width: 0; }
|
|
586
|
-
.view-settings .add.pick { flex-
|
|
582
|
+
.view-settings .add.pick { flex-direction: column; }
|
|
587
583
|
.view-settings .share-now { color: var(--ok); font-weight: 600; }
|
|
588
584
|
.view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
|
|
589
585
|
.view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
|
|
@@ -592,7 +588,11 @@ main > * { transition: opacity .25s ease; }
|
|
|
592
588
|
.view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
|
|
593
589
|
.view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
|
|
594
590
|
.view-settings .share-reach button { flex: none; }
|
|
595
|
-
.view-settings .
|
|
591
|
+
.view-settings .login-row { display: flex; gap: 6px; }
|
|
592
|
+
.view-settings .login-row input { flex: 1; min-width: 0; }
|
|
593
|
+
.view-settings .chip { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 4px 6px 4px 12px; border-radius: 14px; border: 1px solid var(--accent); font-size: 14px; }
|
|
594
|
+
.view-settings .chip button { padding: 0 6px; min-height: 0; }
|
|
595
|
+
.view-settings .problem { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 9px; }
|
|
596
596
|
.view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
|
597
597
|
.view-settings .pick-list button { text-align: left; }
|
|
598
598
|
.view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
|
|
@@ -640,9 +640,16 @@ main > * { transition: opacity .25s ease; }
|
|
|
640
640
|
.view-questions .opt .lbl { font-weight: 600; }
|
|
641
641
|
.view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
642
642
|
.view-questions .opt .det { color: var(--muted); font-size: 13px; }
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
.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; }
|
|
646
653
|
.view-questions .note { margin-top: 4px; }
|
|
647
654
|
.view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
|
|
648
655
|
.view-questions .rep { background: var(--claude); border-radius: 9px; padding: 7px 10px; margin-top: 6px; font-size: 13px; }
|
|
@@ -650,7 +657,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
650
657
|
.view-questions .q details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
|
651
658
|
.view-questions .topics { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; margin-bottom: 4px; }
|
|
652
659
|
.view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
|
|
653
|
-
background: var(--panel); color: var(--text);
|
|
660
|
+
background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
|
|
661
|
+
.view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
654
662
|
.view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
|
|
655
663
|
.view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
|
|
656
664
|
.view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
|
|
@@ -671,9 +679,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
671
679
|
.view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
|
|
672
680
|
.view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
|
|
673
681
|
.view-questions .q.resolved .reask-line { margin-top: 10px; }
|
|
674
|
-
.view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
|
|
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;
|
|
675
683
|
display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
|
|
676
|
-
.view-questions .bar .send-hint { margin-left: auto; }
|
|
677
684
|
/* The + rides above the sticky Send bar rather than over its Send. */
|
|
678
685
|
.view-questions .bar #qstatus { min-width: 0; }
|
|
679
686
|
.view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
|
|
@@ -748,6 +755,8 @@ main > * { transition: opacity .25s ease; }
|
|
|
748
755
|
:root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
|
|
749
756
|
--app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
|
|
750
757
|
}
|
|
758
|
+
/* The nav under the bar already pads the safe area (--app-bottom), so the gap under Send stays the 10px above it. */
|
|
759
|
+
.view-questions .bar { padding-bottom: 10px; }
|
|
751
760
|
body > .top, body.has-sidebar > :not(.sidebar) > .top {
|
|
752
761
|
position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
|
|
753
762
|
border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
|
|
@@ -761,8 +770,12 @@ main > * { transition: opacity .25s ease; }
|
|
|
761
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)))); }
|
|
762
771
|
}
|
|
763
772
|
|
|
764
|
-
/* Typing, a text field focused (shell.js sets :root.typing): the
|
|
765
|
-
|
|
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. */
|
|
774
|
+
@media (max-width: 699px) {
|
|
775
|
+
:root.typing .fab { display: none; }
|
|
776
|
+
/* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
|
|
777
|
+
:root.typing body > .top, :root.typing body.has-sidebar > :not(.sidebar) > .top { display: none; }
|
|
778
|
+
}
|
|
766
779
|
|
|
767
780
|
.settings-modal { position: fixed; inset: max(12px, env(safe-area-inset-top)) 0 calc(12px + max(var(--kb, 0px), var(--app-bottom, 0px))); margin: auto; width: min(680px, calc(100% - 24px));
|
|
768
781
|
height: fit-content; max-width: none; max-height: max(120px, calc(var(--vh, 100dvh) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - 24px - env(safe-area-inset-top)));
|
|
@@ -876,6 +889,9 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
876
889
|
.reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
|
|
877
890
|
.reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
878
891
|
.reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
892
|
+
.reader-row.call { cursor: pointer; }
|
|
893
|
+
.reader-caret { display: inline-block; width: 10px; margin-right: 4px; color: var(--muted); }
|
|
894
|
+
.reader-out { margin: 2px 14px 4px 28px; padding: 6px 8px; border-left: 2px solid var(--line); color: var(--muted); font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
879
895
|
.reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
|
|
880
896
|
.reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
881
897
|
.reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
|
|
@@ -924,13 +940,14 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
924
940
|
.deck-opts { display: flex; flex-direction: column; gap: 6px; }
|
|
925
941
|
.deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
|
|
926
942
|
.deck-opts button.rec { border-color: var(--accent); }
|
|
943
|
+
.deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
|
|
927
944
|
.deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
|
|
928
|
-
.deck-opts
|
|
945
|
+
.deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
|
|
929
946
|
.deck-prompt { margin: 0; padding: 0; list-style: none; }
|
|
930
947
|
.deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
|
931
948
|
.deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
932
949
|
.deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
|
|
933
|
-
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
|
|
950
|
+
.deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
|
|
934
951
|
.deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
|
|
935
952
|
.deck-dots i.on { background: var(--color-notice); }
|
|
936
953
|
.deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
|
|
@@ -949,12 +966,33 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
|
|
|
949
966
|
.view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
|
|
950
967
|
body.has-sidebar { --sidebar-w: 272px; }
|
|
951
968
|
html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
|
|
952
|
-
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
969
|
+
.needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
|
|
953
970
|
.needs-pane .needs-questions main { padding: 0; max-width: none; }
|
|
954
971
|
.needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
|
|
955
972
|
.needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
|
|
956
973
|
.needs-pane .waiting { margin: 0 0 12px; }
|
|
974
|
+
.needs-pane * { min-width: 0; overflow-wrap: anywhere; }
|
|
975
|
+
.needs-pane pre, .needs-pane code { white-space: pre-wrap; }
|
|
976
|
+
.needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
|
|
977
|
+
/* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
|
|
978
|
+
(.widened) lays the pane over the lanes, from the sidebar's edge. */
|
|
979
|
+
.needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
|
|
980
|
+
.needs-pane.viewing > :not(.pane-viewer) { display: none; }
|
|
981
|
+
.needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
|
|
982
|
+
.pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
983
|
+
.pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
984
|
+
.pv-top .pv-grow { flex: 1; }
|
|
985
|
+
.pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
|
|
986
|
+
.pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
|
|
987
|
+
.pv-top .pv-back:hover { background: var(--line); }
|
|
988
|
+
.pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
|
|
989
|
+
.pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
990
|
+
.pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
|
|
991
|
+
.pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
|
|
992
|
+
.pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
|
|
993
|
+
.pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
|
|
994
|
+
.pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
|
|
995
|
+
.pane-viewer .ps-sheet button span + span { color: var(--muted); }
|
|
957
996
|
}
|
|
958
997
|
/* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
|
|
959
998
|
body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
|
|
960
|
-
.msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }
|
package/board/public/ui-cards.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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, 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;
|
|
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, PHASES, 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
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
function blockedWords(it) {
|
|
69
69
|
const deps = it.blockedBy || [];
|
|
70
70
|
const waits = it.bumpedAt && !it.held;
|
|
71
|
-
const refs = deps.map((d, i) => `<a class="card-blocked" href="#
|
|
71
|
+
const refs = deps.map((d, i) => `<a class="card-blocked" href="#selectedCard=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
|
|
72
72
|
const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
|
|
73
73
|
const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
|
|
74
74
|
return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
if (!t) return null;
|
|
100
100
|
const words = tryWords(t.kind);
|
|
101
101
|
if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
|
|
102
|
-
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#
|
|
102
|
+
if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#selectedCard=${encodeURIComponent(it.key)}`,
|
|
103
103
|
title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
|
|
104
104
|
if (t.state !== 'ready' || !t.url) return null;
|
|
105
105
|
return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
|
|
@@ -172,23 +172,15 @@
|
|
|
172
172
|
// Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
|
|
173
173
|
// store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
|
|
174
174
|
// docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
|
|
175
|
-
const BUILD_PHASES =
|
|
175
|
+
const BUILD_PHASES = PHASES.progress;
|
|
176
176
|
const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
|
|
177
177
|
const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
|
|
178
|
-
// A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner
|
|
179
|
-
// phases it reached, which of them is running, whether it dims because the card waits on something
|
|
180
|
-
// questions, a blocker or queue, a reopening)
|
|
181
|
-
// build reached, undimmed
|
|
182
|
-
// (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
|
|
183
|
-
// 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
|
|
184
|
-
// prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
|
|
185
|
-
// docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
|
|
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
|
+
// 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), its build is stale or paused, and its label. An install
|
|
181
|
+
// is no build: its card keeps the phase its build reached, undimmed
|
|
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).
|
|
186
183
|
function phaseStrip(it) {
|
|
187
|
-
if (it.lane === 'todo' && it.builder?.planner) {
|
|
188
|
-
const prototype = it.builder.kind === 'prototype';
|
|
189
|
-
const done = prototype ? 2 : 1;
|
|
190
|
-
return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
|
|
191
|
-
}
|
|
192
184
|
if (it.lane !== 'progress' || it.builder?.planner) return null;
|
|
193
185
|
const all = BUILD_PHASES.length;
|
|
194
186
|
// Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
|
|
@@ -199,7 +191,7 @@
|
|
|
199
191
|
const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
|
|
200
192
|
if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
|
|
201
193
|
const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
|
|
202
|
-
return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
|
|
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}` };
|
|
203
195
|
}
|
|
204
196
|
const dim = !it.builder || asking;
|
|
205
197
|
if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
|
|
@@ -236,16 +228,16 @@
|
|
|
236
228
|
if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
|
|
237
229
|
return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
|
|
238
230
|
}
|
|
239
|
-
// 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
|
|
240
232
|
// (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
|
|
241
233
|
function statusWord(it) {
|
|
242
234
|
const b = it.builder;
|
|
243
235
|
if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
|
|
244
|
-
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');
|
|
245
237
|
if (it.waitsOn === 'checking') return 'Orchestrator review';
|
|
246
238
|
if (it.lane === 'done') return 'done';
|
|
247
239
|
if (it.grilling || it.prototyping) return 'preheating';
|
|
248
|
-
if (it.lane === 'todo') return it.
|
|
240
|
+
if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
|
|
249
241
|
if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
|
|
250
242
|
return 'in review';
|
|
251
243
|
}
|
|
@@ -267,17 +259,41 @@
|
|
|
267
259
|
: `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.'}`;
|
|
268
260
|
return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
|
|
269
261
|
}
|
|
262
|
+
// docs/adr/2026-11-11-1200-every-lane-has-phases.md
|
|
263
|
+
// A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
|
|
264
|
+
// Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
|
|
265
|
+
// phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
|
|
266
|
+
// only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
|
|
267
|
+
const PHASE_LOOK = {
|
|
268
|
+
new: ['new', 'quiet'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
|
|
269
|
+
draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
|
|
270
|
+
merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
|
|
271
|
+
};
|
|
272
|
+
function phaseWord(it) {
|
|
273
|
+
if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
|
|
274
|
+
const [word, tone] = PHASE_LOOK[it.phase];
|
|
275
|
+
return `<span class="card-phase ph-${tone}">${word}</span>`;
|
|
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>`;
|
|
270
282
|
// A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
|
|
271
|
-
//
|
|
272
|
-
// (docs/adr/2026-11-06-
|
|
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
|
|
285
|
+
// in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
|
|
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
|
|
273
287
|
// takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
|
|
274
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).
|
|
275
289
|
const SEP = '<span class="sep"> · </span>';
|
|
276
290
|
const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
|
|
277
291
|
function timeLine(it, unread, bars) {
|
|
278
292
|
const iso = it.sortAt;
|
|
279
|
-
const
|
|
280
|
-
|
|
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),
|
|
295
|
+
it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
|
|
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);
|
|
281
297
|
const news = newWords(unread, 'message');
|
|
282
298
|
const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
|
|
283
299
|
const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
|
|
@@ -285,7 +301,7 @@
|
|
|
285
301
|
return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
|
|
286
302
|
}
|
|
287
303
|
// The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
|
|
288
|
-
//
|
|
304
|
+
// Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
|
|
289
305
|
// durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
|
|
290
306
|
// at `now`, here rather than on the server, so the board's version doesn't change with the clock
|
|
291
307
|
// (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
@@ -296,7 +312,7 @@
|
|
|
296
312
|
+ `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
|
|
297
313
|
}
|
|
298
314
|
// A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
|
|
299
|
-
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is
|
|
315
|
+
// docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
|
|
300
316
|
const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
|
|
301
317
|
// A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
|
|
302
318
|
// card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
|
|
@@ -304,15 +320,18 @@
|
|
|
304
320
|
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>`;
|
|
305
321
|
const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
|
|
306
322
|
: it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
323
|
+
// docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
|
|
324
|
+
// still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
|
|
325
|
+
const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
|
|
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(' · ');
|
|
327
|
+
function workLines(it, { unread = 0, top = false } = {}) {
|
|
311
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);
|
|
312
|
-
// A work card always shows its ticket;
|
|
313
|
-
const
|
|
314
|
-
const ids = it.kind === 'ask' ? askChip(it) : chip;
|
|
329
|
+
// A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
|
|
330
|
+
const ids = it.kind === 'ask' ? askChip(it) : ticket;
|
|
315
331
|
const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
|
|
332
|
+
// A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
|
|
333
|
+
// (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
|
|
334
|
+
const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
|
|
316
335
|
const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
|
|
317
336
|
: it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
|
|
318
337
|
: it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
|
|
@@ -326,13 +345,13 @@
|
|
|
326
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).
|
|
327
346
|
const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
|
|
328
347
|
: `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
|
|
329
|
-
return `${
|
|
330
|
-
${status}${check}${eta}
|
|
348
|
+
return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
|
|
349
|
+
${asked}${status}${check}${eta}
|
|
331
350
|
${chips ? `<div class="line2 meta">${chips}</div>` : ''}
|
|
332
351
|
${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
|
|
333
352
|
}
|
|
334
353
|
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>';
|
|
335
|
-
const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
|
|
354
|
+
const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
|
|
336
355
|
function planButton(items) {
|
|
337
356
|
const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
|
|
338
357
|
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>` : '';
|
package/board/public/ui-enums.js
CHANGED
|
@@ -7,5 +7,14 @@
|
|
|
7
7
|
const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
|
|
8
8
|
const BUILDER_KIND = Object.freeze({ install: 'install' });
|
|
9
9
|
const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
|
|
10
|
-
|
|
10
|
+
// Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
|
|
11
|
+
// In Progress's are the five build steps its progress bar draws; a review's "reviewers" and "merge" read "pending review"
|
|
12
|
+
// and "pending merge" on the card (ui-cards.js PHASE_LOOK).
|
|
13
|
+
const PHASES = Object.freeze({
|
|
14
|
+
todo: Object.freeze(['new', 'grilling', 'prototyping', 'staging', 'ready']),
|
|
15
|
+
progress: Object.freeze(['reading', 'building', 'testing', 'checking', 'landing']),
|
|
16
|
+
review: Object.freeze(['draft', 'reviewers', 'merge', 'stale']),
|
|
17
|
+
done: Object.freeze(['merged', 'closed']),
|
|
18
|
+
});
|
|
19
|
+
Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES });
|
|
11
20
|
})();
|
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);
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
return anyLink(url, url) + esc(raw.slice(url.length));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#
|
|
71
|
+
const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#selectedCard=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
|
|
72
72
|
|
|
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) {
|
package/board/public/ui-order.js
CHANGED
|
@@ -4,20 +4,22 @@
|
|
|
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
|
-
//
|
|
8
|
-
// (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md
|
|
9
|
-
//
|
|
7
|
+
// Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
|
|
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). 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
|
|
10
11
|
// top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
|
|
11
12
|
// 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
|
|
13
|
+
// lane lists its cards newest first by sortAt, a card with no time last, the last added to its item file on top.
|
|
14
|
+
// Present times first, the earliest on top.
|
|
15
|
+
const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
|
|
13
16
|
function laneOrder(a, b) {
|
|
14
|
-
const bumped = (i) => i.bumpedAt || '';
|
|
15
17
|
const at = (i) => String(i.sortAt || '');
|
|
16
18
|
const oldestFirst = a.lane === 'todo';
|
|
17
19
|
const timeless = (i) => (at(i) ? 0 : 1);
|
|
18
20
|
const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
|
|
19
21
|
const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
|
|
20
|
-
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] ||
|
|
22
|
+
return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || age || added || a.key.localeCompare(b.key);
|
|
21
23
|
}
|
|
22
24
|
Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
|
|
23
25
|
})();
|
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
// The progress bar every bar on the board draws from: an In Progress card's five build phases and a
|
|
2
|
-
//
|
|
1
|
+
// The progress bar every bar on the board draws from: an In Progress card's five build phases (ui-cards.js), and a
|
|
2
|
+
// starting board's three start steps (ui-sidebar.js)
|
|
3
3
|
// (docs/adr/2026-11-06-0038-a-progress-bar-pulses-one-step-at-a-time.md). It needs nothing else, since hub/hub.mjs
|
|
4
4
|
// renders the shell's first sidebar with it in a vm context. style.css draws the bars.
|
|
5
5
|
(function () {
|
|
6
6
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
-
// {count, done, now, percent, dim,
|
|
7
|
+
// {count, done, now, percent, dim, label}, or null for no bar. done counts the steps reached, filled; now is the
|
|
8
8
|
// index of the one step still running, which pulses, so no bar can draw two. percent fills the step after the done
|
|
9
|
-
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
10
|
-
// Todo's grey; with none it is In Progress's orange.
|
|
9
|
+
// ones that far. A dim bar draws its running step filled, without the pulse.
|
|
11
10
|
function progressBar(bar) {
|
|
12
11
|
if (!bar) return '';
|
|
13
|
-
const { count, done, now, percent, dim,
|
|
12
|
+
const { count, done, now, percent, dim, label } = bar;
|
|
14
13
|
const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
|
|
15
14
|
: i === done && percent !== undefined ? `<i class="fill" style="--p:${percent}%"></i>` : '<i class=""></i>');
|
|
16
|
-
return `<span class="phase-bars${dim ? ' dim' : ''}
|
|
15
|
+
return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
|
|
17
16
|
}
|
|
18
17
|
// Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
|
|
19
18
|
// card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
|