@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,247 @@
1
+ // Renderers the board's pages share, and the base the other ui-*.js files add to BoardUI. Each returns an HTML
2
+ // string and touches no DOM or storage: ui.test.mjs runs these files in node:vm, where neither exists. Load this first.
3
+ // fetchJSON is the side pages' one way to read the board's API.
4
+ (function () {
5
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
6
+ const record = (records, key) => (Object.hasOwn(records || {}, key) ? records[key] : undefined);
7
+ const time = (iso) => new Date(iso).toLocaleString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' });
8
+ const ago = (iso) => {
9
+ if (!iso) return '';
10
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
11
+ if (s < 60) return 'just now';
12
+ if (s < 3600) return Math.round(s / 60) + 'm ago';
13
+ if (s < 86400) return Math.round(s / 3600) + 'h ago';
14
+ return Math.round(s / 86400) + 'd ago';
15
+ };
16
+ const since = (iso) => {
17
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
18
+ return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
19
+ };
20
+ const clock = (d) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP]M)$/i, (_, m) => ` ${m.toLowerCase()}`);
21
+ // "ETA about 4:30 pm", with the day when it isn't today.
22
+ function etaLine(iso, now = new Date()) {
23
+ const d = new Date(iso);
24
+ const day = d.toDateString() === now.toDateString() ? '' : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })}, `;
25
+ return `ETA about ${day}${clock(d)}`;
26
+ }
27
+ // A card's last line: the time only today, with the day before today, and with the year too past 365 days.
28
+ function cardTime(iso, now = new Date()) {
29
+ const d = new Date(iso);
30
+ const at = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
31
+ if (d.toDateString() === now.toDateString()) return at;
32
+ const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
33
+ return `${day}, ${at}`;
34
+ }
35
+ const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
36
+
37
+ // Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
38
+ const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
39
+ const isImage = (f) => /^image\//.test(f.type || '');
40
+ function attachments(files) {
41
+ if (!files?.length) return '';
42
+ return `<div class="files">${files.map((f) => {
43
+ const url = uploadUrl(f);
44
+ if (!url) return `<span class="file-link">${esc(f.name)}</span>`;
45
+ return isImage(f)
46
+ ? `<a class="file-thumb" href="${esc(url)}" target="_blank" rel="noopener" title="${esc(f.name)}"><img src="${esc(url)}" alt="${esc(f.name)}" loading="lazy"></a>`
47
+ : `<a class="file-link" href="${esc(url)}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
48
+ }).join('')}</div>`;
49
+ }
50
+ const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
51
+ const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
52
+ const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
53
+ // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
54
+ const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
55
+ const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
56
+ const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
57
+ const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
58
+ // Asks Claude for throwaway prototypes of one item (docs/adr/0015-prototypes-come-on-request.md).
59
+ function protoButton(id, state = null, { label = 'Ask for prototypes', disabled = false } = {}) {
60
+ if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made"${disabled ? ' disabled' : ''}>${ELLIPSIS}</button>`;
61
+ return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}"${disabled ? ' disabled' : ''}>${EYE}</button>`;
62
+ }
63
+ // latestBump is the data.id of the newest ask-bump event.
64
+ const protoNotice = (eventId, latestBump) => (latestBump === eventId
65
+ ? { text: 'Prototypes are being made.', bump: false }
66
+ : { text: 'Prototypes are being made. Bump them to the top of the queue?', bump: true });
67
+ const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
68
+ const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
69
+ // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
70
+ const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
71
+ const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
72
+ // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
73
+ // bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
74
+ // it is filled orange, card-unbump {key} (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md,
75
+ // docs/adr/0145-build-on-a-blocked-card-queues-it.md).
76
+ const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
77
+ const cardBumpButton = (key, bumped) => (bumped
78
+ ? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
79
+ : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
80
+ const workingLine = '<span class="working-dot" aria-hidden="true"></span>Working on it';
81
+ // html is markup the caller has escaped; href, title and attrs values are escaped here.
82
+ const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
83
+ function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
84
+ if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
85
+ const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
86
+ const tag = button ? 'button' : href == null ? 'span' : 'a';
87
+ return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
88
+ }
89
+ // A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
90
+ const pendingFiles = (list) => list.map((p) => `<span class="pfile ${esc(p.status)}" title="${esc(p.name)}">
91
+ ${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
92
+ ${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
93
+ ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
94
+ <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`).join('');
95
+
96
+ // Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
97
+ // being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
98
+ // node stays when the new markup has one of its tag at its place, and an element with a key (keyOf) stays wherever
99
+ // under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
100
+ // element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
101
+ // its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
102
+ // matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
103
+ // parent rebuild reopens too.
104
+ const lastHtml = new WeakMap();
105
+ const openDetails = new Set();
106
+ const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
107
+ globalThis.document?.addEventListener('toggle', (ev) => {
108
+ if (ev.target.tagName !== 'DETAILS') return;
109
+ if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
110
+ }, true);
111
+ const KEYS = ['id', 'data-thread', 'data-event', 'data-edit-form', 'data-lane', 'data-fold'];
112
+ function keyOf(n) {
113
+ if (n.nodeType !== 1) return null;
114
+ if (n.tagName === 'DETAILS' && n.hasAttribute('data-key')) return `DETAILS|${n.getAttribute('data-key')}`;
115
+ const a = KEYS.find((k) => n.hasAttribute(k));
116
+ return a ? `${n.tagName}|${a}=${n.getAttribute(a)}` : null;
117
+ }
118
+ function keyedUnder(el, keep) {
119
+ const keyed = new Map();
120
+ const walk = (p) => {
121
+ for (const c of p.children) {
122
+ const k = keyOf(c);
123
+ if (k && !keyed.has(k)) keyed.set(k, c);
124
+ if (!(keep && c.matches(keep))) walk(c);
125
+ }
126
+ };
127
+ walk(el);
128
+ return keyed;
129
+ }
130
+ const holdsFocus = (n) => n.contains(document.activeElement);
131
+ // insertBefore blurs the node it moves, and on iOS closes the keyboard; moveBefore keeps its focus, but moves only
132
+ // between connected nodes.
133
+ function move(parent, n, ref) {
134
+ if (parent.moveBefore && n.isConnected && parent.isConnected) parent.moveBefore(n, ref);
135
+ else parent.insertBefore(n, ref);
136
+ }
137
+ // Without moveBefore, the focused field's ancestors under el never move: each is patched into the node at the same
138
+ // depth on the path to the field's nearest keyed ancestor in the new markup, so a card that changes lanes takes over
139
+ // the old lane's node. → Map from each new markup node on that path to the old node it patches; empty when the
140
+ // paths' depths or tags differ, and app-core.js keepFocus refocuses the field after the move.
141
+ function focusSafe(el, next, keyed) {
142
+ const pins = new Map();
143
+ const a = document.activeElement;
144
+ if (el.moveBefore || !a || a === el || !el.contains(a)) return pins;
145
+ const olds = [];
146
+ for (let n = a; n !== el; n = n.parentNode) olds.unshift(n);
147
+ for (let i = olds.length - 1; i >= 0; i--) {
148
+ const k = keyOf(olds[i]);
149
+ const found = k && [...next.querySelectorAll('*')].find((d) => keyOf(d) === k);
150
+ if (!found) continue;
151
+ const news = [];
152
+ for (let n = found; n !== next; n = n.parentNode) news.unshift(n);
153
+ if (news.length !== i + 1 || news.some((n, j) => n.tagName !== olds[j].tagName)) return pins;
154
+ news.forEach((n, j) => { pins.set(n, olds[j]); keyed.delete(keyOf(olds[j])); });
155
+ return pins;
156
+ }
157
+ return pins;
158
+ }
159
+ // ctx: {keyed, keep, pins, pinned}, pinned being the old nodes in pins, which only their own new node takes.
160
+ function patchNode(old, next, ctx) {
161
+ if (old.nodeType !== 1) { if (old.nodeValue !== next.nodeValue) old.nodeValue = next.nodeValue; return; }
162
+ if (ctx.keep && old.matches(ctx.keep)) return;
163
+ for (const { name } of [...old.attributes]) if (!next.hasAttribute(name)) old.removeAttribute(name);
164
+ for (const { name, value } of [...next.attributes]) if (old.getAttribute(name) !== value) old.setAttribute(name, value);
165
+ if (old.tagName !== 'TEXTAREA') patchChildren(old, next, ctx);
166
+ }
167
+ function patchChildren(parent, next, ctx) {
168
+ const { keyed, pins, pinned } = ctx;
169
+ let cur = parent.firstChild;
170
+ for (const n of [...next.childNodes]) {
171
+ const k = keyOf(n);
172
+ let match = pins.get(n) || null;
173
+ if (!match && k) {
174
+ match = keyed.get(k) || null;
175
+ if (match?.contains(parent)) match = null;
176
+ if (match) keyed.delete(k);
177
+ } else if (!match && cur && !keyOf(cur) && !pinned.has(cur) && cur.nodeName === n.nodeName) match = cur;
178
+ if (!match) {
179
+ parent.insertBefore(n, cur);
180
+ if (n.nodeType === 1) {
181
+ for (const d of n.querySelectorAll('*')) {
182
+ const dk = n.contains(d) && keyOf(d);
183
+ const old = dk && keyed.get(dk);
184
+ if (!old || old.contains(n)) continue;
185
+ keyed.delete(dk);
186
+ move(d.parentNode, old, d);
187
+ d.remove();
188
+ patchNode(old, d, ctx);
189
+ }
190
+ }
191
+ continue;
192
+ }
193
+ if (match === cur) cur = cur.nextSibling;
194
+ else if (match.parentNode === parent && holdsFocus(match)) {
195
+ // Moving the node that holds focus would blur it, so the siblings before it leave instead.
196
+ while (cur !== match) { const after = cur.nextSibling; cur.remove(); cur = after; }
197
+ cur = match.nextSibling;
198
+ } else move(parent, match, cur);
199
+ patchNode(match, n, ctx);
200
+ }
201
+ while (cur) { const after = cur.nextSibling; cur.remove(); cur = after; }
202
+ }
203
+ function setHTML(el, html, { keep = null } = {}) {
204
+ if (lastHtml.get(el) === html) return false;
205
+ if (!el.firstChild) el.innerHTML = html;
206
+ else {
207
+ const t = document.createElement('template');
208
+ t.innerHTML = html;
209
+ const keyed = keyedUnder(el, keep);
210
+ const pins = focusSafe(el, t.content, keyed);
211
+ patchChildren(el, t.content, { keyed, keep, pins, pinned: new Set(pins.values()) });
212
+ }
213
+ lastHtml.set(el, html);
214
+ if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
215
+ return true;
216
+ }
217
+
218
+ // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
219
+ // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
220
+ // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
221
+ const RESTARTING = 'Board restarting…';
222
+ const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
223
+ async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
224
+ let waited = 0;
225
+ let delay = 500;
226
+ for (;;) {
227
+ let failure;
228
+ try {
229
+ const r = await get(url, { cache: 'no-store' });
230
+ const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
231
+ if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
232
+ failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
233
+ } catch { failure = new Error('Offline. Is the host awake?'); }
234
+ if (waited >= limit) throw failure;
235
+ onWait(waited);
236
+ await sleep(delay);
237
+ waited += delay;
238
+ delay = Math.min(delay * 2, 4000);
239
+ }
240
+ }
241
+
242
+ globalThis.BoardUI = {
243
+ LINK_ARROW,
244
+ esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
245
+ attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
246
+ };
247
+ })();
@@ -0,0 +1,93 @@
1
+ // Any other page on the hub as a view of the app shell (shell.js): another site, a prototype, or a board page that isn't
2
+ // a view of its own, in a same-origin frame beside the board header (docs/adr/0055-one-app-for-the-whole-hub.md). The
3
+ // header's title follows the page's, and Open alone ↗ loads the page by itself (?alone=1, hub/shell.mjs pageOf). A
4
+ // page that draws the board header itself, such as a prototype or another site's page, hides this one; its logo then
5
+ // toggles the sidebar as this one's would, and hides while the rail shows. The page's own navigations stay in the frame
6
+ // and in the browser's history; each load hands its URL and title to the shell. A tap in the frame on a link to any
7
+ // other view, the page's own back arrow included, goes to the shell.
8
+ // ctx, from shell.js: {view, siteTitle, boards() → board routes, open(href) shows a URL in the shell or loads it,
9
+ // toggleSidebar(), keydown(e) and pointerdown(e) for switch mode, loaded(url, title) after each load, up() → true when
10
+ // the shown page is in the page tree and the router took it up a level (router.js)}.
11
+ (function () {
12
+ // sidebar.js's rail.
13
+ const RAIL = '(min-width: 900px)';
14
+ const LOGO_CSS = '<style id="hub-frame-logo">html.hub-rail body > header.top a.hub { display: none; }</style>';
15
+ const alone = (url) => {
16
+ const u = new URL(url, location.href);
17
+ u.searchParams.set('alone', '1');
18
+ return u.pathname + u.search + u.hash;
19
+ };
20
+
21
+ (globalThis.AppViews ||= {}).frame = {
22
+ className: 'view-frame',
23
+ title: (view, { siteTitle }) => siteTitle,
24
+ markup: (view, { siteTitle }) => {
25
+ const { esc, header, LINK_ARROW } = BoardUI;
26
+ const open = `<nav class="nav"><a class="alone" href="${esc(alone(view.url))}" target="_blank" rel="noopener">Open alone ${LINK_ARROW}</a></nav>`;
27
+ return `${header({ lead: 'logo', title: siteTitle, logoSrc: '/_hub/kickflip.png', controls: false, trail: open })}
28
+ <iframe class="frame-page" src="${esc(view.url)}" title="${esc(siteTitle)}"></iframe>`;
29
+ },
30
+
31
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
32
+ const frame = B.$('iframe.frame-page');
33
+ const heading = B.$(':scope > .top h1');
34
+ const openAlone = B.$(':scope > .top a.alone');
35
+ const rail = matchMedia(RAIL);
36
+ let shown = null;
37
+ const syncLogo = () => shown?.documentElement.classList.toggle('hub-rail', rail.matches && !document.documentElement.classList.contains('sb-collapsed'));
38
+ B.on(rail, 'change', syncLogo);
39
+ const collapse = new MutationObserver(syncLogo);
40
+ collapse.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
41
+ B.atUnmount(() => collapse.disconnect());
42
+
43
+ function tap(e, win) {
44
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
45
+ if (e.target.closest?.('body > header.top a.hub')) { e.preventDefault(); ctx.toggleSidebar(); return; }
46
+ const a = e.target.closest?.('a[href]');
47
+ if (!a || a.hasAttribute('download') || !['', '_self', '_top', '_parent'].includes(a.target)) return;
48
+ const view = AppRouter.viewOf(a.href, { boards: ctx.boards() });
49
+ if (!view) return;
50
+ const to = new URL(a.href);
51
+ if (to.pathname === win.location.pathname && to.search === win.location.search && to.hash) return;
52
+ // The page's own back arrow (ui-page-header.js) already went back when a page before it is in this frame.
53
+ const back = a.classList.contains('back') && Boolean(a.closest('header.top'));
54
+ if (!back && view.kind === 'frame' && (a.target || '_self') === '_self') return;
55
+ e.preventDefault();
56
+ if (back && BoardUI.backStep({ pushed: history.state?.pushed === true, href: location.href, idx: history.state?.idx }) === 'back') history.back();
57
+ else ctx.open(a.href);
58
+ }
59
+ // A prototype's back arrow goes up to Waiting on you whatever its own backHref says, so the shell takes it before
60
+ // the page's own header script (ui-page-header.js wireBack) can.
61
+ function upTap(e) {
62
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || !e.target.closest?.('header.top a.back') || !ctx.up?.()) return;
63
+ e.preventDefault();
64
+ e.stopPropagation();
65
+ }
66
+
67
+ B.on(frame, 'load', () => {
68
+ let win = null;
69
+ let doc = null;
70
+ try {
71
+ win = frame.contentWindow;
72
+ doc = win.document;
73
+ if (win.location.href === 'about:blank') return;
74
+ } catch { return; /* another origin: the page is its own */ }
75
+ const title = doc.title || ctx.siteTitle;
76
+ heading.textContent = title;
77
+ frame.title = title;
78
+ const url = win.location.pathname + win.location.search + win.location.hash;
79
+ openAlone.href = alone(url);
80
+ root.classList.toggle('own-header', Boolean(doc.querySelector('body > header.top')));
81
+ if (!doc.getElementById('hub-frame-logo')) doc.head?.insertAdjacentHTML('beforeend', LOGO_CSS);
82
+ shown = doc;
83
+ syncLogo();
84
+ B.on(win, 'click', upTap, true);
85
+ B.on(doc, 'click', (e) => tap(e, win));
86
+ // Switch mode (sidebar.js) answers keys and clicks in the frame as it does the shell's own.
87
+ if (ctx.keydown) B.on(doc, 'keydown', ctx.keydown, true);
88
+ if (ctx.pointerdown) B.on(doc, 'pointerdown', ctx.pointerdown, true);
89
+ ctx.loaded(url, title);
90
+ });
91
+ }),
92
+ };
93
+ })();
@@ -0,0 +1,282 @@
1
+ // HubSettings: summary rows below Hub’s health and status open centred settings modals (ADR 0075), using
2
+ // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, and
3
+ // Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
4
+ // a key's save to land. The rules editor saves through POST /_hub/settings/rules
5
+ // and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
6
+ (function () {
7
+ // The last data, which a return to the view paints at once.
8
+ let last = null;
9
+ const { esc } = HubUI;
10
+ const ACTION = '/_hub/settings/notifications';
11
+
12
+ // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the hub.
13
+ const localInput = (d) => {
14
+ const pad = (n) => String(n).padStart(2, '0');
15
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
16
+ };
17
+ function customBounds(input, reset = false) {
18
+ const now = Date.now();
19
+ input.min = localInput(new Date(now + 60_000));
20
+ input.max = localInput(new Date(now + 30 * 86_400_000));
21
+ if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
22
+ }
23
+
24
+ // The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
25
+ // then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
26
+ // and a button that posts `name`=off.
27
+ function durationPicker({ id, title, icon, action, name, choices, on, button }) {
28
+ if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
29
+ if (!choices) return '';
30
+ const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
31
+ return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
32
+ }
33
+
34
+ const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
35
+ choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
36
+ // Grey while off and orange while on, as the machine working (docs/adr/0096-colour-says-whos-waiting.md).
37
+ const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
38
+ action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
39
+
40
+ // The on/off, snooze and weekend rows (docs/adr/0032-notifications-can-be-off-snoozed-or-off-on-weekends.md).
41
+ function notificationRows(d) {
42
+ const { tile } = HubUI;
43
+ const toggle = ({ icon, set, title, checked, attrs = '' }) => `<form class="row field" method="post" action="${ACTION}"><input type="hidden" name="set" value="${set}">${icon}<label class="body"><span class="text"><span class="title">${title}</span></span><input type="checkbox" name="value" value="on" class="toggle"${attrs}${checked ? ' checked' : ''}></label></form>`;
44
+ const onRow = toggle({ icon: tile('power', d.on.on ? '#22c55e' : '#6b7280'), set: 'on', title: 'Push notifications', checked: d.on.on,
45
+ attrs: ` id="notifyOn" data-confirm="${esc(d.on.confirm)}"` });
46
+ const weekendRow = toggle({ icon: tile('calendar', 'var(--color-quiet)'), set: 'weekends', title: 'Off on weekends', checked: d.weekends.on, attrs: ' data-submit' });
47
+ return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
48
+ }
49
+
50
+ const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
51
+ const sectionOf = (hash) => {
52
+ const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
53
+ return sections.some(([section]) => section === id) ? id : null;
54
+ };
55
+ const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
56
+ <dialog class="settings-modal hub-modal" id="hubSettingsModal" aria-labelledby="hubSettingsTitle"><div class="settings-head"><div><h2 id="hubSettingsTitle" tabindex="-1"></h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close hub settings">✕</button></div><div class="set settings-body"><div class="hs-flash"></div><div class="setting-content"></div></div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`;
57
+
58
+ globalThis.HubSettings = {
59
+ markup,
60
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
61
+ const top = B.$('.hs-flash');
62
+ const modal = B.$('#hubSettingsModal');
63
+ const content = B.$('.setting-content');
64
+ let active = null;
65
+ let settingsURL = '/_hub/';
66
+ let returnFocus = null;
67
+ let unfitModal = null;
68
+ let polling = null;
69
+ let flash = null;
70
+ let rules = null;
71
+ let rulesJSON = '';
72
+ let linearKey = null;
73
+
74
+ function paint() {
75
+ if (!last) return;
76
+ const { row, tile, deviceRow } = HubUI;
77
+ top.innerHTML = HubUI.flash(flash);
78
+ const scrollTop = B.$('.settings-body').scrollTop;
79
+ if (active === 'notifications') {
80
+ content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
81
+ } else if (active === 'keep-awake') {
82
+ content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
83
+ : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
84
+ } else if (active === 'linear') {
85
+ if (linearKey) linearKey.set(last.linear);
86
+ else {
87
+ linearKey = LinearKey.mount(content, { linear: last.linear,
88
+ saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
89
+ busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
90
+ }
91
+ } else if (active === 'closed') {
92
+ content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening ? ' disabled' : ''}>${reopening === b.route ? 'Reopening…' : 'Reopen'}</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
93
+ }
94
+ const input = B.$('[data-dur-at]');
95
+ if (input) customBounds(input, true);
96
+ B.$('.settings-body').scrollTop = scrollTop;
97
+ B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
98
+ const summaries = {
99
+ notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
100
+ polling: last.polling,
101
+ 'keep-awake': last.awake.summary,
102
+ linear: last.linear?.summary || 'No key',
103
+ rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
104
+ closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
105
+ };
106
+ for (const [id] of sections) B.$(`[data-summary="${id}"]`).textContent = summaries[id];
107
+ if (active !== 'rules') return;
108
+ const rulesBox = B.$('#hubRules');
109
+ const json = JSON.stringify(last.rules);
110
+ if (!rules) {
111
+ rules = RulesEditor.mount(rulesBox, {
112
+ rules: last.rules, buttons: { primary: 'btn', quiet: 'btn quiet', icon: 'btn quiet' },
113
+ empty: 'No rules for every board yet.', placeholder: 'Add a rule, such as: Screenshots in every PR that changes a page',
114
+ async save(body) {
115
+ const r = await fetch('/_hub/settings/rules', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
116
+ const out = await r.json().catch(() => ({}));
117
+ if (!r.ok) throw new Error(out.error || 'Could not save. Is the host awake?');
118
+ const n = out.events.length;
119
+ last.rules = out.rules;
120
+ B.$('[data-summary="rules"]').textContent = out.rules.length ? `${out.rules.length} rule${out.rules.length === 1 ? '' : 's'}` : 'None yet';
121
+ rulesJSON = JSON.stringify(out.rules);
122
+ return { rules: out.rules, message: !out.changed ? 'Nothing changed.' : `Saved. Sent to ${n} board${n === 1 ? '' : 's'}${out.failed.length ? `; ${out.failed.length} could not take it.` : '.'}` };
123
+ },
124
+ });
125
+ rulesJSON = json;
126
+ } else if (json !== rulesJSON && !rulesBox.hasAttribute('data-dirty')) {
127
+ rules.set(last.rules);
128
+ rulesJSON = json;
129
+ }
130
+ }
131
+
132
+ // hub-kit.js device() titles its row 'This device' only where this device can take pushes.
133
+ function deviceSummary() {
134
+ const d = device.state('');
135
+ if (!d) return '';
136
+ if (d.title !== 'This device') return d.title;
137
+ return d.sub?.startsWith('On') ? 'This device on' : 'This device off';
138
+ }
139
+
140
+ function closeModal(restore = true) {
141
+ polling?.unmount(); polling = null;
142
+ unfitModal?.(); unfitModal = null;
143
+ modal.close();
144
+ linearKey?.destroy(); linearKey = null;
145
+ content.innerHTML = '';
146
+ rules = null;
147
+ active = null;
148
+ if (restore && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
149
+ }
150
+ // Also used by shell.js for legacy links, pushes, back and forward. Replacing the URL keeps Hub's history.
151
+ B.openSettings = async (view, opener) => {
152
+ const id = sectionOf(view?.hash);
153
+ if (active && id !== active && modal.querySelector('[data-dirty]')) {
154
+ if (!(await BoardDialogs.confirm('Leave without saving? Your unsaved edits here will be lost.'))) return false;
155
+ if (!B.alive()) return false;
156
+ }
157
+ if (id !== active) {
158
+ closeModal(!id);
159
+ active = id;
160
+ if (id) {
161
+ returnFocus = opener || B.$(`#${id}`);
162
+ B.$('#hubSettingsTitle').textContent = sections.find(([section]) => section === id)[1];
163
+ content.innerHTML = id === 'rules' ? '<div id="hubRules" class="rules-box"></div>' : '<p class="note">Loading…</p>';
164
+ flash = null;
165
+ paint();
166
+ unfitModal = BoardDialogs.fitSheet(modal);
167
+ ctx.closeSidebar?.();
168
+ modal.showModal();
169
+ B.$('.settings-body').scrollTop = 0;
170
+ B.$('#hubSettingsTitle').focus({ preventScroll: true });
171
+ if (id === 'polling') {
172
+ polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
173
+ polling.mount();
174
+ }
175
+ }
176
+ } else if (id === 'polling') polling?.B.selectWindow?.(view.w || null);
177
+ settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}), ...(id === 'polling' && view.w ? { w: view.w } : {}) });
178
+ history.replaceState(history.state, '', settingsURL);
179
+ return true;
180
+ };
181
+ const dismiss = () => {
182
+ if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
183
+ linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
184
+ };
185
+ B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
186
+ B.on(root, 'click', (e) => {
187
+ const opener = e.target.closest?.('[data-settings-open]');
188
+ if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
189
+ if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
190
+ if (e.target === modal) {
191
+ const r = modal.getBoundingClientRect();
192
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
193
+ }
194
+ });
195
+ B.on(window, 'hashchange', async () => {
196
+ if (!(await B.openSettings(AppRouter.viewOf(location.href))) && B.alive()) history.replaceState(history.state, '', settingsURL);
197
+ });
198
+ let reopening = null;
199
+ B.atUnmount(() => closeModal(false));
200
+ const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
201
+ device.ready.then(() => { if (B.alive()) paint(); });
202
+ let openPicker = null;
203
+ B.on(root, 'click', (e) => {
204
+ const input = e.target.closest?.('[data-dur-at]');
205
+ if (!input) return;
206
+ openPicker = input;
207
+ customBounds(input, input.value === input.defaultValue);
208
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
209
+ });
210
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
211
+ B.on(root, 'pointerdown', (e) => {
212
+ const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
213
+ if (!input) return;
214
+ openPicker = null;
215
+ input.value = input.defaultValue;
216
+ });
217
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
218
+ const saveCustom = (input) => {
219
+ if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
220
+ const at = new Date(input.value);
221
+ if (Number.isNaN(at.getTime())) return;
222
+ input.form.querySelector('input[type="hidden"]').value = at.toISOString();
223
+ input.form.requestSubmit();
224
+ openPicker = null;
225
+ input.defaultValue = input.value;
226
+ };
227
+ B.on(root, 'change', (e) => {
228
+ if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
229
+ });
230
+ B.on(root, 'focusout', (e) => {
231
+ if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
232
+ });
233
+ B.on(root, 'click', async (e) => {
234
+ const button = e.target.closest?.('[data-reopen]');
235
+ if (!button || reopening) return;
236
+ reopening = button.dataset.reopen;
237
+ flash = null;
238
+ paint();
239
+ try {
240
+ const r = await fetch('/_hub/reopen-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route: reopening }) });
241
+ const out = await r.json();
242
+ if (!r.ok) throw new Error(out.error || 'Could not reopen board.');
243
+ last.closed = (last.closed || []).filter((b) => b.route !== out.route);
244
+ ctx.sidebarChanged?.();
245
+ flash = { text: 'Board reopened. Its recorded session is resuming.' };
246
+ } catch (error) { flash = { text: error.message || 'Could not reopen board. Is the host awake?', error: true }; }
247
+ reopening = null;
248
+ if (B.alive()) paint();
249
+ });
250
+ B.on(root, 'change', async (e) => {
251
+ const on = e.target;
252
+ if (on.id !== 'notifyOn') return;
253
+ if (on.checked || await BoardDialogs.confirm(on.dataset.confirm)) on.form.requestSubmit();
254
+ else on.checked = true;
255
+ });
256
+ const forms = HubKit.forms(B, {
257
+ accepts: (action) => action !== '/_hub/push/flush' && action !== '/_hub/settings/polling',
258
+ answer(out) {
259
+ last = out;
260
+ flash = null;
261
+ paint();
262
+ ctx.sidebarChanged?.();
263
+ },
264
+ fail(message) {
265
+ flash = { text: message, error: true };
266
+ paint();
267
+ },
268
+ });
269
+ B.refreshSettings = HubKit.live(B, {
270
+ url: () => '/_hub/api/settings',
271
+ paint(d) {
272
+ if (!d) { if (!last) top.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
273
+ last = d;
274
+ paint();
275
+ },
276
+ busy: () => Boolean(reopening) || device.busy() || forms.busy() || Boolean(root.querySelector('dialog[open], [data-dirty], input:focus, textarea:focus')),
277
+ });
278
+ paint();
279
+ B.openSettings(ctx.view);
280
+ }),
281
+ };
282
+ })();