@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,75 @@
1
+ // The header's bulb and its review sheet: the board's last architecture review and Run, which confirms and asks the
2
+ // board server to start one (docs/adr/0125-the-bulb-runs-an-architecture-review-of-the-boards-repo.md). The bulb glows
3
+ // while a review runs. Needs app-core.js.
4
+ BoardView.part((B) => {
5
+ const { $, state } = B;
6
+ const { esc, LINK_ARROW } = BoardUI;
7
+ const bulb = $('#archBulb');
8
+ const sheet = $('#archSheet');
9
+ const day = (iso) => new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' });
10
+ let starting = false;
11
+
12
+ function body(a) {
13
+ const last = a.last;
14
+ if (last?.running) {
15
+ return `<p>Reviewing ${esc(a.repo)} since ${esc(BoardUI.time(last.at))}. The bulb glows until its wave is ready.</p>`
16
+ + `<button type="button" class="arch-run" data-arch-open="${esc(last.key)}">Open ${esc(last.ref)}</button>`;
17
+ }
18
+ const lastHtml = last ? `<div class="arch-last"><span>Last review: <b>${esc(last.wave)}</b> · ${esc(day(last.at))}</span>`
19
+ + `<span class="muted">${B.plural(last.findings, 'finding')}, ${last.built} built · <a href="#item=${encodeURIComponent(last.key)}" data-arch-open="${esc(last.key)}">${esc(last.ref)} ${LINK_ARROW}</a></span></div>` : '';
20
+ const waits = a.waits ? `<p class="arch-waits" role="status">${esc(a.waits)}</p>` : '';
21
+ return `${lastHtml}${waits}<button type="button" class="primary arch-run" data-arch-run${a.waits || starting ? ' disabled' : ''}>${starting ? 'Starting…' : `Run a review of ${esc(a.repo)}`}</button>`
22
+ + '<p class="muted">Claude reads the repo and makes a wave. Its first card asks you one question per finding.</p>';
23
+ }
24
+
25
+ function renderArch(d) {
26
+ const a = d.arch;
27
+ bulb.hidden = !a;
28
+ const running = Boolean(a?.last?.running);
29
+ bulb.classList.toggle('running', running);
30
+ const label = running ? 'Architecture review running' : 'Architecture review';
31
+ bulb.setAttribute('aria-label', label);
32
+ bulb.title = label;
33
+ if (sheet.open && a) $('#archBody').innerHTML = body(a);
34
+ if (sheet.open && !a) sheet.close();
35
+ }
36
+
37
+ function openCard(key) {
38
+ sheet.close();
39
+ const hash = `#item=${encodeURIComponent(key)}`;
40
+ if (location.hash === hash) window.dispatchEvent(new HashChangeEvent('hashchange'));
41
+ else location.hash = hash;
42
+ }
43
+
44
+ async function run() {
45
+ const a = state.board?.arch;
46
+ if (!a || a.waits || starting) return;
47
+ if (a.last?.open) { openCard(a.last.key); B.toast(`${a.last.ref} is this board’s open review.`); return; }
48
+ sheet.close();
49
+ if (!(await B.confirm(`Claude reads ${a.repo} and makes a wave whose first card asks you one question per finding. Run the architecture review?`)) || !B.alive()) return;
50
+ starting = true;
51
+ try {
52
+ const r = await fetch(B.site.url('api/arch-review'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
53
+ const answer = await r.json().catch(() => ({}));
54
+ if (!r.ok) { B.notice(answer.error || 'Could not start the review. Is the host awake?'); return; }
55
+ if (answer.existing) { openCard(answer.key); B.toast(`${answer.ref} is this board’s open review.`); return; }
56
+ await B.loadBoard();
57
+ } catch { B.notice('Could not start the review. Is the host awake?'); }
58
+ finally { starting = false; if (state.board) renderArch(state.board); }
59
+ }
60
+
61
+ B.on(bulb, 'click', () => {
62
+ const a = state.board?.arch;
63
+ if (!a || sheet.open) return;
64
+ $('#archBody').innerHTML = body(a);
65
+ sheet.showModal();
66
+ });
67
+ B.on(sheet, 'click', (ev) => {
68
+ if (ev.target === sheet || ev.target.closest('#archClose')) { sheet.close(); return; }
69
+ const open = ev.target.closest('[data-arch-open]');
70
+ if (open) { ev.preventDefault(); openCard(open.dataset.archOpen); return; }
71
+ if (ev.target.closest('[data-arch-run]')) run();
72
+ });
73
+
74
+ Object.assign(B, { renderArch });
75
+ });
@@ -0,0 +1,188 @@
1
+ // New tasks on the board view: the + sheet that sends one as an ask event, and bump, edit and delete for a New card or a
2
+ // pending thread message (docs/adr/0014-a-pending-ask-can-be-deleted-or-bumped.md,
3
+ // docs/adr/0047-the-plus-makes-a-new-card.md). Needs app-core.js and app-threads.js.
4
+ BoardView.part((B) => {
5
+ const { $, store, state, toast, quieted, sendDraft, hide } = B;
6
+ const { attachButton } = BoardUI;
7
+
8
+ // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
9
+ const sheet = $('#sheet');
10
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
11
+ if (!$('#askForm .row .attach')) $('#askForm .row').insertAdjacentHTML('afterbegin', attachButton());
12
+ // Files picked before the app shell switched away from this board are still attached, so they show again.
13
+ BoardAttach.paint('ask', B.site.root);
14
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
15
+ if (!$('#askForm .send-hint')) $('#askForm button[type=submit]').insertAdjacentHTML('beforebegin', `<span class="send-hint" aria-hidden="true">${isMac ? '⌘↵' : 'Ctrl+↵'}</span>`);
16
+ // ⌘↵ (Ctrl+↵ off a Mac) sends from any composer: the ask, a thread reply, an edit.
17
+ B.on(B.root, 'keydown', (ev) => {
18
+ if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing) return;
19
+ const form = ev.target instanceof HTMLTextAreaElement ? ev.target.closest('form[data-composer], form.edit-form') : null;
20
+ if (!form) return;
21
+ ev.preventDefault();
22
+ form.requestSubmit();
23
+ });
24
+ $('#askText').value = store.get('draft:ask', '');
25
+ B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
26
+ let stopFitting = null;
27
+ const stopFit = () => { stopFitting?.(); stopFitting = null; };
28
+ B.on(sheet, 'close', stopFit);
29
+ B.atUnmount(stopFit);
30
+ function openSheet() {
31
+ if (sheet.open) return;
32
+ stopFit();
33
+ stopFitting = BoardDialogs.fitSheet(sheet);
34
+ sheet.classList.remove('closing');
35
+ sheet.showModal();
36
+ $('#askText').focus();
37
+ $('#compose').setAttribute('aria-expanded', 'true');
38
+ }
39
+ function closeSheet() {
40
+ if (!sheet.open || sheet.classList.contains('closing')) return;
41
+ $('#compose').setAttribute('aria-expanded', 'false');
42
+ if (reduceMotion.matches) { sheet.close(); return; }
43
+ sheet.classList.add('closing');
44
+ B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
45
+ }
46
+ B.on($('#compose'), 'click', openSheet);
47
+ // n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
48
+ // nothing focused the key goes to <body>, outside the view, so this listens on the document. A key switch mode
49
+ // used (sidebar.js) comes defaultPrevented and doesn't count.
50
+ const TWICE_MS = 500;
51
+ let lastN = -Infinity;
52
+ B.on(document, 'keydown', (ev) => {
53
+ if (ev.defaultPrevented) { lastN = -Infinity; return; }
54
+ if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
55
+ const field = ev.target instanceof Element && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
56
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
57
+ if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
58
+ lastN = -Infinity;
59
+ ev.preventDefault();
60
+ openSheet();
61
+ });
62
+ B.on($('#sheetClose'), 'click', closeSheet);
63
+ B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
64
+ B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
65
+ B.on($('#askForm'), 'submit', async (ev) => {
66
+ ev.preventDefault();
67
+ $('#askText').blur();
68
+ const text = $('#askText').value.trim();
69
+ const files = BoardAttach.files('ask', B.site.root);
70
+ if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
71
+ $('#askStatus').textContent = 'Sending…';
72
+ try {
73
+ await sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => B.send('ask', { text, ...(files.length ? { files } : {}) }));
74
+ BoardAttach.clear('ask', B.site.root);
75
+ $('#askStatus').textContent = '';
76
+ closeSheet();
77
+ toast('Sent');
78
+ } catch { $('#askStatus').textContent = 'Could not send'; }
79
+ });
80
+
81
+ // A bump moves the New card to the top at once, its icon filling as it goes
82
+ // (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md).
83
+ async function bumpAsk(id) {
84
+ const icon = `button[data-bump="${CSS.escape(id)}"]`;
85
+ const at = new Date().toISOString();
86
+ const sending = B.send('ask-bump', { id }, B.drawItem(`ask:${id}`, (it) => ({ ...it, bumpedAt: at, ask: { ...it.ask, bumpedAt: at } })));
87
+ B.fadeBump(icon, true);
88
+ try { await sending; toast('Bumped to the top'); } catch { B.fadeBump(icon, false); toast(B.FAILED); }
89
+ }
90
+ B.on(B.root, 'click', (ev) => {
91
+ const bump = ev.target.closest('button[data-bump]');
92
+ if (!bump) return;
93
+ ev.preventDefault();
94
+ bump.disabled = true;
95
+ bumpAsk(bump.dataset.bump);
96
+ });
97
+ B.bumpAsk = bumpAsk;
98
+ // A New card's icons sit on the card; a message in a thread, an ask's included, carries its thread's key.
99
+ async function pendingMessage(b, id) {
100
+ const card = b.closest('details.item');
101
+ const thread = b.closest('[data-thread]')?.dataset.thread || card?.querySelector('[data-thread]')?.dataset.thread || card?.dataset.key || null;
102
+ const key = thread && thread !== `ask:${id}` ? thread : undefined;
103
+ let msg = b.closest('.msg');
104
+ if (!msg && b.dataset.edit && card) {
105
+ card.open = true;
106
+ for (let i = 0; i < 30 && !msg; i++) { msg = card.querySelector(`.msg[data-event="${CSS.escape(id)}"]`); if (!msg) await new Promise((r) => setTimeout(r, 100)); }
107
+ }
108
+ return { thread, key, msg, data: { id, ...(key ? { key } : {}) } };
109
+ }
110
+ // An edit box lives in state.edits and its thread draws it (ui-threads.js threadMessages), so a repaint keeps it, and
111
+ // so does leaving the board and opening it again in this page.
112
+ for (const [id, e] of B.place?.edits || []) state.edits.set(id, e);
113
+ B.keep(() => ({ edits: [...state.edits] }));
114
+ function closeEditor(id) {
115
+ const e = state.edits.get(id);
116
+ state.edits.delete(id);
117
+ if (e) B.repaintThread(e.thread);
118
+ }
119
+ B.on(B.root, 'input', (ev) => {
120
+ const e = state.edits.get(ev.target.closest?.('form[data-edit-form]')?.dataset.editForm);
121
+ if (e) e.text = ev.target.value;
122
+ });
123
+ B.on(B.root, 'click', async (ev) => {
124
+ const eb = ev.target.closest('button[data-edit]');
125
+ if (eb) {
126
+ ev.preventDefault();
127
+ const id = eb.dataset.edit;
128
+ const { thread, msg, data } = await pendingMessage(eb, id);
129
+ if (!msg || !thread || state.edits.has(id)) return;
130
+ const text = (state.threadCache.get(thread) || []).find((m) => m.eventId === id)?.text ?? msg.querySelector('.body').textContent;
131
+ state.edits.set(id, { thread, data, text });
132
+ B.repaintThread(thread);
133
+ $(`form[data-edit-form="${CSS.escape(id)}"] textarea`)?.focus();
134
+ return;
135
+ }
136
+ const cancel = ev.target.closest('.edit-form button[data-edit-cancel]');
137
+ if (cancel) { closeEditor(cancel.closest('form').dataset.editForm); return; }
138
+ const del = ev.target.closest('button[data-withdraw]');
139
+ if (!del) return;
140
+ ev.preventDefault();
141
+ if (!(await B.confirm('Delete this message? Claude will skip it.'))) return;
142
+ const id = del.dataset.withdraw;
143
+ const { key, msg } = await pendingMessage(del, id);
144
+ state.edits.delete(id);
145
+ state.withdrawnAsks.add(id);
146
+ B.render();
147
+ if (msg) msg.hidden = true;
148
+ try {
149
+ await B.send('ask-withdrawn', { id, ...(key ? { key } : {}) });
150
+ await hide(id, true);
151
+ toast('Deleted');
152
+ if (key) B.loadThread(key, { force: true });
153
+ } catch { state.withdrawnAsks.delete(id); B.render(); if (msg) msg.hidden = false; toast(B.FAILED); }
154
+ });
155
+ B.on(B.root, 'submit', async (ev) => {
156
+ const form = ev.target.closest('form.edit-form');
157
+ if (!form) return;
158
+ ev.preventDefault();
159
+ const id = form.dataset.editForm;
160
+ const e = state.edits.get(id);
161
+ if (!e) return;
162
+ const text = form.querySelector('textarea').value.trim();
163
+ if (!text) { toast('Write something, or delete the message'); return; }
164
+ // The new text shows at once, in the thread and on the New card; a failed save puts the old text back and reopens
165
+ // the edit box with what was typed (docs/adr/0151-every-owner-tap-the-server-can-apply-draws-at-once.md).
166
+ const before = state.threadCache.get(e.thread);
167
+ if (before) state.threadCache.set(e.thread, before.map((m) => (m.eventId === id ? { ...m, text, edited: true } : m)));
168
+ closeEditor(id);
169
+ const title = text.split('\n')[0];
170
+ try {
171
+ await B.send('ask-edited', { ...e.data, text }, B.drawItem(`ask:${id}`, (it) => ({ ...it, title, ask: { ...it.ask, text, edited: true } })));
172
+ toast('Edited');
173
+ B.loadThread(e.thread, { force: true });
174
+ } catch {
175
+ if (before) state.threadCache.set(e.thread, before);
176
+ state.edits.set(id, { ...e, text });
177
+ B.repaintThread(e.thread);
178
+ toast(B.FAILED);
179
+ }
180
+ });
181
+ // The asks watch polls every 10 s while an ask waits for its reply, sooner than the board's own refresh.
182
+ let asksSeen = false;
183
+ B.watch((list) => {
184
+ if (!list) return;
185
+ if (asksSeen) B.loadBoard();
186
+ asksSeen = true;
187
+ }, { kind: 'ask', limit: 50 });
188
+ });
@@ -0,0 +1,48 @@
1
+ // The answer banner at the top of the board view: asks Claude answered without making work, whose answer this
2
+ // browser hasn't seen (domain.mjs answers; docs/adr/0016-every-ask-is-a-card.md). Needs app-core.js.
3
+ BoardView.part((B) => {
4
+ const { $, store, state, seen, markSeen, plural, svg } = B;
5
+ const { esc } = BoardUI;
6
+ const DISMISSED = 'dismissedAnswers';
7
+
8
+ function unseenAnswers(d) {
9
+ const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
10
+ return (d.answers || []).filter((a) => seen[a.key] && a.at > seen[a.key] && !(dismissed[a.id] >= a.at) && !state.withdrawnAsks.has(a.id));
11
+ }
12
+
13
+ function renderBanner(d) {
14
+ // A New card seen before its answer counts the answer as new; one first seen in Done starts from now (app-core.js markSeen).
15
+ for (const it of d.items) if (it.isNew) markSeen(it, true);
16
+ const shown = unseenAnswers(d);
17
+ const el = $('#answers');
18
+ el.hidden = !shown.length;
19
+ if (!shown.length) { el.innerHTML = ''; return; }
20
+ const [newest] = shown;
21
+ const more = shown.length > 1 ? `<span class="more"> and ${shown.length - 1} more</span>` : '';
22
+ el.innerHTML = `<button type="button" class="answer-open" data-answer="${esc(newest.key)}">
23
+ <strong>${plural(shown.length, 'answer')}</strong><span class="sep"> · </span><span class="text">${esc(newest.text)}</span>${more}
24
+ </button>
25
+ <button type="button" class="ghost icon answer-dismiss" aria-label="Dismiss" title="Dismiss">${svg('<path d="M18 6 6 18M6 6l12 12"/>', 16)}</button>`;
26
+ }
27
+
28
+ B.on($('#answers'), 'click', (ev) => {
29
+ const d = state.board;
30
+ if (!d) return;
31
+ if (ev.target.closest('.answer-dismiss')) {
32
+ const dismissed = Object.assign(Object.create(null), store.get(DISMISSED, {}));
33
+ for (const a of unseenAnswers(d)) dismissed[a.id] = a.at;
34
+ store.set(DISMISSED, dismissed);
35
+ renderBanner(d);
36
+ return;
37
+ }
38
+ const open = ev.target.closest('[data-answer]');
39
+ if (!open) return;
40
+ const key = open.dataset.answer;
41
+ markSeen(d.items.find((i) => i.key === key));
42
+ const hash = `#item=${encodeURIComponent(key)}`;
43
+ if (location.hash === hash) history.replaceState(null, '', location.pathname + location.search);
44
+ location.hash = hash;
45
+ });
46
+
47
+ Object.assign(B, { renderBanner });
48
+ });