@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.
- package/CONTEXT.md +513 -0
- package/LICENSE +57 -0
- package/README.md +64 -2
- package/SKILL.md +142 -0
- package/bin/doctor.mjs +4 -0
- package/bin/install.mjs +3 -0
- package/bin/kickflip.mjs +180 -0
- package/bin/statusline.mjs +5 -0
- package/bin/wait-events.mjs +3 -0
- package/board/arch-review.mjs +5 -0
- package/board/bin/agent.mjs +2 -0
- package/board/bin/board.mjs +379 -0
- package/board/bin/builder-stop-hook.mjs +6 -0
- package/board/build-script.mjs +1 -0
- package/board/domain.mjs +5 -0
- package/board/on-host.mjs +1 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +188 -0
- package/board/public/app-banner.js +48 -0
- package/board/public/app-cards.js +393 -0
- package/board/public/app-core.js +130 -0
- package/board/public/app-header.js +126 -0
- package/board/public/app-lanes.js +142 -0
- package/board/public/app-needs.js +31 -0
- package/board/public/app-snapshot.js +131 -0
- package/board/public/app-threads.js +167 -0
- package/board/public/app.html +86 -0
- package/board/public/app.js +438 -0
- package/board/public/attach.js +84 -0
- package/board/public/dialogs.js +358 -0
- package/board/public/hub-kit.js +168 -0
- package/board/public/hub.css +170 -0
- package/board/public/kickflip.png +0 -0
- package/board/public/linear-key.js +141 -0
- package/board/public/prompts.js +64 -0
- package/board/public/prototype-kit.js +193 -0
- package/board/public/prototype-switcher.js +226 -0
- package/board/public/prototype.html +55 -0
- package/board/public/router.js +226 -0
- package/board/public/rules-editor.js +127 -0
- package/board/public/shell.js +298 -0
- package/board/public/sidebar.css +219 -0
- package/board/public/sidebar.js +359 -0
- package/board/public/style.css +665 -0
- package/board/public/toasts.js +181 -0
- package/board/public/ui-cards.js +218 -0
- package/board/public/ui-enums.js +10 -0
- package/board/public/ui-header.js +93 -0
- package/board/public/ui-hub.js +112 -0
- package/board/public/ui-links.js +150 -0
- package/board/public/ui-markdown.js +80 -0
- package/board/public/ui-order.js +18 -0
- package/board/public/ui-page-header.js +65 -0
- package/board/public/ui-prompts.js +39 -0
- package/board/public/ui-questions.js +151 -0
- package/board/public/ui-sidebar.js +215 -0
- package/board/public/ui-snapshot.js +62 -0
- package/board/public/ui-threads.js +90 -0
- package/board/public/ui.js +247 -0
- package/board/public/view-frame.js +93 -0
- package/board/public/view-hub-settings.js +282 -0
- package/board/public/view-hub.js +150 -0
- package/board/public/view-notes.js +32 -0
- package/board/public/view-polling.js +87 -0
- package/board/public/view-questions.js +411 -0
- package/board/public/view-settings.js +383 -0
- package/board/review.mjs +1 -0
- package/board/server.mjs +17 -0
- package/board/store.mjs +15 -0
- package/board/tickets.mjs +3 -0
- package/board/worktree.mjs +15 -0
- package/docs/hub-session.md +11 -0
- package/hooks-plugin/.claude-plugin/plugin.json +5 -0
- package/hooks-plugin/hooks/codex-share-auto.mjs +23 -0
- package/hooks-plugin/hooks/hooks.json +54 -0
- package/hooks-plugin/hooks/usage-gate.mjs +18 -0
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -0
- package/hub/boards.mjs +4 -0
- package/hub/close-board.mjs +1 -0
- package/hub/duration.mjs +1 -0
- package/hub/files.mjs +1 -0
- package/hub/github.mjs +4 -0
- package/hub/home.mjs +1 -0
- package/hub/hub.mjs +19 -0
- package/hub/icon-180.png +0 -0
- package/hub/icon-192.png +0 -0
- package/hub/icon-512.png +0 -0
- package/hub/icons.mjs +1 -0
- package/hub/linear.mjs +2 -0
- package/hub/live.mjs +2 -0
- package/hub/locks.mjs +1 -0
- package/hub/new-board.mjs +2 -0
- package/hub/permission-hook.mjs +3 -0
- package/hub/pr-report.mjs +1 -0
- package/hub/presence.mjs +1 -0
- package/hub/prompts.mjs +2 -0
- package/hub/push.mjs +2 -0
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -0
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -0
- package/hub/rules.mjs +1 -0
- package/hub/schedule.mjs +1 -0
- package/hub/sessions.mjs +2 -0
- package/hub/shell.mjs +2 -0
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +19 -0
- package/hub/sw.js +144 -0
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +4 -0
- package/hub/upgrade.mjs +1 -0
- package/hub/usage.mjs +3 -0
- package/hub/views.mjs +1 -0
- package/package.json +26 -4
- package/shared/codex.mjs +1 -0
- package/shared/colors.css +15 -0
- package/shared/fake-gh.mjs +54 -0
- package/shared/repos.mjs +1 -0
- package/shared/service.mjs +33 -0
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -0
- package/starter/cross-site.mjs +1 -0
- package/starter/public/claude.js +68 -0
- package/starter/public/index.html +49 -0
- package/starter/server.mjs +4 -0
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
// paint() sets the bar's duration from this and its delay from switching.started.
|
|
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, failedStart, 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' : data?.upgrade ? ', update available' : ''}${n ? `, ${n} waiting on other boards` : ''}`);
|
|
68
|
+
}
|
|
69
|
+
// switching: {opened, started, timer} while switch mode shows, opened when it opened the drawer, started on performance.now().
|
|
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
|
+
html.classList.toggle('sb-update', Boolean(update || data?.upgrade));
|
|
75
|
+
if (!data) return;
|
|
76
|
+
const scroll = aside.querySelector('.sb-scroll');
|
|
77
|
+
const top = painted ? scroll?.scrollTop : undefined;
|
|
78
|
+
// Screen readers announce a role="status" node only when its own content changes, so the node outlives the repaint.
|
|
79
|
+
const held = aside.querySelector('.sb-update');
|
|
80
|
+
aside.innerHTML = sidebar(data, { here, device, switching: Boolean(switching) });
|
|
81
|
+
if (switching) {
|
|
82
|
+
const bar = aside.querySelector('.ks-bar');
|
|
83
|
+
bar.style.animationDelay = `${switching.started - performance.now()}ms`;
|
|
84
|
+
bar.style.animationDuration = `${IDLE_MS}ms`;
|
|
85
|
+
}
|
|
86
|
+
const fresh = aside.querySelector('.sb-update');
|
|
87
|
+
if (held) fresh.replaceWith(held);
|
|
88
|
+
const row = updateRow(update, data.upgrade);
|
|
89
|
+
if (!held || row !== updateHTML) (held || fresh).innerHTML = row;
|
|
90
|
+
updateHTML = row;
|
|
91
|
+
const list = aside.querySelector('.sb-scroll');
|
|
92
|
+
if (list) list.scrollTop = top ?? list.scrollHeight;
|
|
93
|
+
painted = true;
|
|
94
|
+
logoCount();
|
|
95
|
+
}
|
|
96
|
+
function setData(next) {
|
|
97
|
+
if (!next) return;
|
|
98
|
+
data = next;
|
|
99
|
+
write(DATA_KEY, JSON.stringify(data));
|
|
100
|
+
paint();
|
|
101
|
+
}
|
|
102
|
+
function setUpdate(state) {
|
|
103
|
+
update = state;
|
|
104
|
+
paint();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isOpen = () => aside.classList.contains('open');
|
|
108
|
+
function open() {
|
|
109
|
+
if (rail.matches || isOpen()) return;
|
|
110
|
+
aside.classList.add('open');
|
|
111
|
+
const list = aside.querySelector('.sb-scroll');
|
|
112
|
+
if (list) list.scrollTop = list.scrollHeight;
|
|
113
|
+
scrim?.classList.add('on');
|
|
114
|
+
}
|
|
115
|
+
function close() {
|
|
116
|
+
if (!isOpen()) return;
|
|
117
|
+
aside.classList.remove('open');
|
|
118
|
+
scrim?.classList.remove('on');
|
|
119
|
+
}
|
|
120
|
+
// sb-anim slides the rail only on a tap, so a page that opens collapsed doesn't slide into it.
|
|
121
|
+
let settle = null;
|
|
122
|
+
function collapse(on) {
|
|
123
|
+
collapsed = on;
|
|
124
|
+
write(COLLAPSED_KEY, on ? '1' : '0');
|
|
125
|
+
html.classList.add('sb-anim');
|
|
126
|
+
html.classList.toggle('sb-collapsed', on);
|
|
127
|
+
clearTimeout(settle);
|
|
128
|
+
settle = setTimeout(() => html.classList.remove('sb-anim'), 400);
|
|
129
|
+
}
|
|
130
|
+
function toggle() {
|
|
131
|
+
if (rail.matches) collapse(!collapsed);
|
|
132
|
+
else if (isOpen()) close();
|
|
133
|
+
else open();
|
|
134
|
+
}
|
|
135
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
136
|
+
async function refresh() {
|
|
137
|
+
const response = await fetch('/_hub/sidebar.json');
|
|
138
|
+
if (!response.ok) throw new Error('Could not refresh boards.');
|
|
139
|
+
setData(await response.json());
|
|
140
|
+
}
|
|
141
|
+
function newBoard() {
|
|
142
|
+
close();
|
|
143
|
+
BoardDialogs.repoSheet({
|
|
144
|
+
async save(input, selected) {
|
|
145
|
+
const response = await fetch('/_hub/new-board', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) });
|
|
146
|
+
if (!response.ok) throw new Error(await response.text());
|
|
147
|
+
const { route } = await response.json();
|
|
148
|
+
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 }] });
|
|
149
|
+
if (rail.matches) collapse(false); else open();
|
|
150
|
+
aside.querySelector(`[data-starting="${route}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
151
|
+
refresh().catch(() => {});
|
|
152
|
+
},
|
|
153
|
+
onClose: () => aside.querySelector('[data-new-board]')?.focus({ preventScroll: true }),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
aside.addEventListener('click', (e) => {
|
|
157
|
+
const refreshApp = e.target.closest?.('[data-app-reload]');
|
|
158
|
+
if (refreshApp) {
|
|
159
|
+
if (!refreshApp.disabled && update !== 'reloading') { setUpdate('reloading'); reload(); }
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const upgradeApp = e.target.closest?.('[data-app-upgrade]');
|
|
163
|
+
if (upgradeApp) { if (!upgradeApp.disabled) upgrade(upgradeApp); return; }
|
|
164
|
+
if (e.target.closest?.('[data-new-board]')) { newBoard(); return; }
|
|
165
|
+
const failed = e.target.closest?.('[data-failed-start]');
|
|
166
|
+
if (failed) openFailed(failed.dataset.failedStart);
|
|
167
|
+
});
|
|
168
|
+
// Upgrade asks the hub to run kickflip update (docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md). The row
|
|
169
|
+
// keeps its words until the hub answers with what it did, and a refusal shows as a failed update.
|
|
170
|
+
let upgrading = false;
|
|
171
|
+
async function upgrade(button) {
|
|
172
|
+
if (upgrading) return;
|
|
173
|
+
upgrading = true;
|
|
174
|
+
button.disabled = true;
|
|
175
|
+
let next;
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch('/_hub/upgrade', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
178
|
+
const body = await response.json().catch(() => ({}));
|
|
179
|
+
next = response.ok ? body.upgrade : { ...data.upgrade, state: 'failed', error: body.error || 'The hub didn’t answer.' };
|
|
180
|
+
} catch {
|
|
181
|
+
next = { ...data.upgrade, state: 'failed', error: 'The hub didn’t answer.' };
|
|
182
|
+
} finally { upgrading = false; }
|
|
183
|
+
setData({ ...data, upgrade: next });
|
|
184
|
+
}
|
|
185
|
+
// A failed start's sheet (docs/adr/0130-a-failed-start-retries-from-its-sheet.md). Dismiss deletes its Starting
|
|
186
|
+
// record; Retry starts it again, and its row reads Starting… until the hub's next copy says otherwise.
|
|
187
|
+
function openFailed(route) {
|
|
188
|
+
const s = (data?.starting || []).find((x) => x.route === route && x.failed);
|
|
189
|
+
if (!s || document.getElementById('failedStartSheet')) return;
|
|
190
|
+
const previous = document.activeElement;
|
|
191
|
+
const el = document.createElement('dialog');
|
|
192
|
+
el.setAttribute('id', 'failedStartSheet');
|
|
193
|
+
el.setAttribute('class', 'sheet');
|
|
194
|
+
el.setAttribute('aria-labelledby', 'failedStartTitle');
|
|
195
|
+
el.innerHTML = failedStart(s, { home: data.home });
|
|
196
|
+
let sending = false;
|
|
197
|
+
const done = () => { if (!sending) el.close(); };
|
|
198
|
+
async function act(request, after) {
|
|
199
|
+
sending = true;
|
|
200
|
+
el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = true; });
|
|
201
|
+
const refused = el.querySelector('.fs-refused');
|
|
202
|
+
refused.hidden = true;
|
|
203
|
+
try {
|
|
204
|
+
const response = await fetch(`/_hub/new-board/${encodeURIComponent(route)}${request.path || ''}`, request.options);
|
|
205
|
+
if (!response.ok) throw new Error(await response.text());
|
|
206
|
+
after();
|
|
207
|
+
sending = false;
|
|
208
|
+
el.close();
|
|
209
|
+
refresh().catch(() => {});
|
|
210
|
+
} catch (e) {
|
|
211
|
+
sending = false;
|
|
212
|
+
el.querySelectorAll('.fs-actions button').forEach((b) => { b.disabled = false; });
|
|
213
|
+
refused.textContent = e.message;
|
|
214
|
+
refused.hidden = false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
el.addEventListener('click', (e) => {
|
|
218
|
+
if (sending) return;
|
|
219
|
+
if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
|
|
220
|
+
if (e.target.closest('[data-failed-dismiss]')) {
|
|
221
|
+
act({ options: { method: 'DELETE' } }, () => setData({ ...data, starting: data.starting.filter((x) => x.route !== route) }));
|
|
222
|
+
} else if (e.target.closest('[data-failed-retry]')) {
|
|
223
|
+
act({ path: '/retry', options: { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' } }, () => {
|
|
224
|
+
const { error, ...rest } = s;
|
|
225
|
+
const again = { ...rest, phase: 'starting', askedAt: new Date().toISOString(), failed: false };
|
|
226
|
+
setData({ ...data, starting: data.starting.map((x) => (x.route === route ? again : x)) });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
|
|
231
|
+
el.addEventListener('close', () => {
|
|
232
|
+
el.remove();
|
|
233
|
+
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
234
|
+
});
|
|
235
|
+
document.body.appendChild(el);
|
|
236
|
+
el.showModal();
|
|
237
|
+
(el.querySelector('[data-failed-retry]') || el.querySelector('[data-failed-dismiss]')).focus({ preventScroll: true });
|
|
238
|
+
}
|
|
239
|
+
document.addEventListener('click', (e) => {
|
|
240
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
241
|
+
if (e.target.closest?.('[data-sidebar-close]')) { e.preventDefault(); if (rail.matches) collapse(true); else close(); return; }
|
|
242
|
+
if (!pageLogo(e.target.closest?.('.top a.hub'))) return;
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
toggle();
|
|
245
|
+
});
|
|
246
|
+
scrim?.addEventListener('click', close);
|
|
247
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !e.defaultPrevented && isOpen()) close(); });
|
|
248
|
+
|
|
249
|
+
let lastS = -Infinity;
|
|
250
|
+
function startSwitch() {
|
|
251
|
+
lastS = -Infinity;
|
|
252
|
+
switching = { opened: !rail.matches && !isOpen(), started: performance.now(), timer: setTimeout(() => endSwitch(), IDLE_MS) };
|
|
253
|
+
if (switching.opened) open();
|
|
254
|
+
aside.classList.add('ks-on');
|
|
255
|
+
paint();
|
|
256
|
+
}
|
|
257
|
+
function endSwitch({ keepDrawer = false } = {}) {
|
|
258
|
+
if (!switching) return;
|
|
259
|
+
clearTimeout(switching.timer);
|
|
260
|
+
const { opened } = switching;
|
|
261
|
+
switching = null;
|
|
262
|
+
// No repaint here: a click that ended it must land on the row it pressed, so the keys only hide (sidebar.css).
|
|
263
|
+
aside.classList.remove('ks-on');
|
|
264
|
+
if (opened && !keepDrawer) close();
|
|
265
|
+
}
|
|
266
|
+
// A framed page's event comes from another window, so its target is checked by duck type, not instanceof.
|
|
267
|
+
const inField = (t) => Boolean(t?.closest?.(FIELD));
|
|
268
|
+
const dialogOpen = (t) => Boolean(document.querySelector('dialog[open]') || t?.ownerDocument?.querySelector?.('dialog[open]'));
|
|
269
|
+
|
|
270
|
+
// help: {el, previous} while the shortcuts sheet shows, previous holding the focus to give back.
|
|
271
|
+
let help = null;
|
|
272
|
+
let lastH = -Infinity;
|
|
273
|
+
function closeHelp() {
|
|
274
|
+
if (!help) return;
|
|
275
|
+
const { el, previous } = help;
|
|
276
|
+
help = null;
|
|
277
|
+
if (el.open) el.close();
|
|
278
|
+
el.remove();
|
|
279
|
+
if (previous?.isConnected) previous.focus({ preventScroll: true });
|
|
280
|
+
}
|
|
281
|
+
function toggleHelp() {
|
|
282
|
+
if (help) { closeHelp(); return; }
|
|
283
|
+
const el = document.createElement('dialog');
|
|
284
|
+
el.setAttribute('id', 'shortcutsSheet');
|
|
285
|
+
el.setAttribute('class', 'sheet shortcuts-sheet');
|
|
286
|
+
el.setAttribute('aria-labelledby', 'shortcutsTitle');
|
|
287
|
+
el.innerHTML = shortcuts();
|
|
288
|
+
el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
|
|
289
|
+
el.addEventListener('close', closeHelp);
|
|
290
|
+
el.addEventListener('click', (e) => { if (e.target === el) closeHelp(); });
|
|
291
|
+
help = { el, previous: document.activeElement };
|
|
292
|
+
document.body.appendChild(el);
|
|
293
|
+
el.showModal();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function keydown(e) {
|
|
297
|
+
if (MODIFIERS.has(e.key) || e.isComposing || e.defaultPrevented) return;
|
|
298
|
+
const mods = e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
|
|
299
|
+
if (help && e.key === 'Escape') { e.preventDefault(); closeHelp(); return; }
|
|
300
|
+
if (switching) {
|
|
301
|
+
if (e.key === 'Escape') { e.preventDefault(); endSwitch(); return; }
|
|
302
|
+
if (mods || inField(e.target)) { endSwitch(); return; }
|
|
303
|
+
e.preventDefault();
|
|
304
|
+
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
|
|
305
|
+
const row = switchKeys(data).find((k) => k.num === key || k.letter === key);
|
|
306
|
+
endSwitch({ keepDrawer: Boolean(row) });
|
|
307
|
+
if (row) openRow(row.href);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const key = e.key.toLowerCase();
|
|
311
|
+
if (key !== 's') lastS = -Infinity;
|
|
312
|
+
if (key !== 'h') lastH = -Infinity;
|
|
313
|
+
if ((key !== 's' && key !== 'h') || e.repeat) return;
|
|
314
|
+
if (mods || inField(e.target)) { lastS = lastH = -Infinity; return; }
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
if (key === 'h') {
|
|
317
|
+
if (!matchMedia(DESKTOP).matches || (!help && dialogOpen(e.target))) { lastH = -Infinity; return; }
|
|
318
|
+
if (now - lastH <= TWICE_MS) { lastH = -Infinity; e.preventDefault(); toggleHelp(); return; }
|
|
319
|
+
lastH = now;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (dialogOpen(e.target) || !data) { lastS = -Infinity; return; }
|
|
323
|
+
if (now - lastS <= TWICE_MS) { e.preventDefault(); startSwitch(); return; }
|
|
324
|
+
lastS = now;
|
|
325
|
+
}
|
|
326
|
+
function pointerdown() { endSwitch({ keepDrawer: true }); }
|
|
327
|
+
// Capturing, so switch mode sees a key before any view's own shortcut does.
|
|
328
|
+
document.addEventListener('keydown', keydown, true);
|
|
329
|
+
document.addEventListener('pointerdown', pointerdown, true);
|
|
330
|
+
rail.addEventListener?.('change', () => { endSwitch(); close(); });
|
|
331
|
+
// A swipe opens from the left edge and closes back toward it; a mostly vertical drag is a scroll.
|
|
332
|
+
let swipe = null;
|
|
333
|
+
addEventListener('touchstart', (e) => {
|
|
334
|
+
const t = e.touches[0];
|
|
335
|
+
swipe = !rail.matches && (isOpen() || t.clientX < 24) ? { x: t.clientX, y: t.clientY, opening: !isOpen() } : null;
|
|
336
|
+
}, { passive: true });
|
|
337
|
+
addEventListener('touchmove', (e) => {
|
|
338
|
+
if (!swipe) return;
|
|
339
|
+
const t = e.touches[0];
|
|
340
|
+
const dx = t.clientX - swipe.x;
|
|
341
|
+
if (Math.abs(t.clientY - swipe.y) > Math.abs(dx)) { swipe = null; return; }
|
|
342
|
+
if (swipe.opening && dx > 40) { open(); swipe = null; } else if (!swipe.opening && dx < -50) { close(); swipe = null; }
|
|
343
|
+
}, { passive: true });
|
|
344
|
+
|
|
345
|
+
const checkDevice = () => deviceState().then((d) => { device = d; paint(); });
|
|
346
|
+
paint();
|
|
347
|
+
checkDevice();
|
|
348
|
+
// Coming back to a page from the back-forward cache shows the drawer as it was left.
|
|
349
|
+
addEventListener('pageshow', (e) => { if (e.persisted) close(); });
|
|
350
|
+
|
|
351
|
+
function setHere(route) {
|
|
352
|
+
here = route;
|
|
353
|
+
paint();
|
|
354
|
+
}
|
|
355
|
+
return { setHere, setData, setUpdate, checkDevice, close, toggle, keydown, pointerdown };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
globalThis.Sidebar = { mount };
|
|
359
|
+
})();
|