@lovek4d/kickflip 0.0.0-stage → 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 (127) hide show
  1. package/CONTEXT.md +513 -0
  2. package/LICENSE +57 -0
  3. package/README.md +64 -2
  4. package/SKILL.md +142 -0
  5. package/bin/doctor.mjs +4 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +180 -0
  8. package/bin/statusline.mjs +5 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/board/arch-review.mjs +5 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +379 -0
  13. package/board/bin/builder-stop-hook.mjs +6 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +5 -0
  16. package/board/on-host.mjs +1 -0
  17. package/board/public/app-arch.js +75 -0
  18. package/board/public/app-asks.js +188 -0
  19. package/board/public/app-banner.js +48 -0
  20. package/board/public/app-cards.js +393 -0
  21. package/board/public/app-core.js +130 -0
  22. package/board/public/app-header.js +126 -0
  23. package/board/public/app-lanes.js +142 -0
  24. package/board/public/app-needs.js +31 -0
  25. package/board/public/app-snapshot.js +131 -0
  26. package/board/public/app-threads.js +167 -0
  27. package/board/public/app.html +86 -0
  28. package/board/public/app.js +438 -0
  29. package/board/public/attach.js +84 -0
  30. package/board/public/dialogs.js +358 -0
  31. package/board/public/hub-kit.js +168 -0
  32. package/board/public/hub.css +170 -0
  33. package/board/public/kickflip.png +0 -0
  34. package/board/public/linear-key.js +141 -0
  35. package/board/public/prompts.js +64 -0
  36. package/board/public/prototype-kit.js +193 -0
  37. package/board/public/prototype-switcher.js +226 -0
  38. package/board/public/prototype.html +55 -0
  39. package/board/public/router.js +226 -0
  40. package/board/public/rules-editor.js +127 -0
  41. package/board/public/shell.js +298 -0
  42. package/board/public/sidebar.css +219 -0
  43. package/board/public/sidebar.js +359 -0
  44. package/board/public/style.css +665 -0
  45. package/board/public/toasts.js +181 -0
  46. package/board/public/ui-cards.js +218 -0
  47. package/board/public/ui-enums.js +10 -0
  48. package/board/public/ui-header.js +93 -0
  49. package/board/public/ui-hub.js +112 -0
  50. package/board/public/ui-links.js +150 -0
  51. package/board/public/ui-markdown.js +80 -0
  52. package/board/public/ui-order.js +18 -0
  53. package/board/public/ui-page-header.js +65 -0
  54. package/board/public/ui-prompts.js +39 -0
  55. package/board/public/ui-questions.js +151 -0
  56. package/board/public/ui-sidebar.js +215 -0
  57. package/board/public/ui-snapshot.js +62 -0
  58. package/board/public/ui-threads.js +90 -0
  59. package/board/public/ui.js +247 -0
  60. package/board/public/view-frame.js +93 -0
  61. package/board/public/view-hub-settings.js +282 -0
  62. package/board/public/view-hub.js +150 -0
  63. package/board/public/view-notes.js +32 -0
  64. package/board/public/view-polling.js +87 -0
  65. package/board/public/view-questions.js +411 -0
  66. package/board/public/view-settings.js +383 -0
  67. package/board/review.mjs +1 -0
  68. package/board/server.mjs +17 -0
  69. package/board/store.mjs +15 -0
  70. package/board/tickets.mjs +3 -0
  71. package/board/worktree.mjs +15 -0
  72. package/docs/hub-session.md +11 -0
  73. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  74. package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
  75. package/hooks-plugin/hooks/hooks.json +54 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -0
  80. package/hub/boards.mjs +4 -0
  81. package/hub/close-board.mjs +1 -0
  82. package/hub/duration.mjs +1 -0
  83. package/hub/files.mjs +1 -0
  84. package/hub/github.mjs +4 -0
  85. package/hub/home.mjs +1 -0
  86. package/hub/hub.mjs +19 -0
  87. package/hub/icon-180.png +0 -0
  88. package/hub/icon-192.png +0 -0
  89. package/hub/icon-512.png +0 -0
  90. package/hub/icons.mjs +1 -0
  91. package/hub/linear.mjs +2 -0
  92. package/hub/live.mjs +2 -0
  93. package/hub/locks.mjs +1 -0
  94. package/hub/new-board.mjs +2 -0
  95. package/hub/permission-hook.mjs +3 -0
  96. package/hub/pr-report.mjs +1 -0
  97. package/hub/presence.mjs +1 -0
  98. package/hub/prompts.mjs +2 -0
  99. package/hub/push.mjs +2 -0
  100. package/hub/readiness.mjs +2 -0
  101. package/hub/repos.mjs +3 -0
  102. package/hub/restart-board.mjs +1 -0
  103. package/hub/routes.mjs +1 -0
  104. package/hub/rules.mjs +1 -0
  105. package/hub/schedule.mjs +1 -0
  106. package/hub/sessions.mjs +2 -0
  107. package/hub/shell.mjs +2 -0
  108. package/hub/slack.mjs +2 -0
  109. package/hub/stream.mjs +19 -0
  110. package/hub/sw.js +144 -0
  111. package/hub/trust.mjs +2 -0
  112. package/hub/try.mjs +4 -0
  113. package/hub/upgrade.mjs +1 -0
  114. package/hub/usage.mjs +3 -0
  115. package/hub/views.mjs +1 -0
  116. package/package.json +26 -4
  117. package/shared/codex.mjs +1 -0
  118. package/shared/colors.css +15 -0
  119. package/shared/fake-gh.mjs +54 -0
  120. package/shared/repos.mjs +1 -0
  121. package/shared/service.mjs +33 -0
  122. package/shared/skills.mjs +2 -0
  123. package/shared/usage-gate.mjs +1 -0
  124. package/starter/cross-site.mjs +1 -0
  125. package/starter/public/claude.js +68 -0
  126. package/starter/public/index.html +49 -0
  127. package/starter/server.mjs +4 -0
@@ -0,0 +1,142 @@
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.
3
+ // Needs app-core.js and app-cards.js.
4
+ BoardView.part((B) => {
5
+ const { $, store, state } = B;
6
+ const { esc } = BoardUI;
7
+ const LANES = [
8
+ { id: 'review', name: 'In Review', open: true },
9
+ { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
10
+ { id: 'progress', name: 'In Progress', open: true },
11
+ { id: 'todo', name: 'Todo', open: true },
12
+ { id: 'done', name: 'Done', open: false, capped: true },
13
+ { id: 'asks', name: 'Asks', open: false, capped: true },
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
+ }
27
+
28
+ // A fold's open state lives in storage, not in setHTML's summary-text match, because its row's count changes.
29
+ // Fold ids are wave:<slug> (board/domain.mjs waveFold), so any id maps to its own storage key.
30
+ const FOLDS = new Proxy({}, { get: (_, id) => ({ store: `foldOpen:${String(id)}` }) });
31
+ // A fold's header reads like a lane's: its name, then its count, then an optional note.
32
+ function fold(id, list, name, note = '') {
33
+ if (!list.length) return '';
34
+ return `<details class="fold" data-fold="${esc(id)}" ${store.get(FOLDS[id].store, false) ? 'open' : ''}>
35
+ <summary><span class="lane-name">${name}</span><span class="count">${list.length}</span>${note ? `<span class="count">· ${note}</span>` : ''}</summary>
36
+ <div class="fold-list">${list.map((it) => B.card(it)).join('')}</div>
37
+ </details>`;
38
+ }
39
+
40
+ // One row per finished wave, newest card first, as Done's items arrive sorted.
41
+ function waveFolds(d, items) {
42
+ const slugs = [...new Set(items.filter((i) => i.fold === `wave:${i.wave}`).map((i) => i.wave))];
43
+ return slugs.map((slug) => {
44
+ const w = d.waves.find((x) => x.slug === slug);
45
+ const { dropped } = w.finished;
46
+ return fold(`wave:${slug}`, items.filter((i) => i.fold === `wave:${slug}`), esc(w.title), dropped ? `${dropped} dropped` : '');
47
+ }).join('');
48
+ }
49
+
50
+ // shown is the board's items after the wave filter and pending deletes.
51
+ function lanesHtml(d, shown) {
52
+ const laneState = store.get('lanes', {});
53
+ return LANES.map((lane) => {
54
+ // Items arrive in each lane's fixed order (domain.mjs laneOrder).
55
+ const items = shown.filter((i) => i.lane === lane.id);
56
+ if (lane.hideEmpty && !items.length) return '';
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' : ''}>
61
+ <summary><span class="lane-name">${lane.name}</span><span class="count">${items.length}</span>${lane.id === 'review' ? B.githubHeader(d) : ''}</summary>
62
+ <div class="list">${rows || '<div class="empty">Nothing here.</div>'}
63
+ ${lane.id === 'todo' ? BoardUI.planButton(items) : ''}</div>
64
+ </details>`;
65
+ }).join('');
66
+ }
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
+ }
89
+ function renderWaves(d) {
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>` : ''}` : '');
98
+ }
99
+ const showWave = (slug) => { state.waveFilter = slug; store.set('wave', slug); B.render(); };
100
+
101
+ B.on(B.root, 'toggle', (ev) => {
102
+ const el = ev.target;
103
+ if (el.matches?.('details.lane')) {
104
+ const s = store.get('lanes', {}); s[el.dataset.lane] = el.open; store.set('lanes', s);
105
+ } else if (el.matches?.('details.fold')) store.set(FOLDS[el.dataset.fold].store, el.open);
106
+ }, true);
107
+
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; }
111
+ const pb = ev.target.closest('button[data-plan]');
112
+ if (pb) {
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;
114
+ pb.disabled = true;
115
+ try { await B.send('plan-waves', { keys: pb.dataset.plan.split(' ') }); pb.textContent = 'Planning… its questions appear in Needs you'; }
116
+ catch { B.notice('Could not send. Is the host awake?'); pb.disabled = false; }
117
+ return;
118
+ }
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);
139
+ });
140
+
141
+ Object.assign(B, { FOLDS, lanesHtml, renderWaves });
142
+ });
@@ -0,0 +1,31 @@
1
+ // The board view's Needs you box: one line per thing waiting on the owner (domain.mjs needsOf), led by the board
2
+ // session's pending prompts, answered in place (prompts.js). Needs app-core.js.
3
+ BoardView.part((B) => {
4
+ const { $, plural, state, toast } = B;
5
+ const { esc, promptItem } = BoardUI;
6
+
7
+ const prompts = PromptAnswers.wire(B, {
8
+ list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
9
+ paint: () => { if (state.board) renderNeeds(state.board); },
10
+ notify: (text) => toast(text),
11
+ after: () => B.loadBoard(),
12
+ });
13
+
14
+ function renderNeeds(d) {
15
+ const parts = (line) => [
16
+ line.questions ? `<a href="${esc(line.questions.url)}">${plural(line.questions.open, 'question')}</a>` : '',
17
+ line.ready ? `<a href="${esc(line.ready)}" target="_blank" rel="noopener">Ready on GitHub</a>` : '',
18
+ line.action ? `<a href="${esc(line.action.url)}">${esc(line.action.label)}</a>` : '',
19
+ ].filter(Boolean).join(' · ');
20
+ const needs = (d.needs || []).filter((line) => !line.prompt || prompts.shows(line.prompt));
21
+ const w = $('#waiting');
22
+ w.hidden = needs.length === 0;
23
+ w.innerHTML = needs.length ? `<h2 style="margin:0 0 6px">Needs you · ${needs.length}</h2>
24
+ <ul>${needs.map((line) => (line.prompt ? promptItem(line.prompt, prompts.state(line.prompt))
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>`
27
+ : `<li><a href="${esc(line.url)}">${esc(line.title)}</a> <span class="muted small">${plural(line.questions.open, 'question')}</span></li>`)).join('')}</ul>` : '';
28
+ }
29
+
30
+ Object.assign(B, { renderNeeds });
31
+ });
@@ -0,0 +1,131 @@
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, 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).
4
+ // Needs app-core.js and ui-snapshot.js; app.js paints it.
5
+ BoardView.part((B) => {
6
+ const { $$, state } = B;
7
+ const { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX } = BoardUI;
8
+ // The hub serves every site from one origin, so each board keeps its own key.
9
+ const KEY = 'boardSnapshot:' + B.site.url('');
10
+ // Every board's snapshots together stay within three at ui-snapshot.js's cap, the boards saved least recently
11
+ // dropping first, so they leave room in the browser's 5 MB of storage for the drafts and seen marks, which would
12
+ // otherwise fail to save.
13
+ const BUDGET = 3 * SNAPSHOT_MAX;
14
+ const ORDER = 'boardSnapshots';
15
+ const BUILD = document.documentElement.dataset.build;
16
+
17
+ function readSnapshot() {
18
+ let raw = null;
19
+ try { raw = localStorage.getItem(KEY); } catch {}
20
+ return raw ? unpackSnapshot(raw, BUILD) : null;
21
+ }
22
+ // Makes room for this board's raw snapshot.
23
+ function keepRecent(raw) {
24
+ let order = [];
25
+ try { order = JSON.parse(localStorage.getItem(ORDER) || '[]'); } catch {}
26
+ const stored = [];
27
+ for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith('boardSnapshot:') && k !== KEY) stored.push(k); }
28
+ const recent = [...order.filter((k) => stored.includes(k)), ...stored.filter((k) => !order.includes(k))];
29
+ let used = raw.length;
30
+ const kept = [KEY];
31
+ for (const k of recent) {
32
+ const size = localStorage.getItem(k)?.length || 0;
33
+ if (used + size <= BUDGET) { used += size; kept.push(k); } else localStorage.removeItem(k);
34
+ }
35
+ localStorage.setItem(ORDER, JSON.stringify(kept));
36
+ }
37
+ let saveTimer = null;
38
+ function saveSnapshot() {
39
+ saveTimer?.();
40
+ saveTimer = null;
41
+ if (state.view !== 'fresh' || !state.board) return;
42
+ const raw = packSnapshot(state.board, state.threadCache, {
43
+ build: BUILD,
44
+ savedAt: state.board.generatedAt || new Date().toISOString(),
45
+ keep: [...state.openItems, ...preloadKeys(state.board.items, [])],
46
+ });
47
+ try {
48
+ if (!raw) { localStorage.removeItem(KEY); return; }
49
+ keepRecent(raw);
50
+ localStorage.setItem(KEY, raw);
51
+ } catch { try { localStorage.removeItem(KEY); } catch {} }
52
+ }
53
+ const saveSnapshotSoon = () => { if (!saveTimer) saveTimer = B.later(1000, saveSnapshot); };
54
+ B.on(window, 'pagehide', () => { if (saveTimer) saveSnapshot(); });
55
+ // The app shell paints a board it comes back to from this, live when nothing changed since (shell.js).
56
+ B.atUnmount(saveSnapshot);
57
+
58
+ // view is ui-snapshot.js liveControls' view; style.css dims the snapshot and its held controls from the view root's data-board.
59
+ function setView(view) {
60
+ state.view = view;
61
+ B.root.dataset.board = view;
62
+ }
63
+ for (const type of ['click', 'submit']) {
64
+ B.on(B.root, type, (ev) => {
65
+ const { blocked } = liveControls(state.view);
66
+ if (!blocked) return;
67
+ const control = type === 'submit' ? ev.target.querySelector?.('button[type=submit]') : ev.target.closest?.(blocked);
68
+ if (control?.matches(blocked)) { ev.preventDefault(); ev.stopImmediatePropagation(); }
69
+ }, true);
70
+ }
71
+
72
+ // The lines a fresh board can change: each lane count, Needs you line and thread message. A card shows its own sign below.
73
+ function lines() {
74
+ const out = new Map();
75
+ for (const count of $$('.lane[data-lane] > summary .count')) out.set(`lane:${count.closest('.lane').dataset.lane}`, count);
76
+ $$('#waiting li').forEach((li, i) => out.set(`needs:${i}`, li));
77
+ for (const msgs of $$('[data-thread] > .msgs')) [...msgs.children].forEach((m, i) => out.set(`thread:${msgs.parentElement.dataset.thread}|${i}`, m));
78
+ return out;
79
+ }
80
+ const texts = (els) => Object.fromEntries([...els].map(([id, el]) => [id, el.textContent.replace(/\s+/g, ' ').trim()]));
81
+ let before = null;
82
+ const holdForFlash = () => { before = texts(lines()); };
83
+ function flashChanged() {
84
+ if (!before || state.view !== 'fresh') return;
85
+ const els = lines();
86
+ const after = texts(els);
87
+ for (const id of changedLines(before, after)) {
88
+ els.get(id).classList.add('flash');
89
+ before[id] = after[id];
90
+ }
91
+ }
92
+ const endFlash = () => { before = null; };
93
+
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 });
131
+ });
@@ -0,0 +1,167 @@
1
+ // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
+ BoardView.part((B) => {
3
+ const { $, store, state, toast, quieted, sendDraft, links } = B;
4
+ const { esc, setHTML, threadMessages, attachButton, protoButton, protoNotice, doneButton } = BoardUI;
5
+
6
+ const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
7
+
8
+ // The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
9
+ function syncDone(el, key) {
10
+ const send = el.querySelector('form button[type=submit]');
11
+ if (!send) return;
12
+ const html = doneButton(state.board?.items.find((i) => i.key === key));
13
+ const current = el.querySelector('form [data-confirm="finish"]');
14
+ if (current?.outerHTML === html) return;
15
+ current?.remove();
16
+ if (html) send.insertAdjacentHTML('beforebegin', html);
17
+ }
18
+
19
+ // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
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] } }));
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), '');
28
+ const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
29
+ B.on(B.root, 'toggle', (ev) => {
30
+ const el = ev.target;
31
+ if (!el.matches?.('details.item') || el.open) return;
32
+ seenBefore.set(el.dataset.key, B.seen[el.dataset.key]);
33
+ moreTaps.delete(el.dataset.key);
34
+ firstDrawn.delete(el.dataset.key);
35
+ }, true);
36
+ B.on(B.root, 'click', (ev) => {
37
+ const key = ev.target.closest('[data-thread-more]')?.closest('[data-thread]')?.dataset.thread;
38
+ if (key == null) return;
39
+ moreTaps.set(key, (moreTaps.get(key) || 0) + 1);
40
+ B.repaintThread(key);
41
+ });
42
+
43
+ const editsIn = (key) => new Map([...state.edits].filter(([, e]) => e.thread === key));
44
+ function renderThread(key, msgs) {
45
+ const el = $(`[data-thread="${CSS.escape(key)}"]`);
46
+ if (!el) return;
47
+ syncDone(el, key);
48
+ const edits = editsIn(key);
49
+ if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
50
+ const view = viewOf(key);
51
+ const sig = JSON.stringify([msgs, [...edits.keys()], view]);
52
+ if (el.dataset.sig === sig) return;
53
+ el.dataset.sig = sig;
54
+ // The reply form is built once per card, so a refresh never drops what is being typed.
55
+ if (!el.querySelector('.msgs')) {
56
+ const draft = store.get('draft:' + key, '');
57
+ el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}"><textarea rows="2" placeholder="Reply in this thread…">${esc(draft)}</textarea>
58
+ <div class="pending-files" data-files></div>
59
+ <div class="row">${attachButton()}${protoButton(key)}<button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
60
+ BoardAttach.paint('item:' + key, B.site.root);
61
+ }
62
+ syncDone(el, key);
63
+ B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(msgs, links(key), edits, view)));
64
+ B.flashChanged?.(el);
65
+ // A thread's request is answered by a later thread line, so only a pending one changes the eye.
66
+ const req = msgs.filter((m) => m.kind === 'prototype-request').pop();
67
+ const proto = req && (req.state === 'waiting' || req.state === 'working') ? { pending: req.eventId } : null;
68
+ const pb = el.querySelector('form [data-proto]');
69
+ if (pb && pb.dataset.protoPending !== proto?.pending) pb.outerHTML = protoButton(key, proto);
70
+ }
71
+
72
+ // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
73
+ // starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
74
+ // (app.html BoardBoot). A response older than the one shown is dropped. A failed load keeps the previous render.
75
+ const inflight = new Map();
76
+ const loadedAt = new Map();
77
+ const shown = new Map();
78
+ let seq = 0;
79
+ function loadThread(key, { force = false, maxAge = 0, from = null } = {}) {
80
+ if (!force && inflight.has(key)) return inflight.get(key);
81
+ if (maxAge && Date.now() - (loadedAt.get(key) || 0) < maxAge) return Promise.resolve();
82
+ const n = ++seq;
83
+ const p = (from || fetch(B.site.url('api/thread?key=' + encodeURIComponent(key)), { cache: 'no-store' })
84
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`thread ${r.status}`)))))
85
+ .then((msgs) => {
86
+ if ((shown.get(key) || 0) > n) return;
87
+ shown.set(key, n);
88
+ loadedAt.set(key, Date.now());
89
+ state.threadCache.set(key, msgs);
90
+ renderThread(key, msgs);
91
+ B.saveSnapshotSoon?.();
92
+ })
93
+ .catch(() => {})
94
+ .finally(() => { if (inflight.get(key) === p) inflight.delete(key); });
95
+ inflight.set(key, p);
96
+ return p;
97
+ }
98
+
99
+ B.on(B.root, 'input', (ev) => {
100
+ const f = ev.target.closest('form[data-reply]');
101
+ if (!f || ev.target.tagName !== 'TEXTAREA' || quieted('draft:' + f.dataset.reply, ev.target)) return;
102
+ store.set('draft:' + f.dataset.reply, ev.target.value);
103
+ });
104
+ B.on(B.root, 'click', async (ev) => {
105
+ const b = ev.target.closest('form[data-reply] button[data-quick]');
106
+ if (!b) return;
107
+ const key = b.closest('form').dataset.reply;
108
+ b.disabled = true;
109
+ try { await B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })); loadThread(key, { force: true }); }
110
+ catch { toast('Could not send'); }
111
+ b.disabled = false;
112
+ });
113
+ B.on(B.root, 'click', async (ev) => {
114
+ const b = ev.target.closest('form[data-reply] button[data-proto]');
115
+ if (!b) return;
116
+ const key = b.dataset.proto;
117
+ const pending = b.dataset.protoPending;
118
+ if (pending) {
119
+ let latest = null;
120
+ try { latest = (await B.site.events({ kind: 'ask-bump', limit: 1 }))[0]?.data?.id ?? null; } catch {}
121
+ const n = protoNotice(pending, latest);
122
+ if (!n.bump) { B.notice(n.text); return; }
123
+ if (!(await B.confirm(n.text))) return;
124
+ await B.bumpAsk(pending);
125
+ return;
126
+ }
127
+ if (!(await B.confirm('Ask Claude for prototypes of this item?'))) return;
128
+ b.disabled = true;
129
+ try {
130
+ await B.sentHere(key, () => B.send('prototype-request', { key }));
131
+ b.title = b.ariaLabel = 'Prototypes asked for';
132
+ toast('Asked Claude for prototypes');
133
+ loadThread(key, { force: true });
134
+ } catch { b.disabled = false; toast('Could not send'); }
135
+ });
136
+ B.on(B.root, 'submit', async (ev) => {
137
+ const f = ev.target.closest('form[data-reply]');
138
+ if (!f) return;
139
+ ev.preventDefault();
140
+ const ta = f.querySelector('textarea');
141
+ ta.blur();
142
+ const text = ta.value.trim();
143
+ const id = f.dataset.composer;
144
+ const files = BoardAttach.files(id, B.site.root);
145
+ if ((!text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
146
+ const key = f.dataset.reply;
147
+ const btn = f.querySelector('button[type=submit]'); btn.disabled = true;
148
+ try {
149
+ await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
150
+ () => B.sentHere(key, () => B.send('item-message', { key, text, ...(files.length ? { files } : {}) })));
151
+ BoardAttach.clear(id, B.site.root);
152
+ btn.disabled = false;
153
+ loadThread(f.dataset.reply, { force: true });
154
+ } catch { B.notice('Could not send. Is the host awake?'); btn.disabled = false; }
155
+ });
156
+ // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
157
+ B.on(B.root, 'attach-change', (ev) => {
158
+ const b = ev.target.querySelector('button[type=submit]');
159
+ if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
160
+ });
161
+
162
+ Object.assign(B, {
163
+ thread, renderThread, loadThread,
164
+ repaintThread: (key) => { if (state.threadCache.has(key)) renderThread(key, state.threadCache.get(key)); },
165
+ syncDone: (key) => { const el = $(`[data-thread="${CSS.escape(key)}"]`); if (el) syncDone(el, key); },
166
+ });
167
+ });
@@ -0,0 +1,86 @@
1
+ <!doctype html>
2
+ <html lang="en" data-build="{{build}}">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>Claude</title>
7
+ <script>
8
+ // The board this URL opens, or `/`'s last board, starts loading with its open cards' threads here, beside the scripts
9
+ // below; app.js loadBoard takes the loads over when it mounts that board. A board's other views load their own, and a
10
+ // site this device's last sidebar copy lists as no board loads nothing here. Above the stylesheets, since a script
11
+ // after one waits for it to load.
12
+ window.BoardBoot = (function () {
13
+ if (!/^\/(?:[^/]+\/)?$/.test(location.pathname)) return null;
14
+ let route = null;
15
+ try { route = decodeURIComponent(location.pathname.split('/')[1] || '') || localStorage.getItem('hub:lastBoard'); } catch {}
16
+ if (!route || route === '_hub') return null;
17
+ try { if (JSON.parse(localStorage.getItem('hub:sidebar') || 'null')?.sites?.some((s) => s.route === route && !s.board)) return null; } catch {}
18
+ const base = '/' + encodeURIComponent(route) + '/';
19
+ const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))); p.catch(() => {}); return p; };
20
+ let open = [];
21
+ try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
22
+ const focus = /^#item=([^&]+)/.exec(location.hash);
23
+ if (focus) open.push(decodeURIComponent(focus[1]));
24
+ return { route, board: get('api/board'), threads: new Map([...new Set(open)].map((k) => [k, get('api/thread?key=' + encodeURIComponent(k))])) };
25
+ })();
26
+ </script>
27
+ <script>try { if (localStorage.getItem('hub:sidebarCollapsed') === '1') document.documentElement.classList.add('sb-collapsed'); } catch {}</script>
28
+ <link rel="stylesheet" href="/_hub/colors.css?v={{colorsVersion}}">
29
+ <link rel="stylesheet" href="/_hub/app/style.css">
30
+ <link rel="stylesheet" href="/_hub/app/hub.css">
31
+ <link rel="stylesheet" href="/_hub/app/sidebar.css">
32
+ <link rel="icon" type="image/png" href="/_hub/kickflip.png">
33
+ <link rel="apple-touch-icon" href="{{touchIcon}}">
34
+ <link rel="manifest" href="/manifest.webmanifest">
35
+ <meta name="apple-mobile-web-app-title" content="Claude">
36
+ </head>
37
+ <body class="has-sidebar">
38
+ <aside id="sidebar" class="sidebar" aria-label="Boards">{{sidebar}}</aside>
39
+ <div id="sidebarScrim" class="sidebar-scrim"></div>
40
+ <script id="sidebarData" type="application/json">{{sidebarData}}</script>
41
+ <script src="/_hub/app/ui.js"></script>
42
+ <script src="/_hub/app/ui-links.js"></script>
43
+ <script src="/_hub/app/ui-enums.js"></script>
44
+ <script src="/_hub/app/ui-order.js"></script>
45
+ <script src="/_hub/app/ui-threads.js"></script>
46
+ <script src="/_hub/app/ui-cards.js"></script>
47
+ <script src="/_hub/app/ui-header.js"></script>
48
+ <script src="/_hub/app/ui-snapshot.js"></script>
49
+ <script src="/_hub/app/ui-page-header.js"></script>
50
+ <script src="/_hub/app/ui-sidebar.js"></script>
51
+ <script src="/_hub/app/ui-questions.js"></script>
52
+ <script src="/_hub/app/ui-prompts.js"></script>
53
+ <script src="/_hub/app/ui-markdown.js"></script>
54
+ <script src="/_hub/app/ui-hub.js"></script>
55
+ <script src="/_hub/app/toasts.js"></script>
56
+ <script src="/_hub/app/dialogs.js"></script>
57
+ <script src="/_hub/app/prompts.js"></script>
58
+ <script src="/_hub/app/rules-editor.js"></script>
59
+ <script src="/_hub/app/claude.js"></script>
60
+ <script src="/_hub/app/attach.js"></script>
61
+ <script src="/_hub/app/hub-kit.js"></script>
62
+ <script src="/_hub/app/linear-key.js"></script>
63
+ <script src="/_hub/app/sidebar.js"></script>
64
+ <script src="/_hub/app/app.js"></script>
65
+ <script src="/_hub/app/app-core.js"></script>
66
+ <script src="/_hub/app/app-threads.js"></script>
67
+ <script src="/_hub/app/app-cards.js"></script>
68
+ <script src="/_hub/app/app-lanes.js"></script>
69
+ <script src="/_hub/app/app-asks.js"></script>
70
+ <script src="/_hub/app/app-needs.js"></script>
71
+ <script src="/_hub/app/app-header.js"></script>
72
+ <script src="/_hub/app/app-arch.js"></script>
73
+ <script src="/_hub/app/app-banner.js"></script>
74
+ <script src="/_hub/app/app-snapshot.js"></script>
75
+ <script src="/_hub/app/view-settings.js"></script>
76
+ <script src="/_hub/app/view-questions.js"></script>
77
+ <script src="/_hub/app/view-notes.js"></script>
78
+ <script src="/_hub/app/view-hub.js"></script>
79
+ <script src="/_hub/app/view-hub-settings.js"></script>
80
+ <script src="/_hub/app/view-polling.js"></script>
81
+ <script src="/_hub/app/view-frame.js"></script>
82
+ <script src="/_hub/app/router.js"></script>
83
+ <script src="/_hub/app/shell.js"></script>
84
+ <script>AppShell.start();</script>
85
+ </body>
86
+ </html>