claude-code-hub 0.37.0 → 0.39.0-rc.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/README.md +110 -40
- package/lib/net-guard.js +20 -3
- package/package.json +7 -5
- package/public/app.js +206 -47
- package/public/index.html +131 -10
- package/public/locked.html +169 -0
- package/public/sw.js +12 -2
- package/server.js +367 -58
package/public/app.js
CHANGED
|
@@ -2,10 +2,15 @@ let apps = {};
|
|
|
2
2
|
let activeApp = null;
|
|
3
3
|
const iframes = {};
|
|
4
4
|
const loadedApps = new Set();
|
|
5
|
+
const guardedApps = new Set();
|
|
5
6
|
let allowedOrigins = new Set();
|
|
6
|
-
// {theme: 'dark'|'light', colorTheme: '<id>'} — survives hub
|
|
7
|
-
// late-loading iframes and fresh sessions get the last chosen theme.
|
|
7
|
+
// {theme: 'dark'|'light', colorTheme: '<id>', colorThemes: {<configDir>: '<id>'}} — survives hub
|
|
8
|
+
// reloads so late-loading iframes and fresh sessions get the last chosen theme. Light/dark is
|
|
9
|
+
// global; the color theme is per config dir, and colorTheme is the last one picked anywhere, which
|
|
10
|
+
// a dir that has never been themed inherits.
|
|
8
11
|
const themeState = loadThemeState();
|
|
12
|
+
// Keys themeState.colorThemes. Null until /api/config lands, so the first paint uses the fallback.
|
|
13
|
+
let activeConfigDir = null;
|
|
9
14
|
// {project, encoded, name} — the current project scope. Deliberately NOT persisted, unlike
|
|
10
15
|
// hub-theme: starting unset means there is nothing to race against each sub-app's own
|
|
11
16
|
// project self-restore on boot. Once set it never returns to null.
|
|
@@ -24,11 +29,19 @@ const palette = {
|
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
function loadThemeState() {
|
|
32
|
+
let state;
|
|
27
33
|
try {
|
|
28
|
-
|
|
34
|
+
state = JSON.parse(localStorage.getItem('hub-theme')) ?? {};
|
|
29
35
|
} catch {
|
|
30
|
-
|
|
36
|
+
state = {};
|
|
31
37
|
}
|
|
38
|
+
if (!state.colorThemes || typeof state.colorThemes !== 'object') state.colorThemes = {};
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The color theme of the active config dir, falling back to the last one picked anywhere.
|
|
43
|
+
function activeColorTheme() {
|
|
44
|
+
return themeState.colorThemes[activeConfigDir] ?? themeState.colorTheme;
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
function osTheme() {
|
|
@@ -38,21 +51,33 @@ function osTheme() {
|
|
|
38
51
|
// Sub-apps disagree on their first-run default (marketplace follows the OS, the rest start dark),
|
|
39
52
|
// so an unset theme resolves to the OS preference here and reaches every iframe on load.
|
|
40
53
|
function themeMessage() {
|
|
41
|
-
return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme:
|
|
54
|
+
return { type: 'hub:theme', theme: themeState.theme ?? osTheme(), colorTheme: activeColorTheme() };
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
// {<themeId>: {dark, light}} from /api/config, which derives it from
|
|
45
|
-
// registry that generates each sub-app's themes.css. Empty until
|
|
46
|
-
// registry is unreadable;
|
|
47
|
-
let
|
|
57
|
+
// {<themeId>: {dark, light}}, each a map of CSS variables, from /api/config, which derives it from
|
|
58
|
+
// scripts/themes.json — the same registry that generates each sub-app's themes.css. Empty until
|
|
59
|
+
// config arrives, and empty if the registry is unreadable; then the hub keeps index.html's colors.
|
|
60
|
+
let themePalettes = {};
|
|
61
|
+
// The last applied variables, so the loading screen paints in the theme before /api/config lands.
|
|
62
|
+
const PALETTE_KEY = 'hub-palette';
|
|
48
63
|
|
|
49
|
-
// Paints the
|
|
64
|
+
// Paints the hub's own surfaces (loading screen, palette) in the active theme. The hub relays
|
|
50
65
|
// hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
|
|
51
66
|
function applyHubTheme() {
|
|
52
67
|
const mode = themeState.theme ?? osTheme();
|
|
53
|
-
document.documentElement
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
const root = document.documentElement;
|
|
69
|
+
root.classList.toggle('light', mode === 'light');
|
|
70
|
+
const key = `${activeColorTheme()}/${mode}`;
|
|
71
|
+
let vars = themePalettes[activeColorTheme()]?.[mode];
|
|
72
|
+
try {
|
|
73
|
+
if (vars) localStorage.setItem(PALETTE_KEY, JSON.stringify({ key, vars }));
|
|
74
|
+
else {
|
|
75
|
+
const cached = JSON.parse(localStorage.getItem(PALETTE_KEY));
|
|
76
|
+
if (cached?.key === key) vars = cached.vars;
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
root.style.cssText = '';
|
|
80
|
+
for (const [name, value] of Object.entries(vars ?? {})) root.style.setProperty(name, value);
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
// Claude's on-disk project-directory name. Matches memory/server.js encodeProjectPath. The hub
|
|
@@ -91,6 +116,10 @@ function postTo(appId, message) {
|
|
|
91
116
|
iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
|
|
92
117
|
}
|
|
93
118
|
|
|
119
|
+
function appIdOf(source) {
|
|
120
|
+
return Object.keys(iframes).find((id) => iframes[id].contentWindow === source);
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
// exceptWindow lets an echo of a sub-app's own message skip that sub-app.
|
|
95
124
|
function broadcast(message, exceptWindow) {
|
|
96
125
|
for (const [id, iframe] of Object.entries(iframes)) {
|
|
@@ -119,12 +148,14 @@ async function init() {
|
|
|
119
148
|
// Twice: light/dark comes from localStorage and shouldn't wait on the fetch, the accent can't be
|
|
120
149
|
// resolved until the registry arrives with it.
|
|
121
150
|
applyHubTheme();
|
|
151
|
+
showLoading();
|
|
122
152
|
const res = await fetch('/api/config');
|
|
123
153
|
const config = await res.json();
|
|
124
154
|
setApps(config.apps);
|
|
125
155
|
defaultConfigDir = config.defaultConfigDir ?? null;
|
|
156
|
+
activeConfigDir = config.activeConfigDir ?? null;
|
|
126
157
|
applyTitle(config.activeConfigDir);
|
|
127
|
-
|
|
158
|
+
themePalettes = config.themePalettes ?? {};
|
|
128
159
|
applyHubTheme();
|
|
129
160
|
buildIframes();
|
|
130
161
|
switchTab(Object.keys(apps)[0]);
|
|
@@ -153,27 +184,91 @@ function reloadIframes() {
|
|
|
153
184
|
loadedApps.clear();
|
|
154
185
|
projectState = null;
|
|
155
186
|
// Assigning src navigates even when the URL is unchanged.
|
|
156
|
-
for (const [id, iframe] of Object.entries(iframes)) iframe.src =
|
|
157
|
-
}
|
|
187
|
+
for (const [id, iframe] of Object.entries(iframes)) iframe.src = appSrc(id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The terminal token rides in the fragment so it never reaches a server log or a Referer.
|
|
191
|
+
function appSrc(id, path = '') {
|
|
192
|
+
const token = apps[id].terminalToken;
|
|
193
|
+
return apps[id].url + path + (token ? `#t=${token}` : '');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const LOADING_VERBS = {
|
|
197
|
+
kanban: [
|
|
198
|
+
'Shuffling cards…',
|
|
199
|
+
'Herding tasks…',
|
|
200
|
+
'Waking sessions…',
|
|
201
|
+
'Sorting the board…',
|
|
202
|
+
'Pinning sticky notes…',
|
|
203
|
+
'Moving cards to done…',
|
|
204
|
+
'Nudging agents…',
|
|
205
|
+
'Drawing columns…',
|
|
206
|
+
'Reading the backlog…',
|
|
207
|
+
],
|
|
208
|
+
cost: [
|
|
209
|
+
'Counting tokens…',
|
|
210
|
+
'Tallying the bill…',
|
|
211
|
+
'Stacking bars…',
|
|
212
|
+
'Crunching numbers…',
|
|
213
|
+
'Checking the cache hits…',
|
|
214
|
+
'Adding up the days…',
|
|
215
|
+
'Balancing the books…',
|
|
216
|
+
'Plotting the trend…',
|
|
217
|
+
'Weighing the models…',
|
|
218
|
+
],
|
|
219
|
+
memory: [
|
|
220
|
+
'Recalling memories…',
|
|
221
|
+
'Walking the tree…',
|
|
222
|
+
'Dusting off notes…',
|
|
223
|
+
'Reading CLAUDE.md…',
|
|
224
|
+
'Following imports…',
|
|
225
|
+
'Tracing the context…',
|
|
226
|
+
'Flipping through pages…',
|
|
227
|
+
'Connecting the dots…',
|
|
228
|
+
'Checking what sticks…',
|
|
229
|
+
],
|
|
230
|
+
marketplace: [
|
|
231
|
+
'Unpacking plugins…',
|
|
232
|
+
'Polishing skills…',
|
|
233
|
+
'Dusting off agents…',
|
|
234
|
+
'Wiring up hooks…',
|
|
235
|
+
'Fetching manifests…',
|
|
236
|
+
'Reading SKILL.md files…',
|
|
237
|
+
'Listing commands…',
|
|
238
|
+
'Scanning MCP servers…',
|
|
239
|
+
'Gathering plugins…',
|
|
240
|
+
],
|
|
241
|
+
};
|
|
158
242
|
|
|
159
|
-
|
|
160
|
-
|
|
243
|
+
let loadingTimer = null;
|
|
244
|
+
|
|
245
|
+
function showLoading() {
|
|
246
|
+
const verbs = LOADING_VERBS[activeApp] ?? Object.values(LOADING_VERBS).flat();
|
|
247
|
+
const text = document.getElementById('loading-text');
|
|
248
|
+
let i = Math.floor(Math.random() * verbs.length);
|
|
249
|
+
text.textContent = verbs[i];
|
|
250
|
+
clearInterval(loadingTimer);
|
|
251
|
+
loadingTimer = setInterval(() => {
|
|
252
|
+
i = (i + 1 + Math.floor(Math.random() * (verbs.length - 1))) % verbs.length;
|
|
253
|
+
text.textContent = verbs[i];
|
|
254
|
+
}, 1200);
|
|
161
255
|
document.getElementById('loading-overlay').classList.remove('fade-out');
|
|
162
256
|
}
|
|
163
257
|
|
|
164
258
|
function hideLoading() {
|
|
259
|
+
clearInterval(loadingTimer);
|
|
165
260
|
document.getElementById('loading-text').textContent = '';
|
|
166
261
|
document.getElementById('loading-overlay').classList.add('fade-out');
|
|
167
262
|
}
|
|
168
263
|
|
|
169
264
|
function buildIframes() {
|
|
170
265
|
const container = document.getElementById('iframe-container');
|
|
171
|
-
for (const
|
|
266
|
+
for (const id of Object.keys(apps)) {
|
|
172
267
|
const iframe = document.createElement('iframe');
|
|
173
268
|
iframe.id = `iframe-${id}`;
|
|
174
|
-
iframe.src =
|
|
269
|
+
iframe.src = appSrc(id);
|
|
175
270
|
iframe.className = 'hidden';
|
|
176
|
-
iframe.allow = 'clipboard-write';
|
|
271
|
+
iframe.allow = 'clipboard-write; microphone';
|
|
177
272
|
iframe.addEventListener('load', () => onIframeLoad(id));
|
|
178
273
|
container.appendChild(iframe);
|
|
179
274
|
iframes[id] = iframe;
|
|
@@ -197,6 +292,7 @@ function switchTab(appId) {
|
|
|
197
292
|
|
|
198
293
|
function onIframeLoad(appId) {
|
|
199
294
|
loadedApps.add(appId);
|
|
295
|
+
guardedApps.delete(appId);
|
|
200
296
|
if (appId === activeApp) hideLoading();
|
|
201
297
|
postTo(appId, themeMessage());
|
|
202
298
|
postProjectTo(appId);
|
|
@@ -212,15 +308,38 @@ function onIframeLoad(appId) {
|
|
|
212
308
|
}
|
|
213
309
|
|
|
214
310
|
function listenMessages() {
|
|
311
|
+
window.addEventListener('beforeunload', (e) => {
|
|
312
|
+
if (guardedApps.size) e.preventDefault();
|
|
313
|
+
});
|
|
215
314
|
window.addEventListener('message', (e) => {
|
|
216
315
|
if (!allowedOrigins.has(e.origin)) return;
|
|
217
316
|
const data = e.data ?? {};
|
|
218
317
|
if (data.type === 'hub:navigate') {
|
|
219
318
|
if (!apps[data.app]) return;
|
|
220
319
|
switchTab(data.app);
|
|
221
|
-
if (data.url
|
|
320
|
+
if (typeof data.url !== 'string' || !data.url) return;
|
|
321
|
+
// A url such as '@evil.example/' turns the app host into userinfo, and the token rides in the fragment.
|
|
322
|
+
const src = appSrc(data.app, data.url);
|
|
323
|
+
if (URL.canParse(src) && new URL(src).origin === originOf(data.app)) iframes[data.app].src = src;
|
|
222
324
|
} else if (data.type === 'hub:keydown') {
|
|
223
325
|
handleForwardedKey(data);
|
|
326
|
+
} else if (data.type === 'hub:closeGuard') {
|
|
327
|
+
const appId = appIdOf(e.source);
|
|
328
|
+
if (!appId) return;
|
|
329
|
+
if (data.on === true) guardedApps.add(appId);
|
|
330
|
+
else guardedApps.delete(appId);
|
|
331
|
+
} else if (data.type === 'hub:terminalToken') {
|
|
332
|
+
// This hub page may have outlived the restart too, so the cached token is not trusted.
|
|
333
|
+
const appId = appIdOf(e.source);
|
|
334
|
+
if (!appId) return;
|
|
335
|
+
sendJson('GET', '/api/config')
|
|
336
|
+
.then((cfg) => {
|
|
337
|
+
const token = cfg.apps?.[appId]?.terminalToken;
|
|
338
|
+
if (!token) return;
|
|
339
|
+
apps[appId].terminalToken = token;
|
|
340
|
+
postTo(appId, { type: 'hub:terminalToken', token });
|
|
341
|
+
})
|
|
342
|
+
.catch(() => {});
|
|
224
343
|
} else if (data.type === 'hub:openExternal') {
|
|
225
344
|
// In the installed PWA window a framed sub-app's own target=_blank opens
|
|
226
345
|
// nothing, so the shims hand external links up to the top frame instead.
|
|
@@ -236,10 +355,13 @@ function listenMessages() {
|
|
|
236
355
|
// Legacy senders pass only {theme}; colorTheme is optional and sticky.
|
|
237
356
|
if (data.theme !== 'light' && data.theme !== 'dark') return;
|
|
238
357
|
const hasColor = typeof data.colorTheme === 'string' && /^[a-z0-9-]{0,32}$/.test(data.colorTheme);
|
|
239
|
-
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !==
|
|
358
|
+
const changed = data.theme !== themeState.theme || (hasColor && data.colorTheme !== activeColorTheme());
|
|
240
359
|
if (!changed) return;
|
|
241
360
|
themeState.theme = data.theme;
|
|
242
|
-
if (hasColor)
|
|
361
|
+
if (hasColor) {
|
|
362
|
+
themeState.colorTheme = data.colorTheme;
|
|
363
|
+
if (activeConfigDir) themeState.colorThemes[activeConfigDir] = data.colorTheme;
|
|
364
|
+
}
|
|
243
365
|
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
244
366
|
applyHubTheme();
|
|
245
367
|
broadcast(themeMessage(), e.source);
|
|
@@ -254,18 +376,30 @@ function handleForwardedKey(d) {
|
|
|
254
376
|
const alt = typeof d.alt === 'boolean' ? d.alt : true;
|
|
255
377
|
const ctrl = typeof d.alt === 'boolean' ? d.ctrl : d.key.startsWith('Arrow');
|
|
256
378
|
if (!alt || d.shift === true) return;
|
|
379
|
+
const k = bindingKey(d);
|
|
257
380
|
if (!ctrl) {
|
|
258
|
-
if (/^[1-9]$/.test(
|
|
381
|
+
if (/^[1-9]$/.test(k)) switchByIndex(Number(k) - 1);
|
|
259
382
|
return;
|
|
260
383
|
}
|
|
261
|
-
if (
|
|
262
|
-
else if (
|
|
263
|
-
else if (PALETTE_KEYS[
|
|
384
|
+
if (k === 'ArrowLeft') cycleTab(-1);
|
|
385
|
+
else if (k === 'ArrowRight') cycleTab(1);
|
|
386
|
+
else if (PALETTE_KEYS[k]) togglePalette(PALETTE_KEYS[k]);
|
|
264
387
|
}
|
|
265
388
|
|
|
266
389
|
// Ctrl+Alt+<letter> bindings. Sub-app shims forward every letter, so this is the one keymap.
|
|
267
390
|
const PALETTE_KEYS = { p: 'project', w: 'configDir' };
|
|
268
391
|
|
|
392
|
+
// macOS composes Option+<key> into a character — Option+1 is '¡', Option+P is 'π' — and holding
|
|
393
|
+
// Control does not undo it, so e.key alone cannot carry these bindings there. e.code is the
|
|
394
|
+
// physical key, which is wrong for non-US layouts, hence only as a fallback. Takes a real
|
|
395
|
+
// KeyboardEvent or a forwarded {key, code} payload; a payload without code degrades to key.
|
|
396
|
+
function bindingKey(e) {
|
|
397
|
+
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
398
|
+
if (/^[a-z1-9]$/.test(lower)) return lower;
|
|
399
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
400
|
+
return m ? m[1].toLowerCase() : e.key;
|
|
401
|
+
}
|
|
402
|
+
|
|
269
403
|
function cycleTab(delta) {
|
|
270
404
|
const ids = Object.keys(apps);
|
|
271
405
|
const idx = ids.indexOf(activeApp);
|
|
@@ -280,11 +414,13 @@ function switchByIndex(idx) {
|
|
|
280
414
|
|
|
281
415
|
function listenKeys() {
|
|
282
416
|
document.addEventListener('keydown', (e) => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
417
|
+
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey) {
|
|
418
|
+
const mode = PALETTE_KEYS[bindingKey(e)];
|
|
419
|
+
if (mode) {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
togglePalette(mode);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
288
424
|
}
|
|
289
425
|
// While the palette is open the input owns the keyboard — don't let tab shortcuts fire
|
|
290
426
|
// mid-path (Alt+digit especially, since Windows paths contain digits). Escape still closes:
|
|
@@ -297,10 +433,10 @@ function listenKeys() {
|
|
|
297
433
|
return;
|
|
298
434
|
}
|
|
299
435
|
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
436
|
+
const k = bindingKey(e);
|
|
437
|
+
if (/^[1-9]$/.test(k)) {
|
|
302
438
|
e.preventDefault();
|
|
303
|
-
switchByIndex(
|
|
439
|
+
switchByIndex(Number(k) - 1);
|
|
304
440
|
return;
|
|
305
441
|
}
|
|
306
442
|
}
|
|
@@ -342,28 +478,37 @@ function subseq(text, q) {
|
|
|
342
478
|
}
|
|
343
479
|
|
|
344
480
|
// Matches the full path so worktrees with opaque basenames stay reachable, but ranks basename
|
|
345
|
-
// hits above parent-path hits
|
|
481
|
+
// hits above parent-path hits, and within those an exact name, then a prefix, then a word start.
|
|
482
|
+
// Rank ties fall back to the recency order set in loadProjects.
|
|
346
483
|
// Returns {p, tier} so the renderer can mark the field that actually explains the match instead of
|
|
347
484
|
// re-deriving it.
|
|
485
|
+
const PATH_RANK = 4;
|
|
486
|
+
|
|
348
487
|
function projectRows(projects, q) {
|
|
349
488
|
if (!q) return projects.slice(0, 100).map((p) => ({ p, tier: 0 }));
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
489
|
+
const rankOf = (p) => {
|
|
490
|
+
const name = p.name.toLowerCase();
|
|
491
|
+
const at = name.indexOf(q);
|
|
492
|
+
if (name === q) return 0;
|
|
493
|
+
if (at === 0) return 1;
|
|
494
|
+
if (at > 0) return /[^a-z0-9]/.test(name[at - 1]) ? 2 : 3;
|
|
495
|
+
const path = p.path.toLowerCase();
|
|
496
|
+
if (path.split(/[/\\]/).includes(q)) return PATH_RANK;
|
|
497
|
+
if (path.includes(q)) return PATH_RANK + 1;
|
|
353
498
|
return -1;
|
|
354
499
|
};
|
|
355
|
-
let out = projects.map((p) => ({ p,
|
|
500
|
+
let out = projects.map((p) => ({ p, rank: rankOf(p) })).filter((x) => x.rank >= 0);
|
|
356
501
|
if (out.length === 0) {
|
|
357
502
|
// Subsequence only as a fallback — it would otherwise swamp real substring matches.
|
|
358
503
|
out = projects
|
|
359
504
|
.map((p) => {
|
|
360
|
-
const
|
|
361
|
-
return { p,
|
|
505
|
+
const rank = subseq(p.name.toLowerCase(), q) ? 0 : subseq(p.path.toLowerCase(), q) ? PATH_RANK : -1;
|
|
506
|
+
return { p, rank };
|
|
362
507
|
})
|
|
363
|
-
.filter((x) => x.
|
|
508
|
+
.filter((x) => x.rank >= 0);
|
|
364
509
|
}
|
|
365
|
-
out.sort((a, b) => a.
|
|
366
|
-
return out.slice(0, 100);
|
|
510
|
+
out.sort((a, b) => a.rank - b.rank || b.p.ts - a.p.ts);
|
|
511
|
+
return out.slice(0, 100).map(({ p, rank }) => ({ p, tier: rank < PATH_RANK ? 0 : 1 }));
|
|
367
512
|
}
|
|
368
513
|
|
|
369
514
|
// The [start, end) slices of `lower` that q matched, mirroring the tiers in projectRows: one
|
|
@@ -431,6 +576,10 @@ async function removeConfigDir(dirPath) {
|
|
|
431
576
|
console.warn('remove config dir failed:', err.message);
|
|
432
577
|
return;
|
|
433
578
|
}
|
|
579
|
+
if (dirPath in themeState.colorThemes) {
|
|
580
|
+
delete themeState.colorThemes[dirPath];
|
|
581
|
+
localStorage.setItem('hub-theme', JSON.stringify(themeState));
|
|
582
|
+
}
|
|
434
583
|
if (palette.open && palette.mode === 'configDir') renderPalette();
|
|
435
584
|
}
|
|
436
585
|
|
|
@@ -639,12 +788,15 @@ async function commitConfigDir(row) {
|
|
|
639
788
|
closePalette();
|
|
640
789
|
// The server would no-op too, but the client would still reload every iframe.
|
|
641
790
|
if (target === palette.configDirs.active) return;
|
|
642
|
-
showLoading(
|
|
791
|
+
showLoading();
|
|
643
792
|
try {
|
|
644
793
|
setApps((await sendJson('POST', '/api/config-dirs/activate', { path: target })).apps);
|
|
645
794
|
// The palette renders from this cache before its refetch lands, and initialSel keys off it.
|
|
646
795
|
palette.configDirs.active = target;
|
|
796
|
+
activeConfigDir = target;
|
|
647
797
|
applyTitle(target);
|
|
798
|
+
// Each iframe gets the new dir's color theme from its load handler; this repaints the palette.
|
|
799
|
+
applyHubTheme();
|
|
648
800
|
reloadIframes();
|
|
649
801
|
} catch (err) {
|
|
650
802
|
console.warn('config dir switch failed:', err.message);
|
|
@@ -705,8 +857,15 @@ function bindPalette() {
|
|
|
705
857
|
palette.sel = Number(li.dataset.idx);
|
|
706
858
|
commitPalette();
|
|
707
859
|
});
|
|
860
|
+
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key —
|
|
861
|
+
// losing focus is the one signal left. Busy disables the input and Alt+Tab blurs the window;
|
|
862
|
+
// neither is a dismissal.
|
|
863
|
+
input.addEventListener('blur', () => {
|
|
864
|
+
if (palette.open && !input.disabled && document.hasFocus()) closePalette();
|
|
865
|
+
});
|
|
708
866
|
document.getElementById('palette').addEventListener('mousedown', (e) => {
|
|
709
867
|
if (e.target.id === 'palette') closePalette();
|
|
868
|
+
else if (e.target !== input) e.preventDefault();
|
|
710
869
|
});
|
|
711
870
|
}
|
|
712
871
|
|
package/public/index.html
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<meta name="theme-color" content="#1a1a2e">
|
|
7
7
|
<link rel="manifest" href="/manifest.json">
|
|
8
|
+
<!-- A manifest alone does not set the favicon: without these, app windows,
|
|
9
|
+
shortcuts and the history list fall back to /favicon.ico and then to whatever
|
|
10
|
+
icon localhost was last seen with, which is a sub-app's. -->
|
|
11
|
+
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg">
|
|
12
|
+
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
|
13
|
+
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
|
|
14
|
+
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
|
8
15
|
<title>Claude Code Hub</title>
|
|
9
16
|
<style>
|
|
10
17
|
:root {
|
|
@@ -18,6 +25,7 @@
|
|
|
18
25
|
--text: #e8e8ef;
|
|
19
26
|
--text-dim: #8a8fa3;
|
|
20
27
|
--shadow: rgba(0, 0, 0, 0.55);
|
|
28
|
+
--ghost-mix: 22%;
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/* applyHubTheme() owns light/dark and stamps this class, resolving the pushed hub:theme first
|
|
@@ -32,6 +40,7 @@
|
|
|
32
40
|
--text: #18181b;
|
|
33
41
|
--text-dim: #71717a;
|
|
34
42
|
--shadow: rgba(0, 0, 0, 0.18);
|
|
43
|
+
--ghost-mix: 10%;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
@@ -71,7 +80,9 @@
|
|
|
71
80
|
gap: 14px;
|
|
72
81
|
align-items: center;
|
|
73
82
|
justify-content: center;
|
|
74
|
-
background:
|
|
83
|
+
background:
|
|
84
|
+
radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
|
|
85
|
+
var(--bg);
|
|
75
86
|
z-index: 10;
|
|
76
87
|
transition: opacity 0.3s ease;
|
|
77
88
|
}
|
|
@@ -81,16 +92,85 @@
|
|
|
81
92
|
pointer-events: none;
|
|
82
93
|
}
|
|
83
94
|
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
/* Clawd, drawn from the Claude Code banner's block art; cck's boot splash uses the same one. */
|
|
96
|
+
.clawd {
|
|
97
|
+
width: 64px;
|
|
98
|
+
height: 40px;
|
|
99
|
+
fill: #d97757;
|
|
100
|
+
overflow: visible;
|
|
101
|
+
transform-origin: 50% 100%;
|
|
102
|
+
animation: clawd-jump 0.9s ease-in-out infinite;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@keyframes clawd-jump {
|
|
106
|
+
0%, 100% { transform: translateY(0) scale(1, 1); }
|
|
107
|
+
15% { transform: translateY(0) scale(1.1, 0.85); }
|
|
108
|
+
45% { transform: translateY(-14px) scale(0.95, 1.05); }
|
|
109
|
+
70% { transform: translateY(0) scale(1.08, 0.9); }
|
|
110
|
+
85% { transform: translateY(0) scale(1, 1); }
|
|
91
111
|
}
|
|
92
112
|
|
|
93
|
-
@
|
|
113
|
+
@media (prefers-reduced-motion: reduce) {
|
|
114
|
+
.clawd { animation: none; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Faint outlines of the four apps, one per quadrant, so the wait reads as "the pages are
|
|
118
|
+
coming" rather than a blank screen. */
|
|
119
|
+
.ghosts {
|
|
120
|
+
--ghost: color-mix(in srgb, var(--accent) 60%, var(--text-dim));
|
|
121
|
+
position: absolute;
|
|
122
|
+
inset: 0;
|
|
123
|
+
display: grid;
|
|
124
|
+
grid-template: 1fr 1fr / 1fr 1fr;
|
|
125
|
+
gap: 16px;
|
|
126
|
+
padding: 16px;
|
|
127
|
+
pointer-events: none;
|
|
128
|
+
-webkit-mask-image: radial-gradient(circle at 50% 50%, transparent 0 80px, #000 240px);
|
|
129
|
+
mask-image: radial-gradient(circle at 50% 50%, transparent 0 80px, #000 240px);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ghost {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: 12px;
|
|
135
|
+
padding: 14px;
|
|
136
|
+
min-height: 0;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
border: 1px solid color-mix(in srgb, var(--ghost) calc(var(--ghost-mix) * 0.8), transparent);
|
|
139
|
+
border-radius: 10px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.ghost i {
|
|
143
|
+
display: block;
|
|
144
|
+
min-height: 12px;
|
|
145
|
+
border-radius: 6px;
|
|
146
|
+
background: color-mix(in srgb, var(--ghost) var(--ghost-mix), transparent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ghost .h { height: 18px; width: 45%; }
|
|
150
|
+
.ghost .stack { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
|
151
|
+
.ghost .row { display: flex; gap: 16px; }
|
|
152
|
+
.ghost .row > i { flex: 1; }
|
|
153
|
+
|
|
154
|
+
.ghost-kanban { grid-template-columns: 22% repeat(3, 1fr); }
|
|
155
|
+
.ghost-kanban .side i { height: 24px; }
|
|
156
|
+
.ghost-kanban .col i:not(.h) { height: 60px; }
|
|
157
|
+
|
|
158
|
+
.ghost-cost { grid-template-rows: 56px 1fr auto; }
|
|
159
|
+
.ghost-cost .row > i { height: 56px; }
|
|
160
|
+
.ghost-cost .bars { display: flex; align-items: flex-end; gap: 8px; min-height: 0; }
|
|
161
|
+
.ghost-cost .bars i { flex: 1; }
|
|
162
|
+
.ghost-cost .stack i:not(.h) { height: 16px; }
|
|
163
|
+
|
|
164
|
+
.ghost-memory { grid-template-columns: 28% 1fr; }
|
|
165
|
+
.ghost-memory .side i { height: 18px; }
|
|
166
|
+
.ghost-memory .side .in { margin-left: 14px; }
|
|
167
|
+
.ghost-memory .side .in2 { margin-left: 28px; }
|
|
168
|
+
.ghost-memory .doc i:not(.h) { height: 12px; }
|
|
169
|
+
|
|
170
|
+
.ghost-marketplace { grid-template-rows: 32px 1fr; }
|
|
171
|
+
.ghost-marketplace .cards { display: grid; grid-template-columns: repeat(3, 1fr); grid-auto-rows: 1fr; gap: 12px; min-height: 0; }
|
|
172
|
+
|
|
173
|
+
.loading-overlay > :not(.ghosts) { position: relative; }
|
|
94
174
|
|
|
95
175
|
.loading-text {
|
|
96
176
|
color: var(--text-dim);
|
|
@@ -226,7 +306,48 @@
|
|
|
226
306
|
<body>
|
|
227
307
|
<div class="iframe-container" id="iframe-container">
|
|
228
308
|
<div class="loading-overlay" id="loading-overlay">
|
|
229
|
-
<div class="
|
|
309
|
+
<div class="ghosts" aria-hidden="true">
|
|
310
|
+
<div class="ghost ghost-kanban">
|
|
311
|
+
<div class="stack side"><i class="h"></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
312
|
+
<div class="stack col"><i class="h"></i><i></i><i></i><i></i></div>
|
|
313
|
+
<div class="stack col"><i class="h"></i><i></i><i></i></div>
|
|
314
|
+
<div class="stack col"><i class="h"></i><i></i><i></i><i></i><i></i></div>
|
|
315
|
+
</div>
|
|
316
|
+
<div class="ghost ghost-cost">
|
|
317
|
+
<div class="row"><i></i><i></i><i></i><i></i></div>
|
|
318
|
+
<div class="bars">
|
|
319
|
+
<i style="height:35%"></i><i style="height:60%"></i><i style="height:45%"></i><i style="height:80%"></i>
|
|
320
|
+
<i style="height:55%"></i><i style="height:30%"></i><i style="height:70%"></i><i style="height:50%"></i>
|
|
321
|
+
<i style="height:65%"></i><i style="height:40%"></i><i style="height:75%"></i><i style="height:58%"></i>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="stack"><i class="h"></i><i></i><i></i><i></i></div>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="ghost ghost-memory">
|
|
326
|
+
<div class="stack side">
|
|
327
|
+
<i class="h"></i><i></i><i class="in"></i><i class="in2"></i><i class="in2"></i><i class="in"></i>
|
|
328
|
+
<i></i><i class="in"></i><i class="in2"></i><i class="in"></i><i></i>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="stack doc">
|
|
331
|
+
<i class="h"></i><i></i><i></i><i style="width:70%"></i><i></i><i style="width:85%"></i><i style="width:40%"></i>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="ghost ghost-marketplace">
|
|
335
|
+
<i></i>
|
|
336
|
+
<div class="cards"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
<svg class="clawd" viewBox="1 0 16 10" shape-rendering="crispEdges" aria-hidden="true">
|
|
340
|
+
<rect x="3" y="0" width="12" height="2"/>
|
|
341
|
+
<rect x="3" y="2" width="2" height="2"/>
|
|
342
|
+
<rect x="6" y="2" width="6" height="2"/>
|
|
343
|
+
<rect x="13" y="2" width="2" height="2"/>
|
|
344
|
+
<rect x="1" y="4" width="16" height="2"/>
|
|
345
|
+
<rect x="3" y="6" width="12" height="2"/>
|
|
346
|
+
<rect x="4" y="8" width="1" height="2"/>
|
|
347
|
+
<rect x="6" y="8" width="1" height="2"/>
|
|
348
|
+
<rect x="11" y="8" width="1" height="2"/>
|
|
349
|
+
<rect x="13" y="8" width="1" height="2"/>
|
|
350
|
+
</svg>
|
|
230
351
|
<div class="loading-text" id="loading-text"></div>
|
|
231
352
|
</div>
|
|
232
353
|
</div>
|