@lovek4d/kickflip 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CONTEXT.md +19 -16
  2. package/SKILL.md +7 -7
  3. package/bin/event-lines.mjs +1 -1
  4. package/board/bin/board.mjs +30 -17
  5. package/board/domain.mjs +7 -7
  6. package/board/headless.mjs +8 -8
  7. package/board/public/app-cards.js +2 -0
  8. package/board/public/app-header.js +8 -0
  9. package/board/public/app-lanes.js +43 -19
  10. package/board/public/app-snapshot.js +1 -2
  11. package/board/public/app-threads.js +2 -2
  12. package/board/public/app.html +0 -1
  13. package/board/public/app.js +25 -27
  14. package/board/public/col-resize.js +25 -44
  15. package/board/public/dialogs.js +2 -2
  16. package/board/public/new-task.js +2 -2
  17. package/board/public/prompts.js +1 -1
  18. package/board/public/prototype-kit.js +1 -1
  19. package/board/public/prototype-switcher.js +3 -8
  20. package/board/public/prototype.html +2 -6
  21. package/board/public/router.js +16 -22
  22. package/board/public/shell.js +3 -39
  23. package/board/public/style.css +47 -95
  24. package/board/public/ui-cards.js +29 -22
  25. package/board/public/ui-enums.js +3 -3
  26. package/board/public/ui-page-header.js +2 -2
  27. package/board/public/ui-prompts.js +1 -1
  28. package/board/public/ui-questions.js +2 -3
  29. package/board/public/ui.js +10 -2
  30. package/board/public/view-hub.js +1 -1
  31. package/board/public/view-questions.js +7 -26
  32. package/board/server.mjs +18 -18
  33. package/board/store.mjs +32 -32
  34. package/hub/hub.mjs +8 -8
  35. package/hub/prompts.mjs +2 -2
  36. package/hub/review-send.mjs +1 -1
  37. package/hub/slack.mjs +4 -2
  38. package/hub/views.mjs +1 -1
  39. package/package.json +1 -1
  40. package/board/public/app-needs.js +0 -248
@@ -76,7 +76,7 @@ BoardView.part((B) => {
76
76
  const openReviews = new Set();
77
77
  const reviewsHtml = (d) => BoardUI.reviewLane(d.reviews, {
78
78
  open: { lane: store.get('lanes', {}).reviews, earlier: store.get(FOLDS['reviews-earlier'].store, false), cards: openReviews } });
79
- // Opens PR number's review and its lane or fold, for a Needs you line or a push's #review=<number>.
79
+ // Opens PR number's review and its lane or fold, for a push's #review=<number>.
80
80
  function openReview(number) {
81
81
  openReviews.add(number);
82
82
  const s = store.get('lanes', {}); s.reviews = true; store.set('lanes', s);
@@ -85,17 +85,43 @@ BoardView.part((B) => {
85
85
  $(`[data-review="${number}"]`)?.scrollIntoView({ block: 'start' });
86
86
  }
87
87
 
88
+ // docs/adr/2026-11-12-0600-the-todo-header-has-a-filter.md
89
+ // The Todo header's filter: one box per property a Todo card can have, all checked until the owner unchecks one. An
90
+ // unchecked box hides every Todo card with its property and nothing else, so a card that is held and blocked hides
91
+ // when either box is unchecked. The unchecked boxes are kept on this device (store 'todoFilter'); the menu's open state is in memory.
92
+ const FILTER_BOXES = [
93
+ ['ready', 'Ready', (i) => ['ready', 'staging'].includes(i.phase)],
94
+ ['discussing', 'Discussing', (i) => ['discussing', 'new'].includes(i.phase)],
95
+ ['planning', 'Planning', (i) => ['grilling', 'prototyping'].includes(i.phase)],
96
+ ['blocked', 'Blocked', (i) => Boolean(i.blocked)],
97
+ ['held', 'Held', (i) => Boolean(i.held)],
98
+ ];
99
+ const FUNNEL = '<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"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></svg>';
100
+ let filterOpen = false;
101
+ const unchecked = () => store.get('todoFilter', []);
102
+ const hiddenByFilter = (it) => FILTER_BOXES.some(([id, , has]) => unchecked().includes(id) && has(it));
103
+ const filterHtml = () => {
104
+ const off = unchecked();
105
+ const menu = filterOpen ? `<div class="lane-filter-menu" role="group" aria-label="Show in Todo">${FILTER_BOXES.map(([id, name]) =>
106
+ `<button type="button" class="lane-filter-box" role="checkbox" aria-checked="${!off.includes(id)}" data-todo-filter="${id}"><span class="box"></span>${name}</button>`).join('')}</div>` : '';
107
+ return { button: `<button type="button" class="lane-filter${off.length ? ' on' : ''}" aria-label="Filter Todo" aria-expanded="${filterOpen}" title="${off.length ? 'Filtered' : 'Filter'}">${FUNNEL}</button>`, menu };
108
+ };
109
+
88
110
  // shown is the board's items less pending deletes.
89
111
  function lanesHtml(d, shown) {
90
112
  const laneState = store.get('lanes', {});
91
113
  return reviewsHtml(d) + LANES.map((lane) => {
92
114
  // Items arrive in each lane's fixed order (domain.mjs laneOrder).
93
- const items = shown.filter((i) => i.lane === lane.id);
115
+ const all = shown.filter((i) => i.lane === lane.id);
116
+ const items = lane.id === 'todo' ? all.filter((i) => !hiddenByFilter(i)) : all;
117
+ if (lane.id === 'todo') shown = shown.filter((i) => i.lane !== 'todo' || items.includes(i));
118
+ const filter = lane.id === 'todo' ? filterHtml() : null;
94
119
  const expanded = laneState[lane.id] ?? lane.open;
95
120
  const open = items.filter((i) => !i.fold);
96
121
  const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + fileFolds(d, items);
97
122
  return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
98
- <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
123
+ <summary><span class="lane-name">${lane.name}</span><span class="count">${lane.id === 'done' ? doneCount(d, items) : lane.id === 'todo' && items.length < all.length ? `${items.length} of ${all.length}` : items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}${filter ? filter.button : ''}</summary>
124
+ ${filter ? filter.menu : ''}
99
125
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
100
126
  ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
101
127
  </details>`;
@@ -172,12 +198,11 @@ BoardView.part((B) => {
172
198
  return groupHtml('todo', band.slug, open, head, doneRow + todo.map((it) => B.card(it)).join(''));
173
199
  }
174
200
 
175
- // Opens the band holding card key or named slug, for a #selectedCard= link or a Needs you line.
176
- function openBand({ key, slug }) {
177
- const lane = (key && state.board?.items.find((i) => i.key === key)?.lane) || 'todo';
178
- const band = (state.board?.waveBands || []).find((b) => b.slug === slug || b.keys.includes(key));
201
+ // Opens the band holding card key, for a #selectedCard= link.
202
+ function openBand({ key }) {
203
+ const lane = state.board?.items.find((i) => i.key === key)?.lane || 'todo';
204
+ const band = (state.board?.waveBands || []).find((b) => b.keys.includes(key));
179
205
  if (band) store.set(groupStore(`${lane}/${band.slug}`), true);
180
- return band;
181
206
  }
182
207
 
183
208
  B.on(B.root, 'toggle', (ev) => {
@@ -199,8 +224,15 @@ BoardView.part((B) => {
199
224
  catch { B.notice('Could not send. Is the host awake?'); }
200
225
  return;
201
226
  }
202
- const review = ev.target.closest('a[data-review-open]');
203
- if (review) { ev.preventDefault(); openReview(Number(review.dataset.reviewOpen)); return; }
227
+ const filterBtn = ev.target.closest('button.lane-filter');
228
+ if (filterBtn) { ev.preventDefault(); filterOpen = !filterOpen; B.render(); return; }
229
+ const box = ev.target.closest('[data-todo-filter]');
230
+ if (box) {
231
+ const id = box.dataset.todoFilter;
232
+ store.set('todoFilter', unchecked().includes(id) ? unchecked().filter((x) => x !== id) : [...unchecked(), id]);
233
+ B.render();
234
+ return;
235
+ }
204
236
  const more = ev.target.closest('[data-lane-more]');
205
237
  if (more) {
206
238
  if (more.hasAttribute('data-fetch')) {
@@ -213,7 +245,7 @@ BoardView.part((B) => {
213
245
  }
214
246
  const pb = ev.target.closest('button[data-plan]');
215
247
  if (pb) {
216
- if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Needs you. Nothing is implemented until you answer. Start?'))) return;
248
+ if (!(await B.confirm('Claude triages these Todo cards into one or more waves and asks you about them in Waiting on you. Nothing is implemented until you answer. Start?'))) return;
217
249
  // Spins until a board shows a grill running on one of the cards (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
218
250
  const keys = pb.dataset.plan.split(' ');
219
251
  BoardUI.pending.start('todo:plan', (board) => board.items.some((i) => keys.includes(i.key) && i.grilling), B.stillWaiting);
@@ -224,14 +256,6 @@ BoardView.part((B) => {
224
256
  } catch { BoardUI.pending.drop('todo:plan'); B.render(); B.notice('Could not send. Is the host awake?'); }
225
257
  return;
226
258
  }
227
- const show = ev.target.closest('a[data-band-show]');
228
- if (show) {
229
- ev.preventDefault();
230
- if (!openBand({ slug: show.dataset.bandShow })) return;
231
- const s = store.get('lanes', {}); s.todo = true; store.set('lanes', s);
232
- B.render();
233
- $(`.wb[data-lane="todo"][data-band="${CSS.escape(show.dataset.bandShow)}"]`)?.scrollIntoView({ block: 'start' });
234
- }
235
259
  });
236
260
 
237
261
  // A band's head's fold and its name's goal popover. A long-press on the name (touch only;
@@ -38,11 +38,10 @@ BoardView.part((B) => {
38
38
  if (view) B.root.dataset.board = view; else delete B.root.dataset.board;
39
39
  }
40
40
 
41
- // The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
41
+ // The lines a fresh board can change: each lane count and thread message. A card shows its own sign below.
42
42
  function lines() {
43
43
  const out = new Map();
44
44
  for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
45
- $$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
46
45
  for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
47
46
  return out;
48
47
  }
@@ -20,13 +20,13 @@ BoardView.part((B) => {
20
20
  // card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
21
21
  const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
22
22
  const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
23
- : item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
23
+ : item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', item?.resolvedBy === BUTTON.implement && 'implement-resolves', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
24
24
  const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
25
25
  const sends = (kind, item) => {
26
26
  const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
27
27
  return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
28
28
  : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
29
- : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
29
+ : pair(kind, `${sendButton('Implement', 1, ` class="build${kind.startsWith('todo-ready') ? ' ready' : ''}${kind.includes('implement-resolves') ? ' resolves' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
30
30
  };
31
31
  // The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
32
32
  function syncSends(el, key) {
@@ -77,7 +77,6 @@ window.BoardBoot = (function () {
77
77
  <script src="/_hub/app/app-cards.js"></script>
78
78
  <script src="/_hub/app/app-lanes.js"></script>
79
79
  <script src="/_hub/app/app-asks.js"></script>
80
- <script src="/_hub/app/app-needs.js"></script>
81
80
  <script src="/_hub/app/app-header.js"></script>
82
81
  <script src="/_hub/app/app-arch.js"></script>
83
82
  <script src="/_hub/app/app-reader.js"></script>
@@ -198,7 +198,6 @@ window.BoardView = (function () {
198
198
  setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
199
199
  for (const key of state.openItems) if (state.threadCache.has(key)) B.renderThread(key, state.threadCache.get(key));
200
200
  B.moveStrips(d.items, strips);
201
- B.renderNeeds(d);
202
201
  B.renderBanner(d);
203
202
  });
204
203
  B.flashChanged();
@@ -206,23 +205,16 @@ window.BoardView = (function () {
206
205
  }
207
206
 
208
207
  // #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
209
- // #selectedCard=<file>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
208
+ // #selectedCard=<file>/<id> (or the older #item=) opens that card; push notifications link here, with &reply to land in the thread's reply box
210
209
  // (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
211
210
  let focusKey = null;
212
211
  let replyKey = null;
213
212
  let hashRead = false;
214
213
  // A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
215
214
  const returnToPlace = () => B.restorePlace({ linked: /^#(?:selectedCard|item|review)=/.test(location.hash) });
216
- // The pane's pill the fragment names opens once the pane has loaded; a phone has no pane, its pill is the Waiting on
217
- // you page (shell.js go).
218
- function openPill(id, tries = 50) {
219
- if (!id || !matchMedia('(min-width: 900px)').matches || B.focusQuestion?.(id) || tries <= 0) return;
220
- B.later(100, () => openPill(id, tries - 1));
221
- }
222
215
  function readHash() {
223
- const { selectedCard, openItemsCard, review, reply } = AppRouter.fragmentOf(location.hash);
216
+ const { selectedCard, review, reply } = AppRouter.fragmentOf(location.hash);
224
217
  if (review && state.board) { B.openReview(review); return; }
225
- openPill(openItemsCard);
226
218
  if (!selectedCard) return;
227
219
  focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
228
220
  replyKey = reply ? focusKey : null;
@@ -245,15 +237,6 @@ window.BoardView = (function () {
245
237
  ev.preventDefault();
246
238
  readHash(); render(); scrollToFocus();
247
239
  });
248
- // Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
249
- function focusOnBoard({ key, slug }) {
250
- const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
251
- if (!band && !state.board?.items.some((i) => i.key === key)) return false;
252
- if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
253
- render();
254
- if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
255
- return true;
256
- }
257
240
  // A card missing from the last visit's board waits for the fresh one, which is final.
258
241
  function scrollToFocus(final = true) {
259
242
  if (!focusKey) return;
@@ -360,6 +343,26 @@ window.BoardView = (function () {
360
343
  $('#downLanding').hidden = false;
361
344
  B.root.classList.add('down');
362
345
  }
346
+ // A board that loaded but threw while drawing shows the landing in its place with the error; the link stays online, and
347
+ // the next board that draws clean clears it (land).
348
+ function drawFailed(err) {
349
+ console.error(err);
350
+ state.down = { state: 'draw', error: err };
351
+ $('#downLanding').innerHTML = BoardUI.drawLanding(err);
352
+ $('#downLanding').hidden = false;
353
+ B.root.classList.add('down');
354
+ }
355
+ B.on(B.root, 'click', (ev) => {
356
+ const what = state.down?.error && ev.target.closest?.('button[data-draw]')?.dataset.draw;
357
+ if (what === 'reload') location.reload();
358
+ else if (what === 'copy') {
359
+ navigator.clipboard?.writeText(state.down.error.stack || String(state.down.error)).then(() => B.toast('Copied'), () => B.toast('Couldn’t copy'));
360
+ } else if (what === 'more') {
361
+ const pre = $('#downLanding .down-landing-stack');
362
+ pre.hidden = !pre.hidden;
363
+ ev.target.textContent = pre.hidden ? 'Show details' : 'Hide details';
364
+ }
365
+ });
363
366
  B.every(5000, () => { if (state.down?.retries && !document.hidden) loadBoard(); });
364
367
 
365
368
  // A board from the server, a load's or an events POST's, or a patch from the board on screen (ui-patch.js). One built
@@ -380,7 +383,7 @@ window.BoardView = (function () {
380
383
  B.setView('fresh');
381
384
  if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
382
385
  B.linkUp();
383
- render(load);
386
+ try { render(load); } catch (e) { drawFailed(e); return true; }
384
387
  scrollToFocus();
385
388
  returnToPlace();
386
389
  B.site.syncBadge();
@@ -431,7 +434,7 @@ window.BoardView = (function () {
431
434
  signalled();
432
435
  render();
433
436
  }
434
- Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
437
+ Object.assign(B, { render, loadBoard, send, failed, stillWaiting, reload: signalled });
435
438
  B.headerWired();
436
439
  const sent = BoardUI.sentNotice.take(B.route);
437
440
  if (sent) B.toast(sent);
@@ -439,7 +442,7 @@ window.BoardView = (function () {
439
442
  if (document.hidden) return;
440
443
  if (signalMissed) { signalMissed = false; signalled(); } else loadBoard();
441
444
  });
442
- B.readCopy().then(paintCopy).then(() => { if (B.alive()) loadBoard(); });
445
+ B.readCopy().then(paintCopy).catch((e) => console.error(e)).then(() => { if (B.alive()) loadBoard(); });
443
446
  B.signal?.(signalled);
444
447
  B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
445
448
  }
@@ -454,20 +457,15 @@ window.BoardView = (function () {
454
457
  function markup({ title = '', shared = null } = {}) {
455
458
  const strip = shared ? `<p class="view-only-strip">${sharedStrip(shared)}</p>\n` : '';
456
459
  return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', pages: 'questions', moon: true })}
457
- <div class="split">
458
460
  <main>
459
461
  ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
460
462
  <section id="pauseBanner" class="hub-view" hidden></section>
461
463
  <section id="answers" class="answer-banner" hidden></section>
462
- <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
463
464
  <div id="tryMain"></div>
464
465
  <div id="lanes"></div>
465
466
  <div id="downLanding" hidden></div>
466
467
  ${BoardUI.boardFoot()}
467
468
  </main>
468
- <div class="col-handle" data-col="pane" role="separator" aria-orientation="vertical" aria-label="Resize the Waiting on you pane" tabindex="0"></div>
469
- <aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
470
- </div>
471
469
  ${BoardNewTask.markup()}
472
470
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
473
471
  <div class="sheet-body">
@@ -1,72 +1,53 @@
1
- // Drag-resizing of the desktop columns (docs/adr/2026-11-10-1600-the-desktop-columns-drag-and-this-device-keeps-their-widths.md).
2
- // A `.col-handle[data-col="sidebar"|"pane"]` drags its column's edge: the sidebar rail's, or the seam between the lanes
3
- // and the Waiting on you pane. Widths are clamped (sidebar 220–420px, the lanes and the pane each 360px or more), kept
4
- // per device for every board, and set on <html> as --sb-w and --pane-w, which style.css and sidebar.css read. A double-click
5
- // on a handle forgets the width; ←/→ on a focused handle moves it 16px. Loaded after the body's first elements so the
6
- // stored widths land before the first paint of the board.
1
+ // Drag-resizing of the sidebar rail on a desktop (docs/adr/2026-11-10-1600-the-desktop-columns-drag-and-this-device-keeps-their-widths.md).
2
+ // The `.col-handle[data-col="sidebar"]` drags the rail's edge. Its width is clamped to 220–420px, kept per device for
3
+ // every board, and set on <html> as --sb-w, which style.css and sidebar.css read. A double-click on the handle forgets
4
+ // the width; ←/→ on the focused handle moves it 16px. Loaded after the body's first elements so the stored width lands
5
+ // before the first paint of the board.
7
6
  (function () {
8
- const KEYS = { sidebar: 'hub:sidebarWidth', pane: 'hub:paneWidth' };
9
- const VARS = { sidebar: '--sb-w', pane: '--pane-w' };
10
- const SIDEBAR_MIN = 220, SIDEBAR_MAX = 420, LANE_MIN = 360, STEP = 16, DEFAULT_SIDEBAR = 272;
7
+ const KEY = 'hub:sidebarWidth';
8
+ const VAR = '--sb-w';
9
+ const MIN = 220, MAX = 420, STEP = 16;
11
10
  const html = document.documentElement;
12
11
  const wide = () => window.matchMedia('(min-width: 900px)').matches;
13
- const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
14
- const write = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode or full: kept for this page only */ } };
15
- const forget = (k) => { try { localStorage.removeItem(k); } catch { /* nothing kept */ } };
16
- const stored = (col) => { const n = Number(read(KEYS[col])); return Number.isFinite(n) && n > 0 ? n : null; };
17
- // The sidebar's width now, which the pane's room depends on.
18
- const sidebarNow = (viewport) => (html.classList.contains('sb-collapsed') ? 0 : clamp('sidebar', stored('sidebar') ?? DEFAULT_SIDEBAR, viewport, 0));
12
+ const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
13
+ const write = (v) => { try { localStorage.setItem(KEY, v); } catch { /* private mode or full: kept for this page only */ } };
14
+ const forget = () => { try { localStorage.removeItem(KEY); } catch { /* nothing kept */ } };
15
+ const stored = () => { const n = Number(read()); return Number.isFinite(n) && n > 0 ? n : null; };
19
16
 
20
- // → px clamped for the column: the pane's ceiling leaves the lanes their minimum beside the sidebar, and its floor
21
- // wins where the window is too narrow for both.
22
- function clamp(col, px, viewport, sidebarW) {
23
- if (col === 'sidebar') return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, px));
24
- return Math.max(LANE_MIN, Math.min(px, viewport - sidebarW - LANE_MIN));
17
+ const clamp = (px) => Math.min(MAX, Math.max(MIN, px));
18
+ function paint() {
19
+ const px = stored();
20
+ if (px === null) html.style.removeProperty(VAR);
21
+ else html.style.setProperty(VAR, `${clamp(px)}px`);
25
22
  }
26
- function paint(col) {
27
- const px = stored(col);
28
- if (px === null) { html.style.removeProperty(VARS[col]); return; }
29
- html.style.setProperty(VARS[col], `${col === 'pane' ? clamp('pane', px, window.innerWidth, sidebarNow(window.innerWidth)) : clamp('sidebar', px)}px`);
30
- }
31
- function paintAll() { paint('sidebar'); paint('pane'); }
32
- function set(col, px) { write(KEYS[col], String(Math.round(px))); paintAll(); }
33
- function reset(col) { forget(KEYS[col]); paintAll(); }
34
- paintAll();
35
- window.addEventListener('resize', paintAll);
23
+ function set(px) { write(String(Math.round(px))); paint(); }
24
+ function reset() { forget(); paint(); }
25
+ paint();
36
26
 
37
- // The width a handle's drag edge is at now, and the width the pointer at x asks for.
38
- const current = (col, el) => {
39
- const edge = el.getBoundingClientRect();
40
- return col === 'sidebar' ? edge.left + edge.width / 2 : window.innerWidth - (edge.left + edge.width / 2);
41
- };
42
- const wanted = (col, x) => (col === 'sidebar' ? x : window.innerWidth - x);
27
+ // The width the handle's drag edge is at now.
28
+ const current = (el) => { const edge = el.getBoundingClientRect(); return edge.left + edge.width / 2; };
43
29
 
44
30
  document.addEventListener('pointerdown', (e) => {
45
31
  const el = e.target.closest?.('.col-handle');
46
32
  if (!el || !wide() || e.button > 0) return;
47
- const col = el.dataset.col;
48
33
  e.preventDefault();
49
34
  el.setPointerCapture?.(e.pointerId);
50
35
  el.classList.add('dragging');
51
36
  html.classList.add('col-dragging');
52
- const move = (m) => set(col, wanted(col, m.clientX));
37
+ const move = (m) => set(m.clientX);
53
38
  const end = () => { el.classList.remove('dragging'); html.classList.remove('col-dragging'); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', end); el.removeEventListener('pointercancel', end); };
54
39
  el.addEventListener('pointermove', move);
55
40
  el.addEventListener('pointerup', end);
56
41
  el.addEventListener('pointercancel', end);
57
42
  });
58
43
  document.addEventListener('dblclick', (e) => {
59
- const el = e.target.closest?.('.col-handle');
60
- if (el && wide()) reset(el.dataset.col);
44
+ if (e.target.closest?.('.col-handle') && wide()) reset();
61
45
  });
62
46
  document.addEventListener('keydown', (e) => {
63
47
  const el = e.target.closest?.('.col-handle');
64
48
  if (!el || !wide() || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
65
- const col = el.dataset.col;
66
49
  e.preventDefault();
67
- // → grows the sidebar and shrinks the pane, as the edge moves right.
68
- const dx = (e.key === 'ArrowRight' ? STEP : -STEP) * (col === 'sidebar' ? 1 : -1);
69
- set(col, (stored(col) ?? current(col, el)) + dx);
50
+ set((stored() ?? current(el)) + (e.key === 'ArrowRight' ? STEP : -STEP));
70
51
  });
71
52
 
72
53
  window.ColResize = { clamp, set, reset };
@@ -58,7 +58,7 @@
58
58
  const team = esc(draft.team || '');
59
59
  const note = draft.project === '__new'
60
60
  ? (isNew ? `<p class="muted small">Start creates the project in the ${team} team on Linear.</p>` : `<p class="muted small">Creates the project in the ${team} team on Linear.</p>`)
61
- : `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when you answer yes to its ticket question. Its badge opens it in Linear.</p>`;
61
+ : `<p class="muted small">A task gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key, when it goes ready to build. Its badge opens it in Linear.</p>`;
62
62
  return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
63
63
  }
64
64
  function render() {
@@ -68,7 +68,7 @@
68
68
  let detail;
69
69
  if (draft.source === 'github' && off) detail = why;
70
70
  else if (draft.source === 'none') detail = '<p class="muted small">Every card is a task, numbered T-1, T-2… on this board, with no ticket. Its badge reads Task.</p>';
71
- else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when you answer yes to its ticket question; its badge then reads #41.</span></span></div>`;
71
+ else if (draft.source === 'github') detail = `<div class="who tk-static"><span>Issues in ${esc(repo())}<span class="muted small sub">${isNew ? 'The repo chosen above' : 'The board’s repo, set above'}. A task opens an issue there${draft.account ? ` as gh account ${esc(draft.account)}` : ''} when it goes ready to build; its badge then reads #41.</span></span></div>`;
72
72
  else detail = linearDetail();
73
73
  root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
74
74
  if (draft.source === 'linear' && ready()) {
@@ -77,8 +77,8 @@ globalThis.BoardNewTask = (function () {
77
77
  B.on($('#sheetClose'), 'click', closeSheet);
78
78
  B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
79
79
  B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
80
- // Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
81
- // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
80
+ // Discuss has the session reply to the task, starting its discussion; Implement sends the ask with quick: true, and
81
+ // the session marks the card ready (docs/adr/2026-11-12-0500-a-new-task-is-a-conversation.md).
82
82
  B.on($('#askForm'), 'submit', async (ev) => {
83
83
  ev.preventDefault();
84
84
  $('#askText').blur();
@@ -1,4 +1,4 @@
1
- // Answering pending prompts from their Needs you lines (ui-prompts.js promptItem), on a board view and the hub page,
1
+ // Answering pending prompts from their Needs you lines (ui-prompts.js promptItem) on the hub page,
2
2
  // through the hub's POST /_hub/prompts/<id>/answer (docs/adr/2026-10-30-1516-background-sessions-prompts-come-to-the-phone.md).
3
3
  // Allow once, Deny, Skip and a single question's option send on the tap. Always allow confirms first, naming its rule,
4
4
  // since it covers every later call like it until the session ends. Several questions, or one taking several picks,
@@ -3,7 +3,7 @@
3
3
  // with board.mjs prototype add (docs/adr/2026-11-01-1448-the-board-serves-every-prototype-from-one-template.md). A take file,
4
4
  // public/prototypes/<topic>/<KEY>.js, is a module:
5
5
  // export function render(stage, kit) { stage.innerHTML = kit.hub(); return () => {…} } // the cleanup is optional
6
- // A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.needs-deck, .needs-pane).
6
+ // A take draws any board class at any width: prototype.html undoes the board's width-gated hides in the stage (.key-hint).
7
7
  // render runs on every switch to that take with a fresh, empty stage; it may be async. The stage contains its layout
8
8
  // and paint, so nothing in it, position: fixed included, can cover the viewer's bar, its ✕ or the app shell.
9
9
  // kit.sidebar(data?, {here, switching, device, head, height}) ui-sidebar.js sidebar() as an open drawer card in the
@@ -1,14 +1,14 @@
1
1
  // The full-bleed prototype viewer's controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
2
2
  // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
3
3
  // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
4
- // docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
4
+ // docs/adr/2026-11-12-0700-the-board-has-no-needs-you-deck-or-pane.md).
5
5
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
6
6
  // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
7
7
  // render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
8
8
  // (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
9
9
  // pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
10
10
  // right goes back as its back arrow does, and the footer, full width on the bottom edge at every width, holds the
11
- // ‹ take › pager. Framed in the board's right pane, the pane's top bar takes the ✕'s and the Round pill's place. ←/→
11
+ // ‹ take › pager. ←/→
12
12
  // and a sideways swipe switch unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
13
13
  // the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
14
14
  // the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
@@ -118,10 +118,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
118
118
  .ps-pill { width:min(640px, 100%); }
119
119
  .ps-switch .ps-text { width:min(640px, 100%); }
120
120
  .ps-states { flex-wrap:wrap; overflow-x:visible; }
121
- }
122
- .ps-pane main.proto-main { padding-top:0; }
123
- .ps-pane .ps-top { display:none; }
124
- .ps-pane .ps-states { flex-wrap:wrap; overflow-x:visible; justify-content:center; }`;
121
+ }`;
125
122
 
126
123
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
127
124
 
@@ -132,8 +129,6 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
132
129
  const qid = params.get('q');
133
130
  const site = globalThis.ClaudeSite;
134
131
  if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
135
- // Framed in the board's right pane (app-needs.js openViewer), whose top bar holds the way back and the Round pill.
136
- if (window.frameElement?.closest('.needs-pane')) document.documentElement.classList.add('ps-pane');
137
132
  let take = startTake(keys, params.get('variant'), initial);
138
133
  let question = null;
139
134
  // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
@@ -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
  })();