@lovek4d/kickflip 0.1.0 → 0.4.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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  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/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,63 +1,133 @@
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).
1
+ // Every confirmation and notice is the browser's own window.confirm or window.alert; no page imitates a native dialog.
2
+ // Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
3
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 } = {}) {
4
+ // linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
5
+ // while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
6
+ // inline (linear-key.js,
7
+ // docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
8
+ // new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
9
+ // {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
10
+ // GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
11
+ // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
12
+ // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
13
+ // as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
14
+ // form, since the key field is a form of its own; keyBusy(on) follows each save of the key
15
+ // (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
16
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
7
17
  const { esc } = BoardUI;
8
18
  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;
19
+ let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
20
+ const works = () => Boolean(linear?.set && !linear.refused);
21
+ const keyWorks = () => Boolean(key?.set && !key.refused);
22
+ const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
23
+ const statusWord = () => (draft.source === 'linear' && keyWorks() ? 'linear · connected' : `${draft.source} · ${keyWord()}`);
24
+ function status() {
25
+ if (!key) return '';
26
+ const on = draft.source === 'linear' && keyWorks();
27
+ return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
28
+ }
29
+ // Hub's key field in keySlot. Once a key works, Linear is picked.
30
+ function openInline() {
31
+ if (!inline) {
32
+ keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
33
+ inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
34
+ saved: (out) => { key = out.linear; render(); },
35
+ worked: async () => {
36
+ closeInline();
37
+ draft.source = 'linear';
38
+ await load();
39
+ if (destroyed) return;
40
+ pickDefaults();
41
+ render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
42
+ } });
43
+ keySlot.hidden = false;
44
+ }
45
+ keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
46
+ }
47
+ function closeInline() {
48
+ if (!inline) return;
49
+ inline.destroy();
50
+ inline = null;
51
+ keySlot.hidden = true;
52
+ keySlot.innerHTML = '';
53
+ }
54
+ async function load() {
14
55
  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;
56
+ render();
57
+ linear = await loadLinear().catch((e) => ({ set: true, error: e.message || 'Is the host awake?' }));
58
+ }
59
+ const workspace = () => linear?.url_key ?? linear?.workspace ?? draft.workspace;
21
60
  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();
61
+ const ready = () => works() && !linear.error;
24
62
  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() }
63
+ : draft.source === 'github' ? { source: 'github', ...(draft.account ? { account: draft.account } : {}) }
64
+ : !ready() ? { source: 'linear', workspace: draft.workspace, team: draft.team, project: draft.project }
27
65
  : draft.project === '__new' && isNew ? { source: 'linear', workspace: workspace(), team: draft.team, new_project: newName() }
28
66
  : { source: 'linear', workspace: workspace(), team: draft.team, project: draft.project };
29
67
  const valid = () => draft.source === 'internal' ? /^[A-Z]{2,6}$/.test(draft.prefix || 'INT')
30
68
  : 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())));
69
+ : Boolean(ready() && draft.team && draft.project && (draft.project !== '__new' || (isNew && newName())));
33
70
  const pickDefaults = () => {
34
- if (draft.source !== 'linear' || !linear.set) return;
71
+ if (draft.source !== 'linear' || !ready()) return;
35
72
  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;
73
+ if (draft.project === '__new' || linear.projects?.some((p) => p.id === draft.project && p.team === draft.team)) return;
74
+ draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
37
75
  };
76
+ // A board already on Linear whose Hub key is missing or refused keeps its source and shows why.
77
+ function needsKey() {
78
+ const why = linear.refused ? 'Linear refused Hub’s key' : 'Hub has no Linear key';
79
+ return `<p class="muted small" role="status">${why}, so this board can’t list Linear’s teams or create issues there.</p><p><button type="button" data-tk-key>Add Hub’s Linear key</button></p>`;
80
+ }
38
81
  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>`;
82
+ if (!linear || linear.loading) return '<p class="muted small" role="status">Loading Linear…</p>';
83
+ if (!works()) return needsKey();
41
84
  if (linear.error) return `<p class="muted small" role="alert">Couldn’t list Linear’s teams: ${esc(linear.error)}</p>`;
42
85
  const project = linear.projects?.find((p) => p.id === draft.project);
43
86
  const team = esc(draft.team || '');
44
87
  const note = draft.project === '__new'
45
88
  ? (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
89
  : `<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}`;
90
+ return `<div class="tk-grid"><label class="tk-field"><span>Team</span><select data-tk="team">${(linear.teams || []).map((t) => `<option value="${esc(t.key)}"${t.key === draft.team ? ' selected' : ''}>${esc(t.key)} · ${esc(t.name)}</option>`).join('')}</select></label><label class="tk-field"><span>Project</span><select data-tk="project">${(linear.projects || []).filter((p) => p.team === draft.team).map((p) => `<option value="${esc(p.id)}"${p.id === draft.project ? ' selected' : ''}>${esc(p.name)}</option>`).join('')}<option value="__new"${draft.project === '__new' ? ' selected' : ''}>New project…</option></select></label></div>${draft.project === '__new' ? `<div class="tk-new"><input data-tk="newName" value="${esc(draft.newName ?? title())}" aria-label="New Linear project name" placeholder="Project name" maxlength="80" autocomplete="off">${isNew ? '' : `<button type="button" data-tk-create${creating ? BoardUI.BUSY : ''}>Create</button>`}</div>` : ''}${createError ? `<p class="muted small" role="alert">${esc(createError)}</p>` : ''}${note}`;
48
91
  }
49
92
  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>`;
93
+ const off = !repo();
94
+ 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}"${source === 'github' && off ? ' aria-disabled="true"' : ''}>${name}</button>`).join('')}</div>`;
95
+ const why = `<p class="muted small" role="status">GitHub issues need a repo on GitHub, and ${isNew ? 'the repo chosen above' : 'this board’s repo'} isn’t on GitHub.</p>`;
51
96
  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>`;
97
+ if (draft.source === 'github' && off) detail = why;
98
+ else 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>`;
99
+ 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${draft.account ? ` as gh account ${esc(draft.account)}` : ''}; its badge reads #41.</span></span></div>`;
54
100
  else detail = linearDetail();
55
- root.innerHTML = `${segment}<div class="tk-detail">${detail}</div>`;
56
- if (draft.source === 'linear' && linear.set && !linear.error && !linear.loading) {
101
+ root.innerHTML = `${status()}${segment}<div class="tk-detail">${noGithub && off && draft.source !== 'github' ? why : ''}${detail}</div>`;
102
+ if (draft.source === 'linear' && ready()) {
57
103
  root.querySelector('[data-tk="team"]').value = draft.team || '';
58
104
  root.querySelector('[data-tk="project"]').value = draft.project || '';
59
105
  }
60
106
  }
107
+ // Linear is checked again on each tap until Hub's key works, so a key added elsewhere counts.
108
+ async function pickLinear() {
109
+ if (asking || linear?.loading) return;
110
+ const before = draft.source;
111
+ draft.source = 'linear';
112
+ if (!ready()) await load();
113
+ if (destroyed) return;
114
+ if (!works()) {
115
+ draft.source = before;
116
+ render();
117
+ await keyed;
118
+ if (destroyed) return;
119
+ if (key) { openInline(); return; }
120
+ asking = true;
121
+ const added = await LinearKey.open();
122
+ asking = false;
123
+ if (destroyed || !added) return;
124
+ draft.source = 'linear';
125
+ await load();
126
+ if (destroyed) return;
127
+ }
128
+ pickDefaults();
129
+ render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
130
+ }
61
131
  const click = async (e) => {
62
132
  if (e.target.closest('[data-tk-create]')) {
63
133
  const name = newName();
@@ -73,82 +143,38 @@
73
143
  render(); change(read(), valid());
74
144
  return;
75
145
  }
146
+ if (e.target.closest('[data-tk-key]')) { pickLinear(); return; }
76
147
  const button = e.target.closest('[data-tk-source]');
77
148
  if (!button) return;
149
+ noGithub = button.dataset.tkSource === 'github' && !repo();
150
+ if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
151
+ if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
152
+ closeInline();
78
153
  draft.source = button.dataset.tkSource;
79
- fetchLinear();
80
- pickDefaults();
81
154
  render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
82
155
  };
83
156
  const input = (e) => {
84
157
  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
- }
158
+ if (!field || (e.type === 'input' && e.target.tagName === 'SELECT')) return;
93
159
  draft[field] = field === 'prefix' ? e.target.value.toUpperCase() : e.target.value;
94
160
  if (field === 'prefix') e.target.value = draft.prefix;
95
161
  if (field === 'team') draft.project = isNew ? '__new' : linear.projects?.find((p) => p.team === draft.team)?.id;
96
162
  if (field === 'team' || field === 'project') { createError = ''; render(); }
97
163
  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());
164
+ change(read(), valid(), { typing: e.type === 'input' });
99
165
  };
100
166
  root.addEventListener('click', click);
101
167
  root.addEventListener('input', input);
102
168
  root.addEventListener('change', input);
103
- fetchLinear();
104
169
  render();
170
+ if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
171
+ // Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
172
+ const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
105
173
  return { read, valid, render, nameChanged() {
106
174
  const field = root.querySelector('[data-tk="newName"]');
107
175
  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
176
  },
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
- });
177
+ destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
152
178
  }
153
179
  function fitSheet(sheet) {
154
180
  const vv = window.visualViewport;
@@ -194,9 +220,9 @@
194
220
  el.setAttribute('class', 'sheet');
195
221
  el.setAttribute('aria-labelledby', 'newBoardTitle');
196
222
  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' } };
223
+ const draft = { name: '', purpose: '', github: '', remote: '', branch: '', tickets: { source: 'internal', prefix: 'INT' } };
198
224
  let tickets;
199
- let repos = [], pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
225
+ let repos = [], signedIn = false, pasted, selected, edited = false, sending = false, closed = false, generation = 0, lookupTimer, linear;
200
226
  const controller = new AbortController();
201
227
  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
228
  const content = el.querySelector('[data-repo-content]');
@@ -211,10 +237,19 @@
211
237
  return d;
212
238
  };
213
239
  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>`;
240
+ // A repo already on the host joins from its folder instead (docs/adr/2026-11-03-0248-kickflip-join-adds-the-current-repository-as-a-board.md).
241
+ const joinHint = current ? '' : '<p class="nb-help" data-join-hint>A repo already on the host running Kickflip? Run <code>kickflip join</code> in its folder.</p>';
242
+ function sync() { start.disabled = sending || !(draft.github || draft.remote) || !draft.branch || (!current && (!draft.name.trim() || !tickets?.valid())); }
243
+ // A repo row's id is its GitHub owner/name, or the git link of a repo on another host, which the row names by
244
+ // host/path and labels with its host where a GitHub row says Private or Public
245
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
246
+ const idOf = (r) => r.github || r.remote;
247
+ const nameOf = (r) => r.github || r.name;
248
+ const access = (r) => (r.remote ? r.host : r.private ? 'Private' : 'Public');
249
+ const row = (r, highlighted = false) => `<button type="button" class="nb-repo${highlighted ? ' nb-repo-highlight' : ''}" data-repo="${esc(idOf(r))}"><strong>${esc(nameOf(r))}</strong>${r.listed === false ? '<span class="nb-repo-tag">Not in your list</span>' : ''}<span class="nb-help">${esc(access(r))} · <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 ? `✓ Cloned${r.shared.length ? ` · shared with ${esc(r.shared.map((b) => b.title).join(', '))}` : ' · already cloned'}` : 'Not cloned yet'}</span></button>`;
216
250
  const results = (html) => { el.querySelector('[data-repo-results]').innerHTML = html; };
217
251
  function rows(query = '') {
252
+ if (!signedIn) { results(''); return; }
218
253
  const list = repos.filter((r) => `${r.github} ${r.description}`.toLowerCase().includes(query.trim().toLowerCase()));
219
254
  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
255
  }
@@ -225,8 +260,20 @@
225
260
  const q = value.trim();
226
261
  const host = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?|[^@/\s]+@)([^/:?#\s]+)/i.exec(q);
227
262
  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>`);
263
+ const checking = (name) => 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(name)}</strong><span class="nb-help">Checking…</span></span></div>`);
264
+ const check = (query, name) => async () => {
265
+ try {
266
+ const repo = await request(`/_hub/repo?${query}`);
267
+ if (closed || generation !== version) return;
268
+ pasted = repo;
269
+ results(`<p class="nb-help" role="status">From your link</p><div class="nb-repo-list">${row(repo)}</div>`);
270
+ } catch (e) { if (!closed && generation === version) reason(name, e.message); }
271
+ };
272
+ if (/^(?:file:|[/~]|\.\.?\/)/i.test(q)) { reason(q, 'Paste the repo’s remote link. Local folders don’t work here.'); return; }
228
273
  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.');
274
+ const name = q.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').replace(/^[^@/\s]+@/, '').replace(/:(?!\d+\/)/, '/').replace(/:\d+\//, '/').replace(/\.git\/?$/i, '');
275
+ checking(name);
276
+ check(`remote=${encodeURIComponent(q)}`, name)();
230
277
  return;
231
278
  }
232
279
  const link = /^(?:https?:\/\/(?:www\.)?github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s?#]+\/[^/\s?#]+)(?:\/[^\s]*)?(?:[?#][^\s]*)?$/i.exec(q);
@@ -237,17 +284,9 @@
237
284
  const github = link ? link[1].replace(/\.git$/i, '') : q;
238
285
  const listed = repos.find((r) => r.github.toLowerCase() === github.toLowerCase());
239
286
  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();
287
+ checking(github);
288
+ if (bare) lookupTimer = setTimeout(check(`github=${encodeURIComponent(github)}`, github), 500);
289
+ else check(`github=${encodeURIComponent(github)}`, github)();
251
290
  }
252
291
  async function picker(fresh = false) {
253
292
  tickets?.destroy();
@@ -261,42 +300,45 @@
261
300
  try {
262
301
  const d = await request(`/_hub/repos${fresh ? '?fresh=1' : ''}`);
263
302
  if (closed || generation !== version) return;
303
+ signedIn = d.signedIn;
264
304
  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>';
305
+ repos = [];
306
+ 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><label class="nb-field"><span>Or paste a git link</span><input name="search" type="search" placeholder="https://host/path or user@host:path" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
266
307
  return;
267
308
  }
268
309
  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>';
310
+ content.innerHTML = `<p class="nb-next">1 · Choose a repo</p><p>Choose the repo this board will work on: one of your GitHub repos, or any git repo by its link.</p><label class="nb-field"><span>Search repos or paste a git link</span><input name="search" type="search" placeholder="Search or paste owner/name or a git link…" autocomplete="off" autocapitalize="off" spellcheck="false"></label>${joinHint}<div data-repo-results></div>`;
270
311
  rows();
271
312
  } catch (e) { fail(e); content.innerHTML = '<button type="button" data-check-auth>Try again</button>'; }
272
313
  }
273
- async function pick(github) {
274
- selected = repos.find((r) => r.github === github) || (pasted?.github === github ? pasted : null);
314
+ async function pick(id) {
315
+ selected = repos.find((r) => r.github === id) || (pasted && idOf(pasted) === id ? pasted : null);
275
316
  if (!selected) return;
276
317
  const version = ++generation;
277
318
  clearTimeout(lookupTimer);
278
- draft.github = github;
319
+ draft.github = selected.github || '';
320
+ draft.remote = selected.remote || '';
279
321
  draft.branch = '';
280
- if (!edited) draft.name = github.split('/')[1];
322
+ if (!edited) draft.name = nameOf(selected).split('/').at(-1);
281
323
  error.hidden = true;
282
324
  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>`}`;
325
+ 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(nameOf(selected))}</strong><span class="nb-help">${esc(access(selected))}</span><p>${selected.cloned ? shared ? `Shared with ${esc(shared)}` : 'Already cloned' : '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 implementers work in separate worktrees off this clone. Sharing the clone doesn’t share their working files.</p>${current ? '<p class="nb-next">New implementers 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
326
  if (!current) {
285
327
  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; } }),
328
+ tickets = ticketsControl(content.querySelector('[data-board-tickets]'), { value: draft.tickets, linear, loadLinear: async () => (linear = await request('/_hub/api/linear')),
287
329
  repo: () => draft.github, title: () => draft.name.trim(), isNew: true, change(value) { draft.tickets = value; sync(); } });
288
330
  }
289
331
  start.hidden = false;
290
332
  sync();
291
333
  try {
292
- const d = await request(`/_hub/branches?github=${encodeURIComponent(github)}`);
334
+ const d = await request(`/_hub/branches?${draft.remote ? 'remote' : 'github'}=${encodeURIComponent(id)}`);
293
335
  if (closed || generation !== version) return;
294
- draft.branch = current?.github === github && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
336
+ draft.branch = (current?.github || current?.remote) === id && d.branches.includes(current.branch) ? current.branch : d.defaultBranch;
295
337
  if (!d.branches.includes(draft.branch)) draft.branch = d.branches[0] || '';
296
338
  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
339
  field('branch').value = draft.branch;
298
340
  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.';
341
+ el.querySelector('[data-branch-help]').textContent = draft.branch ? `New implementers branch from ${draft.branch}.` : 'This repo has no branches yet.';
300
342
  sync();
301
343
  } catch (e) { fail(e); }
302
344
  }
@@ -317,7 +359,7 @@
317
359
  form.addEventListener('change', (e) => {
318
360
  if (e.target.getAttribute('name') === 'branch') {
319
361
  draft.branch = e.target.value;
320
- el.querySelector('[data-branch-help]').textContent = `New builders branch from ${draft.branch}.`;
362
+ el.querySelector('[data-branch-help]').textContent = `New implementers branch from ${draft.branch}.`;
321
363
  sync();
322
364
  }
323
365
  });
@@ -328,7 +370,9 @@
328
370
  sync();
329
371
  error.hidden = true;
330
372
  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);
373
+ const repo = draft.remote ? { remote: draft.remote } : { github: draft.github };
374
+ // Start, or Use repo, waits (ui.js waitOn) until the hub answers; a new board's own start then shows in the sidebar.
375
+ await BoardUI.waitOn(start, save(current ? { ...repo, branch: draft.branch } : { name: draft.name.trim(), purpose: draft.purpose.trim(), ...repo, branch: draft.branch, tickets: tickets.read() }, selected));
332
376
  sending = false;
333
377
  el.close();
334
378
  } catch (e) { sending = false; fail(e); sync(); }
@@ -353,8 +397,7 @@
353
397
  picker();
354
398
  return () => { sending = false; el.close(); };
355
399
  }
356
- const confirm = (message, labels) => (labels?.signal?.aborted ? Promise.resolve(false)
357
- : labels?.ok ? sheet(message, labels) : Promise.resolve(window.confirm(message)));
400
+ const confirm = (message, { signal } = {}) => Promise.resolve(!signal?.aborted && window.confirm(message));
358
401
  const notice = (message) => { window.alert(message); return Promise.resolve(); };
359
402
  globalThis.BoardDialogs = { confirm, notice, fitSheet, repoSheet, ticketsControl };
360
403
  })();
@@ -52,6 +52,8 @@
52
52
  // the hub's answer, fail(message) a refusal. A checkbox marked data-submit saves on change, and a time input marked
53
53
  // data-autosave once its picker closes, so turning an iOS wheel doesn't save each value it passes. accepts(action, form)
54
54
  // limits it to some of those forms, since the Hub page and its hub settings each wire their own over one root.
55
+ // While a post is out its control waits (ui.js waitOn): the tapped button, or else the form's toggle or field, with
56
+ // the form's other buttons, the setting's other choices, disabled.
55
57
  // → {busy()}, true while a post is out
56
58
  function forms(B, { answer, fail, accepts = () => true }) {
57
59
  let sending = false;
@@ -65,9 +67,10 @@
65
67
  const body = new URLSearchParams(new FormData(form));
66
68
  if (e.submitter?.name) body.append(e.submitter.name, e.submitter.value);
67
69
  sending = true;
70
+ const post = fetch(action, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body })
71
+ .then(async (r) => [r, await r.json().catch(() => null)]);
68
72
  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);
73
+ const [r, out] = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"]), select'), post, { group: form.querySelectorAll('button') });
71
74
  if (!B.alive()) return;
72
75
  if (r.ok && out) answer(out, action); else fail(out?.error || FAILED);
73
76
  } catch {
@@ -88,7 +91,8 @@
88
91
  return { busy: () => sending };
89
92
  }
90
93
 
91
- // This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, and Test sends a test push.
94
+ // This device's push subscription, as HubUI.deviceRow shows it: Turn on subscribes, Test sends a test push, and each
95
+ // showing of the row tells the hub of a subscription this device no longer has.
92
96
  // iOS allows web push only in a Home Screen app, and asks for permission only from a tap, so requestPermission runs
93
97
  // first in the tap. paint() redraws the row, never before device() returns; changed() follows a subscription this
94
98
  // device just made.
@@ -103,15 +107,31 @@
103
107
  const keyBytes = (k) => Uint8Array.from(atob(k.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
104
108
  const post = (p, body) => fetch(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json());
105
109
  const show = (next, isOn = false) => { if (!B.alive()) return; state = next; on = isOn; paint(); };
110
+ // The endpoint this device last gave the hub, kept in this browser, so that once the subscription goes (the
111
+ // browser dropped or replaced it, or notifications were blocked) the page tells the hub to forget it
112
+ // (docs/adr/2026-11-03-0501-the-hub-reads-healthy-degraded-or-a-site-down.md).
113
+ const KEPT = 'kickflip.push.endpoint';
114
+ const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
115
+ const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
116
+ const settle = (endpoint = null) => {
117
+ const old = kept();
118
+ if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
119
+ keep(endpoint);
120
+ };
106
121
 
107
122
  async function render() {
108
123
  if (!('serviceWorker' in navigator) || !('PushManager' in globalThis)) {
109
124
  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
125
  return;
111
126
  }
112
- if (Notification.permission === 'denied') { show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` }); return; }
127
+ if (Notification.permission === 'denied') {
128
+ settle();
129
+ show({ title: 'Notifications are blocked', sub: `Allow them in ${ios ? 'Settings → Notifications → Claude.' : "this browser's site settings."}` });
130
+ return;
131
+ }
113
132
  const reg = await navigator.serviceWorker.getRegistration('/');
114
133
  const sub = await reg?.pushManager.getSubscription();
134
+ settle(sub?.toJSON().endpoint);
115
135
  if (sub) {
116
136
  post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
117
137
  show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
@@ -121,7 +141,7 @@
121
141
  }
122
142
  async function enable() {
123
143
  busy = true;
124
- show({ ...state, action: { ...state.action, disabled: true } }, on);
144
+ show({ ...state, action: { ...state.action, busy: true } }, on);
125
145
  try {
126
146
  if (await Notification.requestPermission() !== 'granted') { await render(); return; }
127
147
  const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
@@ -135,9 +155,9 @@
135
155
  finally { busy = false; }
136
156
  }
137
157
  async function test() {
138
- show({ ...state, action: { ...state.action, disabled: true } }, true);
158
+ show({ ...state, action: { ...state.action, busy: true } }, true);
139
159
  const r = await post('/_hub/push/test').catch(() => null);
140
- show({ ...state, action: { ...state.action, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
160
+ show({ ...state, action: { ...state.action, busy: false, label: r?.sent ? 'Sent' : 'Could not send' } }, true);
141
161
  }
142
162
  B.on(B.root, 'click', (e) => {
143
163
  const act = e.target.closest?.('[data-device]')?.dataset.device;