@lovek4d/kickflip 0.0.0-stage → 0.1.0

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 (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,360 @@
1
+ // Native confirmations cannot label their actions on iOS; labelled confirmations use a modal sheet.
2
+ // Load before any page script that asks (docs/adr/0023-a-card-can-be-held-and-every-card-button-confirms.md).
3
+ (function () {
4
+ // linear: Hub's /_hub/api/linear answer, or loadLinear() fetches it the first time Linear is the source. A New board's
5
+ // "New project…" reads as new_project, which the hub creates on Start; Board Settings creates it with createProject.
6
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = {}, loadLinear, isNew = false, change = () => {}, createProject } = {}) {
7
+ const { esc } = BoardUI;
8
+ const draft = { prefix: 'INT', ...value };
9
+ let creating = false, createError = '', destroyed = false;
10
+ const fetchLinear = () => {
11
+ if (!loadLinear || draft.source !== 'linear') return;
12
+ const load = loadLinear;
13
+ loadLinear = null;
14
+ linear = { loading: true };
15
+ load().catch((e) => ({ set: true, error: e.message || 'Is the host awake?' })).then((next) => {
16
+ if (destroyed) return;
17
+ linear = next; pickDefaults(); render(); change(read(), valid(), { loaded: true });
18
+ });
19
+ };
20
+ const workspace = () => linear.url_key ?? linear.workspace ?? draft.workspace;
21
+ const newName = () => (draft.newName ?? title()).trim();
22
+ const typed = () => !linear.set && !linear.loading;
23
+ const typedProject = () => (draft.project && draft.project !== '__new' ? draft.project : isNew ? title() : '').trim();
24
+ const read = () => draft.source === 'internal' ? { source: 'internal', prefix: draft.prefix || 'INT' }
25
+ : draft.source === 'github' ? { source: 'github' }
26
+ : typed() ? { source: 'linear', workspace: draft.workspace || '', team: draft.team || '', project: typedProject() }
27
+ : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
28
+ : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
29
+ const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
30
+ : draft.source === 'github' ? Boolean(repo())
31
+ : typed() ? /^[a-z\d][a-z\d-]*$/i.test(draft.workspace || '') && /^[A-Z][A-Z0-9]*$/.test(draft.team || '') && Boolean(typedProject())
32
+ : Boolean(linear.set && !linear.error && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
33
+ const pickDefaults = () => {
34
+ if (draft.source !== 'linear' || !linear.set) return;
35
+ if (!linear.teams?.some((t) => t.key === draft.team)) draft.team = linear.teams?.[0]?.key;
36
+ draft.project ||= isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
37
+ };
38
+ function linearDetail() {
39
+ if (linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
40
+ if (!linear.set) return `<div class="tk-grid"><label class="tk-field"><span>Workspace</span><input data-tk="workspaceText" value="${esc(draft.workspace || '')}" placeholder="your-workspace" autocapitalize="off" autocomplete="off" spellcheck="false"></label><label class="tk-field"><span>Team</span><input data-tk="teamText" value="${esc(draft.team || '')}" placeholder="ENG" autocapitalize="characters" autocomplete="off" spellcheck="false"></label></div><label class="tk-field"><span>Project</span><input data-tk="projectText" value="${esc(typedProject())}" placeholder="Project name" autocomplete="off"></label><p class="muted small">The board’s session creates and moves its issues through its Linear MCP, and creates the project if it’s new. <a href="/_hub/#linear">A Linear key on Hub</a> adds team and project lists and lets kickflip do it itself.</p>`;
41
+ if (linear.error) return `<p class="muted small" role="alert">Couldn’t list Linear’s teams: ${esc(linear.error)}</p>`;
42
+ const project = linear.projects?.find((p) => p.id === draft.project);
43
+ const team = esc(draft.team || '');
44
+ const note = draft.project === '__new'
45
+ ? (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>`)
46
+ : `<p class="muted small">A new card gets a ${team} issue in ${esc(project?.name || draft.project || '')}, in the ${esc(linear.workspace || workspace())} workspace from Hub’s key. Its badge opens it in Linear.</p>`;
47
+ 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 ? ' disabled' : ''}>${creating ? 'Creating…' : 'Create'}</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
48
+ }
49
+ function render() {
50
+ const segment = `<div class="tk-seg" role="group" aria-label="Ticket source">${[['linear', 'Linear'], ['github', 'GitHub issues'], ['internal', 'Internal']].map(([source, name]) => `<button type="button" data-tk-source="${source}" aria-pressed="${draft.source === source}">${name}</button>`).join('')}</div>`;
51
+ let detail;
52
+ if (draft.source === 'internal') detail = `<label class="tk-field"><span>Prefix</span><input data-tk="prefix" value="${esc(draft.prefix)}" placeholder="INT" maxlength="6" pattern="[A-Z]{2,6}" title="2–6 uppercase letters" autocapitalize="characters" autocomplete="off" spellcheck="false"></label><p class="muted small" data-tk-example>Cards number ${esc(draft.prefix || 'INT')}-1, ${esc(draft.prefix || 'INT')}-2… on this board. The badge opens the card.</p>`;
53
+ 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 new card opens an issue there; its badge reads #41.</span></span></div>`;
54
+ else detail = linearDetail();
55
+ root.innerHTML = `${segment}<div class="tk-detail">${detail}</div>`;
56
+ if (draft.source === 'linear' && linear.set && !linear.error && !linear.loading) {
57
+ root.querySelector('[data-tk="team"]').value = draft.team || '';
58
+ root.querySelector('[data-tk="project"]').value = draft.project || '';
59
+ }
60
+ }
61
+ const click = async (e) => {
62
+ if (e.target.closest('[data-tk-create]')) {
63
+ const name = newName();
64
+ if (!name || creating || !createProject) return;
65
+ creating = true; createError = ''; render();
66
+ try {
67
+ const made = await createProject(draft.team, name);
68
+ linear.projects = [...(linear.projects || []), made];
69
+ draft.project = made.id;
70
+ delete draft.newName;
71
+ } catch (error) { createError = error.message || 'Could not create the project.'; }
72
+ creating = false;
73
+ render(); change(read(), valid());
74
+ return;
75
+ }
76
+ const button = e.target.closest('[data-tk-source]');
77
+ if (!button) return;
78
+ draft.source = button.dataset.tkSource;
79
+ fetchLinear();
80
+ pickDefaults();
81
+ render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
82
+ };
83
+ const input = (e) => {
84
+ const field = e.target.dataset.tk;
85
+ if (!field) return;
86
+ if (field.endsWith('Text')) {
87
+ const name = field.slice(0, -4);
88
+ draft[name] = name === 'team' ? e.target.value.toUpperCase().trim() : e.target.value.trim();
89
+ if (name === 'team') e.target.value = draft.team;
90
+ change(read(), valid());
91
+ return;
92
+ }
93
+ draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
94
+ if (field === 'prefix') e.target.value = draft.prefix;
95
+ if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
96
+ if (field === 'team' || field === 'project') { createError = ''; render(); }
97
+ if (field === 'prefix') root.querySelector('[data-tk-example]').textContent = `Cards number ${draft.prefix || 'INT'}-1, ${draft.prefix || 'INT'}-2… on this board. The badge opens the card.`;
98
+ change(read(), valid());
99
+ };
100
+ root.addEventListener('click', click);
101
+ root.addEventListener('input', input);
102
+ root.addEventListener('change', input);
103
+ fetchLinear();
104
+ render();
105
+ return { read, valid, render, nameChanged() {
106
+ const field = root.querySelector('[data-tk="newName"]');
107
+ if (field && draft.newName === undefined) field.value = title();
108
+ const typedField = root.querySelector('[data-tk="projectText"]');
109
+ if (typedField && !draft.project) typedField.value = title();
110
+ },
111
+ destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
112
+ }
113
+ let close = null;
114
+ function sheet(message, { ok, cancel = 'Cancel', signal }) {
115
+ close?.(false);
116
+ return new Promise((resolve) => {
117
+ const previous = document.activeElement;
118
+ const el = document.createElement('dialog');
119
+ el.setAttribute('class', 'confirm-sheet');
120
+ el.setAttribute('role', 'alertdialog');
121
+ el.setAttribute('aria-modal', 'true');
122
+ el.setAttribute('aria-labelledby', 'confirmSheetText');
123
+ el.innerHTML = '<div class="box"><p id="confirmSheetText"></p><button type="button" data-answer="ok"></button></div>'
124
+ + '<div class="box"><button type="button" class="cancel" data-answer="cancel"></button></div>';
125
+ el.querySelector('p').textContent = message;
126
+ el.querySelector('[data-answer="ok"]').textContent = ok;
127
+ el.querySelector('[data-answer="cancel"]').textContent = cancel;
128
+ let settled = false;
129
+ const abort = () => done(false);
130
+ function done(yes) {
131
+ if (settled) return;
132
+ settled = true;
133
+ close = null;
134
+ signal?.removeEventListener('abort', abort);
135
+ el.close();
136
+ el.remove();
137
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
138
+ resolve(yes);
139
+ }
140
+ el.addEventListener('click', (e) => {
141
+ const b = e.target.closest('[data-answer]');
142
+ if (b || e.target === el) done(b?.dataset.answer === 'ok');
143
+ });
144
+ el.addEventListener('cancel', (e) => { e.preventDefault(); done(false); });
145
+ el.addEventListener('close', () => done(false));
146
+ signal?.addEventListener('abort', abort, { once: true });
147
+ document.body.appendChild(el);
148
+ el.showModal();
149
+ el.querySelector('.cancel').focus();
150
+ close = done;
151
+ });
152
+ }
153
+ function fitSheet(sheet) {
154
+ const vv = window.visualViewport;
155
+ // iOS keeps fixed elements on the layout viewport; --kb is the part covered by its keyboard.
156
+ const fit = () => {
157
+ const kb = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
158
+ sheet.style.setProperty('--kb', kb + 'px');
159
+ sheet.style.setProperty('--vh', (vv ? vv.height : window.innerHeight) + 'px');
160
+ sheet.classList.toggle('kb', kb > 0);
161
+ };
162
+ // iOS scrolls the page to show a focused field even under a modal sheet, and then draws the sheet cut off above the
163
+ // keyboard with the board below it, so the page keeps its scroll position. innerHeight reads short during that
164
+ // scroll and no resize follows the restore, so --kb is measured again once the keyboard has settled.
165
+ let y = window.scrollY;
166
+ let settle = 0;
167
+ const pin = () => {
168
+ // The 900px breakpoint must match style.css's html:has(dialog:modal) scroll lock.
169
+ if (matchMedia('(min-width: 900px)').matches) { y = window.scrollY; return; }
170
+ if (window.scrollY === y) return;
171
+ window.scrollTo(window.scrollX, y);
172
+ clearTimeout(settle);
173
+ settle = setTimeout(fit, 350);
174
+ };
175
+ sheet.classList.add('keyboard-sheet');
176
+ fit();
177
+ vv?.addEventListener('resize', fit);
178
+ vv?.addEventListener('scroll', fit);
179
+ window.addEventListener('resize', fit);
180
+ window.addEventListener('scroll', pin);
181
+ return () => {
182
+ clearTimeout(settle);
183
+ vv?.removeEventListener('resize', fit);
184
+ vv?.removeEventListener('scroll', fit);
185
+ window.removeEventListener('resize', fit);
186
+ window.removeEventListener('scroll', pin);
187
+ };
188
+ }
189
+ function repoSheet({ current, save, onClose = () => {} }) {
190
+ if (document.getElementById('newBoardSheet')) return () => {};
191
+ const previous = document.activeElement;
192
+ const el = document.createElement('dialog');
193
+ el.setAttribute('id', 'newBoardSheet');
194
+ el.setAttribute('class', 'sheet');
195
+ el.setAttribute('aria-labelledby', 'newBoardTitle');
196
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
197
+ const draft = { name: '', purpose: '', github: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
198
+ let tickets;
199
+ let repos = [], pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
200
+ const controller = new AbortController();
201
+ el.innerHTML = `<form class="nb-form"><div class="sheet-head"><h2 id="newBoardTitle" tabindex="-1" autofocus>${current ? 'Change repo' : 'New board'}</h2><button type="button" data-cancel-new aria-label="Close sheet">✕</button></div><div data-repo-content></div><p class="nb-error" role="alert" hidden></p><div class="nb-actions sheet-actions"><button type="button" data-cancel-new>Cancel</button><button type="submit" class="primary" hidden disabled>${current ? 'Use repo' : 'Start'}</button></div></form>`;
202
+ const content = el.querySelector('[data-repo-content]');
203
+ const form = el.querySelector('form');
204
+ const start = el.querySelector('[type="submit"]');
205
+ const error = el.querySelector('[role="alert"]');
206
+ const field = (name) => el.querySelector(`[name="${name}"]`);
207
+ const request = async (url, options = {}) => {
208
+ const r = await fetch(url, { ...options, signal: controller.signal });
209
+ const d = await r.json();
210
+ if (!r.ok) throw new Error(d.error || 'Could not load repos. Try again.');
211
+ return d;
212
+ };
213
+ const fail = (e) => { if (!closed) { error.textContent = e.message; error.hidden = false; } };
214
+ function sync() { start.disabled = sending || !draft.github || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
215
+ const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(r.github)}"><strong>${esc(r.github)}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${r.private ? 'Private' : 'Public'} · <time datetime="${esc(r.pushedAt)}">${r.pushedAt ? `Pushed ${esc(new Date(r.pushedAt).toLocaleString())}` : r.listed === false ? 'Last push unknown' : 'No pushes yet'}</time></span><span class="nb-help">${esc(r.description)}</span><span class="nb-local">${r.cloned ? `✓ In .repos${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned in .repos'}</span></button>`;
216
+ const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
217
+ function rows(query = '') {
218
+ const list = repos.filter((r) => `${r.github} ${r.description}`.toLowerCase().includes(query.trim().toLowerCase()));
219
+ results(`<p class="nb-help" role="status">${list.length} of ${repos.length} repos · Newest push first</p><div class="nb-repo-list">${list.map((r) => row(r)).join('')}</div>${list.length ? '' : '<p class="nb-help">No matching repos. Try an owner or repo name.</p>'}`);
220
+ }
221
+ function search(value) {
222
+ const version = ++generation;
223
+ clearTimeout(lookupTimer);
224
+ pasted = null;
225
+ const q = value.trim();
226
+ const host = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)([^/:?#\s]+)/i.exec(q);
227
+ const reason = (name, message) => results(`<div class="nb-box nb-box-no" role="alert"><span><strong>${esc(name)}</strong><span class="nb-why">${esc(message)}</span></span></div>`);
228
+ if (host && !/^(?:www\.)?github\.com$/i.test(host[1])) {
229
+ reason(q.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').replace(/^[^@/\s]+@/, '').replace(/:/, '/').replace(/\.git$/i, ''), 'Only GitHub repos work here.');
230
+ return;
231
+ }
232
+ const link = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(q);
233
+ const bare = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?\/[a-z\d_.-]{1,100}$/i.test(q);
234
+ if (!link && !host && !bare) { rows(q); return; }
235
+ if (host && !link) { reason(q, 'That link doesn’t name a repo. Use github.com/owner/name.'); return; }
236
+ if (bare && repos.some((r) => `${r.github} ${r.description}`.toLowerCase().includes(q.toLowerCase()))) { rows(q); return; }
237
+ const github = link ? link[1].replace(/\.git$/i, '') : q;
238
+ const listed = repos.find((r) => r.github.toLowerCase() === github.toLowerCase());
239
+ if (listed) { results(`<p class="nb-help" role="status">Already in your list</p><div class="nb-repo-list">${row(listed, true)}</div>`); return; }
240
+ results(`<p class="nb-help" role="status">Checking the hub can reach it…</p><div class="nb-box"><span class="nb-spin" aria-hidden="true"></span><span><strong>${esc(github)}</strong><span class="nb-help">Checking…</span></span></div>`);
241
+ const check = async () => {
242
+ try {
243
+ const repo = await request(`/_hub/repo?github=${encodeURIComponent(github)}`);
244
+ if (closed || generation !== version) return;
245
+ pasted = repo;
246
+ results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
247
+ } catch (e) { if (!closed && generation === version) reason(github, e.message); }
248
+ };
249
+ if (bare) lookupTimer = setTimeout(check, 500);
250
+ else check();
251
+ }
252
+ async function picker(fresh = false) {
253
+ tickets?.destroy();
254
+ const version = ++generation;
255
+ clearTimeout(lookupTimer);
256
+ pasted = null;
257
+ selected = null;
258
+ start.hidden = true;
259
+ error.hidden = true;
260
+ content.innerHTML = '<p class="nb-next">1 · Choose a repo</p><p role="status">Loading repos…</p>';
261
+ try {
262
+ const d = await request(`/_hub/repos${fresh ? '?fresh=1' : ''}`);
263
+ if (closed || generation !== version) return;
264
+ if (!d.signedIn) {
265
+ content.innerHTML = '<h3>GitHub isn’t connected</h3><p>gh isn’t signed in on the host running Kickflip. Sign in there to see your repos.</p><code>gh auth login</code><p><button type="button" data-check-auth>Check again</button></p>';
266
+ return;
267
+ }
268
+ repos = d.repos;
269
+ content.innerHTML = '<p class="nb-next">1 · Choose a repo</p><p>Choose the GitHub repo this board will work on.</p><label class="nb-field"><span>Search repos or paste a GitHub link</span><input name="search" type="search" placeholder="Search or paste owner/name…" autocomplete="off" autocapitalize="off" spellcheck="false"></label><div data-repo-results></div>';
270
+ rows();
271
+ } catch (e) { fail(e); content.innerHTML = '<button type="button" data-check-auth>Try again</button>'; }
272
+ }
273
+ async function pick(github) {
274
+ selected = repos.find((r) => r.github === github) || (pasted?.github === github ? pasted : null);
275
+ if (!selected) return;
276
+ const version = ++generation;
277
+ clearTimeout(lookupTimer);
278
+ draft.github = github;
279
+ draft.branch = '';
280
+ if (!edited) draft.name = github.split('/')[1];
281
+ error.hidden = true;
282
+ const shared = selected.shared.map((b) => b.title).join(', ');
283
+ content.innerHTML = `<button type="button" data-back-repos>‹ Back</button><p class="nb-next">${current ? 'Repo details' : '2 · Board details'}</p><div class="nb-selected"><strong>${esc(github)}</strong><span class="nb-help">${selected.private ? 'Private' : 'Public'}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned in .repos' : 'Clones to'}<code class="nb-clone">${esc(selected.clone)}</code></p></div><label class="nb-field"><span>Default branch for this board</span><select name="branch" disabled><option value="">Loading branches…</option></select><span class="nb-help" data-branch-help></span></label><p class="nb-next">Each board’s builders work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New builders and GitHub links will use the selected repo.</p>' : `<label class="nb-field"><span>Name</span><input name="name" type="text" value="${esc(draft.name)}" required maxlength="60" autocomplete="off"></label><label class="nb-field"><span>What it’s for</span><input name="purpose" type="text" value="${esc(draft.purpose)}" maxlength="240"><span class="nb-help">One line to get the board started. Optional.</span></label><p class="nb-next">The hub ${selected.cloned ? 'reuses the clone' : 'clones the repo'} and starts your board. You can keep using your other boards.</p>`}`;
284
+ if (!current) {
285
+ content.querySelectorAll('.nb-next')[content.querySelectorAll('.nb-next').length - 1].insertAdjacentHTML('beforebegin', '<div class="nb-field tk-section" role="group" aria-labelledby="nbTicketsLabel"><span id="nbTicketsLabel">Tickets</span><span class="nb-help">Where this board’s cards get their tickets. You can change it in Board settings.</span><div data-board-tickets></div></div>');
286
+ tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, ...(linear ? { linear } : { loadLinear: async () => { linear = await request('/_hub/api/linear'); return linear; } }),
287
+ repo: () => draft.github, title: () => draft.name.trim(), isNew: true, change(value) { draft.tickets = value; sync(); } });
288
+ }
289
+ start.hidden = false;
290
+ sync();
291
+ try {
292
+ const d = await request(`/_hub/branches?github=${encodeURIComponent(github)}`);
293
+ if (closed || generation !== version) return;
294
+ draft.branch = current?.github === github && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
295
+ if (!d.branches.includes(draft.branch)) draft.branch = d.branches[0] || '';
296
+ field('branch').innerHTML = d.branches.map((b) => `<option value="${esc(b)}"${b === draft.branch ? ' selected' : ''}>${esc(b)}${b === d.defaultBranch ? ' (repo default)' : ''}</option>`).join('');
297
+ field('branch').value = draft.branch;
298
+ field('branch').disabled = false;
299
+ el.querySelector('[data-branch-help]').textContent = draft.branch ? `New builders branch from ${draft.branch}.` : 'This repo has no branches yet.';
300
+ sync();
301
+ } catch (e) { fail(e); }
302
+ }
303
+ const done = () => { if (!sending) el.close(); };
304
+ form.addEventListener('click', (e) => {
305
+ if (sending) return;
306
+ if (e.target.closest('[data-cancel-new]')) done();
307
+ else if (e.target.closest('[data-check-auth]')) picker(true);
308
+ else if (e.target.closest('[data-back-repos]')) picker();
309
+ else { const row = e.target.closest('[data-repo]'); if (row) pick(row.dataset.repo); }
310
+ });
311
+ form.addEventListener('input', (e) => {
312
+ if (e.target.getAttribute('name') === 'search') { search(e.target.value); return; }
313
+ if (e.target.getAttribute('name') === 'name') { draft.name = e.target.value; edited = true; tickets?.nameChanged(); }
314
+ if (e.target.getAttribute('name') === 'purpose') draft.purpose = e.target.value;
315
+ sync();
316
+ });
317
+ form.addEventListener('change', (e) => {
318
+ if (e.target.getAttribute('name') === 'branch') {
319
+ draft.branch = e.target.value;
320
+ el.querySelector('[data-branch-help]').textContent = `New builders branch from ${draft.branch}.`;
321
+ sync();
322
+ }
323
+ });
324
+ form.addEventListener('submit', async (e) => {
325
+ e.preventDefault();
326
+ if (sending || start.disabled || !selected) return;
327
+ sending = true;
328
+ sync();
329
+ error.hidden = true;
330
+ try {
331
+ await save(current ? { github: draft.github, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), github: draft.github, branch: draft.branch, tickets: tickets.read() }, selected);
332
+ sending = false;
333
+ el.close();
334
+ } catch (e) { sending = false; fail(e); sync(); }
335
+ });
336
+ el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
337
+ el.addEventListener('click', (e) => { if (e.target === el) done(); });
338
+ el.addEventListener('close', () => {
339
+ if (closed) return;
340
+ closed = true;
341
+ tickets?.destroy();
342
+ clearTimeout(lookupTimer);
343
+ controller.abort();
344
+ stopFitting();
345
+ el.remove();
346
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
347
+ onClose();
348
+ });
349
+ document.body.appendChild(el);
350
+ const stopFitting = fitSheet(el);
351
+ el.showModal();
352
+ el.querySelector('h2').focus({ preventScroll: true });
353
+ picker();
354
+ return () => { sending = false; el.close(); };
355
+ }
356
+ const confirm = (message, labels) => (labels?.signal?.aborted ? Promise.resolve(false)
357
+ : labels?.ok ? sheet(message, labels) : Promise.resolve(window.confirm(message)));
358
+ const notice = (message) => { window.alert(message); return Promise.resolve(); };
359
+ globalThis.BoardDialogs = { confirm, notice, fitSheet, repoSheet, ticketsControl };
360
+ })();
@@ -0,0 +1,151 @@
1
+ // What the hub page and settings modals share, each through the view's B
2
+ // (app.js BoardView.page), so unmounting the view stops it all: loading and refreshing the view's data, posting its
3
+ // forms for JSON, and this device's notifications row.
4
+ (function () {
5
+ const age = (ms) => { const m = Math.round(ms / 60000); return m < 1 ? 'now' : m < 60 ? `${m} min` : `${Math.round(m / 60)} h`; };
6
+
7
+ // Paints url()'s JSON with paint(data) now and every `every` ms while the view shows, as the header's time says; a
8
+ // refresh waits while busy() or the page is hidden, and the time then says how old the data is. The header's refresh
9
+ // button loads at once. paint(null) means the first load failed. → load()
10
+ function live(B, { url, paint, busy = () => false, every = 30000 }) {
11
+ const refresh = B.$('#refresh');
12
+ const status = B.$('#status');
13
+ let at = 0;
14
+ let loaded = false;
15
+ async function load() {
16
+ if (refresh) refresh.disabled = true;
17
+ refresh?.classList.add('spinning');
18
+ try {
19
+ const r = await fetch(url(), { cache: 'no-store', headers: { accept: 'application/json' } });
20
+ if (!r.ok) throw new Error(String(r.status));
21
+ const data = await r.json();
22
+ if (!B.alive()) return;
23
+ at = Date.now();
24
+ globalThis.AppShell?.codeSeen?.(r.headers?.get('x-hub-code'));
25
+ paint(data);
26
+ if (status) status.textContent = 'now';
27
+ } catch {
28
+ if (!B.alive()) return;
29
+ if (!loaded) paint(null);
30
+ if (status) status.innerHTML = BoardUI.statusLine(undefined, { link: 'offline' });
31
+ }
32
+ loaded = true;
33
+ if (refresh) refresh.disabled = false;
34
+ refresh?.classList.remove('spinning');
35
+ const gear = B.$('#settingsLink');
36
+ gear?.removeAttribute('aria-disabled');
37
+ gear?.removeAttribute('tabindex');
38
+ }
39
+ B.on(refresh, 'click', load);
40
+ const tick = () => {
41
+ if (document.hidden || busy()) { if (at && status) status.textContent = age(Date.now() - at); return; }
42
+ load();
43
+ };
44
+ B.every(every, tick);
45
+ B.on(document, 'visibilitychange', () => { if (!document.hidden && Date.now() - at >= every) tick(); });
46
+ load();
47
+ return load;
48
+ }
49
+
50
+ const FAILED = 'Could not save. Is the host awake?';
51
+ // Every form in the view that posts to /_hub/ posts for JSON in place of loading a page: answer(body, action) takes
52
+ // the hub's answer, fail(message) a refusal. A checkbox marked data-submit saves on change, and a time input marked
53
+ // data-autosave once its picker closes, so turning an iOS wheel doesn't save each value it passes. accepts(action, form)
54
+ // limits it to some of those forms, since the Hub page and its hub settings each wire their own over one root.
55
+ // → {busy()}, true while a post is out
56
+ function forms(B, { answer, fail, accepts = () => true }) {
57
+ let sending = false;
58
+ B.on(B.root, 'submit', async (e) => {
59
+ if (e.defaultPrevented) return;
60
+ const form = e.target;
61
+ const action = form.getAttribute?.('action') || '';
62
+ if (!action.startsWith('/_hub/') || !accepts(action, form)) return;
63
+ e.preventDefault();
64
+ if (sending) return;
65
+ const body = new URLSearchParams(new FormData(form));
66
+ if (e.submitter?.name) body.append(e.submitter.name, e.submitter.value);
67
+ sending = true;
68
+ try {
69
+ const r = await fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
70
+ const out = await r.json().catch(() => null);
71
+ if (!B.alive()) return;
72
+ if (r.ok && out) answer(out, action); else fail(out?.error || FAILED);
73
+ } catch {
74
+ if (B.alive()) fail(FAILED);
75
+ } finally { sending = false; }
76
+ });
77
+ const save = (el) => { if (el.value && el.value !== el.defaultValue && el.checkValidity()) el.form.requestSubmit(); };
78
+ B.on(B.root, 'change', (e) => {
79
+ const el = e.target;
80
+ if (el.form && !accepts(el.form.getAttribute('action') || '', el.form)) return;
81
+ if (el.matches?.('input[data-submit]')) el.form.requestSubmit();
82
+ else if (el.matches?.('input[data-autosave]') && document.activeElement !== el) save(el);
83
+ });
84
+ B.on(B.root, 'focusout', (e) => {
85
+ const el = e.target;
86
+ if (el.matches?.('input[data-autosave]') && accepts(el.form?.getAttribute('action') || '', el.form)) save(el);
87
+ });
88
+ return { busy: () => sending };
89
+ }
90
+
91
+ // This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, and Test sends a test push.
92
+ // iOS allows web push only in a Home Screen app, and asks for permission only from a tap, so requestPermission runs
93
+ // first in the tap. paint() redraws the row, never before device() returns; changed() follows a subscription this
94
+ // device just made.
95
+ // → {state() for HubUI.deviceRow with the hub's line for an `on` device, busy(), ready: a promise settled once the
96
+ // row first shows}
97
+ function device(B, { paint, changed = () => {} }) {
98
+ let state = null;
99
+ let on = false;
100
+ let busy = false;
101
+ const standalone = navigator.standalone || globalThis.matchMedia?.('(display-mode: standalone)').matches;
102
+ const ios = /iPhone|iPad|iPod/.test(navigator.userAgent || '');
103
+ const keyBytes = (k) => Uint8Array.from(atob(k.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
104
+ const post = (p, body) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json());
105
+ const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
106
+
107
+ async function render() {
108
+ if (!('serviceWorker' in navigator) || !('PushManager' in globalThis)) {
109
+ if (ios && !standalone) show({ title: 'Add to Home Screen', sub: 'Tap Share, then Add to Home Screen, and open Claude from your Home Screen to turn on notifications.' });
110
+ return;
111
+ }
112
+ if (Notification.permission === 'denied') { show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` }); return; }
113
+ const reg = await navigator.serviceWorker.getRegistration('/');
114
+ const sub = await reg?.pushManager.getSubscription();
115
+ if (sub) {
116
+ post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
117
+ show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
118
+ } else {
119
+ show({ title: 'This device', sub: 'Off · Claude sessions tell you here when something needs you.', action: { label: 'Turn on', act: 'on' } });
120
+ }
121
+ }
122
+ async function enable() {
123
+ busy = true;
124
+ show({ ...state, action: { ...state.action, disabled: true } }, on);
125
+ try {
126
+ if (await Notification.requestPermission() !== 'granted') { await render(); return; }
127
+ const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
128
+ await navigator.serviceWorker.ready;
129
+ const { key } = await (await fetch('/_hub/push/key')).json();
130
+ const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
131
+ await post('/_hub/push/subscribe', sub.toJSON());
132
+ await render();
133
+ changed();
134
+ } catch (err) { show({ title: 'Could not turn on notifications', sub: String(err?.message || err) }); }
135
+ finally { busy = false; }
136
+ }
137
+ async function test() {
138
+ show({ ...state, action: { ...state.action, disabled: true } }, true);
139
+ const r = await post('/_hub/push/test').catch(() => null);
140
+ show({ ...state, action: { ...state.action, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
141
+ }
142
+ B.on(B.root, 'click', (e) => {
143
+ const act = e.target.closest?.('[data-device]')?.dataset.device;
144
+ if (act === 'on') enable(); else if (act === 'test') test();
145
+ });
146
+ const ready = Promise.resolve().then(render).catch(() => {});
147
+ return { state: (line) => (state && on ? { ...state, sub: `On · ${line}` } : state), busy: () => busy, ready };
148
+ }
149
+
150
+ globalThis.HubKit = { live, forms, device };
151
+ })();