@lovek4d/kickflip 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -163,6 +163,11 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
163
163
  .wb-done { padding: 8px 12px; font-size: 12.5px; color: var(--muted); }
164
164
  .wb-done a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
165
165
  .wb-loosehead { cursor: default; }
166
+ .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; }
167
+ .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; }
168
+ .wb-counts:hover .wb-tip, .wb-counts.open .wb-tip { display: block; }
169
+ .wb-counts .wv-cur { font-weight: 700; color: var(--accent); }
170
+ .wb-lane + .wb-lane, .wb-lane + .wb-loose { border-top: 1px solid var(--line); }
166
171
  .wb-loosehead:hover { background: var(--band); }
167
172
  /* A wave card's step number, inside its id chip, and its ⋯ menu. */
168
173
  .wn { color: var(--muted); font-variant-numeric: tabular-nums; padding-right: 5px; margin-right: 4px; border-right: 1px solid var(--line); }
@@ -202,10 +207,12 @@ code { overflow-wrap: anywhere; }
202
207
  /* The ⌘↵ / Ctrl+↵ hint left of Send: only where there is a keyboard to press it on. */
203
208
  .send-hint { display: none; color: var(--muted); font-size: 12px; white-space: nowrap; }
204
209
  @media (hover: hover) and (pointer: fine) { .send-hint { display: inline; } }
205
- /* A composer's text box (BoardUI.dropZone): while files are dragged over the composer, attach.js marks it .dragging and
206
- the box gets a dashed accent outline with Drop to attach over it. */
210
+ /* A composer's text box (BoardUI.dropZone), its attach button inside at the bottom right with the text kept clear of it:
211
+ while files are dragged over the composer, attach.js marks it .dragging and the box gets a dashed accent outline
212
+ with Drop to attach over it. */
207
213
  .dz { position: relative; clear: both; border-radius: 8px; }
208
- .dz > textarea { display: block; }
214
+ .dz > textarea { display: block; padding-bottom: 40px; }
215
+ .dz > .attach { position: absolute; right: 6px; bottom: 6px; width: 32px; height: 32px; z-index: 1; }
209
216
  .dz-hint { display: none; position: absolute; inset: 0; align-items: center; justify-content: center; border-radius: 8px; pointer-events: none;
210
217
  background: color-mix(in srgb, var(--panel) 80%, transparent); color: var(--accent); font-weight: 600; font-size: 15px; }
211
218
  [data-composer].dragging .dz { outline: 2px dashed var(--accent); outline-offset: 3px; }
@@ -232,7 +239,6 @@ code { overflow-wrap: anywhere; }
232
239
  .msg .body:empty { display: none; }
233
240
  .note-tools { display: flex; align-items: flex-start; gap: 6px; }
234
241
  .note-tools .pending-files { flex: 1; margin-top: 4px; }
235
- .note-tools .attach { margin-top: 2px; }
236
242
 
237
243
  /* Compose button and sheet */
238
244
  main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
@@ -324,7 +330,19 @@ main { padding-bottom: calc(110px + env(safe-area-inset-bottom)); }
324
330
  @media (prefers-reduced-motion: no-preference) { .working-dot { animation: pulse 1.4s ease-in-out infinite; } }
325
331
  form[data-reply] .row .quick { margin-left: auto; margin-right: 6px; }
326
332
  form[data-reply] .row [data-confirm="finish"] { margin-right: 6px; }
327
- .q .proto { display: inline-block; margin: 0 0 8px; }
333
+ /* A composer's send buttons (ui.js sendButton), each with its key as a small gray hint where there is a keyboard and
334
+ room. A pair takes a row of two halves; a Todo card's reply box wraps its pair under the row. */
335
+ .send-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
336
+ .send-actions button, [data-sends] button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
337
+ .send-actions button { min-height: 44px; }
338
+ form[data-reply] .row { flex-wrap: wrap; }
339
+ form[data-reply] .row > .send-actions { flex: 0 0 100%; margin-top: 4px; }
340
+ .key-hint { display: none; font-size: 11px; font-weight: 400; color: var(--muted); white-space: nowrap; }
341
+ button.primary .key-hint { color: color-mix(in srgb, var(--on-accent) 62%, transparent); }
342
+ @media (hover: hover) and (pointer: fine) and (min-width: 600px) { .key-hint { display: inline; } }
343
+ #askStatus { margin: 6px 2px 0; }
344
+ #askStatus:empty { display: none; }
345
+ .q .proto-view { display: inline-block; margin: 0 0 10px; color: var(--accent); }
328
346
  .msg .ask-actions { margin: -4px -6px 0 8px; }
329
347
  button.attach { border: 0; background: transparent; padding: 0; }
330
348
  button.attach:disabled { opacity: .35; }
@@ -348,10 +366,17 @@ button.attach:disabled { opacity: .35; }
348
366
  @media (prefers-reduced-motion: no-preference) { .phase-bars:not(.dim) i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
349
367
  /* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
350
368
  /* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
351
- shorthand stays out so .sub's 12px wins. */
352
- button.sub { display: block; width: 100%; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
369
+ shorthand stays out so .sub's 12px wins. It sets no width: a button is its own block formatting context, so a full-width one
370
+ drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
371
+ button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
353
372
  font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
354
373
  button.sub.agent.stalled { color: var(--color-danger); }
374
+ /* A tap on it calms the line to the in-progress orange, its dot sending a ripple, until the card's next status line (app-cards.js nudge). */
375
+ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
376
+ .nudging .working-dot { position: relative; }
377
+ .nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
378
+ @keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
379
+ @media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
355
380
  .stalled .working-dot { background: currentColor; }
356
381
  .brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
357
382
  .brand .hub { grid-row: 1 / span 2; align-self: center; }
@@ -375,6 +400,9 @@ button.sub.agent.stalled { color: var(--color-danger); }
375
400
  .sub.agent.in-progress { color: var(--color-notice); }
376
401
  .sub.agent.in-todo { color: var(--accent); }
377
402
  .sub.agent.in-progress .working-dot, .sub.agent.in-todo .working-dot { background: currentColor; }
403
+ /* 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
404
+ has picked up are grey, after the lane colours so they win. */
405
+ .sub.agent.quiet, .sub.asked { color: var(--muted); }
378
406
  .phase-bars.plan { --bar-fill: var(--accent); }
379
407
  @keyframes pulse-fill-plan { 50% { background-color: color-mix(in srgb, var(--accent) 35%, transparent); } }
380
408
  @media (prefers-reduced-motion: no-preference) { .phase-bars.plan:not(.dim) i.now { animation-name: pulse-fill-plan; } }
@@ -400,8 +428,6 @@ button.sub.agent.stalled { color: var(--color-danger); }
400
428
  /* The thread's eye and fire are toggles: pressed reads as chosen, and the hint under the box says what Send posts. */
401
429
  form[data-reply] .tools { display: inline-flex; flex: none; }
402
430
  button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chip); }
403
- .flag-hint { margin: 4px 2px 0; min-height: 1.2em; color: var(--muted); font-size: 12px; }
404
- .flag-hint.on { color: var(--accent); }
405
431
  /* INT-30: card line order */
406
432
  .line6 { margin-top: 4px; }
407
433
  .card-time time { font-variant-numeric: tabular-nums; }
@@ -420,6 +446,13 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
420
446
  /* Each lane's sort icon and its menu, grey (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md) */
421
447
  /* INT-35: offline header */
422
448
  .view-board.offline #compose { display: none; }
449
+ /* A board shared with someone else, view only (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
450
+ shell.js marks the page .shared-view, which hides the +, every composer, Board settings, the architecture review and
451
+ a card's buttons, keeping its links (Visit, Notes, Open questions). */
452
+ .shared-view #compose, .shared-view [data-composer], .shared-view #settingsLink, .shared-view #archBulb,
453
+ .shared-view .actions button, .shared-view button[data-confirm] { display: none !important; }
454
+ .view-only-strip { margin: 8px 0; padding: 8px 12px; border-radius: 10px; background: var(--accent-soft); font-size: 13px; }
455
+ .view-only-strip b { color: var(--accent); }
423
456
  .view-board.offline main > * { opacity: .72; }
424
457
  .view-board.offline main, .view-board.offline > .top > :not(.nav), .view-board.offline > .top .nav > :not(.refresh) { pointer-events: none; }
425
458
  /* INT-28: answer banner */
@@ -530,6 +563,17 @@ main > * { transition: opacity .25s ease; }
530
563
  .view-settings .who.new { border-color: var(--accent); }
531
564
  .view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
532
565
  .view-settings .add input { flex: 1; min-width: 0; }
566
+ .view-settings .add.pick { flex-wrap: wrap; }
567
+ .view-settings .share-now { color: var(--ok); font-weight: 600; }
568
+ .view-settings .share-now::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: currentColor; vertical-align: 1px; }
569
+ .view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
570
+ .view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
571
+ .view-settings .share-reach button { flex: none; }
572
+ .view-settings .pick-col { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
573
+ .view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
574
+ .view-settings .pick-list button { text-align: left; }
575
+ .view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
576
+ .view-settings .who .sub { display: block; margin-top: 2px; }
533
577
  .view-settings .replies .reply { margin-top: 8px; }
534
578
  .view-settings label.who { gap: 12px; cursor: pointer; }
535
579
  .view-settings label.who .sub { display: block; margin-top: 2px; }
@@ -576,6 +620,8 @@ main > * { transition: opacity .25s ease; }
576
620
  .view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
577
621
  background: var(--panel); color: var(--text); white-space: normal; text-align: left; overflow-wrap: anywhere; }
578
622
  .view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
623
+ @keyframes pill-arrive { from { opacity: 0; transform: translateX(-12px); box-shadow: 0 0 0 3px var(--accent); } to { opacity: 1; transform: none; box-shadow: 0 0 0 0 transparent; } }
624
+ @media (prefers-reduced-motion: no-preference) { .view-questions .topics button.arrived { animation: pill-arrive .6s ease-out; } }
579
625
  .view-questions .topics .n { color: var(--muted); margin-left: 4px; }
580
626
  .view-questions .topichead { margin: 2px 0 6px; }
581
627
  .view-questions details.done > summary { cursor: pointer; color: var(--muted); font-size: 13px; margin: 16px 0 4px; }
@@ -600,39 +646,24 @@ main > * { transition: opacity .25s ease; }
600
646
  display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
601
647
  .view-questions .bar .send-hint { margin-left: auto; }
602
648
  /* The + rides above the sticky Send bar rather than over its Send. */
603
- .view-questions:has(.bar) .fab { bottom: calc(var(--app-bottom, 0px) + var(--fab-edge) + 72px); }
604
649
  .view-questions .bar #qstatus { min-width: 0; }
650
+ /* Typing with the keyboard up: one compact row docked on the keyboard, and the + out of the way (view-questions.js dock). */
651
+ .view-questions.kb-up .bar { position: fixed; left: 0; right: 0; bottom: var(--kb, 0px); z-index: 7; padding: 6px 16px; box-shadow: 0 -6px 14px rgba(0,0,0,.12); }
652
+ .view-questions.kb-up .bar .send-hint { display: none; }
605
653
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
606
654
  .view-questions .solo { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 4px 0; }
607
655
  /* docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md */
608
656
  .view-questions .topichead { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
609
657
  .view-questions .topichead .try-spin { width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px; }
610
- .view-questions .q .pr-open { display: inline-block; margin: 0 0 8px; }
611
658
  .view-questions .pr-rule { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }
612
659
  .view-questions .pr-rule svg { flex: none; }
613
660
 
614
- /* A question's takes sit in as many columns as keep each at least 380px wide, one on a phone; each its option above its
615
- preview, and each row's takes top-aligned, since each frame takes its own page's height. */
661
+ /* A question's drawn takes sit in as many columns as keep each at least 380px wide, one on a phone; each its option above
662
+ its diagram, and each row's takes top-aligned. */
616
663
  .view-questions .takes { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: 10px; align-items: start; margin: 6px 0; }
617
664
  .view-questions .take { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--bg); padding: 10px; }
618
665
  .view-questions .take:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
619
666
  .view-questions .take .opt, .view-questions .take .opt:has(input:checked) { border: 0; padding: 0; margin: 0; background: none; }
620
- /* view-questions.js sets a frame's height to its page's once measured; until then it shows nothing, so a take that turns
621
- its question into the full-width layout never flashes cut off. */
622
- .view-questions .take-shot { position: relative; display: block; margin-top: 8px; height: 360px; border: 1px solid var(--line); border-radius: 10px;
623
- overflow: hidden; transition: height .15s ease; }
624
- .view-questions .take-shot iframe { width: 100%; height: 100%; border: 0; background: var(--bg); }
625
- .view-questions .take-shot.measuring { transition: none; }
626
- .view-questions .take-shot.measuring iframe { visibility: hidden; }
627
- .view-questions .take-shot.measuring::before { content: 'Loading take…'; position: absolute; inset: 0; display: grid; place-items: center;
628
- color: var(--muted); font-size: 12.5px; }
629
- .view-questions:has(.takes-full) main { max-width: none; }
630
- .view-questions .takes-full { grid-template-columns: minmax(0, 1fr); }
631
- .view-questions .take-choices { display: flex; flex-wrap: wrap; gap: 8px; }
632
- .view-questions .take-choices .opt { flex: 1 1 160px; margin: 0; }
633
- .view-questions .take-choices .opt[aria-current="true"] { border-color: var(--accent); }
634
- .view-questions .takes-full .take { padding: 0; border: 0; }
635
- .view-questions .takes-full .take-shot { margin-top: 0; transition: none; }
636
667
 
637
668
  /* A take with nothing to see: boxes and arrows, then what it means in practice. */
638
669
  .view-questions .flow { margin: 10px 0 4px; }
@@ -683,7 +714,7 @@ main > * { transition: opacity .25s ease; }
683
714
  .view-frame .alone { white-space: nowrap; font-size: 13px; }
684
715
 
685
716
  @media (max-width: 899px) {
686
- :root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top):not(.ps-embed) {
717
+ :root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
687
718
  --app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
688
719
  }
689
720
  body > .top, body.has-sidebar > :not(.sidebar) > .top {
@@ -691,7 +722,7 @@ main > * { transition: opacity .25s ease; }
691
722
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
692
723
  }
693
724
  .top .nav #refresh, .top .nav a.icon, .top .nav .arch-bulb { min-width: 44px; min-height: 44px; }
694
- 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)); }
725
+ 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)); }
695
726
  .view-frame:not(.own-header) { padding-bottom: var(--app-bottom, 0px); }
696
727
  .sheet { bottom: max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px)));
697
728
  max-height: calc(var(--vh, 100dvh) + var(--kb, 0px) - max(var(--app-bottom, 0px), var(--kb, 0px)) - env(safe-area-inset-top) - 12px); }
@@ -716,21 +747,18 @@ main > * { transition: opacity .25s ease; }
716
747
  .lk-over, .lk-over > main { display: contents; }
717
748
 
718
749
  .tk-section { margin: 10px 0; }
719
- /* The Tickets source row's live state, and Hub's key field opened under it (dialogs.js ticketsControl keyState). */
750
+ /* The Tickets source row's live state (dialogs.js ticketsControl keyState). */
720
751
  .tk-status { display: flex; align-items: center; gap: 8px; margin: 8px 0 0; font-size: 13px; color: var(--muted); }
721
752
  .tk-status b { font-weight: 600; color: var(--text); }
722
753
  .tk-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); flex: none; }
723
754
  .tk-status.on .dot { background: var(--accent); }
724
755
  .tk-status.on b { color: var(--accent); }
725
- .tk-inline { margin: 6px 0 10px; border: 1px solid var(--accent); border-radius: 10px; padding: 8px 10px; background: var(--accent-soft); }
726
- .tk-inline .tk-ask { margin: 2px 0 6px; font-size: 13px; }
727
- .tk-inline .hub-view main { padding: 0; }
728
756
  .tk-seg { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; margin: 8px 0; border: 1px solid var(--line); border-radius: 9px; background: var(--chip); }
729
757
  .tk-seg button { min-width: 0; min-height: 44px; padding: 6px 4px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font: inherit; font-size: 13px; cursor: pointer; }
730
758
  .tk-seg button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600; }
731
- .ask-seg { grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 10px 0 0; }
732
- .ask-choice-help { margin: 4px 2px 0; }
733
759
  .tk-seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
760
+ .landing-seg button { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 6px; text-align: center; }
761
+ .landing-seg .sub { font-weight: 400; opacity: .8; line-height: 1.25; }
734
762
  .tk-grid { display: grid; gap: 8px; grid-template-columns: minmax(0, 1fr); }
735
763
  @media (min-width: 560px) { .tk-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); } }
736
764
  .tk-field { display: block; min-width: 0; }
@@ -844,3 +872,46 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
844
872
  .rv-links a { color: var(--accent); text-decoration: none; }
845
873
  .rv-acts { display: flex; flex-wrap: wrap; gap: 8px; }
846
874
  .view-questions .bar #qstatus .try-spin { color: var(--accent); margin-right: 6px; }
875
+
876
+ /* 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
877
+ Waiting on you (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md). */
878
+ .needs-pane { display: none; }
879
+ .needs-deck { margin: 0 0 12px; border: 1px solid color-mix(in srgb, var(--color-notice) 45%, var(--line)); border-radius: 14px;
880
+ background: color-mix(in srgb, var(--color-notice) 7%, var(--panel)); overflow: hidden; }
881
+ .deck-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px 4px; }
882
+ .deck-head h2 { margin: 0; flex: 1; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-notice); }
883
+ .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; }
884
+ .deck-card { padding: 2px 14px 12px; min-width: 0; overflow-wrap: anywhere; }
885
+ .deck-what { margin: 0 0 2px; font-size: 12px; color: var(--muted); }
886
+ .deck-card h3 { margin: 0 0 4px; font-size: 15px; line-height: 1.3; }
887
+ .deck-card h3 a { color: inherit; text-decoration: none; }
888
+ .deck-card .ctx { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
889
+ .deck-proto { margin: 0 0 10px; color: var(--accent); }
890
+ .deck-opts { display: flex; flex-direction: column; gap: 6px; }
891
+ .deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
892
+ .deck-opts button.rec { border-color: var(--accent); }
893
+ .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
894
+ .deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
895
+ .deck-prompt { margin: 0; padding: 0; list-style: none; }
896
+ .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
897
+ .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
898
+ .deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
899
+ .deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
900
+ .deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
901
+ .deck-dots i.on { background: var(--color-notice); }
902
+ .deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
903
+ .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));
904
+ background: color-mix(in srgb, var(--color-notice) 14%, var(--panel)); color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; }
905
+ .deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
906
+ @media (min-width: 900px) {
907
+ .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
908
+ .split > main { max-width: none; margin: 0; }
909
+ .needs-deck { display: none; }
910
+ /* 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. */
911
+ .view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
912
+ body.has-sidebar { --sidebar-w: 272px; }
913
+ html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
914
+ .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 24px; border-left: 1px solid var(--line); }
915
+ .needs-pane .needs-questions main { padding: 0; max-width: none; }
916
+ .needs-pane .waiting { margin: 0 0 12px; }
917
+ }
@@ -56,10 +56,12 @@
56
56
  const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
57
57
  return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
58
58
  }
59
- // Each unfinished dep, linking to its card: "blocked by #53, #54".
59
+ // Each unfinished dep, linking to its card: "blocked by #53, #54", or "waits on #53" on a bumped card not held, which
60
+ // the pull takes once nothing blocks it (docs/adr/2026-11-05-0802-todo-has-no-queue.md).
60
61
  function blockedWords(it) {
61
62
  const deps = it.blockedBy || [];
62
- return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
63
+ const lead = it.bumpedAt && !it.held ? 'waits on ' : 'blocked by ';
64
+ return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : lead}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
63
65
  }
64
66
  const tryWords = (kind) => kind === 'app' ? {
65
67
  go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
@@ -112,16 +114,17 @@
112
114
  if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
113
115
  const words = tryWords(t.kind);
114
116
  const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
115
- return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
117
+ const attrs = BoardUI.pending.has(`${it.key}:try`) ? BoardUI.BUSY : reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : '';
118
+ return `<button type="button" data-try="${esc(it.key)}"${attrs}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
116
119
  }
117
120
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
118
121
  // Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
119
122
  // the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
120
123
  // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
121
124
  // (`open`). Visit is left alone: it changes only once Main rebuilds.
122
- function mainPull(pull, { sending = false, open = false } = {}) {
125
+ function mainPull(pull, { open = false } = {}) {
123
126
  if (!pull) return '';
124
- const busy = pull.running || sending;
127
+ const busy = pull.running || BoardUI.pending.has('main:pull');
125
128
  const failed = !busy && pull.error;
126
129
  const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
127
130
  const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
@@ -143,21 +146,13 @@
143
146
  function mergeButton(it) {
144
147
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
145
148
  const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
146
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
149
+ // A merging card has no Action needed left, so resolvedBy is null by then: merging keeps the colour resolvedBy gave it.
150
+ return `<button class="${it.merging || it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
147
151
  }
148
- // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
149
- // not: it confirms, then
150
- // sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
151
- // mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
152
- // docs/adr/2026-11-03-0219-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
153
- // Implement is plain until the card is ready, then builder orange (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
154
- function buildButton(it) {
155
- if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
156
- return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
157
- }
158
- // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on, and
159
- // ends with how long ago that line was set.
160
- const builderLine = (b) => [b.progress || 'preheating', b.at && lasted(b.at)].filter(Boolean).join(' · ');
152
+ // A builder reads "starting" until its first status line, from the Implement tap or the session's dispatch on, and
153
+ // ends with how long ago that line was set. One whose process is gone leads with stopped; one whose live process
154
+ // went quiet keeps its words and only turns grey (domain.mjs builderOf; docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
155
+ const builderLine = (b) => [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · ');
161
156
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
162
157
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
163
158
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
@@ -185,12 +180,12 @@
185
180
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
186
181
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
187
182
  const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
188
- return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Preheating, no step yet'}${answers}` };
183
+ return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
189
184
  }
190
185
  const dim = !it.builder || asking;
191
186
  if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
192
187
  const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
193
- const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
188
+ const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
194
189
  return { reached, running: false, dim, label: `${label}${answers}` };
195
190
  }
196
191
  // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
@@ -213,12 +208,28 @@
213
208
  const { since, picked } = it.orchestrator;
214
209
  return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
215
210
  }
216
- // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
211
+ // A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
212
+ // A quiet one is grey, with no dot.
217
213
  function builderStatus(it) {
218
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}`;
219
- const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
214
+ const { quiet } = it.builder;
215
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
216
+ const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
220
217
  if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
221
- return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
218
+ const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
219
+ return `<button type="button" class="${classes} stalled${nudging}" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
220
+ }
221
+ // The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
222
+ // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
223
+ function statusWord(it) {
224
+ const b = it.builder;
225
+ if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
226
+ if (b && b.kind !== BUILDER_KIND.install) return b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing';
227
+ if (it.waitsOn === 'checking') return 'Orchestrator review';
228
+ if (it.lane === 'done') return 'done';
229
+ if (it.grilling || it.prototyping) return 'preheating';
230
+ if (it.lane === 'todo') return it.ready ? 'ready' : 'to do';
231
+ if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
232
+ return 'in review';
222
233
  }
223
234
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
224
235
  // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
@@ -283,13 +294,13 @@
283
294
  const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
284
295
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
285
296
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
286
- : it.orchestrator && !it.builder ? orchestratorStatus(it)
297
+ : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${esc(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
287
298
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
288
299
  : it.builder ? builderStatus(it)
289
- : it.grilling || it.prototyping ? `<div class="line3 sub agent planner${tone(it)}">${DOT}${it.grilling ? 'preheating' : 'prototyping'}</div>`
300
+ : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
290
301
  : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
291
302
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
292
- : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
303
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
293
304
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
294
305
  // 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).
295
306
  const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
@@ -303,7 +314,7 @@
303
314
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
304
315
  function planButton(items) {
305
316
  const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
306
- return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
317
+ return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
307
318
  }
308
319
 
309
320
  // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
@@ -344,5 +355,5 @@
344
355
  <div class="list">${list}${fold}</div></details>`;
345
356
  }
346
357
 
347
- Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
358
+ Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, statusWord, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
348
359
  })();
@@ -65,13 +65,13 @@
65
65
  ? `<ul class="hub-links">${links.map(linkRow).join('')}</ul>`
66
66
  : '<p class="muted">The hub doesn\'t list this board yet. It picks a board up from the manifest on its next check.</p>');
67
67
 
68
- // The checklist a new board shows at the top until its session answered the hello and GitHub checks reach it; setup
68
+ // The checklist a new board shows at the top until its session answered the ping and GitHub checks reach it; setup
69
69
  // is /api/board's, null once both pass. Cards can start meanwhile.
70
70
  const setupCard = (setup) => {
71
71
  if (!setup) return '';
72
72
  const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
73
73
  return `<h2>Connecting this board</h2><ul class="setup-steps">
74
- ${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
74
+ ${step(setup.events, 'Claude answered the ping', 'Waiting for Claude to answer the ping')}
75
75
  ${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
76
76
  </ul>`;
77
77
  };
@@ -1,21 +1,23 @@
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, queued, bumpedAt, 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, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
7
- // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
8
- // then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
- // newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
10
- // lead it, bumped first and then oldest first, so the top one is the next to build
11
- // (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
7
+ // Todo lists bumped cards first, the latest bump on top, a bumped New ask included
8
+ // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
9
+ // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
10
+ // top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
11
+ // docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
12
+ // lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
12
13
  function laneOrder(a, b) {
13
14
  const bumped = (i) => i.bumpedAt || '';
14
- const unsorted = (i) => (i.isNew ? 0 : 1);
15
- const queued = (i) => (i.queued ? 1 : 0);
16
15
  const at = (i) => String(i.sortAt || '');
17
- const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
18
- return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
16
+ const oldestFirst = a.lane === 'todo';
17
+ const timeless = (i) => (at(i) ? 0 : 1);
18
+ const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
19
+ const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
20
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || bumped(b).localeCompare(bumped(a)) || age || added || a.key.localeCompare(b.key);
19
21
  }
20
22
  Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
21
23
  })();