@lovek4d/kickflip 0.1.0 → 0.3.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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -1,4 +1,5 @@
1
- // The board view's lanes and wave filter: each lane's header and cards, the "N older" and finished-wave folds, and the wave bars.
1
+ // The board view's lanes and the Waves section above them: each lane's header and cards, Done's and Asks' Show more,
2
+ // Done's finished-wave folds, and the wave rows that filter the lanes and start a wave's triage.
2
3
  // Needs app-core.js and app-cards.js.
3
4
  BoardView.part((B) => {
4
5
  const { $, store, state } = B;
@@ -8,11 +9,24 @@ BoardView.part((B) => {
8
9
  { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
9
10
  { id: 'progress', name: 'In Progress', open: true },
10
11
  { id: 'todo', name: 'Todo', open: true },
11
- { id: 'done', name: 'Done', open: false },
12
+ { id: 'done', name: 'Done', open: false, capped: true },
13
+ { id: 'asks', name: 'Asks', open: false, capped: true },
12
14
  ];
15
+ // docs/adr/0144-answered-asks-have-their-own-section-below-done.md
16
+ // A capped lane shows STEP cards, and each Show more tap adds STEP more, kept in memory per lane. A card the owner has
17
+ // open stays shown past the cap. Show more says how many are left (docs/adr/0012-lists-never-cut-silently.md).
18
+ const STEP = 10;
19
+ const moreTaps = new Map();
20
+ function cappedRows(laneId, cards) {
21
+ const lastOpen = cards.findLastIndex((it) => state.openItems.has(it.key));
22
+ const shown = Math.max(STEP * (1 + (moreTaps.get(laneId) || 0)), lastOpen + 1);
23
+ const left = cards.length - shown;
24
+ return cards.slice(0, shown).map((it) => B.card(it)).join('')
25
+ + (left > 0 ? `<button type="button" class="thread-more" data-lane-more="${laneId}">Show more <span class="n">· ${left} older card${left === 1 ? '' : 's'}</span></button>` : '');
26
+ }
13
27
 
14
28
  // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
15
- // Fold ids are 'older', 'asks' or wave:<slug> (board/domain.mjs), so any id maps to its own storage key.
29
+ // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
16
30
  const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
17
31
  // A fold's header reads like a lane's: its name, then its count, then an optional note.
18
32
  function fold(id, list, name, note = '') {
@@ -37,14 +51,13 @@ BoardView.part((B) => {
37
51
  function lanesHtml(d, shown) {
38
52
  const laneState = store.get('lanes', {});
39
53
  return LANES.map((lane) => {
54
+ // Items arrive in each lane's fixed order (domain.mjs laneOrder).
40
55
  const items = shown.filter((i) => i.lane === lane.id);
41
56
  if (lane.hideEmpty && !items.length) return '';
42
- const open = laneState[lane.id] ?? lane.open;
43
- const older = items.filter((i) => i.fold === 'older');
44
- const asks = items.filter((i) => i.fold === 'asks');
45
- const rows = items.filter((i) => !i.fold).map((it) => B.card(it)).join('') + fold('asks', asks, 'Answered')
46
- + waveFolds(d, items) + fold('older', older, 'Older');
47
- return `<details class="lane" data-lane="${lane.id}" ${open ? 'open' : ''}>
57
+ const expanded = laneState[lane.id] ?? lane.open;
58
+ const open = items.filter((i) => !i.fold);
59
+ const rows = (lane.capped ? cappedRows(lane.id, open) : open.map((it) => B.card(it)).join('')) + waveFolds(d, items);
60
+ return `<details class="lane" data-lane="${lane.id}" ${expanded ? 'open' : ''}>
48
61
  <summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
49
62
  <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
50
63
  ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
@@ -52,21 +65,38 @@ BoardView.part((B) => {
52
65
  }).join('');
53
66
  }
54
67
 
68
+ // The Waves section: a lane of one row per open wave (domain.mjs waveRowsOf). Tapping a row shows only its cards;
69
+ // Notes and Triage act without filtering. A lineup has them at the row's foot, and a wave file has only Triage, at
70
+ // the end of its name line.
71
+ const triaging = new Set();
72
+ const triageTopic = (r) => `${r.kind}:${r.slug}`;
73
+ function triageLink(r) {
74
+ if (r.triage.running || triaging.has(triageTopic(r))) return '<span class="wv-busy" role="status"><span class="try-spin" aria-hidden="true"></span>Triaging…</span>';
75
+ if (r.triage.questions) return `<span class="muted">Triage: ${B.plural(r.triage.questions, 'open question')}</span>`;
76
+ return `<button type="button" class="wv-link" data-triage="${esc(triageTopic(r))}">Triage</button>`;
77
+ }
78
+ function waveRow(r) {
79
+ const on = r.slug === state.waveFilter;
80
+ const lineup = r.kind === 'lineup';
81
+ const links = lineup ? `<div class="wv-links"><span class="external-link"><a href="notes.html?wave=${encodeURIComponent(r.slug)}">Notes ${BoardUI.LINK_ARROW}</a></span><span class="external-link"><span class="external-separator" aria-hidden="true"> · </span>${triageLink(r)}</span></div>` : '';
82
+ return `<div class="wv-row${on ? ' on' : ''}" data-wave-row="${esc(r.slug)}">
83
+ <div class="wv-id"><button type="button" class="wv-name" aria-pressed="${on}">${BoardUI.WAVE_ICON}<span>${esc(r.title)}</span></button><span class="muted small">${r.merged} of ${r.total} merged</span>${lineup ? '' : `<span class="wv-end">${triageLink(r)}</span>`}</div>
84
+ ${r.goal ? `<div class="wv-note"><b>Goal</b> ${esc(r.goal)}</div>` : ''}
85
+ ${r.order ? `<div class="wv-note"><b>Order</b> ${r.order.map((o) => esc(o.ref)).join(' → ')}</div>` : ''}
86
+ ${links}
87
+ </div>`;
88
+ }
55
89
  function renderWaves(d) {
56
- if (d.waves.some((w) => w.finished && w.slug === state.waveFilter)) { state.waveFilter = null; store.set('wave', null); }
57
- const rows = d.waves.map((w) => {
58
- const items = d.items.filter((i) => i.wave === w.slug);
59
- const merged = items.filter((i) => i.pr?.state === 'MERGED' || (!i.pr && i.lane === 'done')).length;
60
- const drafts = items.filter((i) => i.pr?.state === 'OPEN' && i.pr.isDraft).length;
61
- const review = items.filter((i) => i.pr?.state === 'OPEN' && !i.pr.isDraft).length;
62
- return { w, total: items.length, merged, drafts, review };
63
- }).filter((r) => r.total && !r.w.finished);
64
- $('#waves').innerHTML = rows.map(({ w, total, merged, drafts, review }) => `<button class="wave ${w.slug === state.waveFilter ? 'on' : ''}" data-wave="${esc(w.slug)}">
65
- <span class="wave-name">${esc(w.title)}</span>
66
- <span class="muted small">${merged} of ${total} ${w.internal ? 'done' : 'merged'}${review ? ` · ${review} in review` : ''}${drafts ? ` · ${drafts} draft${drafts === 1 ? '' : 's'}` : ''}</span>
67
- <span class="bar"><span style="width:${Math.round((merged / total) * 100)}%"></span></span>
68
- </button>`).join('');
90
+ const rows = d.waveRows || [];
91
+ if (state.waveFilter && !rows.some((r) => r.slug === state.waveFilter)) { state.waveFilter = null; store.set('wave', null); }
92
+ const open = store.get('lanes', {}).waves ?? true;
93
+ const shown = rows.find((r) => r.slug === state.waveFilter);
94
+ BoardUI.setHTML($('#waves'), rows.length ? `<details class="lane" data-lane="waves" ${open ? 'open' : ''}>
95
+ <summary><span class="lane-name">Waves</span><span class="count">${rows.length}</span></summary>
96
+ <div class="list">${rows.map(waveRow).join('')}</div>
97
+ </details>${shown ? `<div class="wv-showing muted small">Showing ${esc(shown.title)} only · <button type="button" class="wv-all">Show all</button></div>` : ''}` : '');
69
98
  }
99
+ const showWave = (slug) => { state.waveFilter = slug; store.set('wave', slug); B.render(); };
70
100
 
71
101
  B.on(B.root, 'toggle', (ev) => {
72
102
  const el = ev.target;
@@ -76,16 +106,36 @@ BoardView.part((B) => {
76
106
  }, true);
77
107
 
78
108
  B.on(B.root, 'click', async (ev) => {
109
+ const more = ev.target.closest('[data-lane-more]');
110
+ if (more) { moreTaps.set(more.dataset.laneMore, (moreTaps.get(more.dataset.laneMore) || 0) + 1); B.render(); return; }
79
111
  const pb = ev.target.closest('button[data-plan]');
80
112
  if (pb) {
81
- if (!(await B.confirm('Claude groups these follow-ups into a small wave and asks you about the plan in Needs you. Nothing builds until you answer. Start?'))) return;
113
+ 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;
82
114
  pb.disabled = true;
83
- try { await B.site.send('plan-followups', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
115
+ try { await B.send('plan-waves', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
84
116
  catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
85
117
  return;
86
118
  }
87
- const wb = ev.target.closest('button[data-wave]');
88
- if (wb) { state.waveFilter = wb.dataset.wave !== state.waveFilter ? wb.dataset.wave : null; store.set('wave', state.waveFilter); B.render(); }
119
+ const tb = ev.target.closest('button[data-triage]');
120
+ if (tb) {
121
+ const topic = tb.dataset.triage;
122
+ const row = state.board.waveRows.find((r) => triageTopic(r) === topic);
123
+ const asks = row.kind === 'lineup'
124
+ ? `Claude reads the code for ${row.title}'s unstarted cards and asks you in Needs you about splitting, merging, adding or closing any. Start?`
125
+ : `Claude checks ${row.title}'s unfinished cards, fixes what it can and asks you in Needs you about any that need you. Start?`;
126
+ if (!(await B.confirm(asks))) return;
127
+ triaging.add(topic);
128
+ B.render();
129
+ try { await B.send('wave-triage', { [row.kind]: row.slug }); }
130
+ catch { B.notice('Could not send. Is the host awake?'); }
131
+ finally { triaging.delete(topic); B.render(); }
132
+ return;
133
+ }
134
+ const go = ev.target.closest('a[data-wave-show]');
135
+ if (go) { ev.preventDefault(); showWave(go.dataset.waveShow); $('#waves').scrollIntoView({ block: 'start' }); return; }
136
+ if (ev.target.closest('.wv-all')) { showWave(null); return; }
137
+ const row = ev.target.closest('.wv-row');
138
+ if (row && !ev.target.closest('a')) showWave(row.dataset.waveRow !== state.waveFilter ? row.dataset.waveRow : null);
89
139
  });
90
140
 
91
141
  Object.assign(B, { FOLDS, lanesHtml, renderWaves });
@@ -23,6 +23,7 @@ BoardView.part((B) => {
23
23
  w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
24
  <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
25
25
  : line.key ? `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
+ : line.waveRow ? `<li><a href="#waves" data-wave-show="${esc(line.waveRow)}">${esc(line.title)}</a> <span class="muted small">· ${parts(line)}</span></li>`
26
27
  : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
27
28
  }
28
29
 
@@ -1,9 +1,10 @@
1
1
  // The last visit's board on the board view: reading and saving it, holding the controls that send until the fresh
2
- // board lands, and flashing the lines the fresh board changed (docs/adr/0028-the-board-opens-on-the-last-visit.md).
2
+ // board lands, flashing the lines the fresh board changed (docs/adr/0028-the-board-opens-on-the-last-visit.md), and
3
+ // the sign on a card that changed lanes or was updated (docs/adr/0135-an-updated-card-takes-a-brief-tint.md).
3
4
  // Needs app-core.js and ui-snapshot.js; app.js paints it.
4
5
  BoardView.part((B) => {
5
6
  const { $$, state } = B;
6
- const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, SNAPSHOT_MAX } = BoardUI;
7
+ const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX } = BoardUI;
7
8
  // The hub serves every site from one origin, so each board keeps its own key.
8
9
  const KEY = 'boardSnapshot:' + B.site.url('');
9
10
  // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
@@ -11,11 +12,12 @@ BoardView.part((B) => {
11
12
  // otherwise fail to save.
12
13
  const BUDGET = 3 * SNAPSHOT_MAX;
13
14
  const ORDER = 'boardSnapshots';
15
+ const BUILD = document.documentElement.dataset.build;
14
16
 
15
17
  function readSnapshot() {
16
18
  let raw = null;
17
19
  try { raw = localStorage.getItem(KEY); } catch {}
18
- return raw ? unpackSnapshot(raw) : null;
20
+ return raw ? unpackSnapshot(raw, BUILD) : null;
19
21
  }
20
22
  // Makes room for this board's raw snapshot.
21
23
  function keepRecent(raw) {
@@ -38,6 +40,7 @@ BoardView.part((B) => {
38
40
  saveTimer = null;
39
41
  if (state.view !== 'fresh' || !state.board) return;
40
42
  const raw = packSnapshot(state.board, state.threadCache, {
43
+ build: BUILD,
41
44
  savedAt: state.board.generatedAt || new Date().toISOString(),
42
45
  keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
43
46
  });
@@ -66,13 +69,9 @@ BoardView.part((B) => {
66
69
  }, true);
67
70
  }
68
71
 
69
- // The lines a fresh board can change: each card line, lane count, Needs you line and thread message.
72
+ // The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
70
73
  function lines() {
71
74
  const out = new Map();
72
- for (const wrap of $$('details.item[data-key] > summary .title-wrap')) {
73
- const key = wrap.closest('[data-key]').dataset.key;
74
- [...wrap.children].forEach((el, i) => out.set(`${key}|${i}`, el));
75
- }
76
75
  for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
77
76
  $$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
78
77
  for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
@@ -86,12 +85,47 @@ BoardView.part((B) => {
86
85
  const els = lines();
87
86
  const after = texts(els);
88
87
  for (const id of changedLines(before, after)) {
89
- const el = els.get(id);
90
- (el.closest('details.item') || el).classList.add('flash');
88
+ els.get(id).classList.add('flash');
91
89
  before[id] = after[id];
92
90
  }
93
91
  }
94
92
  const endFlash = () => { before = null; };
95
93
 
96
- Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash });
94
+ // Since the page last drew it, a last visit's board included, a card that changed lanes, or a work card that takes
95
+ // the place of a drawn New task (its fromAsk), flashes once; any other card whose updateSign changed takes a brief
96
+ // tint on its title row. Neither shows for a change the owner sent from this page: sentHere(key, send) holds the
97
+ // card's next change unsigned until the first board load started after send settles.
98
+ let drawn = new Map();
99
+ const ownChanges = new Map();
100
+ async function sentHere(key, send) {
101
+ ownChanges.set(key, Infinity);
102
+ try { return await send(); } finally { ownChanges.set(key, B.boardLoad || 0); }
103
+ }
104
+ // Each sign's style.css animation length. A lane repaint resets a card's classes, so a sign still running is put back
105
+ // as far into its animation as it was.
106
+ const SIGNS = { flash: 1600, tint: 2400 };
107
+ const running = new Map();
108
+ function signCards(load = null) {
109
+ const now = Date.now();
110
+ for (const it of state.board.items) {
111
+ const was = drawn.get(it.key);
112
+ const moved = was ? was.lane !== it.lane : Boolean(it.fromAsk) && drawn.has(`ask:${it.fromAsk}`);
113
+ const updated = Boolean(was) && was.sign !== updateSign(it);
114
+ const own = ownChanges.get(it.key);
115
+ if (own !== undefined) { if (moved || updated || (load !== null && load > own)) ownChanges.delete(it.key); continue; }
116
+ if ((moved || updated) && state.view === 'fresh') running.set(it.key, { sign: moved ? 'flash' : 'tint', at: now });
117
+ }
118
+ drawn = new Map(state.board.items.map((i) => [i.key, { lane: i.lane, sign: updateSign(i) }]));
119
+ for (const [key, { sign, at }] of running) {
120
+ const card = now - at < SIGNS[sign] && B.root.querySelector(`details.item[data-key="${CSS.escape(key)}"]`);
121
+ if (!card) { running.delete(key); continue; }
122
+ // Re-adding the class after a layout read restarts the animation, from the delay's point.
123
+ card.classList.remove('flash', 'tint');
124
+ card.style.setProperty('--sign-delay', `-${now - at}ms`);
125
+ void card.offsetWidth;
126
+ card.classList.add(sign);
127
+ }
128
+ }
129
+
130
+ Object.assign(B, { readSnapshot, saveSnapshotSoon, setView, holdForFlash, flashChanged, endFlash, sentHere, signCards });
97
131
  });
@@ -6,11 +6,10 @@ BoardView.part((B) => {
6
6
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
7
 
8
8
  // The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
9
- const sendingDone = new Set();
10
9
  function syncDone(el, key) {
11
10
  const send = el.querySelector('form button[type=submit]');
12
11
  if (!send) return;
13
- const html = doneButton(state.board?.items.find((i) => i.key === key), sendingDone.has(key));
12
+ const html = doneButton(state.board?.items.find((i) => i.key === key));
14
13
  const current = el.querySelector('form [data-confirm="finish"]');
15
14
  if (current?.outerHTML === html) return;
16
15
  current?.remove();
@@ -18,10 +17,13 @@ BoardView.part((B) => {
18
17
  }
19
18
 
20
19
  // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
21
- // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed.
22
- const seenBefore = new Map(Object.entries(B.seen));
23
- const moreTaps = new Map();
24
- const firstDrawn = new Map();
20
+ // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed. A
21
+ // board left and opened again in this page keeps them, so its open threads show what they showed.
22
+ const kept = B.place?.threads;
23
+ const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
24
+ const moreTaps = new Map(kept?.moreTaps);
25
+ const firstDrawn = new Map(kept?.firstDrawn);
26
+ B.keep(() => ({ threads: { seenBefore: [...seenBefore], moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
25
27
  const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
26
28
  const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
27
29
  B.on(B.root, 'toggle', (ev) => {
@@ -104,7 +106,7 @@ BoardView.part((B) => {
104
106
  if (!b) return;
105
107
  const key = b.closest('form').dataset.reply;
106
108
  b.disabled = true;
107
- try { await B.site.send('item-message', { key, text: b.dataset.quick }); loadThread(key, { force: true }); }
109
+ try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
108
110
  catch { toast('Could not send'); }
109
111
  b.disabled = false;
110
112
  });
@@ -119,13 +121,13 @@ BoardView.part((B) => {
119
121
  const n = protoNotice(pending, latest);
120
122
  if (!n.bump) { B.notice(n.text); return; }
121
123
  if (!(await B.confirm(n.text))) return;
122
- try { await B.site.send('ask-bump', { id: pending }); toast('Bumped to the top'); } catch { toast('Could not bump'); }
124
+ await B.bumpAsk(pending);
123
125
  return;
124
126
  }
125
127
  if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
126
128
  b.disabled = true;
127
129
  try {
128
- await B.site.send('prototype-request', { key });
130
+ await B.sentHere(key, () => B.send('prototype-request', { key }));
129
131
  b.title = b.ariaLabel = 'Prototypes asked for';
130
132
  toast('Asked Claude for prototypes');
131
133
  loadThread(key, { force: true });
@@ -145,7 +147,7 @@ BoardView.part((B) => {
145
147
  const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
146
148
  try {
147
149
  await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
148
- () => B.site.send('item-message', { key, text, ...(files.length ? { files } : {}) }));
150
+ () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) })));
149
151
  BoardAttach.clear(id, B.site.root);
150
152
  btn.disabled = false;
151
153
  loadThread(f.dataset.reply, { force: true });
@@ -158,7 +160,7 @@ BoardView.part((B) => {
158
160
  });
159
161
 
160
162
  Object.assign(B, {
161
- thread, renderThread, loadThread, sendingDone,
163
+ thread, renderThread, loadThread,
162
164
  repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
163
165
  syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
164
166
  });
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="en">
2
+ <html lang="en" data-build="{{build}}">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -41,6 +41,7 @@ window.BoardBoot = (function () {
41
41
  <script src="/_hub/app/ui.js"></script>
42
42
  <script src="/_hub/app/ui-links.js"></script>
43
43
  <script src="/_hub/app/ui-enums.js"></script>
44
+ <script src="/_hub/app/ui-order.js"></script>
44
45
  <script src="/_hub/app/ui-threads.js"></script>
45
46
  <script src="/_hub/app/ui-cards.js"></script>
46
47
  <script src="/_hub/app/ui-header.js"></script>
@@ -58,6 +59,7 @@ window.BoardBoot = (function () {
58
59
  <script src="/_hub/app/claude.js"></script>
59
60
  <script src="/_hub/app/attach.js"></script>
60
61
  <script src="/_hub/app/hub-kit.js"></script>
62
+ <script src="/_hub/app/linear-key.js"></script>
61
63
  <script src="/_hub/app/sidebar.js"></script>
62
64
  <script src="/_hub/app/app.js"></script>
63
65
  <script src="/_hub/app/app-core.js"></script>
@@ -67,6 +69,7 @@ window.BoardBoot = (function () {
67
69
  <script src="/_hub/app/app-asks.js"></script>
68
70
  <script src="/_hub/app/app-needs.js"></script>
69
71
  <script src="/_hub/app/app-header.js"></script>
72
+ <script src="/_hub/app/app-arch.js"></script>
70
73
  <script src="/_hub/app/app-banner.js"></script>
71
74
  <script src="/_hub/app/app-snapshot.js"></script>
72
75
  <script src="/_hub/app/view-settings.js"></script>
@@ -3,8 +3,9 @@
3
3
  // and mount() runs the parts in load order, then renders the board from /api/board and per-item threads from
4
4
  // /api/thread. Actions go out as events through B.site.send (each wakes Claude). Parts add listeners, intervals,
5
5
  // timers, watches and unmount steps only through B.on, B.every, B.later, B.watch and B.atUnmount, so unmount() undoes
6
- // them all and no board's state outlives its view. BoardView.markup({title}) is the root's inner
7
- // HTML. BoardView.page(root, site, run) gives a board's other views (view-*.js) the same site, $ and recorders.
6
+ // them all and no board's state outlives its view but what B.keep keeps for its next mount in this page.
7
+ // BoardView.markup({title}) is the root's inner HTML. BoardView.page(root, site, run) gives a board's other views
8
+ // (view-*.js) the same site, $ and recorders.
8
9
  // opts, both from the app shell (shell.js):
9
10
  // signal(fn) → off calls fn on each change signal for this board, from the hub's one stream
10
11
  // live {fresh, generatedAt}
@@ -14,6 +15,9 @@
14
15
  window.BoardView = (function () {
15
16
  const parts = [];
16
17
  const part = (fn) => { parts.push(fn); };
18
+ // route → what a board's view kept as it unmounted, which its next mount in this page reads as B.place. A reload
19
+ // starts with none.
20
+ const places = new Map();
17
21
 
18
22
  // Every board shares the hub's one origin, so each keeps its keys under site:<route>:; app.html's preload reads
19
23
  // openItems by the same key. Thread and question drafts are already per card and keep their own keys.
@@ -111,7 +115,12 @@ window.BoardView = (function () {
111
115
  waveFilter: store.get('wave', null),
112
116
  };
113
117
  const signal = opts.signal ? (fn) => { if (B.alive()) B.own(opts.signal(fn)); } : null;
114
- Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause });
118
+ // B.keep(fn): a part adds fn() → {…} to what the board keeps as it unmounts, read before any part's unmount step.
119
+ const keepers = [];
120
+ const keep = (fn) => { if (B.alive()) keepers.push(fn); };
121
+ const place = places.get(B.route) || null;
122
+ places.delete(B.route);
123
+ Object.assign(B, { store, state, signal, live: opts.live || null, pause: opts.pause, place, keep });
115
124
 
116
125
  function mount() {
117
126
  if (B.alive()) return;
@@ -119,7 +128,12 @@ window.BoardView = (function () {
119
128
  for (const p of parts) p(B);
120
129
  start(B);
121
130
  }
122
- return { B, mount, unmount: stop };
131
+ function unmount() {
132
+ if (B.alive()) places.set(B.route, Object.assign({}, ...keepers.map((fn) => fn())));
133
+ keepers.length = 0;
134
+ stop();
135
+ }
136
+ return { B, mount, unmount };
123
137
  }
124
138
 
125
139
  // A view other than the board: run(B) adds what it needs through B's recorders when the view mounts.
@@ -139,17 +153,19 @@ window.BoardView = (function () {
139
153
  const { setHTML, preloadKeys } = BoardUI;
140
154
 
141
155
  // Renders open threads from state.threadCache; loadBoard and opening a card load them.
142
- function render() {
156
+ function render(load = null) {
143
157
  const d = state.board;
144
158
  const title = $(':scope > .top h1');
145
159
  if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
146
160
  if (d.title && document.title !== d.title) document.title = d.title;
147
161
  steady(() => {
148
162
  B.renderHeader(d);
163
+ B.renderArch(d);
149
164
  B.renderWaves(d);
150
165
  BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main));
166
+ B.renderMainLog(d.main, load);
151
167
  const live = d.items.filter((i) => !state.withdrawnAsks.has(i.id));
152
- const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter) : live;
168
+ const shown = state.waveFilter ? live.filter((i) => i.wave === state.waveFilter || i.lineup?.slug === state.waveFilter) : live;
153
169
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
154
170
  for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
155
171
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
@@ -158,7 +174,9 @@ window.BoardView = (function () {
158
174
  B.renderNeeds(d);
159
175
  B.renderBanner(d);
160
176
  });
177
+ paintFades();
161
178
  B.flashChanged();
179
+ B.signCards(load);
162
180
  }
163
181
 
164
182
  // #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
@@ -166,6 +184,8 @@ window.BoardView = (function () {
166
184
  let focusKey = null;
167
185
  let replyKey = null;
168
186
  let hashRead = false;
187
+ // A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
188
+ const returnToPlace = () => B.restorePlace({ linked: /^#item=/.test(location.hash) });
169
189
  function readHash() {
170
190
  const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
171
191
  if (!m) return;
@@ -214,7 +234,7 @@ window.BoardView = (function () {
214
234
  const snap = B.readSnapshot();
215
235
  if (!snap) return false;
216
236
  const live = Boolean(B.live) && snap.board?.generatedAt === B.live.generatedAt;
217
- state.board = snap.board;
237
+ served = state.board = snap.board;
218
238
  for (const [key, msgs] of Object.entries(snap.threads)) state.threadCache.set(key, msgs);
219
239
  state.fresh = live ? B.live.fresh : { at: snap.savedAt, sig: null };
220
240
  B.setView(live ? 'fresh' : 'snapshot');
@@ -222,6 +242,7 @@ window.BoardView = (function () {
222
242
  hashRead = true;
223
243
  render();
224
244
  scrollToFocus(false);
245
+ returnToPlace();
225
246
  if (!live) B.holdForFlash();
226
247
  return live;
227
248
  }
@@ -235,6 +256,7 @@ window.BoardView = (function () {
235
256
  if (boot?.route && boot.route !== B.route) boot = null;
236
257
  let preloaded = false;
237
258
  async function loadBoard({ via = 'poll' } = {}) {
259
+ const load = B.boardLoad = (B.boardLoad || 0) + 1;
238
260
  const started = boot;
239
261
  boot = null;
240
262
  const threads = [...state.openItems].map((k) => B.loadThread(k, { from: started?.threads.get(k) || null, force: via !== 'poll' }));
@@ -242,15 +264,7 @@ window.BoardView = (function () {
242
264
  try {
243
265
  const board = await (started?.board || fetch(B.site.url('api/board'), { cache: 'no-store' })
244
266
  .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`board ${r.status}`)))));
245
- const first = state.view !== 'fresh';
246
- state.board = board;
247
- B.setView('fresh');
248
- if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
249
- B.linkUp();
250
- render();
251
- scrollToFocus();
252
- B.site.syncBadge();
253
- B.saveSnapshotSoon();
267
+ land(board, load);
254
268
  landed = true;
255
269
  if (!preloaded) {
256
270
  preloaded = true;
@@ -264,6 +278,79 @@ window.BoardView = (function () {
264
278
  if (state.view === 'fresh') B.endFlash();
265
279
  }
266
280
 
281
+ // A board from the server, a load's or an events POST's, with the taps still in flight drawn over it. One built
282
+ // before the board on screen leaves it as it is, so a slow load can't undo a newer one. → whether it landed
283
+ let served = null;
284
+ const drawing = new Set();
285
+ const drawn = () => [...drawing].reduce((board, draw) => draw(board), served);
286
+ function land(board, load) {
287
+ if (state.view === 'fresh' && served && board.generatedAt < served.generatedAt) return false;
288
+ const first = state.view !== 'fresh';
289
+ served = board;
290
+ state.board = drawn();
291
+ B.setView('fresh');
292
+ if (!hashRead || (first && focusKey)) { readHash(); hashRead = true; }
293
+ B.linkUp();
294
+ render(load);
295
+ scrollToFocus();
296
+ returnToPlace();
297
+ B.site.syncBadge();
298
+ B.saveSnapshotSoon();
299
+ return true;
300
+ }
301
+ function undraw(draw) {
302
+ drawing.delete(draw);
303
+ state.board = drawn();
304
+ render();
305
+ }
306
+
307
+ // An owner tap (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md): the events POST answers with
308
+ // the board as the tap left it, which lands as a load of its own, so the tap needs no reload. draw(board) → board is
309
+ // what the tap changes when its result follows from the tap alone: the page draws it before the POST and keeps it
310
+ // over every board that lands until the POST settles; a failed POST takes it back off and throws.
311
+ async function send(kind, data, draw = null) {
312
+ if (draw) {
313
+ drawing.add(draw);
314
+ state.board = drawn();
315
+ render();
316
+ }
317
+ let out;
318
+ try { out = await B.site.send(kind, data); }
319
+ catch (e) {
320
+ if (draw && B.alive()) undraw(draw);
321
+ throw e;
322
+ }
323
+ drawing.delete(draw);
324
+ // A board newer than the POST's already holds the tap.
325
+ if (B.alive() && !land(out.board, B.boardLoad = (B.boardLoad || 0) + 1) && draw) undraw(draw);
326
+ return out;
327
+ }
328
+ // A draw that changes the item with that key, the board's order kept as the server sorts it.
329
+ const drawItem = (key, change) => (board) => ({ ...board, items: board.items.map((i) => (i.key === key ? change({ ...i }) : i)).sort(BoardUI.laneOrder) });
330
+
331
+ // The bump icon's fill fades in as a tap bumps a card and out as one unbumps it, on a toast's timing (toasts.js), and
332
+ // keeps fading through any redraw meanwhile. selector names the icon.
333
+ const fades = new Map();
334
+ function paintFades() {
335
+ for (const [selector, { name, ms }] of fades) {
336
+ const icon = $(selector);
337
+ if (!icon) continue;
338
+ icon.classList.add(name);
339
+ icon.style.setProperty('--bump-fade', `${ms}ms`);
340
+ }
341
+ }
342
+ function fadeBump(selector, on) {
343
+ const fade = { name: on ? 'bump-in' : 'bump-out', ms: on ? BoardToasts.enterTime() : BoardToasts.exitTime() };
344
+ $(selector)?.classList.remove('bump-in', 'bump-out');
345
+ fades.set(selector, fade);
346
+ paintFades();
347
+ B.later(fade.ms, () => {
348
+ if (fades.get(selector) !== fade) return;
349
+ fades.delete(selector);
350
+ $(selector)?.classList.remove(fade.name);
351
+ });
352
+ }
353
+
267
354
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
268
355
  // 30 s poll stays as the fallback and the offline check (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
269
356
  let reloading = null;
@@ -278,7 +365,9 @@ window.BoardView = (function () {
278
365
  });
279
366
  }
280
367
 
281
- Object.assign(B, { render, loadBoard });
368
+ // The toast for a tap drawn at once whose POST failed, as the page takes the drawing back off.
369
+ const FAILED = 'That didn’t go through. Is the host awake?';
370
+ Object.assign(B, { render, loadBoard, send, drawItem, fadeBump, reload: signalled, FAILED });
282
371
  const live = paintSnapshot();
283
372
  B.headerWired();
284
373
  if (live) B.linkUp();
@@ -294,16 +383,16 @@ window.BoardView = (function () {
294
383
  }
295
384
 
296
385
  function markup({ title = '' } = {}) {
297
- return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png' })}
386
+ return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true })}
298
387
  <main>
299
388
  <section id="setup" class="setup" aria-live="polite" hidden></section>
300
389
  <section id="answers" class="answer-banner" hidden></section>
301
390
  <section id="waiting" class="waiting" hidden></section>
302
- <div id="waves" class="waves"></div>
391
+ <div id="waves"></div>
303
392
  <div id="tryMain"></div>
304
393
  <div id="lanes"></div>
305
394
  </main>
306
- <button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
395
+ <button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
307
396
  <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
308
397
  <form id="askForm" data-composer="ask">
309
398
  <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
@@ -315,6 +404,28 @@ window.BoardView = (function () {
315
404
  </div>
316
405
  </form>
317
406
  </dialog>
407
+ <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
408
+ <div class="sheet-body">
409
+ <div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
410
+ <p id="mainLogSource" class="main-log-source muted small"></p>
411
+ <pre id="mainLogLines" class="main-log-lines" tabindex="0" aria-label="Main’s build log"></pre>
412
+ <p id="mainLogReason" class="small" role="status" hidden></p>
413
+ <div class="main-log-actions"><button type="button" class="primary" id="mainLogRetry">Retry</button></div>
414
+ </div>
415
+ </dialog>
416
+ <dialog id="holdSheet" class="sheet hold-sheet" aria-labelledby="holdTitle">
417
+ <div class="sheet-body">
418
+ <div class="sheet-head"><h2 id="holdTitle" tabindex="-1"></h2><button type="button" class="ghost" id="holdClose" aria-label="Close">✕</button></div>
419
+ <p id="holdLede" class="hold-lede muted"></p>
420
+ <div id="holdPicker"></div>
421
+ </div>
422
+ </dialog>
423
+ <dialog id="archSheet" class="sheet arch-sheet" aria-labelledby="archTitle">
424
+ <div class="sheet-body">
425
+ <div class="sheet-head"><h2 id="archTitle">Architecture review</h2><button type="button" class="ghost" id="archClose" aria-label="Close sheet">✕</button></div>
426
+ <div id="archBody" class="arch-body"></div>
427
+ </div>
428
+ </dialog>
318
429
  <dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
319
430
  <div class="sheet-body">
320
431
  <div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>