@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.
Files changed (76) hide show
  1. package/CONTEXT.md +60 -48
  2. package/SKILL.md +20 -14
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +8 -8
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +118 -104
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/hub-lines.mjs +1 -0
  13. package/board/onboarding.mjs +2 -2
  14. package/board/prototype-page.mjs +1 -1
  15. package/board/public/app-arch.js +3 -3
  16. package/board/public/app-banner.js +1 -1
  17. package/board/public/app-cards.js +17 -9
  18. package/board/public/app-header.js +1 -1
  19. package/board/public/app-lanes.js +39 -61
  20. package/board/public/app-needs.js +116 -30
  21. package/board/public/app-reader.js +54 -44
  22. package/board/public/app-threads.js +2 -2
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +23 -13
  25. package/board/public/dialogs.js +1 -1
  26. package/board/public/hub-kit.js +1 -1
  27. package/board/public/hub.css +2 -0
  28. package/board/public/linear-key.js +1 -1
  29. package/board/public/new-task.js +1 -1
  30. package/board/public/progress-review.js +4 -4
  31. package/board/public/prototype-kit.js +27 -16
  32. package/board/public/prototype-switcher.js +47 -32
  33. package/board/public/prototype.html +7 -4
  34. package/board/public/router.js +35 -7
  35. package/board/public/shell.js +19 -13
  36. package/board/public/sidebar.css +4 -4
  37. package/board/public/sidebar.js +2 -2
  38. package/board/public/style.css +74 -36
  39. package/board/public/ui-cards.js +56 -37
  40. package/board/public/ui-enums.js +10 -1
  41. package/board/public/ui-hub.js +1 -5
  42. package/board/public/ui-links.js +6 -6
  43. package/board/public/ui-order.js +8 -6
  44. package/board/public/ui-progress-bar.js +6 -7
  45. package/board/public/ui-questions.js +30 -13
  46. package/board/public/ui-sidebar.js +3 -2
  47. package/board/public/ui-threads.js +20 -19
  48. package/board/public/ui.js +15 -4
  49. package/board/public/view-hub-settings.js +28 -31
  50. package/board/public/view-hub.js +7 -9
  51. package/board/public/view-notes.js +2 -2
  52. package/board/public/view-questions.js +35 -6
  53. package/board/public/view-settings.js +63 -31
  54. package/board/render-check.mjs +10 -10
  55. package/board/review.mjs +1 -1
  56. package/board/server.mjs +18 -20
  57. package/board/stop.mjs +1 -1
  58. package/board/store.mjs +26 -25
  59. package/board/tickets.mjs +1 -1
  60. package/board/transcript.mjs +4 -2
  61. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  62. package/hub/awake.mjs +1 -1
  63. package/hub/boards.mjs +4 -4
  64. package/hub/github.mjs +3 -3
  65. package/hub/home.mjs +1 -1
  66. package/hub/hub.mjs +11 -11
  67. package/hub/incoming.mjs +2 -2
  68. package/hub/locks.mjs +1 -1
  69. package/hub/new-board.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/try.mjs +6 -7
  72. package/hub/upgrade.mjs +1 -1
  73. package/hub/views.mjs +1 -1
  74. package/package.json +1 -1
  75. package/shared/tailscale.mjs +1 -1
  76. package/shared/usage-gate.mjs +1 -1
@@ -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-ready { color: var(--color-success); }
88
- .card-staging { color: var(--color-notice); }
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, .wb-loose { border-top: 1px solid var(--line); }
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-band-move], 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; }
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-wrap: wrap; }
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 .pick-col { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
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
- .view-questions .opt .suggested { margin-left: 6px; white-space: nowrap; }
644
- .view-questions .opt .suggested-why { margin-top: 4px; color: var(--muted); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
645
- .view-questions .opt .suggested-why a { color: var(--accent); }
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); white-space: normal; text-align: left; overflow-wrap: anywhere; }
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 floating + is disabled and the page stays as it is. */
765
- :root.typing .fab { opacity: .35; pointer-events: none; }
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 small { display: block; color: var(--muted); font-size: 12.5px; }
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; }
@@ -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="#item=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
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: `#item=${encodeURIComponent(it.key)}`,
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 = ['reading', 'building', 'testing', 'checking', 'landing'];
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 works on: how many
179
- // phases it reached, which of them is running, whether it dims because the card waits on something other than a builder (its
180
- // questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
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 wave file's raw status
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.staging ? 'staging' : it.ready ? 'ready' : 'to do';
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
- // staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), stalled in red or stale in grey
272
- // (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
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 parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>',
280
- it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && '<span class="card-stale">stale</span>'].filter(Boolean);
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
- // Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
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 the latest bump.
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
- const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && thinkingLine()].filter(Boolean).join(' · ');
308
- // In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
309
- // its bump with the band's ⋯ menu.
310
- function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
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; New is only on an ask (askChip).
313
- const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
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 `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
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>` : '';
@@ -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
- Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
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
  })();
@@ -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, nowRow, deviceRow, usageCard };
121
+ globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, deviceRow, usageCard };
126
122
  })();
@@ -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; a wave's own short id, such as B1, links only from that wave's cards, since a plan
18
- // for another wave reuses those names.
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 wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
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.wave !== wave)) continue;
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.wave === wave);
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 || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
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) {
@@ -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
- // Todo lists bumped cards first, the latest bump on top, a bumped New ask included
8
- // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
9
- // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
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 wave file on top.
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] || bumped(b).localeCompare(bumped(a)) || age || added || a.key.localeCompare(b.key);
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 Todo card's planner
2
- // dashes (ui-cards.js), and a starting board's three start steps (ui-sidebar.js)
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
- // {count, done, now, percent, dim, tone, label}, or null for no bar. done counts the steps reached, filled; now is the
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. tone is a lane class, 'in-todo' drawing it in
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, tone, label } = bar;
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' : ''}${tone ? ` ${tone}` : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
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.