@lovek4d/kickflip 0.7.0 → 0.7.1

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