@lovek4d/kickflip 0.7.1 → 0.7.4

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 (62) hide show
  1. package/CONTEXT.md +25 -21
  2. package/SKILL.md +13 -9
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +1 -1
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +68 -50
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -1
  12. package/board/onboarding.mjs +3 -3
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +2 -14
  15. package/board/public/app-banner.js +8 -3
  16. package/board/public/app-cards.js +7 -20
  17. package/board/public/app-core.js +2 -2
  18. package/board/public/app-lanes.js +7 -15
  19. package/board/public/app-reader.js +7 -3
  20. package/board/public/app-snapshot.js +1 -1
  21. package/board/public/app.js +1 -1
  22. package/board/public/attach.js +18 -2
  23. package/board/public/dialogs.js +10 -12
  24. package/board/public/hub.css +0 -3
  25. package/board/public/linear-key.js +1 -1
  26. package/board/public/sidebar.css +1 -1
  27. package/board/public/style.css +42 -36
  28. package/board/public/ui-cards.js +33 -27
  29. package/board/public/ui-enums.js +2 -2
  30. package/board/public/ui-links.js +0 -1
  31. package/board/public/ui-order.js +1 -1
  32. package/board/public/ui-questions.js +8 -6
  33. package/board/public/ui-sidebar.js +1 -1
  34. package/board/public/ui-threads.js +26 -22
  35. package/board/public/ui.js +15 -9
  36. package/board/public/view-hub-settings.js +25 -30
  37. package/board/public/view-hub.js +23 -5
  38. package/board/public/view-questions.js +1 -1
  39. package/board/public/view-settings.js +4 -4
  40. package/board/server.mjs +18 -18
  41. package/board/store.mjs +30 -27
  42. package/board/tickets.mjs +3 -3
  43. package/board/worktree.mjs +15 -15
  44. package/hub/board-repo.mjs +1 -1
  45. package/hub/github.mjs +3 -3
  46. package/hub/hub.mjs +11 -11
  47. package/hub/linear.mjs +1 -1
  48. package/hub/new-board.mjs +2 -2
  49. package/hub/pr-report.mjs +2 -2
  50. package/hub/readiness.mjs +1 -1
  51. package/hub/repos.mjs +3 -3
  52. package/hub/review-send.mjs +1 -1
  53. package/hub/schedule.mjs +1 -1
  54. package/hub/slack.mjs +1 -1
  55. package/hub/try.mjs +8 -6
  56. package/hub/views.mjs +1 -1
  57. package/package.json +1 -1
  58. package/shared/fake-gh.mjs +19 -4
  59. package/shared/gh.mjs +4 -0
  60. package/shared/owner-check.mjs +4 -3
  61. package/shared/service.mjs +12 -12
  62. package/starter/server.mjs +3 -3
@@ -73,6 +73,8 @@ main { max-width: 860px; margin: 0 auto; padding: 12px 16px 80px; }
73
73
  button.chip { border: 0; }
74
74
  .chip.tone-quiet { color: var(--color-quiet); } .chip.tone-normal { color: var(--color-info); } .chip.tone-notice { color: var(--color-notice); }
75
75
  .chip.tone-danger { color: var(--color-danger); } .chip.tone-ready { color: var(--color-success); } .chip.tone-merged { color: var(--color-merged); }
76
+ /* A PR's chips while GitHub's limit holds the hub's check (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md) */
77
+ .chip[data-limited] { opacity: .45; }
76
78
  /* Action needed keeps the notice orange; unread takes the accent so the two never read alike. */
77
79
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; background: var(--color-quiet); }
78
80
  .dot.pass { background: var(--color-success); } .dot.pending { background: var(--color-notice); } .dot.fail { background: var(--color-danger); }
@@ -141,7 +143,7 @@ button.chip { border: 0; }
141
143
  .thread-more:active { background: var(--accent-soft); }
142
144
  .thread-more:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
143
145
  .thread-more .n { color: var(--muted); font-weight: 400; }
144
- /* [answered asks have their own section below Done, and Done and Asks show 10 cards at a time](docs/adr/2026-11-03-0259-answered-asks-have-their-own-section-below-done.md): Done's and Asks' Show more, the thread's look as a row of the lane's list */
146
+ /* [answered asks fold into the one Done list](docs/adr/2026-11-09-1430-answered-asks-fold-into-the-one-done-list.md): Done's Show more, the thread's look as a row of the lane's list */
145
147
  .list > .thread-more { width: 100%; margin: 0; border-top: 1px solid var(--line); border-radius: 0; }
146
148
 
147
149
  @media (max-width: 560px) {
@@ -152,9 +154,8 @@ button.chip { border: 0; }
152
154
  }
153
155
  .calls ul { margin: 4px 0 8px; padding-left: 18px; font-size: 13px; }
154
156
  button.link { background: none; border: 0; color: var(--accent); padding: 8px 12px; font-size: 13px; cursor: pointer; }
155
- .wave-icon { display: inline-block; vertical-align: -2px; flex: none; }
156
- .wv-link { border: 0; background: none; padding: 0; min-height: 0; color: var(--accent); font: inherit; cursor: pointer; }
157
- .wv-link:disabled { color: var(--muted); cursor: default; }
157
+ .wb-chevron { flex: none; color: var(--muted); }
158
+ .wb[data-open] > .wb-head .wb-chevron { transform: rotate(90deg); }
158
159
  .wv-busy { display: inline-flex; align-items: center; gap: 6px; color: var(--color-notice); }
159
160
  /* docs/adr/2026-11-03-1840-waves-are-bands-in-todo.md: each wave is a lane-style band inside Todo's list. */
160
161
  .wb + .wb { border-top: 1px solid var(--line); }
@@ -162,15 +163,11 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
162
163
  background: var(--band); font-size: 13px; font-weight: 500; cursor: pointer; user-select: none; }
163
164
  .wb-head:hover { background: color-mix(in srgb, var(--band), var(--text) 5%); }
164
165
  .wb-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
165
- .wb-head .wave-icon { color: var(--muted); }
166
166
  .wb-head .count { color: var(--muted); font-weight: 400; }
167
- .wb[data-open] > .wb-head { border-bottom: 1px solid var(--line); }
167
+ .wb-body { border-top: 1px solid var(--line); }
168
168
  .wb-label { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; }
169
169
  .wb-name { -webkit-touch-callout: none; }
170
- .wb-end { display: flex; align-items: baseline; gap: 10px; margin-left: auto; font-weight: 400; }
171
170
  .wb-links { display: inline-flex; align-items: baseline; white-space: nowrap; }
172
- .wb-next { color: var(--muted); white-space: nowrap; }
173
- .wb-head .wv-link { font-weight: 500; }
174
171
  .wb-pop { position: absolute; left: 34px; right: 12px; top: calc(100% - 2px); z-index: 6; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px;
175
172
  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; }
176
173
  .wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
@@ -303,7 +300,7 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
303
300
  }
304
301
  /* The hub lives at the tailnet root, outside this site, so its link is root-absolute. */
305
302
  .brand { align-items: center; }
306
- .hub { display: inline-flex; flex: none; }
303
+ a.hub { display: inline-flex; flex: none; }
307
304
  .hub img { width: 32px; height: auto; display: block; image-rendering: pixelated; }
308
305
  @keyframes pulse { 50% { opacity: .35; } }
309
306
  /* A lane's folds are inset boxes in its list, so they read as part of that lane, not as more lanes. */
@@ -378,13 +375,18 @@ button.attach:disabled { opacity: .35; }
378
375
  /* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
379
376
  .pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
380
377
  .pickup-wait .working-dot { background: currentColor; animation: none; }
381
- .msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
382
- .msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
378
+ /* Every status line shares this spacing and gutter: the mark sits in the first 23px, and wrapped text hangs under the
379
+ first line's text, never under the mark. A fold's summary hangs the same way inside its box. Each kind keeps only its colour. */
380
+ .status-line { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
381
+ div.status-line { padding-left: 33px; text-indent: -23px; }
382
+ .status-line > summary { padding-left: 23px; text-indent: -23px; }
383
+ .status-line > ol { text-indent: 0; }
384
+ .msg.builder-status { color: var(--color-notice); }
383
385
  .msg.builder-status .working-dot { animation: none; }
384
- .msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
386
+ .msg.request { color: var(--muted); }
385
387
  .msg.request.readied { color: var(--color-success); }
386
388
  .msg.request.hub.fault { color: var(--color-danger); }
387
- .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
389
+ .msg.agent { font-weight: 600; }
388
390
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
389
391
  .msg.agent .muted { font-weight: 400; }
390
392
  /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
@@ -407,7 +409,6 @@ button.attach:disabled { opacity: .35; }
407
409
  .sub.agent.stale .working-dot { animation: none; }
408
410
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
409
411
  .paused { color: var(--muted); font-weight: 600; }
410
- .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
411
412
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
412
413
  .msg.calls summary::-webkit-details-marker { display: none; }
413
414
  .msg.calls[open] summary .mark-icon { transform: rotate(90deg); }
@@ -641,14 +642,18 @@ main > * { transition: opacity .25s ease; }
641
642
  .view-questions .opt .rec { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
642
643
  .view-questions .opt .det { color: var(--muted); font-size: 13px; }
643
644
  /* 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; }
645
+ (docs/adr/2026-11-09-1500-a-questions-descriptions-are-markdown-and-its-text-is-one-brief-line.md). So does any .md
646
+ box: the orchestrator's thread messages, markdown replies and View transcript's text rows
647
+ (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
648
+ lines, not the text's own newlines. */
649
+ :is(.msg .body, .reader-row).md { white-space: normal; }
650
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
651
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
652
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
653
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
654
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
655
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
656
+ :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) > :last-child { margin-bottom: 0; }
652
657
  .view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
653
658
  .view-questions .note { margin-top: 4px; }
654
659
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
@@ -732,18 +737,18 @@ main > * { transition: opacity .25s ease; }
732
737
  .view-notes .plan li { margin: 3px 0; }
733
738
  .view-notes .plan li.nested { margin-left: 20px; }
734
739
  .view-notes .plan code { font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 0 3px; }
735
- .view-notes .plan pre { overflow-x: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
736
- .view-notes .plan pre code { background: none; padding: 0; overflow-wrap: normal; }
740
+ .view-notes .plan pre { white-space: pre-wrap; overflow-x: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
741
+ .view-notes .plan pre code { background: none; padding: 0; }
737
742
  .view-notes .plan blockquote { border-left: 3px solid var(--line); padding-left: 10px; color: var(--muted); }
738
743
  .view-notes .plan hr { border: 0; border-top: 1px solid var(--line); margin: 16px 0; }
739
744
  .view-notes .plan a { color: var(--accent); }
740
- .view-notes .md-table { overflow-x: auto; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
741
- .view-notes .md-table table { border-collapse: collapse; font-size: 13px; line-height: 1.4; }
742
- .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); min-width: 5em; overflow-wrap: normal; }
745
+ .view-notes .md-table { overflow-x: hidden; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); }
746
+ .view-notes .md-table table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.4; }
747
+ .view-notes .md-table th, .view-notes .md-table td { text-align: left; vertical-align: top; padding: 6px 8px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
743
748
  .view-notes .md-table th { background: var(--bg); font-weight: 600; }
744
749
  .view-notes .md-table tr:last-child td { border-bottom: 0; }
745
- .view-notes .md-table .wide { min-width: 14em; }
746
- .view-notes .md-table .wider { min-width: 24em; }
750
+ /* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
751
+ .view-questions, .view-notes, .reader-sheet, .pane-viewer { overflow-x: hidden; }
747
752
  /* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
748
753
  .view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
749
754
  .view-frame > .top { flex: none; }
@@ -829,18 +834,18 @@ main > * { transition: opacity .25s ease; }
829
834
  its content gives way to a spinner in its text colour; the New version row's spinner sits in its pill; a bump icon keeps
830
835
  its arrow and pulses orange until the card moves; a toggle, select or field keeps its face with a spinner beside it */
831
836
  button[aria-busy="true"], button[aria-busy="true"]:disabled, .ask-actions .icon[aria-busy="true"]:disabled { cursor: default; opacity: 1; }
832
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) { position: relative; -webkit-text-fill-color: transparent; }
833
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) > * { visibility: hidden; }
834
- :is(button[aria-busy="true"]:not([data-card-bump], [data-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go)::after { content: ''; position: absolute; inset: 0; margin: auto;
837
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) { position: relative; -webkit-text-fill-color: transparent; }
838
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go) > * { visibility: hidden; }
839
+ :is(button[aria-busy="true"]:not([data-card-bump], .sb-update-row), .sb-update-row[aria-busy="true"] .sb-go)::after { content: ''; position: absolute; inset: 0; margin: auto;
835
840
  width: 12px; height: 12px; box-sizing: border-box; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: try-spin .8s linear infinite; }
836
841
  @keyframes wait-orange { 50% { color: var(--color-notice); opacity: 1; } }
837
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) { color: var(--muted); animation: wait-orange 1s ease-in-out infinite; }
838
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentColor; }
842
+ button[aria-busy="true"]:is([data-card-bump]) { color: var(--muted); animation: wait-orange 1s ease-in-out infinite; }
843
+ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
839
844
  .try-spin.tap-beside { width: 12px; height: 12px; border-width: 2px; margin: 0 8px 0 auto; color: var(--muted); }
840
845
  .tk-field > span > .try-spin.tap-beside { margin: 0 0 0 6px; vertical-align: -2px; }
841
846
  @media (prefers-reduced-motion: reduce) {
842
847
  :is(button[aria-busy="true"], .sb-update-row[aria-busy="true"] .sb-go)::after { animation: none; }
843
- button[aria-busy="true"]:is([data-card-bump], [data-bump]) { animation: none; color: var(--color-notice); }
848
+ button[aria-busy="true"]:is([data-card-bump]) { animation: none; color: var(--color-notice); }
844
849
  }
845
850
  .install { display: inline-flex; align-items: center; gap: 4px; }
846
851
  .external-links .install.tone-ready { color: var(--color-success); }
@@ -996,3 +1001,4 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
996
1001
  }
997
1002
  /* 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. */
998
1003
  body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
1004
+ .pfile.retry { cursor: pointer; }
@@ -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, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
3
+ const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, 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
@@ -24,14 +24,21 @@
24
24
  const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
25
25
  const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
26
26
  const live = pr.state === 'OPEN';
27
- const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
28
- const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
27
+ // docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md: while GitHub's limit holds the hub's check
28
+ // (pr.limited, its reset), the PR's chips dim and their titles say when it resets.
29
+ const note = pr.limited ? ` · GitHub rate limited, resets at ${clock(new Date(pr.limited))}` : '';
30
+ const dim = pr.limited ? { 'data-limited': '' } : {};
31
+ const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}${note}`, attrs: dim }) : '';
32
+ const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet${note}`, attrs: dim }) : '';
29
33
  const name = prName(pr);
30
- const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
34
+ const label = look.label ? { title: `${look.label}${note}`, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}`, ...dim } } : { title: note.slice(3) || undefined, attrs: { ...EXTERNAL, ...dim } };
31
35
  return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
32
36
  }
33
37
  const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
34
- const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
38
+ const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: EXTERNAL });
39
+ // docs/adr/2026-11-09-1500-a-task-is-a-card-with-no-ticket-keyed-t-n.md: a card with no ticket is a task, its T-<n>
40
+ // only in the badge's title.
41
+ const taskChip = (it) => badge('quiet', 'Task', { title: `${it.ref} · a task, no ticket` });
35
42
  const REVIEW = {
36
43
  approved: { tone: 'ready', mark: '✓', label: 'approved' },
37
44
  changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
@@ -128,9 +135,9 @@
128
135
  return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
129
136
  }
130
137
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
131
- // Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
138
+ // Main's refresh icon on a board with an origin (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
132
139
  // the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
133
- // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
140
+ // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover that opens when a pull fails
134
141
  // (`open`). Visit is left alone: it changes only once Main rebuilds.
135
142
  function mainPull(pull, { open = false } = {}) {
136
143
  if (!pull) return '';
@@ -140,14 +147,18 @@
140
147
  const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
141
148
  return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
142
149
  }
150
+ // While Main re-serves, or after its new build failed, the old server still serves (main.serving): the bar names its
151
+ // commit and keeps Visit to it, with Serving… or Failed left of Visit.
143
152
  function mainBar(main, pulling) {
144
153
  if (!main) return '';
145
154
  const words = tryWords(main.kind);
146
155
  const url = tryURL(main);
147
- const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
148
- : main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
149
- : main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
150
- return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
156
+ const old = main.serving;
157
+ const status = main.state === 'running' ? badge('notice', spinner + esc(old ? 'Serving…' : words.going), { attrs: { role: 'status' } })
158
+ : main.state === 'failed' ? badge('danger', esc(old ? 'Failed' : words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' }) : '';
159
+ const visit = (main.state === 'ready' || old) && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
160
+ const shown = old || main;
161
+ return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(shown.sha)}</code> · ${esc(ago(shown.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${status}${visit}</div></div>`;
151
162
  }
152
163
  // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
153
164
  // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
@@ -311,27 +322,24 @@
311
322
  return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
312
323
  + `<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>`;
313
324
  }
314
- // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
315
- // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
316
- const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
325
+ // A New card's bump, edit and delete: it bumps as a card, and edits and deletes its ask; its line comes from its ask
326
+ // state (docs/adr/2026-11-09-1900-every-ask-is-a-task-card-from-the-moment-it-arrives.md).
327
+ const askActions = (it) => `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}${editButton(it.ask.eventId)}${withdrawButton(it.ask.eventId)}</div>`;
317
328
  // A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
318
329
  // 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).
319
330
  const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
320
331
  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>`;
321
- const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
332
+ const cardActions = (it) => (it.isNew ? askActions(it) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
322
333
  : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
323
334
  // docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
324
335
  // still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
325
336
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
326
337
  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 } = {}) {
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);
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;
338
+ function workLines(it, { unread = 0 } = {}) {
339
+ const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
340
+ // A card shows its ticket, or Task when it has none; the card an ask became shows New there until it is sorted.
341
+ const ids = it.isNew ? badge('notice', 'New') : ticket;
331
342
  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>` : '';
335
343
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
336
344
  : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
337
345
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
@@ -345,13 +353,11 @@
345
353
  // 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).
346
354
  const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
347
355
  : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
348
- return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
349
- ${asked}${status}${check}${eta}
356
+ return `${cardActions(it)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
357
+ ${status}${check}${eta}
350
358
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
351
359
  ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
352
360
  }
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>';
354
- const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
355
361
  function planButton(items) {
356
362
  const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
357
363
  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>` : '';
@@ -395,5 +401,5 @@
395
401
  <div class="list">${list}${fold}</div></details>`;
396
402
  }
397
403
 
398
- Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
404
+ Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askActions, askLine, planButton, reviewLane });
399
405
  })();
@@ -3,8 +3,8 @@
3
3
  // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
4
  // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
- const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge', reply: 'reply' });
7
- const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply' });
7
+ const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', 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).
@@ -20,7 +20,6 @@
20
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
- if (i.kind === 'ask') continue;
24
23
  for (const ref of new Set([i.ref, i.id])) {
25
24
  if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
26
25
  byRef.set(ref, [...(byRef.get(ref) || []), i]);
@@ -3,7 +3,7 @@
3
3
  // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
- const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
6
+ const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3 };
7
7
  // Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
8
8
  // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
9
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
@@ -1,6 +1,6 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // ui-markdown.js loads after this file, so markdown is looked up when a question draws.
5
5
  const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
6
6
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
@@ -44,10 +44,12 @@
44
44
  : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
45
45
  : badge('normal', `Prototype round ${r.n}`);
46
46
  const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
47
+ // Each of an answer's replies, oldest first.
48
+ const repliesOf = (a, links) => (a.replies?.length ? a.replies : a.reply ? [a.reply] : []).map((r) => `<div class="rep${r.md ? ' md' : ''}"><div class="muted small">Claude</div>${replyBody(r, links)}</div>`).join('');
47
49
  function answerHistory(q, links = {}) {
48
50
  if (!q.answers.length) return '';
49
51
  const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
50
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : ''}`).join('');
52
+ ${repliesOf(a, links)}`).join('');
51
53
  const last = q.answers[q.answers.length - 1];
52
54
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
53
55
  }
@@ -99,7 +101,7 @@
99
101
  const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
100
102
  const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
101
103
  const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
102
- ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
104
+ ${repliesOf(last, links)}` : '';
103
105
  return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
104
106
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
105
107
  <h3>${linkText(q.question, links)}</h3>
@@ -131,15 +133,15 @@
131
133
  ${answerHistory(q, links)}
132
134
  </section>`;
133
135
  }
134
- // A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
135
- const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
136
+ // A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from.
137
+ const topicName = (t) => t.title;
136
138
  // /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
137
139
  // is on the board (board/server.mjs pageBoard). cards: the board's items.
138
140
  function cardTopics(topics, cards) {
139
141
  const byKey = new Map(cards.map((c) => [c.key, c]));
140
142
  return topics.map((t) => {
141
143
  const c = t.title === undefined && byKey.get(t.card);
142
- return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
144
+ return c ? { ...t, title: `${c.ref}: ${c.title}` } : t;
143
145
  });
144
146
  }
145
147
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
@@ -129,7 +129,7 @@
129
129
  + '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
130
130
  }
131
131
 
132
- const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
132
+ const ticketsLabel = (t) => (t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : t.source === 'linear' ? `Linear · ${t.team}` : 'None');
133
133
  // A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
134
134
  // reading is older than ten minutes, with that age in its tooltip.
135
135
  const CTX_STALE_MIN = 10;
@@ -1,8 +1,13 @@
1
1
  // A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
2
- // asks, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
2
+ // asks, the owner's messages and their replies. Needs ui.js, ui-links.js, ui-enums.js and ui-markdown.js.
3
3
  (function () {
4
- const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
4
+ const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
+ // ui-markdown.js loads after this file, so markdown is looked up when a thread draws.
7
+ const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
8
+ // The orchestrator's own messages are markdown in compact styles; the owner's, a shared person's and GitHub's or
9
+ // Slack's stay as written (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
10
+ const drawsMarkdown = (m) => m.who === 'claude' && !m.html;
6
11
  // A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
7
12
  function cardLink(link) {
8
13
  const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
@@ -25,9 +30,9 @@
25
30
  const withReplies = (msgs) => {
26
31
  // A folded submit's answers each reply, quoting their own block.
27
32
  const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
28
- ...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
33
+ ...(m.answers || [m]).filter((a) => a.eventId && a.reply).flatMap((a) => (a.replies?.length ? a.replies : [a.reply]).map((reply, k) => ({ m: { ts: reply.at || m.ts, who: 'claude', kind: 'reply', reply }, of: m, quoted: m.answers ? a.text : m.text, later: k > 0, at: String(reply.at || m.ts), i, part: 1 })))]);
29
34
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
30
- return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
35
+ return out.map(({ m, of, quoted, later }, n) => (of ? { ...m, quote: later || out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
31
36
  };
32
37
  // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
33
38
  // files it adds to the message, under composer id edit:<event id>.
@@ -35,26 +40,26 @@
35
40
  <div class="pending-files" data-files></div>
36
41
  <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
37
42
  const line = (m, links, edits) => (m.kind === 'reply'
38
- ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
39
- : m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
43
+ ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body${m.reply.md ? ' md' : ''}">${replyBody(m.reply, links)}</div></div>`
44
+ : m.kind === 'ask-link' ? `<div class="msg status-line link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
40
45
  // docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
41
- : m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
46
+ : m.kind === 'visual' ? `<div class="msg status-line link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
47
  // A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
43
- : m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
44
- : m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
48
+ : m.request && m.card ? `<div class="msg status-line request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
49
+ : m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
45
50
  // docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
46
51
  // own line, its kind's mark and words, red for a fault.
47
- : m.kind === 'hub' ? `<div class="msg request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
52
+ : m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
48
53
  // docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
49
- : m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
50
- : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
54
+ : m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
55
+ : m.kind === 'questions' ? `<div class="msg status-line builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
51
56
  : m.folded ? foldedRun(m, links, edits)
52
- : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
53
- : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
54
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
57
+ : m.kind === 'status' ? `<div class="msg status-line builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
58
+ : m.kind === 'calls' ? `<details class="msg status-line calls"><summary>${statusMark('judgment calls')}${escText(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
59
+ : m.kind === 'agent' ? `<div class="msg status-line agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
55
60
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
56
61
  <div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
57
- <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
62
+ <div class="body${drawsMarkdown(m) ? ' md' : ''}"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : drawsMarkdown(m) ? markdown(m.text, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
58
63
  ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
59
64
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
60
65
  ${m.eventId ? msgFooter(m) : ''}
@@ -68,7 +73,7 @@
68
73
  const tail = m.folded.at(-1).finished ? 'finished' : 'running';
69
74
  const log = m.folded.find((f) => f.log)?.log;
70
75
  const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
71
- return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
76
+ return `<details class="msg status-line calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
72
77
  }
73
78
  function foldStatus(lines) {
74
79
  const runs = new Map();
@@ -115,12 +120,11 @@
115
120
  }
116
121
 
117
122
  // docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
118
- // An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends ask-done or
119
- // card-done (app-cards.js); the board applies it before the session follows up
123
+ // An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends card-done
124
+ // (app-cards.js); the board applies it before the session follows up
120
125
  // (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
121
- // Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
122
- // (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
123
- const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
126
+ // Primary while a card waits only on it (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
127
+ const doneTone = (it) => (it.awaitsDone ? 'closes' : '');
124
128
  function doneButton(it) {
125
129
  const tone = doneTone(it);
126
130
  return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
@@ -35,7 +35,10 @@
35
35
  const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
36
36
  return `${day}, ${at}`;
37
37
  }
38
- const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
38
+ // A reply as HTML with its refs linked: a markdown reply drawn by ui-markdown.js, which, like ui-links.js, loads after
39
+ // this file; an older HTML one as written. Its box takes class md for a markdown reply's compact styles.
40
+ const replyBody = (reply, links) => (reply.md ? globalThis.BoardUI.markdown(reply.md, links)
41
+ : globalThis.BoardUI.linkHtml(reply.html ?? esc(JSON.stringify(reply.json)), links));
39
42
 
40
43
  // Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
41
44
  const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
@@ -127,11 +130,9 @@
127
130
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
128
131
  const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
129
132
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
130
- // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange and disabled once
131
- // bumped. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
132
- // it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
133
+ // A card's bump: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where it is filled orange,
134
+ // card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
133
135
  // docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
134
- const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
135
136
  const cardBumpButton = (key, bumped) => (bumped
136
137
  ? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
137
138
  : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
@@ -151,11 +152,16 @@
151
152
  return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
152
153
  }
153
154
  // A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
154
- const pendingFiles = (list) => list.map((p) => `<span class="pfile ${esc(p.status)}" title="${esc(p.name)}">
155
+ // An errored chip retries on click, except "Over 20 MB", which a retry can't change.
156
+ const pendingFiles = (list) => list.map((p) => {
157
+ const retry = p.status === 'error' && p.error !== 'Over 20 MB';
158
+ const title = retry ? `${p.name}: tap to retry` : p.status === 'error' ? `${p.name}: a retry can't succeed, remove it` : p.name;
159
+ return `<span class="pfile ${esc(p.status)}${retry ? ' retry' : ''}" title="${esc(title)}"${retry ? ` data-retry="${esc(p.id)}"` : ''}>
155
160
  ${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
156
161
  ${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
157
- ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
158
- <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`).join('');
162
+ ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}${retry ? ' · tap to retry' : ''}</span>` : ''}
163
+ <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`;
164
+ }).join('');
159
165
 
160
166
  // Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
161
167
  // being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
@@ -459,6 +465,6 @@
459
465
  globalThis.BoardUI = {
460
466
  LINK_ARROW, escText, onBackdrop,
461
467
  esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
462
- attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
468
+ attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
463
469
  };
464
470
  })();