@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,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,266 @@
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 saves
3
+ // at once. The rules editor saves through POST /_hub/settings/rules and marks [data-dirty] while an edit is unsaved, so
4
+ // the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
5
+ (function () {
6
+ // The last data, which a return to the view paints at once.
7
+ let last = null;
8
+ const { esc } = HubUI;
9
+ const ACTION = '/_hub/settings/notifications';
10
+
11
+ // datetime-local speaks the device's clock; the hidden snooze field carries its UTC ISO value to the hub.
12
+ const localInput = (d) => {
13
+ const pad = (n) => String(n).padStart(2, '0');
14
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
15
+ };
16
+ function customBounds(input, reset = false) {
17
+ const now = Date.now();
18
+ input.min = localInput(new Date(now + 60_000));
19
+ input.max = localInput(new Date(now + 30 * 86_400_000));
20
+ if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
21
+ }
22
+
23
+ function snoozeRow(d) {
24
+ const icon = HubUI.tile('snooze', '#a855f7');
25
+ if (d.snoozed) return `<div class="row snooze-on">${icon}<span class="body"><span class="text title">${esc(d.snoozed.title)}</span><form method="post" action="${ACTION}"><button class="btn quiet" name="snooze" value="off">End</button></form></span></div>`;
26
+ if (!d.snooze) return '';
27
+ const chips = d.snooze.choices.map((c) => `<button class="btn quiet" name="snooze" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
28
+ return `<div class="block" role="group" aria-labelledby="snoozeTitle"><div class="snooze-head">${icon}<span class="title" id="snoozeTitle">Snooze</span></div><div class="snooze-chips"><form class="snooze-choices" method="post" action="${ACTION}">${chips}</form><form class="snooze-custom" method="post" action="${ACTION}"><input type="hidden" name="snooze"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-snooze-at required aria-label="Custom snooze until a date and time"></form></div></div>`;
29
+ }
30
+
31
+ // The on/off, snooze and weekend rows (docs/adr/0032-notifications-can-be-off-snoozed-or-off-on-weekends.md).
32
+ function notificationRows(d) {
33
+ const { tile } = HubUI;
34
+ 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>`;
35
+ const onRow = toggle({ icon: tile('power', d.on.on ? '#22c55e' : '#6b7280'), set: 'on', title: 'Push notifications', checked: d.on.on,
36
+ attrs: ` id="notifyOn" data-confirm="${esc(d.on.confirm)}"` });
37
+ const weekendRow = toggle({ icon: tile('calendar', '#0ea5e9'), set: 'weekends', title: 'Off on weekends', checked: d.weekends.on, attrs: ' data-submit' });
38
+ return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
39
+ }
40
+
41
+ const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
42
+ const sectionOf = (hash) => {
43
+ const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
44
+ return sections.some(([section]) => section === id) ? id : null;
45
+ };
46
+ 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('')}
47
+ <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>`;
48
+
49
+ globalThis.HubSettings = {
50
+ markup,
51
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
52
+ const top = B.$('.hs-flash');
53
+ const modal = B.$('#hubSettingsModal');
54
+ const content = B.$('.setting-content');
55
+ let active = null;
56
+ let settingsURL = '/_hub/';
57
+ let returnFocus = null;
58
+ let unfitModal = null;
59
+ let polling = null;
60
+ let flash = null;
61
+ let rules = null;
62
+ let rulesJSON = '';
63
+
64
+ function paint() {
65
+ if (!last) return;
66
+ const { row, tile, deviceRow } = HubUI;
67
+ top.innerHTML = HubUI.flash(flash);
68
+ const scrollTop = B.$('.settings-body').scrollTop;
69
+ if (active === 'notifications') {
70
+ 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>`;
71
+ const input = B.$('[data-snooze-at]');
72
+ if (input) customBounds(input, true);
73
+ } else if (active === 'linear') {
74
+ const typed = B.$('#linearKey')?.value || '';
75
+ const l = last.linear;
76
+ const remove = l.set ? `<form method="post" action="/_hub/settings/linear"><input type="hidden" name="remove" value="1"><button type="button" class="btn quiet danger" data-linear-remove>Remove</button></form>` : '';
77
+ content.innerHTML = `<div class="group">${row({ icon: tile('key', l.set ? '#5e6ad2' : '#6b7280'), title: esc(l.title), sub: esc(l.sub), wrap: !l.set, trail: remove })}<form class="block lk-form" method="post" action="/_hub/settings/linear"><input id="linearKey" name="key" type="password" required placeholder="${l.set ? 'Paste a new key to replace it' : 'Paste a Linear API key'}" aria-label="Linear API key" autocomplete="off" autocapitalize="off" spellcheck="false"><button class="btn">Save</button></form></div><p class="note">Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. Without a key, each board’s session does that through its Linear MCP.</p>`;
78
+ B.$('#linearKey').value = typed;
79
+ } else if (active === 'closed') {
80
+ 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>`;
81
+ }
82
+ B.$('.settings-body').scrollTop = scrollTop;
83
+ const summaries = {
84
+ notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.title, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
85
+ polling: last.polling,
86
+ linear: last.linear?.summary || 'No key',
87
+ rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
88
+ closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
89
+ };
90
+ for (const [id] of sections) B.$(`[data-summary="${id}"]`).textContent = summaries[id];
91
+ if (active !== 'rules') return;
92
+ const rulesBox = B.$('#hubRules');
93
+ const json = JSON.stringify(last.rules);
94
+ if (!rules) {
95
+ rules = RulesEditor.mount(rulesBox, {
96
+ rules: last.rules, buttons: { primary: 'btn', quiet: 'btn quiet', icon: 'btn quiet' },
97
+ empty: 'No rules for every board yet.', placeholder: 'Add a rule, such as: Screenshots in every PR that changes a page',
98
+ async save(body) {
99
+ const r = await fetch('/_hub/settings/rules', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
100
+ const out = await r.json().catch(() => ({}));
101
+ if (!r.ok) throw new Error(out.error || 'Could not save. Is the host awake?');
102
+ const n = out.events.length;
103
+ last.rules = out.rules;
104
+ B.$('[data-summary="rules"]').textContent = out.rules.length ? `${out.rules.length} rule${out.rules.length === 1 ? '' : 's'}` : 'None yet';
105
+ rulesJSON = JSON.stringify(out.rules);
106
+ 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.` : '.'}` };
107
+ },
108
+ });
109
+ rulesJSON = json;
110
+ } else if (json !== rulesJSON && !rulesBox.hasAttribute('data-dirty')) {
111
+ rules.set(last.rules);
112
+ rulesJSON = json;
113
+ }
114
+ }
115
+
116
+ // hub-kit.js device() titles its row 'This device' only where this device can take pushes.
117
+ function deviceSummary() {
118
+ const d = device.state('');
119
+ if (!d) return '';
120
+ if (d.title !== 'This device') return d.title;
121
+ return d.sub?.startsWith('On') ? 'This device on' : 'This device off';
122
+ }
123
+
124
+ function closeModal(restore = true) {
125
+ polling?.unmount(); polling = null;
126
+ unfitModal?.(); unfitModal = null;
127
+ modal.close();
128
+ content.innerHTML = '';
129
+ rules = null;
130
+ active = null;
131
+ if (restore && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
132
+ }
133
+ // Also used by shell.js for legacy links, pushes, back and forward. Replacing the URL keeps Hub's history.
134
+ B.openSettings = async (view, opener) => {
135
+ const id = sectionOf(view?.hash);
136
+ if (active && id !== active && modal.querySelector('[data-dirty]')) {
137
+ if (!(await BoardDialogs.confirm('Leave without saving? Your unsaved edits here will be lost.'))) return false;
138
+ if (!B.alive()) return false;
139
+ }
140
+ if (id !== active) {
141
+ closeModal(!id);
142
+ active = id;
143
+ if (id) {
144
+ returnFocus = opener || B.$(`#${id}`);
145
+ B.$('#hubSettingsTitle').textContent = sections.find(([section]) => section === id)[1];
146
+ content.innerHTML = id === 'rules' ? '<div id="hubRules" class="rules-box"></div>' : '<p class="note">Loading…</p>';
147
+ flash = null;
148
+ paint();
149
+ unfitModal = BoardDialogs.fitSheet(modal);
150
+ ctx.closeSidebar?.();
151
+ modal.showModal();
152
+ B.$('.settings-body').scrollTop = 0;
153
+ B.$('#hubSettingsTitle').focus({ preventScroll: true });
154
+ if (id === 'polling') {
155
+ polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
156
+ polling.mount();
157
+ }
158
+ }
159
+ } else if (id === 'polling') polling?.B.selectWindow?.(view.w || null);
160
+ settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}), ...(id === 'polling' && view.w ? { w: view.w } : {}) });
161
+ history.replaceState(history.state, '', settingsURL);
162
+ return true;
163
+ };
164
+ const dismiss = () => B.openSettings({ kind: 'hub' });
165
+ B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
166
+ B.on(root, 'click', (e) => {
167
+ const opener = e.target.closest?.('[data-settings-open]');
168
+ if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
169
+ if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
170
+ if (e.target === modal) {
171
+ const r = modal.getBoundingClientRect();
172
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
173
+ }
174
+ });
175
+ B.on(window, 'hashchange', async () => {
176
+ if (!(await B.openSettings(AppRouter.viewOf(location.href))) && B.alive()) history.replaceState(history.state, '', settingsURL);
177
+ });
178
+ let reopening = null;
179
+ B.atUnmount(() => closeModal(false));
180
+ const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
181
+ device.ready.then(() => { if (B.alive()) paint(); });
182
+ let openPicker = null;
183
+ B.on(root, 'click', (e) => {
184
+ const input = e.target.closest?.('[data-snooze-at]');
185
+ if (!input) return;
186
+ openPicker = input;
187
+ customBounds(input, input.value === input.defaultValue);
188
+ try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
189
+ });
190
+ // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
191
+ B.on(root, 'pointerdown', (e) => {
192
+ const input = e.target.closest?.('.snooze-choices button') && B.$('[data-snooze-at]');
193
+ if (!input) return;
194
+ openPicker = null;
195
+ input.value = input.defaultValue;
196
+ });
197
+ // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
198
+ const saveCustom = (input) => {
199
+ if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
200
+ const at = new Date(input.value);
201
+ if (Number.isNaN(at.getTime())) return;
202
+ input.form.querySelector('[name="snooze"]').value = at.toISOString();
203
+ input.form.requestSubmit();
204
+ openPicker = null;
205
+ input.defaultValue = input.value;
206
+ };
207
+ B.on(root, 'change', (e) => {
208
+ if (e.target.matches?.('[data-snooze-at]') && document.activeElement !== e.target) saveCustom(e.target);
209
+ });
210
+ B.on(root, 'focusout', (e) => {
211
+ if (e.target.matches?.('[data-snooze-at]')) saveCustom(e.target);
212
+ });
213
+ B.on(root, 'click', async (e) => {
214
+ const button = e.target.closest?.('[data-reopen]');
215
+ if (!button || reopening) return;
216
+ reopening = button.dataset.reopen;
217
+ flash = null;
218
+ paint();
219
+ try {
220
+ const r = await fetch('/_hub/reopen-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ route: reopening }) });
221
+ const out = await r.json();
222
+ if (!r.ok) throw new Error(out.error || 'Could not reopen board.');
223
+ last.closed = (last.closed || []).filter((b) => b.route !== out.route);
224
+ ctx.sidebarChanged?.();
225
+ flash = { text: 'Board reopened. Its recorded session is resuming.' };
226
+ } catch (error) { flash = { text: error.message || 'Could not reopen board. Is the host awake?', error: true }; }
227
+ reopening = null;
228
+ if (B.alive()) paint();
229
+ });
230
+ B.on(root, 'click', async (e) => {
231
+ const remove = e.target.closest?.('[data-linear-remove]');
232
+ if (remove && await BoardDialogs.confirm('Remove the Linear key? Boards on Linear can’t create issues or move tickets until you add one again.', { ok: 'Remove' }) && B.alive()) remove.closest('form').requestSubmit();
233
+ });
234
+ B.on(root, 'change', async (e) => {
235
+ const on = e.target;
236
+ if (on.id !== 'notifyOn') return;
237
+ if (on.checked || await BoardDialogs.confirm(on.dataset.confirm)) on.form.requestSubmit();
238
+ else on.checked = true;
239
+ });
240
+ const forms = HubKit.forms(B, {
241
+ accepts: (action) => action !== '/_hub/push/flush' && action !== '/_hub/settings/polling',
242
+ answer(out) {
243
+ last = out;
244
+ flash = null;
245
+ paint();
246
+ ctx.sidebarChanged?.();
247
+ },
248
+ fail(message) {
249
+ flash = { text: message, error: true };
250
+ paint();
251
+ },
252
+ });
253
+ B.refreshSettings = HubKit.live(B, {
254
+ url: () => '/_hub/api/settings',
255
+ paint(d) {
256
+ if (!d) { if (!last) top.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
257
+ last = d;
258
+ paint();
259
+ },
260
+ busy: () => Boolean(reopening) || device.busy() || forms.busy() || Boolean(root.querySelector('dialog[open], [data-dirty], input:focus, textarea:focus')),
261
+ });
262
+ paint();
263
+ B.openSettings(ctx.view);
264
+ }),
265
+ };
266
+ })();
@@ -0,0 +1,99 @@
1
+ // The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Codex share, Needs you for the pending
2
+ // prompts of sessions that own no board, the hub's health, each site's health and board links,
3
+ // polling status, then hub settings summary rows (ADR 0075). Health comes from GET /_hub/api/hub.
4
+ // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
5
+ (function () {
6
+ // The last data, which a return to the view paints at once.
7
+ let last = null;
8
+ const { esc } = HubUI;
9
+
10
+ function rememberedHtml(sessions = []) {
11
+ return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
12
+ }
13
+
14
+ function sitesHtml(groups) {
15
+ const { row, CHEVRON } = HubUI;
16
+ const site = (s) => {
17
+ const lines = s.lines.length ? `<span class="links">${s.lines.map((l) => `<span><b>${esc(l.link)}</b> ${esc(l.detail)}</span>`).join('')}</span>` : '';
18
+ return row({ icon: s.icon, title: esc(s.title), sub: `<span class="st ${esc(s.tone)}">${esc(s.word)}</span>${lines}`, wrap: true, trail: CHEVRON, href: s.href }) + rememberedHtml(s.remembered);
19
+ };
20
+ return groups.map((g) => `<h2 class="label">${esc(g.label)}</h2>\n<div class="group">${g.sites.map(site).join('')}</div>\n`).join('');
21
+ }
22
+
23
+ // A pending prompt from a session that owns no board waits here (prompts.js).
24
+ function promptsHtml(list, prompts) {
25
+ const shown = list.filter(prompts.shows);
26
+ return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
27
+ }
28
+
29
+ function html(d, { flash, prompts }) {
30
+ const { row, tile, nowRow } = HubUI;
31
+ const usage = HubUI.usageCard(d.usage, d.codex, d.pause);
32
+ return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
33
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}
34
+ ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
35
+ ${sitesHtml(d.groups)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
36
+ <h2 class="label">Polling status</h2>
37
+ <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div>`;
38
+ }
39
+
40
+ const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
41
+
42
+ // iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
43
+ function badge(n) {
44
+ if (!('setAppBadge' in navigator)) return;
45
+ try { (n ? navigator.setAppBadge(n) : navigator.clearAppBadge()).catch(() => {}); } catch { /* not allowed here */ }
46
+ }
47
+
48
+ (globalThis.AppViews ||= {}).hub = {
49
+ className: 'hub-view view-hub',
50
+ title: () => 'Hub',
51
+ markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', settingsHref: null })}
52
+ <main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
53
+
54
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
55
+ const main = B.$('.hub-status');
56
+ // A change in the Notifications modal can start or end the hold the banner shows.
57
+ const settings = HubSettings.create(B.$('.hub-settings'), siteRoot, { ...ctx, sidebarChanged: () => { ctx.sidebarChanged?.(); load(); } });
58
+ settings.mount();
59
+ B.atUnmount(() => settings.unmount());
60
+ B.on(B.$('#refresh'), 'click', () => settings.B.refreshSettings?.());
61
+ let flash = null;
62
+ B.openSettings = (...args) => settings.B.openSettings(...args);
63
+ const foot = B.$('.hub-foot');
64
+ const paint = () => {
65
+ if (!last) return;
66
+ main.innerHTML = html(last, { flash, prompts });
67
+ foot.innerHTML = footer(last);
68
+ };
69
+ const prompts = PromptAnswers.wire(B, {
70
+ list: () => last?.prompts || [],
71
+ paint,
72
+ notify(text) { flash = { text, error: true }; paint(); },
73
+ after: () => load(),
74
+ });
75
+ const forms = HubKit.forms(B, {
76
+ accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
77
+ answer(out, action) {
78
+ if (['/_hub/settings/codex', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
79
+ last = out; flash = null; paint();
80
+ if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.` : 'Stopped: new work waits again.');
81
+ } else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
82
+ ctx.sidebarChanged?.();
83
+ },
84
+ fail(message) { flash = { text: message, error: true }; paint(); },
85
+ });
86
+ const load = HubKit.live(B, {
87
+ url: () => '/_hub/api/hub',
88
+ paint(d) {
89
+ if (!d) { if (!last) main.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
90
+ last = d;
91
+ paint();
92
+ badge(Number(d.total) || 0);
93
+ },
94
+ busy: () => forms.busy() || Boolean(root.querySelector('[data-dirty]')) || Boolean(root.querySelector('dialog[open]')),
95
+ });
96
+ paint();
97
+ }),
98
+ };
99
+ })();
@@ -0,0 +1,29 @@
1
+ // An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
2
+ // (docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
3
+ (globalThis.AppViews ||= {}).notes = {
4
+ className: 'view-notes',
5
+ title: () => 'Notes',
6
+ markup: ({ item }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: `./#item=${encodeURIComponent(item || '')}`, controls: false })}
7
+ <main>
8
+ <article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
9
+ </main>`,
10
+
11
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
12
+ const { $, site } = B;
13
+ const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
14
+ const key = ctx.view.item || '';
15
+ const el = $('#plan');
16
+ const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
17
+ try {
18
+ const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
19
+ const r = await get(site.url(`api/notes?item=${encodeURIComponent(key)}`), { onWait: () => { el.innerHTML = restartNotice(); } });
20
+ if (!B.alive()) return;
21
+ if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
22
+ $('#ref').textContent = `${r.body.ref} notes`;
23
+ document.title = `${r.body.ref} notes`;
24
+ el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
25
+ } catch (e) {
26
+ el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
27
+ }
28
+ }),
29
+ };
@@ -0,0 +1,87 @@
1
+ // HubPolling, the editor inside Hub’s Polling modal (ADR 0075): the day’s windows, overnight hold
2
+ // and daily wakes, in hub/views.mjs pollingView's words from GET /_hub/api/polling. ?w= names the window shown,
3
+ // which a tap on the day bar changes in place. Each change posts to /_hub/settings/polling and saves at once.
4
+ (function () {
5
+ // The last data, which a return to the view paints at once.
6
+ let last = null;
7
+ const { esc } = HubUI;
8
+ const ACTION = '/_hub/settings/polling';
9
+ // Each change posts op and w, the shown window's start.
10
+ const form = (op, w, inner, attrs = '') => `<form method="post" action="${ACTION}"${attrs}>${op ? `<input type="hidden" name="op" value="${op}">` : ''}<input type="hidden" name="w" value="${esc(w)}">${inner}</form>`;
11
+
12
+ function dayBar(day, sel) {
13
+ const segs = day.segments.map((g) => `<a class="seg${g.w === sel ? ' sel' : ''}${g.held ? ' held' : ''}" href="${esc(AppRouter.urlOf({ kind: 'hub', hash: '#polling', w: g.w }))}" data-w="${esc(g.w)}" style="left:${g.left};width:${g.width};--k:${g.shade}"`
14
+ + ` aria-label="${esc(g.aria)}"${g.w === sel ? ' aria-current="true"' : ''}>${g.label ? `<span>${esc(g.label)}</span>` : ''}</a>`).join('');
15
+ const pins = day.pins.map((p) => `<span class="pin" style="left:${p.left}" title="${esc(p.title)}" aria-hidden="true">▼</span>`).join('');
16
+ const ticks = ['12am', '6am', '12pm', '6pm', '12am'].map((t, i) => `<span style="left:${i * 25}%">${t}</span>`).join('');
17
+ const legend = ['<span><i style="background:var(--accent)"></i>darker checks more often</span>',
18
+ day.held && '<span><i style="background:repeating-linear-gradient(135deg,var(--accent) 0 3px,var(--chip) 3px 5px)"></i>pushes held</span>',
19
+ day.wakes && '<span style="color:var(--color-notice)">▼ wake</span>',
20
+ '<span><i style="background:var(--color-danger);width:2px"></i>now</span>'].filter(Boolean).join('');
21
+ return `<div class="day">${pins}<span class="now" style="left:${day.now}" aria-hidden="true"></span><div class="bar">${segs}</div><div class="ticks" aria-hidden="true">${ticks}</div></div><div class="legend">${legend}</div>`;
22
+ }
23
+
24
+ function html(d, sel, flash) {
25
+ const { row, tile, nowRow } = HubUI;
26
+ const w = d.windows.find((x) => x.from === sel);
27
+ const chips = w.every.map((c) => `<button class="btn${c.on ? '' : ' quiet'}" name="value" value="${c.minutes}"${c.on ? ' aria-pressed="true"' : ''}>${esc(c.label)}</button>`).join('');
28
+ const wakeChips = d.wakes.map((t) => `<button class="btn quiet" name="value" value="${esc(t.value)}" aria-label="Remove the ${esc(t.label)} wake">${esc(t.label)} ✕</button>`).join('');
29
+ return `${HubUI.flash(flash)}<h2 class="label" style="margin-top:4px">Now</h2>
30
+ <div class="group">${nowRow(d.now)}</div>
31
+ <h2 class="label" id="day">Your day</h2>
32
+ <div class="group"><div class="block">${dayBar(d.day, w.from)}</div></div>
33
+ <h2 class="label" id="window">${esc(w.range)}</h2>
34
+ <div class="group">
35
+ ${form('from', w.from, `<label class="body"><span class="text title">Starts</span><input type="time" name="value" value="${esc(w.from)}" required data-autosave></label>`, ' class="row field"')}
36
+ ${row({ title: 'Ends', trail: `<span class="val">${esc(w.ends)}</span>` })}
37
+ ${form('every', w.from, `<span class="title">Checks every</span><span class="chips">${chips}</span><span class="sub" style="margin-top:6px">${esc(w.checks)}</span>`, ' class="block"')}
38
+ ${form('', w.from, `<button class="btn quiet" name="op" value="split"${w.split ? '' : ' disabled'}>Split in two</button><button class="btn quiet danger" name="op" value="remove"${w.remove ? '' : ' disabled'}>Remove</button>`, ' class="block split"')}
39
+ </div>
40
+ <h2 class="label" id="quiet">Quiet times</h2>
41
+ <div class="group">${form('hold', w.from, `${tile('moon', '#6366f1')}<label class="body"><span class="text"><span class="title">Hold pushes overnight</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${d.hold.on ? ' checked' : ''}></label>`, ' class="row field"')}</div>
42
+ <h2 class="label" id="wakes">Daily wakes</h2>
43
+ <div class="group"><div class="block"><div class="chips" style="margin-top:0">${wakeChips ? form('wake-remove', w.from, wakeChips) : ''}${form('wake-add', w.from, '<input type="time" name="value" required aria-label="New wake time"><button class="btn quiet">+ Add</button>', ' class="field"')}</div></div></div>`;
44
+ }
45
+
46
+ globalThis.HubPolling = {
47
+ create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
48
+ const main = root;
49
+ let sel = ctx.view?.w || null;
50
+ let flash = null;
51
+ const shown = () => (last.windows.some((x) => x.from === sel) ? sel : last.select);
52
+ const paint = () => { if (last) main.innerHTML = html(last, shown(), flash); };
53
+ // The shown window lives in the URL, so a reload or a shared link shows it too.
54
+ const select = (w) => {
55
+ sel = w;
56
+ history.replaceState(history.state, '', AppRouter.urlOf({ kind: 'hub', hash: '#polling', w }));
57
+ paint();
58
+ };
59
+ B.selectWindow = select;
60
+ B.on(root, 'click', (e) => {
61
+ const seg = e.target.closest?.('a.seg[data-w]');
62
+ if (!seg || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
63
+ e.preventDefault();
64
+ select(seg.dataset.w);
65
+ });
66
+ const forms = HubKit.forms(B, {
67
+ answer(out, action) {
68
+ if (action === ACTION) { last = out; flash = null; select(out.select); }
69
+ else { flash = { text: out.message, error: !out.ok }; load(); }
70
+ ctx.changed?.();
71
+ ctx.sidebarChanged?.();
72
+ },
73
+ fail(message) { flash = { text: message, error: true }; paint(); },
74
+ });
75
+ const load = HubKit.live(B, {
76
+ url: () => `/_hub/api/polling${sel ? `?w=${encodeURIComponent(sel)}` : ''}`,
77
+ paint(d) {
78
+ if (!d) { if (!last) main.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; return; }
79
+ last = d;
80
+ paint();
81
+ },
82
+ busy: () => forms.busy() || Boolean(root.querySelector('input:focus')),
83
+ });
84
+ paint();
85
+ }),
86
+ };
87
+ })();