@lovek4d/kickflip 0.11.0 → 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.
@@ -22,10 +22,9 @@
22
22
  #stage .top { position: static; }
23
23
  #stage .hub-view main { padding-bottom: 16px; }
24
24
  /* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
25
- viewer's footer; in the board's right pane (.ps-pane) there is no ✕ row, so it starts at the top. */
25
+ viewer's footer. */
26
26
  #stage:has(> .kit-served) { max-width: none; padding: 0; }
27
27
  .kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
28
- .ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
29
28
  .kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
30
29
  .kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
31
30
  #protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
@@ -50,10 +49,8 @@
50
49
  #stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
51
50
  @media (min-width: 900px) { #stage .kit-sidebar.sidebar { width: 272px; } }
52
51
  /* Everything a take draws shows at every width: the board's width-gated hides are undone here (style.css hides
53
- .needs-deck from 900px and .needs-pane below it, .key-hint below 600px). render-check.mjs refuses a take whose box is
52
+ .key-hint below 600px). render-check.mjs refuses a take whose box is
54
53
  still hidden at one of its two widths, so a gate missed here fails the round instead of drawing blank. */
55
- #stage .needs-deck { display: block; }
56
- #stage .needs-pane { display: block; position: static; height: auto; overflow: visible; border-left: 0; padding: 0; }
57
54
  #stage .key-hint { display: inline; }
58
55
  #stage .kit-sheet-strip { padding: 32px 0 0; border-radius: 12px; background: rgba(0,0,0,.35); overflow: hidden; }
59
56
  #stage .kit-sheet { max-width: 560px; margin: 0 auto; border: 1px solid var(--line); border-bottom: 0; border-radius: 16px 16px 0 0; background: var(--panel); color: var(--text); box-shadow: 0 -8px 30px rgba(0,0,0,.18); }
@@ -99,7 +96,6 @@
99
96
  <script src="app-cards.js"></script>
100
97
  <script src="app-lanes.js"></script>
101
98
  <script src="app-asks.js"></script>
102
- <script src="app-needs.js"></script>
103
99
  <script src="app-header.js"></script>
104
100
  <script src="app-arch.js"></script>
105
101
  <script src="app-reader.js"></script>
@@ -3,7 +3,7 @@
3
3
  // ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
4
4
  // A view is {kind, …}:
5
5
  // start `/`, which the shell turns into the last board
6
- // board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
6
+ // board {route, hash} /<route>/, hash such as #selectedCard=<key>, or the older #item=<key>&reply
7
7
  // settings {route} /<route>/settings.html, a request to open the modal over that board
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
9
  // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
@@ -81,24 +81,23 @@ globalThis.AppRouter = (function () {
81
81
 
82
82
  // The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
83
83
  // to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
84
- // A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
84
+ // A board's prototype page.
85
85
  const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
86
86
  function questionOf(view, boards) {
87
87
  return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
88
88
  }
89
- // wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
90
- function levelOf(view, { boards = new Set(), wide = false } = {}) {
89
+ function levelOf(view, { boards = new Set() } = {}) {
91
90
  if (view?.kind === 'board') return 0;
92
91
  if (view?.kind === 'questions') return 1;
93
- return questionOf(view, boards) ? (wide ? 1 : 2) : null;
92
+ return questionOf(view, boards) ? 2 : null;
94
93
  }
95
94
  // → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
96
95
  // which the board keeps open.
97
- function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
96
+ function parentsOf(view, { boards = new Set(), card = null } = {}) {
98
97
  const q = questionOf(view, boards);
99
98
  if (!q && view?.kind !== 'questions') return [];
100
99
  const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
101
- return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
100
+ return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
102
101
  }
103
102
 
104
103
  // Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
@@ -111,8 +110,8 @@ globalThis.AppRouter = (function () {
111
110
  // boards() → the board routes now.
112
111
  // → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
113
112
  // 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
114
- function listen({ boards, go, wide = () => false }) {
115
- const b = () => ({ boards: boards(), wide: wide() });
113
+ function listen({ boards, go }) {
114
+ const b = () => ({ boards: boards() });
116
115
  const mark = (pushed) => (pushed ? { pushed: true } : {});
117
116
  let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
118
117
  let landing = null;
@@ -225,32 +224,27 @@ globalThis.AppRouter = (function () {
225
224
  return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
226
225
  }
227
226
 
228
- // A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
229
- // open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
230
- // → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
227
+ // A board's fragment is &-joined keys: selectedCard=<key> (the focused card), review=<PR number>, and a bare reply.
228
+ // The older #item=<key> reads as selectedCard.
229
+ // → {selectedCard, review, reply}, each null or false when absent.
231
230
  function fragmentOf(hash) {
232
- const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
231
+ const out = { selectedCard: null, review: null, reply: false };
233
232
  const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
234
233
  for (const part of String(hash).replace(/^#/, '').split('&')) {
235
234
  const eq = part.indexOf('=');
236
235
  const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
237
236
  if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
238
- else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
239
237
  else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
240
238
  else if (k === 'reply') out.reply = true;
241
239
  }
242
240
  return out;
243
241
  }
244
242
  // hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
245
- // alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
243
+ // alone in its fragment, so a change to the card replaces it; reply is spent once the card is shown.
246
244
  function hashWith(hash, changes) {
247
- const now = fragmentOf(hash);
248
- const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
249
- const parts = [];
250
- if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
251
- if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
252
- return parts.length ? '#' + parts.join('&') : '';
245
+ const next = { selectedCard: fragmentOf(hash).selectedCard, ...changes };
246
+ return next.selectedCard ? `#selectedCard=${encodeURIComponent(next.selectedCard)}` : '';
253
247
  }
254
248
 
255
- return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
249
+ return { viewOf, urlOf, mounted, entryOf, listen, fragmentOf, hashWith };
256
250
  })();
@@ -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); }
@@ -395,19 +395,22 @@ div.status-line { padding-left: 33px; text-indent: -23px; }
395
395
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
396
396
  .msg.agent .muted { font-weight: 400; }
397
397
  /* A planner's line in the thread, dot included, is the owner's blue; its finish stays green
398
- (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). */
399
399
  .msg.builder-status.planner, .msg.agent.started.planner { color: var(--accent); }
400
400
  .planner .working-dot { background: currentColor; }
401
- /* 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
402
402
  In Progress, blue in In Review; a Todo card's planner dashes are its grey, the running one pulsing
403
403
  (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). */
404
404
  .sub.agent.in-todo { color: var(--muted); }
405
405
  .sub.agent.in-progress { color: var(--color-notice); }
406
406
  .sub.agent.in-review { color: var(--color-info); }
407
407
  .sub.agent.in-todo .working-dot, .sub.agent.in-progress .working-dot, .sub.agent.in-review .working-dot { background: currentColor; }
408
- /* 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
409
- has picked up are grey, after the lane colours so they win. */
410
- .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); }
411
414
  .sub.agent.waiting-gray { color: var(--muted); }
412
415
  /* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
413
416
  .sub.agent.stale { color: var(--muted); }
@@ -447,8 +450,11 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
447
450
  /* INT-30: card line order */
448
451
  .line6 { margin-top: 4px; }
449
452
  .card-time time { font-variant-numeric: tabular-nums; }
450
- .card-unread { color: var(--accent); font-weight: 600; }
451
- .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); }
452
458
  /* Lane headers: Linear-style group bands */
453
459
  .lane > summary { display: flex; align-items: center; gap: 8px; margin: 0; min-height: 34px; padding: 5px 12px;
454
460
  background: var(--band); border: 1px solid var(--line); border-radius: 10px; font-size: 13px; font-weight: 500; color: var(--text); }
@@ -471,7 +477,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
471
477
  .view-only-strip b { color: var(--accent); }
472
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,
473
479
  undimmed, and only the buttons that send, ui.js SENDS, are held, the labelled ones saying why; app-header.js stops their taps. */
474
- .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; }
475
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. */
476
482
  .down-landing { min-height: 60vh; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 32px 24px; text-align: center; }
477
483
  .down-landing-mark { display: block; margin-bottom: 8px; width: 70px; height: 60px; object-fit: contain; }
@@ -479,7 +485,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
479
485
  .down-landing-hint { margin: 0; font-size: 14px; color: var(--muted); }
480
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); }
481
487
  .down-landing-stack[hidden] { display: none; }
482
- .view-board.down main > :not(#downLanding), .view-board.down :is(#needsPane, #compose) { display: none; }
488
+ .view-board.down main > :not(#downLanding), .view-board.down #compose { display: none; }
483
489
  .view-settings.down .settings-body > :not(.down-landing) { display: none; }
484
490
  /* INT-28: answer banner */
485
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);
@@ -534,7 +540,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
534
540
  .board-foot .arch-bulb[hidden] { display: none; }
535
541
  .board-foot .arch-bulb.running { color: var(--color-notice); }
536
542
  .board-foot a.icon[aria-disabled="true"] { opacity: .35; pointer-events: none; }
537
- /* 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. */
538
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); }
539
545
  .top .pages-switch:hover { color: var(--text); }
540
546
  .top .pages-switch:active { background: none; }
@@ -659,13 +665,13 @@ main > * { transition: opacity .25s ease; }
659
665
  (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md), whose blocks break the
660
666
  lines, not the text's own newlines. */
661
667
  :is(.msg .body, .reader-row).md { white-space: normal; }
662
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) { overflow-wrap: anywhere; }
663
- :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; }
664
- :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; }
665
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) :is(ul, ol) { padding-left: 18px; }
666
- :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; }
667
- :is(.view-questions .ctx, .view-questions .opt .det, .deck-card .ctx, .deck-opts .det, .md) table { display: block; max-width: 100%; overflow-x: hidden; }
668
- :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; }
669
675
  .view-questions .opt .suggested-why { margin-top: 4px; color: var(--color-info); font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
670
676
  .view-questions .note { margin-top: 4px; }
671
677
  .view-questions .ans { background: var(--accent-soft); border-radius: 9px; padding: 7px 10px; margin-top: 8px; font-size: 13px; }
@@ -703,8 +709,7 @@ main > * { transition: opacity .25s ease; }
703
709
  /* The + rides above the sticky Send bar rather than over its Send. */
704
710
  .view-questions .bar #qstatus { min-width: 0; }
705
711
  .view-questions .bar #qstatus.sent { color: var(--color-success); font-weight: 600; }
706
- /* With no open question, the moon and its line sit at the centre of what the page shows between its top and the nav,
707
- 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. */
708
713
  .view-questions .q-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center;
709
714
  min-height: calc(var(--vh, 100dvh) - var(--app-bottom, 0px) - max(16px, env(safe-area-inset-top))); }
710
715
  .view-questions .q-empty p { margin: 0; font-size: 15px; }
@@ -761,8 +766,8 @@ main > * { transition: opacity .25s ease; }
761
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; }
762
767
  .view-notes .md-table th { background: var(--bg); font-weight: 600; }
763
768
  .view-notes .md-table tr:last-child td { border-bottom: 0; }
764
- /* The sidebar and every pane never scroll sideways: content wraps at the pane's width, overflow-x hidden is the last guard. */
765
- .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; }
766
771
  /* A framed page (view-frame.js) fills the space beside the header, and scrolls inside its frame. */
767
772
  .view-frame { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
768
773
  .view-frame > .top { flex: none; }
@@ -781,7 +786,7 @@ main > * { transition: opacity .25s ease; }
781
786
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
782
787
  }
783
788
  .top .nav #refresh, .top .nav a.icon, .top .pages-switch { min-width: 44px; min-height: 44px; }
784
- 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)); }
785
790
  .view-frame:not(.own-header) { padding-bottom: var(--app-bottom, 0px); }
786
791
  .sheet { bottom: max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px)));
787
792
  max-height: calc(var(--vh, 100dvh) + var(--kb, 0px) - max(var(--app-bottom, 0px), var(--kb, 0px)) - env(safe-area-inset-top) - 12px); }
@@ -943,92 +948,34 @@ button[aria-busy="true"]:is([data-card-bump]) svg { fill: currentColor; }
943
948
  .rv-acts { display: flex; flex-wrap: wrap; gap: 8px; }
944
949
  .view-questions .bar #qstatus .try-spin { color: var(--accent); margin-right: 6px; }
945
950
 
946
- /* 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
947
- Waiting on you (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md). */
948
- .needs-pane { display: none; }
949
951
  .col-handle { display: none; }
950
- .needs-deck { margin: 0 0 12px; border: 1px solid color-mix(in srgb, var(--color-notice) 45%, var(--line)); border-radius: 14px;
951
- background: color-mix(in srgb, var(--color-notice) 7%, var(--panel)); overflow: hidden; }
952
- .deck-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px 4px; }
953
- .deck-head h2 { margin: 0; flex: 1; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--color-notice); }
954
- .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; }
955
- .deck-card { padding: 2px 14px 12px; min-width: 0; overflow-wrap: anywhere; }
956
- .deck-what { margin: 0 0 2px; font-size: 12px; color: var(--muted); }
957
- .deck-card h3 { margin: 0 0 4px; font-size: 15px; line-height: 1.3; }
958
- .deck-card h3 a { color: inherit; text-decoration: none; }
959
- .deck-card .ctx { margin: 0 0 10px; font-size: 13px; color: var(--muted); }
960
- .deck-proto { margin: 0 0 10px; color: var(--accent); }
961
- .deck-opts { display: flex; flex-direction: column; gap: 6px; }
962
- .deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
963
- .deck-opts button.rec { border-color: var(--accent); }
964
- .deck-opts button.armed { border-color: var(--sel-line); background: var(--sel-bg); font-weight: 600; }
965
- .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
966
- .deck-opts .det { color: var(--muted); font-size: 12.5px; font-weight: 400; }
967
- .deck-prompt { margin: 0; padding: 0; list-style: none; }
968
- .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
969
- .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
970
- .deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
971
- .deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
972
- .deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
973
- .deck-dots i.on { background: var(--color-notice); }
974
- .deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
975
- .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));
976
- background: color-mix(in srgb, var(--color-notice) 14%, var(--panel)); color: var(--text); font-weight: 600; font-size: 14px; text-decoration: none; }
977
- .deck-more::after { content: '›'; color: var(--accent); font-size: 18px; }
978
- .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). */
979
954
  @media (min-width: 900px) {
980
- .split { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) var(--pane-w, minmax(0, 1fr)); align-items: start; }
981
- .split > main { max-width: none; margin: 0; }
982
- .needs-deck { display: none; }
983
- /* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
984
- .top .pages-switch { display: none; }
985
- .top .nav a.pages-switch { display: none; }
986
- /* 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. */
987
- .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; }
988
956
  body.has-sidebar { --sidebar-w: var(--sb-w, 272px); }
989
957
  html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
990
- /* 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. */
991
959
  .col-handle { display: block; position: absolute; z-index: 5; top: 0; bottom: 0; width: 9px; cursor: col-resize; touch-action: none; }
992
960
  .col-handle::after { content: ''; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: transparent; transition: background .12s, width .12s, left .12s; }
993
961
  .col-handle:is(:hover, :focus-visible, .dragging)::after { left: 3px; width: 3px; background: var(--accent); }
994
962
  .col-handle:focus-visible { outline: none; }
995
963
  html.col-dragging, html.col-dragging * { cursor: col-resize !important; user-select: none; }
996
- .split > .col-handle { right: calc(var(--pane-w, 50%) - 4px); }
997
- .split:has(.needs-pane.widened) > .col-handle { display: none; }
998
- /* 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. */
999
- .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;
1000
966
  height: auto; max-height: none; border-width: 0 0 0 1px; border-radius: 0; box-shadow: none; }
1001
967
  .sheet.reader-sheet[open] { animation: reader-in .22s ease-out; }
1002
968
  .sheet.reader-sheet.closing { animation: reader-out .18s ease-in forwards; }
1003
969
  .reader-sheet::backdrop { background: transparent; }
1004
- .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); }
1005
- .needs-pane .needs-questions main { padding: 0; max-width: none; }
1006
- .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; }
1007
- .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; }
1008
- .needs-pane .waiting { margin: 0 0 12px; }
1009
- .needs-pane * { min-width: 0; overflow-wrap: anywhere; }
1010
- .needs-pane pre, .needs-pane code { white-space: pre-wrap; }
1011
- .needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
1012
- /* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
1013
- (.widened) lays the pane over the lanes, from the sidebar's edge. */
1014
- .needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
1015
- .needs-pane.viewing > :not(.pane-viewer) { display: none; }
1016
- .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); }
1017
- .pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
1018
- .pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
1019
- .pv-top .pv-grow { flex: 1; }
1020
- .pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
1021
- .pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
1022
- .pv-top .pv-back:hover { background: var(--line); }
1023
- .pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
1024
- .pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
1025
- .pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
1026
- .pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
1027
- .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); }
1028
- .pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
1029
- .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; }
1030
- .pane-viewer .ps-sheet button span + span { color: var(--muted); }
1031
970
  }
1032
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. */
1033
972
  body .view-questions.view-questions main { padding-bottom: var(--app-bottom, 0px); }
1034
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,18 +280,18 @@
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
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 says whose turn it is, blue for the
291
- // owner's, orange while the orchestrator owes the reply.
292
- const TURN_LOOK = { owner: ['discussing · your turn', 'info'], claude: ["discussing · Claude's turn", 'notice'] };
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'] };
293
293
  function phaseWord(it) {
294
- if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
294
+ if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || it.phase === 'new' || (it.phase === 'ready' && !it.ready)) return '';
295
295
  const [word, tone] = it.phase === 'discussing' ? TURN_LOOK[it.turn] : PHASE_LOOK[it.phase];
296
296
  return `<span class="card-phase ph-${tone}">${word}</span>`;
297
297
  }
@@ -300,26 +300,29 @@
300
300
  // orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
301
301
  const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
302
302
  const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
303
- // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
304
- // held while host recovers on a bumped ready card a busy host holds, in grey
305
- // (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
306
- // in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
307
- // 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
308
- // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
309
- // 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).
310
313
  const SEP = '<span class="sep"> · </span>';
311
314
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
312
315
  function timeLine(it, unread, bars) {
313
316
  const iso = it.sortAt;
314
317
  const pause = pauseOf(it);
315
- 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)]),
316
320
  it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
317
- 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);
318
322
  const news = newWords(unread, 'message');
319
- const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
320
- const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
321
- if (!parts.length && !count && !tail) return '';
322
- 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('');
323
326
  }
324
327
  // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
325
328
  // Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
@@ -346,15 +349,16 @@
346
349
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting…</span>';
347
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(' · ');
348
351
  function workLines(it, { unread = 0 } = {}) {
349
- 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);
350
- // A card shows its ticket, or Task when it has none; the card an ask became shows New there until it is sorted.
351
- 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.
352
354
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
353
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>`
354
359
  : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
355
360
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
356
361
  : it.builder ? builderStatus(it)
357
- : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
358
362
  : it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
359
363
  : it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
360
364
  : it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
@@ -3,8 +3,8 @@
3
3
  // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
4
  // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
- const BUTTON = Object.freeze({ openQuestions: 'openQuestions', 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).
@@ -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) {