@lovek4d/kickflip 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CONTEXT.md +414 -0
  2. package/LICENSE +57 -0
  3. package/README.md +23 -2
  4. package/SKILL.md +121 -0
  5. package/bin/doctor.mjs +3 -0
  6. package/bin/install.mjs +3 -0
  7. package/bin/kickflip.mjs +125 -0
  8. package/bin/statusline.mjs +4 -0
  9. package/bin/wait-events.mjs +3 -0
  10. package/bin/web-ts.mjs +2 -0
  11. package/board/bin/agent.mjs +2 -0
  12. package/board/bin/board.mjs +207 -0
  13. package/board/bin/builder-stop-hook.mjs +5 -0
  14. package/board/build-script.mjs +1 -0
  15. package/board/domain.mjs +3 -0
  16. package/board/public/app-asks.js +169 -0
  17. package/board/public/app-banner.js +48 -0
  18. package/board/public/app-cards.js +200 -0
  19. package/board/public/app-core.js +96 -0
  20. package/board/public/app-header.js +108 -0
  21. package/board/public/app-lanes.js +92 -0
  22. package/board/public/app-needs.js +30 -0
  23. package/board/public/app-snapshot.js +97 -0
  24. package/board/public/app-threads.js +165 -0
  25. package/board/public/app.html +83 -0
  26. package/board/public/app.js +327 -0
  27. package/board/public/attach.js +84 -0
  28. package/board/public/dialogs.js +360 -0
  29. package/board/public/hub-kit.js +151 -0
  30. package/board/public/hub.css +164 -0
  31. package/board/public/kickflip.png +0 -0
  32. package/board/public/prompts.js +64 -0
  33. package/board/public/prototype-kit.js +193 -0
  34. package/board/public/prototype-switcher.js +226 -0
  35. package/board/public/prototype.html +55 -0
  36. package/board/public/router.js +223 -0
  37. package/board/public/rules-editor.js +127 -0
  38. package/board/public/shell.js +298 -0
  39. package/board/public/sidebar.css +199 -0
  40. package/board/public/sidebar.js +287 -0
  41. package/board/public/style.css +570 -0
  42. package/board/public/toasts.js +179 -0
  43. package/board/public/ui-cards.js +153 -0
  44. package/board/public/ui-enums.js +10 -0
  45. package/board/public/ui-header.js +84 -0
  46. package/board/public/ui-hub.js +94 -0
  47. package/board/public/ui-links.js +142 -0
  48. package/board/public/ui-markdown.js +80 -0
  49. package/board/public/ui-page-header.js +63 -0
  50. package/board/public/ui-prompts.js +39 -0
  51. package/board/public/ui-questions.js +130 -0
  52. package/board/public/ui-sidebar.js +177 -0
  53. package/board/public/ui-snapshot.js +56 -0
  54. package/board/public/ui-threads.js +81 -0
  55. package/board/public/ui.js +235 -0
  56. package/board/public/view-frame.js +93 -0
  57. package/board/public/view-hub-settings.js +266 -0
  58. package/board/public/view-hub.js +99 -0
  59. package/board/public/view-notes.js +29 -0
  60. package/board/public/view-polling.js +87 -0
  61. package/board/public/view-questions.js +391 -0
  62. package/board/public/view-settings.js +328 -0
  63. package/board/server.mjs +12 -0
  64. package/board/store.mjs +7 -0
  65. package/board/tickets.mjs +3 -0
  66. package/board/worktree.mjs +14 -0
  67. package/docs/hub-session.md +11 -0
  68. package/hooks-plugin/.claude-plugin/plugin.json +5 -0
  69. package/hooks-plugin/hooks/codex-share-auto.mjs +21 -0
  70. package/hooks-plugin/hooks/hooks.json +54 -0
  71. package/hooks-plugin/hooks/usage-gate.mjs +18 -0
  72. package/hub/board-repo.mjs +1 -0
  73. package/hub/boards.mjs +4 -0
  74. package/hub/close-board.mjs +1 -0
  75. package/hub/files.mjs +1 -0
  76. package/hub/github.mjs +5 -0
  77. package/hub/home.mjs +1 -0
  78. package/hub/hub.mjs +17 -0
  79. package/hub/icon-180.png +0 -0
  80. package/hub/icon-192.png +0 -0
  81. package/hub/icon-512.png +0 -0
  82. package/hub/icons.mjs +1 -0
  83. package/hub/linear.mjs +2 -0
  84. package/hub/live.mjs +2 -0
  85. package/hub/locks.mjs +1 -0
  86. package/hub/new-board.mjs +2 -0
  87. package/hub/permission-hook.mjs +3 -0
  88. package/hub/presence.mjs +1 -0
  89. package/hub/prompts.mjs +2 -0
  90. package/hub/push.mjs +2 -0
  91. package/hub/repos.mjs +2 -0
  92. package/hub/routes.mjs +1 -0
  93. package/hub/rules.mjs +1 -0
  94. package/hub/schedule.mjs +1 -0
  95. package/hub/sessions.mjs +2 -0
  96. package/hub/shell.mjs +1 -0
  97. package/hub/stream.mjs +19 -0
  98. package/hub/sw.js +144 -0
  99. package/hub/try.mjs +3 -0
  100. package/hub/usage.mjs +3 -0
  101. package/hub/views.mjs +1 -0
  102. package/package.json +26 -4
  103. package/shared/codex.mjs +1 -0
  104. package/shared/colors.css +13 -0
  105. package/shared/repos.mjs +1 -0
  106. package/shared/service.mjs +33 -0
  107. package/shared/usage-gate.mjs +1 -0
  108. package/starter/cross-site.mjs +1 -0
  109. package/starter/public/claude.js +67 -0
  110. package/starter/public/index.html +49 -0
  111. package/starter/server.mjs +4 -0
@@ -0,0 +1,287 @@
1
+ // The sidebar's behaviour on a page with <aside id="sidebar" class="sidebar"> and <div id="sidebarScrim"
2
+ // class="sidebar-scrim">, after ui-page-header.js and ui-sidebar.js (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
3
+ // Sidebar.mount({here, data}) paints `data` or the last copy this device stored; the app shell hands it each new copy
4
+ // through setData from its one stream (shell.js). The page header's logo (a.hub) toggles the sidebar at every
5
+ // width, with what waits on the other sites in its accessible label. Below 900px the sidebar is a drawer: the logo or a swipe in from the
6
+ // left edge opens it, and the scrim, Escape, ✕, the drawer's own logo or a swipe back closes it. From 900px it is a
7
+ // rail, which its own logo collapses and the page's logo brings back; this device remembers that, and <html> carries
8
+ // sb-collapsed meanwhile (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md).
9
+ // Switch mode: s pressed twice within a second, outside a text field, a contenteditable or an open dialog, shows each
10
+ // row's key (ui-sidebar.js switchKeys), opening the drawer or showing a collapsed rail until it ends; the next key opens
11
+ // that row through open(href). Escape, a click, a held modifier, typing in a field or 3 seconds with no key end it
12
+ // with nothing opened. A key it uses is defaultPrevented, so the board's other shortcuts skip it.
13
+ // The shortcuts sheet: h pressed twice within a second, on a device with a fine pointer that hovers, under the same
14
+ // rules as s, shows or hides ui-sidebar.js shortcuts in a centred sheet over the dimmed page; Escape closes it.
15
+ // mount({here, data, open, reload}): open(href) shows a row's page, by default by loading it; reload() reloads the app.
16
+ // → {setHere(route), setData(data), setUpdate(state), checkDevice(), close(), toggle(), keydown(e), pointerdown()}, toggle doing what the
17
+ // logo does, checkDevice reading this device's push subscription again, and keydown and pointerdown taking a framed
18
+ // page's events (view-frame.js), as the document's own
19
+ (function () {
20
+ const DATA_KEY = 'hub:sidebar';
21
+ // app.html reads it before the first paint, so a collapsed rail never shows for a moment.
22
+ const COLLAPSED_KEY = 'hub:sidebarCollapsed';
23
+ const RAIL = '(min-width: 900px)';
24
+ // A phone has no keys to press, so it never shows the shortcuts sheet.
25
+ const DESKTOP = '(hover: hover) and (pointer: fine)';
26
+ const TWICE_MS = 1000;
27
+ // sidebar.css .ks-bar drains over the same time.
28
+ const IDLE_MS = 3000;
29
+ const FIELD = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';
30
+ const MODIFIERS = new Set(['Shift', 'Meta', 'Control', 'Alt', 'AltGraph', 'CapsLock', 'Fn']);
31
+ const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
32
+ const write = (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode or full: no copy */ } };
33
+
34
+ // This device's push subscription, as ui-sidebar.js sidebar takes it.
35
+ async function deviceState() {
36
+ const standalone = navigator.standalone || matchMedia('(display-mode: standalone)').matches;
37
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) return /iPhone|iPad|iPod/.test(navigator.userAgent) && !standalone ? 'install' : null;
38
+ if (Notification.permission === 'denied') return 'blocked';
39
+ try {
40
+ const reg = await navigator.serviceWorker.getRegistration('/');
41
+ return (await reg?.pushManager.getSubscription()) ? 'on' : 'off';
42
+ } catch { return null; }
43
+ }
44
+
45
+ function mount({ here = '', data = null, open: openRow = (href) => location.assign(href), reload = () => {} } = {}) {
46
+ const aside = document.getElementById('sidebar');
47
+ const scrim = document.getElementById('sidebarScrim');
48
+ // ui.js replaces the BoardUI global when it loads after this, so the renderers are held here.
49
+ const { sidebar, updateRow, othersWaiting, switchKeys, shortcuts } = globalThis.BoardUI || {};
50
+ const none = { setHere() {}, setData() {}, setUpdate() {}, checkDevice() {}, close() {}, toggle() {}, keydown() {}, pointerdown() {} };
51
+ if (!aside || !sidebar) return none;
52
+ if (!data) { try { data = JSON.parse(read(DATA_KEY) || 'null'); } catch { data = null; } }
53
+ let device = null;
54
+ let update = null;
55
+ let updateHTML = '';
56
+ const rail = matchMedia(RAIL);
57
+ const html = document.documentElement;
58
+ let collapsed = read(COLLAPSED_KEY) === '1';
59
+ html.classList.toggle('sb-collapsed', collapsed);
60
+
61
+ // The sidebar's head is a page header too, so the page's own logo is the one outside it.
62
+ const pageLogo = (el) => (el && !el.closest('.sidebar') ? el : null);
63
+ function logoCount() {
64
+ const logo = [...document.querySelectorAll('.top a.hub')].find(pageLogo);
65
+ if (!logo) return;
66
+ const n = othersWaiting(data, here);
67
+ logo.setAttribute('aria-label', `Boards${update ? ', new version ready' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
68
+ }
69
+ // switching: {timer, opened} while switch mode shows, opened when it opened the drawer.
70
+ let switching = null;
71
+ let painted = false;
72
+ // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
73
+ function paint() {
74
+ if (!data) return;
75
+ const scroll = aside.querySelector('.sb-scroll');
76
+ const top = painted ? scroll?.scrollTop : undefined;
77
+ // Screen readers announce a role="status" node only when its own content changes, so the node outlives the repaint.
78
+ const held = aside.querySelector('.sb-update');
79
+ aside.innerHTML = sidebar(data, { here, device, switching: Boolean(switching) });
80
+ const fresh = aside.querySelector('.sb-update');
81
+ if (held) fresh.replaceWith(held);
82
+ const row = updateRow(update);
83
+ if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
84
+ updateHTML = row;
85
+ const list = aside.querySelector('.sb-scroll');
86
+ if (list) list.scrollTop = top ?? list.scrollHeight;
87
+ painted = true;
88
+ logoCount();
89
+ }
90
+ function setData(next) {
91
+ if (!next) return;
92
+ data = next;
93
+ write(DATA_KEY, JSON.stringify(data));
94
+ paint();
95
+ }
96
+ function setUpdate(state) {
97
+ update = state;
98
+ html.classList.toggle('sb-update', Boolean(update));
99
+ paint();
100
+ }
101
+
102
+ const isOpen = () => aside.classList.contains('open');
103
+ function open() {
104
+ if (rail.matches || isOpen()) return;
105
+ aside.classList.add('open');
106
+ const list = aside.querySelector('.sb-scroll');
107
+ if (list) list.scrollTop = list.scrollHeight;
108
+ scrim?.classList.add('on');
109
+ }
110
+ function close() {
111
+ if (!isOpen()) return;
112
+ aside.classList.remove('open');
113
+ scrim?.classList.remove('on');
114
+ }
115
+ // sb-anim slides the rail only on a tap, so a page that opens collapsed doesn't slide into it.
116
+ let settle = null;
117
+ function collapse(on) {
118
+ collapsed = on;
119
+ write(COLLAPSED_KEY, on ? '1' : '0');
120
+ html.classList.add('sb-anim');
121
+ html.classList.toggle('sb-collapsed', on);
122
+ clearTimeout(settle);
123
+ settle = setTimeout(() => html.classList.remove('sb-anim'), 400);
124
+ }
125
+ function toggle() {
126
+ if (rail.matches) collapse(!collapsed);
127
+ else if (isOpen()) close();
128
+ else open();
129
+ }
130
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
131
+ async function refresh() {
132
+ const response = await fetch('/_hub/sidebar.json');
133
+ if (!response.ok) throw new Error('Could not refresh boards.');
134
+ setData(await response.json());
135
+ }
136
+ function newBoard() {
137
+ close();
138
+ BoardDialogs.repoSheet({
139
+ async save(input, selected) {
140
+ const response = await fetch('/_hub/new-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
141
+ if (!response.ok) throw new Error(await response.text());
142
+ const { route } = await response.json();
143
+ setData({ ...data, starting: [...(data?.starting || []).filter((s) => s.route !== route), { route, title: input.name, repo: selected.clone, phase: selected.cloned ? 'starting' : 'cloning', askedAt: new Date().toISOString(), failed: false }] });
144
+ if (rail.matches) collapse(false); else open();
145
+ aside.querySelector(`[data-starting="${route}"]`)?.scrollIntoView({ block: 'nearest' });
146
+ refresh().catch(() => {});
147
+ },
148
+ onClose: () => aside.querySelector('[data-new-board]')?.focus({ preventScroll: true }),
149
+ });
150
+ }
151
+ aside.addEventListener('click', async (e) => {
152
+ const refreshApp = e.target.closest?.('[data-app-reload]');
153
+ if (refreshApp) {
154
+ if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
155
+ return;
156
+ }
157
+ if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
158
+ const dismiss = e.target.closest?.('[data-dismiss-board]');
159
+ if (!dismiss || dismiss.disabled) return;
160
+ dismiss.disabled = true;
161
+ try {
162
+ const response = await fetch(`/_hub/new-board/${encodeURIComponent(dismiss.dataset.dismissBoard)}`, { method: 'DELETE' });
163
+ if (!response.ok) throw new Error(await response.text());
164
+ await refresh();
165
+ } catch (e) { dismiss.disabled = false; globalThis.BoardDialogs?.notice(e.message); }
166
+ });
167
+ document.addEventListener('click', (e) => {
168
+ if (e.metaKey || e.ctrlKey || e.shiftKey) return;
169
+ if (e.target.closest?.('[data-sidebar-close]')) { e.preventDefault(); if (rail.matches) collapse(true); else close(); return; }
170
+ if (!pageLogo(e.target.closest?.('.top a.hub'))) return;
171
+ e.preventDefault();
172
+ toggle();
173
+ });
174
+ scrim?.addEventListener('click', close);
175
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !e.defaultPrevented && isOpen()) close(); });
176
+
177
+ let lastS = -Infinity;
178
+ function startSwitch() {
179
+ lastS = -Infinity;
180
+ switching = { opened: !rail.matches && !isOpen(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
181
+ if (switching.opened) open();
182
+ aside.classList.add('ks-on');
183
+ paint();
184
+ }
185
+ function endSwitch({ keepDrawer = false } = {}) {
186
+ if (!switching) return;
187
+ clearTimeout(switching.timer);
188
+ const { opened } = switching;
189
+ switching = null;
190
+ // No repaint here: a click that ended it must land on the row it pressed, so the keys only hide (sidebar.css).
191
+ aside.classList.remove('ks-on');
192
+ if (opened && !keepDrawer) close();
193
+ }
194
+ // A framed page's event comes from another window, so its target is checked by duck type, not instanceof.
195
+ const inField = (t) => Boolean(t?.closest?.(FIELD));
196
+ const dialogOpen = (t) => Boolean(document.querySelector('dialog[open]') || t?.ownerDocument?.querySelector?.('dialog[open]'));
197
+
198
+ // help: {el, previous} while the shortcuts sheet shows, previous holding the focus to give back.
199
+ let help = null;
200
+ let lastH = -Infinity;
201
+ function closeHelp() {
202
+ if (!help) return;
203
+ const { el, previous } = help;
204
+ help = null;
205
+ if (el.open) el.close();
206
+ el.remove();
207
+ if (previous?.isConnected) previous.focus({ preventScroll: true });
208
+ }
209
+ function toggleHelp() {
210
+ if (help) { closeHelp(); return; }
211
+ const el = document.createElement('dialog');
212
+ el.setAttribute('id', 'shortcutsSheet');
213
+ el.setAttribute('class', 'sheet shortcuts-sheet');
214
+ el.setAttribute('aria-labelledby', 'shortcutsTitle');
215
+ el.innerHTML = shortcuts();
216
+ el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
217
+ el.addEventListener('close', closeHelp);
218
+ el.addEventListener('click', (e) => { if (e.target === el) closeHelp(); });
219
+ help = { el, previous: document.activeElement };
220
+ document.body.appendChild(el);
221
+ el.showModal();
222
+ }
223
+
224
+ function keydown(e) {
225
+ if (MODIFIERS.has(e.key) || e.isComposing || e.defaultPrevented) return;
226
+ const mods = e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
227
+ if (help && e.key === 'Escape') { e.preventDefault(); closeHelp(); return; }
228
+ if (switching) {
229
+ if (e.key === 'Escape') { e.preventDefault(); endSwitch(); return; }
230
+ if (mods || inField(e.target)) { endSwitch(); return; }
231
+ e.preventDefault();
232
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
233
+ const row = switchKeys(data).find((k) => k.num === key || k.letter === key);
234
+ endSwitch({ keepDrawer: Boolean(row) });
235
+ if (row) openRow(row.href);
236
+ return;
237
+ }
238
+ const key = e.key.toLowerCase();
239
+ if (key !== 's') lastS = -Infinity;
240
+ if (key !== 'h') lastH = -Infinity;
241
+ if ((key !== 's' && key !== 'h') || e.repeat) return;
242
+ if (mods || inField(e.target)) { lastS = lastH = -Infinity; return; }
243
+ const now = Date.now();
244
+ if (key === 'h') {
245
+ if (!matchMedia(DESKTOP).matches || (!help && dialogOpen(e.target))) { lastH = -Infinity; return; }
246
+ if (now - lastH <= TWICE_MS) { lastH = -Infinity; e.preventDefault(); toggleHelp(); return; }
247
+ lastH = now;
248
+ return;
249
+ }
250
+ if (dialogOpen(e.target) || !data) { lastS = -Infinity; return; }
251
+ if (now - lastS <= TWICE_MS) { e.preventDefault(); startSwitch(); return; }
252
+ lastS = now;
253
+ }
254
+ function pointerdown() { endSwitch({ keepDrawer: true }); }
255
+ // Capturing, so switch mode sees a key before any view's own shortcut does.
256
+ document.addEventListener('keydown', keydown, true);
257
+ document.addEventListener('pointerdown', pointerdown, true);
258
+ rail.addEventListener?.('change', () => { endSwitch(); close(); });
259
+ // A swipe opens from the left edge and closes back toward it; a mostly vertical drag is a scroll.
260
+ let swipe = null;
261
+ addEventListener('touchstart', (e) => {
262
+ const t = e.touches[0];
263
+ swipe = !rail.matches && (isOpen() || t.clientX < 24) ? { x: t.clientX, y: t.clientY, opening: !isOpen() } : null;
264
+ }, { passive: true });
265
+ addEventListener('touchmove', (e) => {
266
+ if (!swipe) return;
267
+ const t = e.touches[0];
268
+ const dx = t.clientX - swipe.x;
269
+ if (Math.abs(t.clientY - swipe.y) > Math.abs(dx)) { swipe = null; return; }
270
+ if (swipe.opening && dx > 40) { open(); swipe = null; } else if (!swipe.opening && dx < -50) { close(); swipe = null; }
271
+ }, { passive: true });
272
+
273
+ const checkDevice = () => deviceState().then((d) => { device = d; paint(); });
274
+ paint();
275
+ checkDevice();
276
+ // Coming back to a page from the back-forward cache shows the drawer as it was left.
277
+ addEventListener('pageshow', (e) => { if (e.persisted) close(); });
278
+
279
+ function setHere(route) {
280
+ here = route;
281
+ paint();
282
+ }
283
+ return { setHere, setData, setUpdate, checkDevice, close, toggle, keydown, pointerdown };
284
+ }
285
+
286
+ globalThis.Sidebar = { mount };
287
+ })();