@lovek4d/kickflip 0.10.1 → 0.11.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 (40) hide show
  1. package/CONTEXT.md +19 -16
  2. package/SKILL.md +7 -7
  3. package/bin/event-lines.mjs +1 -1
  4. package/board/bin/board.mjs +30 -17
  5. package/board/domain.mjs +7 -7
  6. package/board/headless.mjs +8 -8
  7. package/board/public/app-cards.js +2 -0
  8. package/board/public/app-header.js +8 -0
  9. package/board/public/app-lanes.js +43 -19
  10. package/board/public/app-snapshot.js +1 -2
  11. package/board/public/app-threads.js +2 -2
  12. package/board/public/app.html +0 -1
  13. package/board/public/app.js +25 -27
  14. package/board/public/col-resize.js +25 -44
  15. package/board/public/dialogs.js +2 -2
  16. package/board/public/new-task.js +2 -2
  17. package/board/public/prompts.js +1 -1
  18. package/board/public/prototype-kit.js +1 -1
  19. package/board/public/prototype-switcher.js +3 -8
  20. package/board/public/prototype.html +2 -6
  21. package/board/public/router.js +16 -22
  22. package/board/public/shell.js +3 -39
  23. package/board/public/style.css +47 -95
  24. package/board/public/ui-cards.js +29 -22
  25. package/board/public/ui-enums.js +3 -3
  26. package/board/public/ui-page-header.js +2 -2
  27. package/board/public/ui-prompts.js +1 -1
  28. package/board/public/ui-questions.js +2 -3
  29. package/board/public/ui.js +10 -2
  30. package/board/public/view-hub.js +1 -1
  31. package/board/public/view-questions.js +7 -26
  32. package/board/server.mjs +18 -18
  33. package/board/store.mjs +32 -32
  34. package/hub/hub.mjs +8 -8
  35. package/hub/prompts.mjs +2 -2
  36. package/hub/review-send.mjs +1 -1
  37. package/hub/slack.mjs +4 -2
  38. package/hub/views.mjs +1 -1
  39. package/package.json +1 -1
  40. package/board/public/app-needs.js +0 -248
@@ -242,35 +242,10 @@ window.AppShell = (function () {
242
242
  // go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
243
243
  // own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
244
244
  let router = null;
245
- const wide = matchMedia('(min-width: 900px)');
246
- // Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
247
- // from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
248
- // hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
249
- // or null for none). A board's prototype page does the same and opens in the pane
250
- // (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
251
- const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
252
- // ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
253
- // the pane is asked again until it has.
254
- const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
255
- function askPane(ask, tries = 50) {
256
- if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
257
- setTimeout(() => askPane(ask, tries - 1), 100);
258
- }
245
+ // Board and Waiting on you are a board's two pages at every width, and a prototype is a page of its own
246
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md, docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md).
259
247
  function go(view, href, how, from) {
260
248
  if (!AppRouter.mounted(view)) return false;
261
- if (inPane(view)) {
262
- const ask = paneAsk(view, href);
263
- const board = { kind: 'board', route: view.route, hash: '' };
264
- const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
265
- if (how === 'pop') history.replaceState(history.state, '', boardHref);
266
- // On its own board it only asks the pane, so the URL, history and an open card stay as they are.
267
- else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
268
- askPane(ask);
269
- sidebar.close();
270
- return true;
271
- }
272
- return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
273
- }
274
249
  if (view.kind === 'settings') {
275
250
  if (current?.view.kind === 'board' && current.route === view.route) {
276
251
  const here = AppRouter.viewOf(location.href, { boards: boards() });
@@ -322,17 +297,7 @@ window.AppShell = (function () {
322
297
  }
323
298
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
324
299
 
325
- let pane = null;
326
- if (inPane(first)) {
327
- pane = paneAsk(first, location.href);
328
- first = { kind: 'board', route: first.route, hash: '' };
329
- history.replaceState(history.state, '', AppRouter.urlOf(first));
330
- }
331
- router = AppRouter.listen({ boards, go, wide: () => wide.matches });
332
- // Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
333
- wide.addEventListener('change', () => {
334
- if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
335
- });
300
+ router = AppRouter.listen({ boards, go });
336
301
  opener = (url, { replace = false, frames = true } = {}) => {
337
302
  const view = AppRouter.viewOf(url, { boards: boards() });
338
303
  if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
@@ -348,7 +313,6 @@ window.AppShell = (function () {
348
313
  navigator.serviceWorker?.startMessages?.();
349
314
 
350
315
  show(first);
351
- if (pane) askPane(pane);
352
316
  openStream();
353
317
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
354
318
  addEventListener('pageshow', openStream);
@@ -85,7 +85,7 @@ button.chip { border: 0; }
85
85
  .sub.agent { color: var(--color-notice); }
86
86
  .sub.next { color: var(--color-notice); }
87
87
  .card-hold { color: var(--color-quiet); }
88
- .card-blocked, .card-action { color: var(--color-notice); }
88
+ .card-blocked { color: var(--color-notice); }
89
89
  /* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
90
90
  .card-phase.ph-quiet { color: var(--color-quiet); }
91
91
  .card-phase.ph-info { color: var(--color-info); }
@@ -285,6 +285,9 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
285
285
  border-radius: 15px 15px 0 0; padding-bottom: calc(max(12px, calc(env(safe-area-inset-bottom) - var(--app-bottom, 0px))) + var(--kb, 0px)); }
286
286
  .sheet.keyboard-sheet.kb form { padding-bottom: calc(10px + var(--kb, 0px)); }
287
287
  #askText { font-size: 16px; }
288
+ /* The thread box is as tall as the New task box: 4 rows, its 8px top padding and the 40px under the text for the
289
+ attach row (the borders sit inside the box). It grows past that as it does elsewhere. */
290
+ form[data-reply] .dz.bare > textarea { font-size: 16px; min-height: calc(4lh + 48px); }
288
291
  }
289
292
  @keyframes sheet-in { from { transform: translateY(100%); } }
290
293
  @keyframes sheet-out { to { transform: translateY(100%); } }
@@ -392,19 +395,22 @@ div.status-line { padding-left: 33px; text-indent: -23px; }
392
395
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
393
396
  .msg.agent .muted { font-weight: 400; }
394
397
  /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
395
- (docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). On the card it takes the lane's colour below. */
398
+ (docs/adr/2026-11-03-1551-planners-and-implementers-are-two-kinds-of-agent.md). On the card it is blue too (.sub.agent.planner below). */
396
399
  .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
397
400
  .planner .working-dot { background: currentColor; }
398
- /* A run's status line, dot included, takes its card's lane colour, a planner's too: grey in Todo, the phase-bar orange in
401
+ /* A builder run's status line, dot included, takes its card's lane colour: grey in Todo, the phase-bar orange in
399
402
  In Progress, blue in In Review; a Todo card's planner dashes are its grey, the running one pulsing
400
403
  (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). */
401
404
  .sub.agent.in-todo { color: var(--muted); }
402
405
  .sub.agent.in-progress { color: var(--color-notice); }
403
406
  .sub.agent.in-review { color: var(--color-info); }
404
407
  .sub.agent.in-todo .working-dot, .sub.agent.in-progress .working-dot, .sub.agent.in-review .working-dot { background: currentColor; }
405
- /* docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md: a quiet builder and a request no planner
406
- has picked up are grey, after the lane colours so they win. */
407
- .sub.agent.quiet, .sub.asked { color: var(--muted); }
408
+ /* docs/adr/2026-11-12-0600-every-planner-launches-from-the-orchestrators-brief.md: a grill, prototype or visual's status
409
+ line, its brief's and its agent's, is blue in every lane, after the lane colours so it wins. */
410
+ .sub.agent.planner { color: var(--color-info); }
411
+ /* docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md: a quiet builder is grey, after the
412
+ lane colours so it wins. */
413
+ .sub.agent.quiet { color: var(--muted); }
408
414
  .sub.agent.waiting-gray { color: var(--muted); }
409
415
  /* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
410
416
  .sub.agent.stale { color: var(--muted); }
@@ -444,8 +450,11 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
444
450
  /* INT-30: card line order */
445
451
  .line6 { margin-top: 4px; }
446
452
  .card-time time { font-variant-numeric: tabular-nums; }
447
- .card-unread { color: var(--accent); font-weight: 600; }
448
- .card-unread .sep { color: var(--muted); font-weight: 400; }
453
+ .card-alert { color: var(--color-notice); font-weight: 600; }
454
+ .card-alert .sep { color: var(--muted); font-weight: 400; }
455
+ .card-alert .card-action { color: var(--color-notice); }
456
+ .card-alert .card-stalled { color: var(--color-danger); }
457
+ .lane[data-lane="done"] .card-alert .card-unread { color: var(--color-info); }
449
458
  /* Lane headers: Linear-style group bands */
450
459
  .lane > summary { display: flex; align-items: center; gap: 8px; margin: 0; min-height: 34px; padding: 5px 12px;
451
460
  background: var(--band); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; font-weight: 500; color: var(--text); }
@@ -468,13 +477,15 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
468
477
  .view-only-strip b { color: var(--accent); }
469
478
  /* 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,
470
479
  undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
471
- .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-try-kill], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
480
+ .offline :is(form button[type="submit"], .actions button, button[data-confirm], 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-try-kill], button[data-reopen]) { opacity: .55; cursor: not-allowed; }
472
481
  /* 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. */
473
482
  .down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
474
483
  .down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
475
484
  .down-landing-line { margin: 0; font-size: 20px; font-weight: 600; line-height: 1.3; text-wrap: balance; }
476
485
  .down-landing-hint { margin: 0; font-size: 14px; color: var(--muted); }
477
- .view-board.down main > :not(#downLanding), .view-board.down :is(#needsPane, #compose) { display: none; }
486
+ .down-landing-stack { margin: 0; max-width: 100%; overflow: auto; text-align: left; font-size: 12px; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--muted) 14%, transparent); }
487
+ .down-landing-stack[hidden] { display: none; }
488
+ .view-board.down main > :not(#downLanding), .view-board.down #compose { display: none; }
478
489
  .view-settings.down .settings-body > :not(.down-landing) { display: none; }
479
490
  /* INT-28: answer banner */
480
491
  .answer-banner { display: flex; align-items: center; gap: 4px; background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
@@ -529,7 +540,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
529
540
  .board-foot .arch-bulb[hidden] { display: none; }
530
541
  .board-foot .arch-bulb.running { color: var(--color-notice); }
531
542
  .board-foot a.icon[aria-disabled="true"] { opacity: .35; pointer-events: none; }
532
- /* The pages switch below 900px: the board header's ? with the open-question count on its corner, and Waiting on you's check back to the board. */
543
+ /* The pages switch: the board header's ? with the open-question count on its corner, and Waiting on you's check back to the board. */
533
544
  .top .pages-switch { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; width: auto; height: auto; margin: 0; padding: 4px; border: 0; border-radius: 0; line-height: 1; color: var(--muted); }
534
545
  .top .pages-switch:hover { color: var(--text); }
535
546
  .top .pages-switch:active { background: none; }
@@ -654,13 +665,13 @@ main > * { transition: opacity .25s ease; }
654
665
  (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
655
666
  lines, not the text's own newlines. */
656
667
  :is(.msg .body, .reader-row).md { white-space: normal; }
657
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
658
- :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; }
659
- :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; }
660
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
661
- :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; }
662
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
663
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) > :last-child { margin-bottom: 0; }
668
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) { overflow-wrap: anywhere; }
669
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) :is(p, ul, ol, blockquote, pre, table) { margin: 0 0 6px; }
670
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) :is(h1, h2, h3, h4, h5, h6) { font-size: inherit; font-weight: 600; margin: 6px 0 2px; }
671
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) :is(ul, ol) { padding-left: 18px; }
672
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) :is(pre, code) { white-space: pre-wrap; font-size: 12px; }
673
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
674
+ :is(.view-questions .ctx, .view-questions .opt .det, .md) > :last-child { margin-bottom: 0; }
664
675
  .view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
665
676
  .view-questions .note { margin-top: 4px; }
666
677
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
@@ -698,8 +709,7 @@ main > * { transition: opacity .25s ease; }
698
709
  /* The + rides above the sticky Send bar rather than over its Send. */
699
710
  .view-questions .bar #qstatus { min-width: 0; }
700
711
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
701
- /* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav,
702
- or of what the desktop pane has left under its Needs you lines. */
712
+ /* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav. */
703
713
  .view-questions .q-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center;
704
714
  min-height: calc(var(--vh, 100dvh) - var(--app-bottom, 0px) - max(16px, env(safe-area-inset-top))); }
705
715
  .view-questions .q-empty p { margin: 0; font-size: 15px; }
@@ -756,8 +766,8 @@ main > * { transition: opacity .25s ease; }
756
766
  .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; }
757
767
  .view-notes .md-table th { background: var(--bg); font-weight: 600; }
758
768
  .view-notes .md-table tr:last-child td { border-bottom: 0; }
759
- /* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
760
- .view-questions, .view-notes, .reader-sheet, .pane-viewer { overflow-x: hidden; }
769
+ /* The sidebar and every page never scroll sideways: content wraps at its width, overflow-x hidden is the last guard. */
770
+ .view-questions, .view-notes, .reader-sheet { overflow-x: hidden; }
761
771
  /* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
762
772
  .view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
763
773
  .view-frame > .top { flex: none; }
@@ -776,7 +786,7 @@ main > * { transition: opacity .25s ease; }
776
786
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
777
787
  }
778
788
  .top .nav #refresh, .top .nav a.icon, .top .pages-switch { min-width: 44px; min-height: 44px; }
779
- body > main, body.has-sidebar > :not(.sidebar) > main, body.has-sidebar > :not(.sidebar) > .split > main { padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: calc(96px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
789
+ body > main, body.has-sidebar > :not(.sidebar) > main { padding-top: max(16px, env(safe-area-inset-top)); padding-bottom: calc(96px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
780
790
  .view-frame:not(.own-header) { padding-bottom: var(--app-bottom, 0px); }
781
791
  .sheet { bottom: max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px)));
782
792
  max-height: calc(var(--vh, 100dvh) + var(--kb, 0px) - max(var(--app-bottom, 0px), var(--kb, 0px)) - env(safe-area-inset-top) - 12px); }
@@ -938,92 +948,34 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
938
948
  .rv-acts { display: flex; flex-wrap: wrap; gap: 8px; }
939
949
  .view-questions .bar #qstatus .try-spin { color: var(--accent); margin-right: 6px; }
940
950
 
941
- /* Needs you (app-needs.js): a deck at the top of the board on a phone; from 900px a pane beside the lanes, its lines over
942
- Waiting on you (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md). */
943
- .needs-pane { display: none; }
944
951
  .col-handle { display: none; }
945
- .needs-deck { margin: 0 0 12px; border: 1px solid color-mix(in srgb, var(--color-notice) 45%, var(--line)); border-radius: 14px;
946
- background: color-mix(in srgb, var(--color-notice) 7%, var(--panel)); overflow: hidden; }
947
- .deck-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px 4px; }
948
- .deck-head h2 { margin: 0; flex: 1; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-notice); }
949
- .deck-n { min-width: 20px; padding: 1px 7px; border-radius: 999px; background: var(--color-notice); color: #1c1407; font-size: 12px; font-weight: 700; text-align: center; }
950
- .deck-card { padding: 2px 14px 12px; min-width: 0; overflow-wrap: anywhere; }
951
- .deck-what { margin: 0 0 2px; font-size: 12px; color: var(--muted); }
952
- .deck-card h3 { margin: 0 0 4px; font-size: 15px; line-height: 1.3; }
953
- .deck-card h3 a { color: inherit; text-decoration: none; }
954
- .deck-card .ctx { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
955
- .deck-proto { margin: 0 0 10px; color: var(--accent); }
956
- .deck-opts { display: flex; flex-direction: column; gap: 6px; }
957
- .deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
958
- .deck-opts button.rec { border-color: var(--accent); }
959
- .deck-opts button.armed { border-color: var(--sel-line); background: var(--sel-bg); font-weight: 600; }
960
- .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
961
- .deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
962
- .deck-prompt { margin: 0; padding: 0; list-style: none; }
963
- .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
964
- .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
965
- .deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
966
- .deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
967
- .deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
968
- .deck-dots i.on { background: var(--color-notice); }
969
- .deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
970
- .deck-more { display: flex; align-items: center; justify-content: space-between; min-height: 46px; padding: 10px 14px; border-top: 1px solid color-mix(in srgb, var(--color-notice) 28%, var(--line));
971
- background: color-mix(in srgb, var(--color-notice) 14%, var(--panel)); color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; }
972
- .deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
973
- .deck-more:hover { text-decoration: none; }
952
+ /* From 900px the lanes take the board's full width; Waiting on you is the header's ?
953
+ (docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md). */
974
954
  @media (min-width: 900px) {
975
- .split { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--pane-w, minmax(0, 1fr)); align-items: start; }
976
- .split > main { max-width: none; margin: 0; }
977
- .needs-deck { display: none; }
978
- /* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
979
- .top .pages-switch { display: none; }
980
- .top .nav a.pages-switch { display: none; }
981
- /* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
982
- .view-board .fab { right: calc(var(--pane-w, calc(50% - var(--sidebar-w, 0px) / 2)) + var(--fab-edge)); }
955
+ .view-board > main { max-width: none; margin: 0; }
983
956
  body.has-sidebar { --sidebar-w: var(--sb-w, 272px); }
984
957
  html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
985
- /* The drag edges (col-resize.js): a hairline that thickens on hover, on the pane's left edge and the sidebar's right. */
958
+ /* The sidebar's drag edge (col-resize.js): a hairline that thickens on hover. */
986
959
  .col-handle { display: block; position: absolute; z-index: 5; top: 0; bottom: 0; width: 9px; cursor: col-resize; touch-action: none; }
987
960
  .col-handle::after { content: ''; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: transparent; transition: background .12s, width .12s, left .12s; }
988
961
  .col-handle:is(:hover, :focus-visible, .dragging)::after { left: 3px; width: 3px; background: var(--accent); }
989
962
  .col-handle:focus-visible { outline: none; }
990
963
  html.col-dragging, html.col-dragging * { cursor: col-resize !important; user-select: none; }
991
- .split > .col-handle { right: calc(var(--pane-w, 50%) - 4px); }
992
- .split:has(.needs-pane.widened) > .col-handle { display: none; }
993
- /* The reader sheet (app-reader.js) is a panel over the pane's half, at the width the seam gives it, while it is open. */
994
- .sheet.reader-sheet { position: fixed; z-index: 30; inset: 56px 0 0 auto; margin: 0; width: var(--pane-w, calc((100vw - var(--sidebar-w, 0px)) / 2)); max-width: none;
964
+ /* The reader sheet (app-reader.js) is a panel over the right half of the space beside the sidebar while it is open. */
965
+ .sheet.reader-sheet { position: fixed; z-index: 30; inset: 56px 0 0 auto; margin: 0; width: calc((100vw - var(--sidebar-w, 0px)) / 2); max-width: none;
995
966
  height: auto; max-height: none; border-width: 0 0 0 1px; border-radius: 0; box-shadow: none; }
996
967
  .sheet.reader-sheet[open] { animation: reader-in .22s ease-out; }
997
968
  .sheet.reader-sheet.closing { animation: reader-out .18s ease-in forwards; }
998
969
  .reader-sheet::backdrop { background: transparent; }
999
- .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
1000
- .needs-pane .needs-questions main { padding: 0; max-width: none; }
1001
- .needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
1002
- .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
1003
- .needs-pane .waiting { margin: 0 0 12px; }
1004
- .needs-pane * { min-width: 0; overflow-wrap: anywhere; }
1005
- .needs-pane pre, .needs-pane code { white-space: pre-wrap; }
1006
- .needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
1007
- /* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
1008
- (.widened) lays the pane over the lanes, from the sidebar's edge. */
1009
- .needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
1010
- .needs-pane.viewing > :not(.pane-viewer) { display: none; }
1011
- .needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
1012
- .pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
1013
- .pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
1014
- .pv-top .pv-grow { flex: 1; }
1015
- .pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
1016
- .pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
1017
- .pv-top .pv-back:hover { background: var(--line); }
1018
- .pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
1019
- .pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
1020
- .pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
1021
- .pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
1022
- .pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
1023
- .pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
1024
- .pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
1025
- .pane-viewer .ps-sheet button span + span { color: var(--muted); }
1026
970
  }
1027
971
  /* 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. */
1028
972
  body .view-questions.view-questions main { padding-bottom: var(--app-bottom, 0px); }
1029
973
  .pfile.retry { cursor: pointer; }
974
+ /* The Todo header's filter (docs/adr/2026-11-12-0600-the-todo-header-has-a-filter.md): a funnel at the right, orange while a box is unchecked, and its checklist under the header. */
975
+ .lane { position: relative; }
976
+ .lane-filter { margin-left: auto; display: inline-flex; align-items: center; padding: 4px; border: 0; background: none; color: var(--muted); cursor: pointer; }
977
+ .lane-filter.on { color: var(--color-notice); }
978
+ .lane-filter-menu { position: absolute; z-index: 5; top: 36px; right: 8px; display: flex; flex-direction: column; min-width: 160px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 18px rgba(0, 0, 0, .25); }
979
+ .lane-filter-box { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border: 0; background: none; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
980
+ .lane-filter-box .box { width: 14px; height: 14px; border: 1.5px solid var(--muted); border-radius: 4px; }
981
+ .lane-filter-box[aria-checked="true"] .box { border-color: var(--accent); background: var(--accent); }
@@ -254,7 +254,7 @@
254
254
  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');
255
255
  if (it.waitsOn === 'checking') return 'Orchestrator review';
256
256
  if (it.lane === 'done') return 'done';
257
- if (it.grilling || it.prototyping) return 'preheating';
257
+ if (it.planning) return it.planning;
258
258
  if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
259
259
  if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
260
260
  return 'in review';
@@ -280,16 +280,19 @@
280
280
  // docs/adr/2026-11-11-1200-every-lane-has-phases.md
281
281
  // A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
282
282
  // Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
283
- // phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
283
+ // phase only as its progress bar, a New card has none (its footer line says it), and a card In Review for an owner wait other than the merge has none. Ready reads
284
284
  // only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
285
285
  const PHASE_LOOK = {
286
- new: ['new', 'quiet'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
286
+ new: ['new', 'quiet'], discussing: ['discussing', 'info'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
287
287
  draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
288
288
  merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
289
289
  };
290
+ // docs/adr/2026-11-12-0500-a-new-task-is-a-conversation.md: a discussing card reads just "discussing", blue on the
291
+ // owner's turn and grey while the orchestrator owes the reply.
292
+ const TURN_LOOK = { owner: ['discussing', 'info'], claude: ['discussing', 'quiet'] };
290
293
  function phaseWord(it) {
291
- if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
292
- const [word, tone] = PHASE_LOOK[it.phase];
294
+ if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || it.phase === 'new' || (it.phase === 'ready' && !it.ready)) return '';
295
+ const [word, tone] = it.phase === 'discussing' ? TURN_LOOK[it.turn] : PHASE_LOOK[it.phase];
293
296
  return `<span class="card-phase ph-${tone}">${word}</span>`;
294
297
  }
295
298
  // docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md
@@ -297,26 +300,29 @@
297
300
  // orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
298
301
  const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
299
302
  const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
300
- // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
301
- // held while host recovers on a bumped ready card a busy host holds, in grey
302
- // (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
303
- // in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
304
- // 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
305
- // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
306
- // 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).
303
+ // A card's last two rows. The footer: the time its lane sorts by (domain.mjs sortAt), then its hold and what blocks it,
304
+ // or its phase word (phaseWord) when neither shows, then a build's stale in grey, held while host recovers on a bumped
305
+ // ready card a busy host holds, in grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md),
306
+ // a paused build's words (pauseWords), then an In Progress card's phase bars
307
+ // (docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md). The row under it, drawn only when it has
308
+ // something: Action needed and the unread messages in bold orange, new questions counting as one (app-cards.js takes
309
+ // the count away when the card opens), then stalled in red, rightmost
310
+ // (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md,
311
+ // docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
312
+ // docs/adr/2026-11-12-0600-action-needed-and-new-messages-get-their-own-row.md).
307
313
  const SEP = '<span class="sep"> · </span>';
308
314
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
309
315
  function timeLine(it, unread, bars) {
310
316
  const iso = it.sortAt;
311
317
  const pause = pauseOf(it);
312
- const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it), actionWords(it),
318
+ const holdBlock = [pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it)].filter(Boolean);
319
+ const footer = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, ...(holdBlock.length ? holdBlock : [phaseWord(it)]),
313
320
  it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
314
- 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);
321
+ !it.stalled && it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', pauseWords(pause), bars].filter(Boolean);
315
322
  const news = newWords(unread, 'message');
316
- const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
317
- const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
318
- if (!parts.length && !count && !tail) return '';
319
- return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
323
+ const alert = [actionWords(it), news && `<span class="card-unread">${news}</span>`, it.stalled && '<span class="card-stalled">stalled</span>'].filter(Boolean);
324
+ return [footer.length && `<div class="line6 sub card-time">${footer.join(SEP)}</div>`,
325
+ alert.length && `<div class="line6 sub card-alert">${alert.join(SEP)}</div>`].filter(Boolean).join('');
320
326
  }
321
327
  // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
322
328
  // Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
@@ -343,15 +349,16 @@
343
349
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting…</span>';
344
350
  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(' · ');
345
351
  function workLines(it, { unread = 0 } = {}) {
346
- 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);
347
- // A card shows its ticket, or Task when it has none; the card an ask became shows New there until it is sorted.
348
- const ids = it.isNew ? badge('notice', 'New') : ticket;
352
+ const ids = 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);
353
+ // A card shows its ticket, or Task when it has none; the card an ask became shows its ticket from the moment it lands.
349
354
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
350
355
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
356
+ // docs/adr/2026-11-12-0600-every-planner-launches-from-the-orchestrators-brief.md: preheating, planning prototype
357
+ // or planning visual while the orchestrator writes the brief, generating visual while a visual agent draws, in blue.
358
+ : it.planning && !it.builder ? `<div class="line3 sub agent planner">${DOT}${esc(it.planning)}</div>`
351
359
  : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
352
360
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
353
361
  : it.builder ? builderStatus(it)
354
- : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
355
362
  : it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
356
363
  : it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
357
364
  : it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
@@ -3,15 +3,15 @@
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', github: 'github', merge: 'merge', reply: 'reply' });
7
- const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', github: 'github', merge: 'merge', reply: 'reply', implement: 'implement' });
7
+ const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', stalled: 'stalled', implement: 'implement' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
10
10
  // Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
11
11
  // In Progress's are the five build steps its progress bar draws; a review's "reviewers" and "merge" read "pending review"
12
12
  // and "pending merge" on the card (ui-cards.js PHASE_LOOK).
13
13
  const PHASES = Object.freeze({
14
- todo: Object.freeze(['new', 'grilling', 'prototyping', 'staging', 'ready']),
14
+ todo: Object.freeze(['new', 'discussing', 'grilling', 'prototyping', 'staging', 'ready']),
15
15
  progress: Object.freeze(['reading', 'building', 'testing', 'checking', 'landing']),
16
16
  review: Object.freeze(['draft', 'reviewers', 'merge', 'stale']),
17
17
  done: Object.freeze(['merged', 'closed']),
@@ -47,9 +47,9 @@
47
47
  // lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
48
48
  // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh) paint disabled with refresh spinning until the
49
49
  // page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
50
- // false, trail (HTML) takes their place. pages is the switch between a board's two pages below 900px
50
+ // false, trail (HTML) takes their place. pages is the switch between a board's two pages
51
51
  // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): 'questions' puts the ? after the controls, with
52
- // the open-question count in its badge (app-needs.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
52
+ // the open-question count in its badge (app-header.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
53
53
  // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
54
54
  // away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
55
55
  function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
@@ -22,7 +22,7 @@
22
22
  // '<question index>:<label>'. It waits (ui.js BUSY) and the line's other buttons are disabled.
23
23
  function promptItem(p, { picks = {}, sending = null } = {}) {
24
24
  const off = (tapped) => (sending === null ? '' : sending === tapped ? BoardUI.BUSY : ' disabled');
25
- const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
25
+ const where = [p.board, ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
26
26
  const head = `<div class="prompt-head"><b>${esc(p.questions ? 'Question' : p.tool)}</b><span class="muted small">${where}</span></div>`;
27
27
  if (p.questions) {
28
28
  const questions = p.questions.map((q, i) => `<div class="prompt-q"><p>${q.header ? `<span class="chip">${esc(q.header)}</span> ` : ''}${esc(q.question)}</p>
@@ -21,7 +21,7 @@
21
21
  // resolved it. A sent question draws as resolvedCard, locked, until its badge reopens it.
22
22
  const isSent = (q) => q.state === 'answered' || q.state === 'replied' || q.state === 'resolved';
23
23
  // A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
24
- // phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
24
+ // prototype viewer's Choose this (prototype-switcher.js) write it.
25
25
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
26
26
  const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
27
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) : '';
@@ -178,8 +178,7 @@
178
178
  },
179
179
  };
180
180
 
181
- // A prototype viewer's Round pill and its sheet, on the full page (prototype-switcher.js) and in the board's right pane
182
- // (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
181
+ // A prototype viewer's Round pill and its sheet (prototype-switcher.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
183
182
  // lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
184
183
  // card with a single round gets none.
185
184
  function roundPill(rounds, qid) {
@@ -106,6 +106,7 @@
106
106
  'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
107
107
  move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
108
108
  'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
109
+ 'Marked as discussing': mini('<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'),
109
110
  'Marked as staging': mini('<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5"/><path d="m2 17 10 5 10-5"/>'),
110
111
  'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
111
112
  Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
@@ -406,11 +407,18 @@
406
407
  // A view's place when it has no copy to show: the kickflip mark and down's two lines, as the no-access page has them.
407
408
  const downLanding = (down) => `<div class="down-landing" data-down="${down.state}"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
408
409
  <p class="down-landing-line">${esc(down.line)}</p><p class="down-landing-hint">${esc(down.hint)}</p></div>`;
410
+ // The landing for a board that loaded but threw while drawing (docs/adr/2026-11-12-0400-a-board-that-throws-while-drawing-shows-a-landing-with-the-error.md):
411
+ // the error's message, its stack behind Show details, Reload and Copy error (app.js wires them).
412
+ const drawLanding = (err) => `<div class="down-landing" data-down="draw"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
413
+ <p class="down-landing-line">This board couldn't draw</p><p class="down-landing-hint">The board loaded, but it threw while drawing. Your data is fine.</p>
414
+ <p class="down-landing-hint"><b>${esc(err?.name || 'Error')}:</b> ${esc(err?.message ?? String(err))}</p>
415
+ <div class="down-landing-buttons"><button type="button" data-draw="reload">Reload</button> <button type="button" data-draw="copy">Copy error</button> <button type="button" data-draw="more">Show details</button></div>
416
+ <pre class="down-landing-stack" hidden>${esc(err?.stack || '')}</pre></div>`;
409
417
  // The line over a side page painted from its copy while the board can't be reached.
410
418
  const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
411
419
  // The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
412
420
  // label and size. Reading controls, folds, links, Edit and drafts stay live.
413
- 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-try-kill], button[data-reopen]`;
421
+ const SENDS = `form button[type="submit"], .actions button, button[data-confirm], 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-try-kill], button[data-reopen]`;
414
422
  // While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
415
423
  // tells the tap it is waiting. on is the view's B.on.
416
424
  function holdSends(on, root, held, onHeld) {
@@ -464,7 +472,7 @@
464
472
 
465
473
  globalThis.BoardUI = {
466
474
  LINK_ARROW, escText, onBackdrop,
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,
475
+ esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, drawLanding, copyNote, SENDS, holdSends,
468
476
  attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
469
477
  };
470
478
  })();
@@ -65,7 +65,7 @@
65
65
  ? HubUI.row({ icon: HubUI.tile('coffee', 'var(--color-quiet)'), title: 'Keep awake', sub: esc(a.unsupported), wrap: true, attrs: ' id="keep-awake"' })
66
66
  : `<form class="row field" id="keep-awake" method="post" action="/_hub/settings/keep-awake">${HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)')}<label class="body"><span class="text"><span class="title">Keep awake</span><span class="sub wrap">${AWAKE_NOTE}</span></span><input type="checkbox" id="awake" name="keep_awake" value="on" class="toggle" data-submit${a.on ? ' checked' : ''}></label></form>`;
67
67
 
68
- // A pending prompt from a session that owns no board waits here (prompts.js).
68
+ // Every pending prompt waits here, a board session's naming its board (prompts.js).
69
69
  function promptsHtml(list, prompts) {
70
70
  const shown = list.filter(prompts.shows);
71
71
  return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';