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/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 reloads so
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
- return JSON.parse(localStorage.getItem('hub-theme')) ?? {};
34
+ state = JSON.parse(localStorage.getItem('hub-theme')) ?? {};
29
35
  } catch {
30
- return {};
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: themeState.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 scripts/themes.json — the same
45
- // registry that generates each sub-app's themes.css. Empty until config arrives, and empty if the
46
- // registry is unreadable; either way the palette keeps the --accent from index.html.
47
- let themeAccents = {};
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 palette — the hub's only themed surface — in the active theme. The hub relays
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.classList.toggle('light', mode === 'light');
54
- const pair = themeAccents[themeState.colorTheme];
55
- if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
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
- themeAccents = config.themeAccents ?? {};
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 = apps[id].url;
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
- function showLoading(text = '') {
160
- document.getElementById('loading-text').textContent = text;
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 [id, cfg] of Object.entries(apps)) {
266
+ for (const id of Object.keys(apps)) {
172
267
  const iframe = document.createElement('iframe');
173
268
  iframe.id = `iframe-${id}`;
174
- iframe.src = cfg.url;
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) iframes[data.app].src = apps[data.app].url + 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 !== themeState.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) themeState.colorTheme = data.colorTheme;
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(d.key)) switchByIndex(Number(d.key) - 1);
381
+ if (/^[1-9]$/.test(k)) switchByIndex(Number(k) - 1);
259
382
  return;
260
383
  }
261
- if (d.key === 'ArrowLeft') cycleTab(-1);
262
- else if (d.key === 'ArrowRight') cycleTab(1);
263
- else if (PALETTE_KEYS[d.key.toLowerCase()]) togglePalette(PALETTE_KEYS[d.key.toLowerCase()]);
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
- // Matches both cases: with Ctrl+Alt held, some layouts report AltGr-shifted characters.
284
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && PALETTE_KEYS[e.key.toLowerCase()]) {
285
- e.preventDefault();
286
- togglePalette(PALETTE_KEYS[e.key.toLowerCase()]);
287
- return;
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 digit = parseInt(e.key, 10);
301
- if (digit >= 1 && digit <= 9) {
436
+ const k = bindingKey(e);
437
+ if (/^[1-9]$/.test(k)) {
302
438
  e.preventDefault();
303
- switchByIndex(digit - 1);
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. Tier ties fall back to the recency order set in loadProjects.
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 tierOf = (p) => {
351
- if (p.name.toLowerCase().includes(q)) return 0;
352
- if (p.path.toLowerCase().includes(q)) return 1;
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, tier: tierOf(p) })).filter((x) => x.tier >= 0);
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 tier = subseq(p.name.toLowerCase(), q) ? 0 : subseq(p.path.toLowerCase(), q) ? 1 : -1;
361
- return { p, tier };
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.tier >= 0);
508
+ .filter((x) => x.rank >= 0);
364
509
  }
365
- out.sort((a, b) => a.tier - b.tier || b.p.ts - a.p.ts);
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(`Switching to ${target}...`);
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: var(--bg);
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
- .spinner {
85
- width: 28px;
86
- height: 28px;
87
- border: 3px solid var(--border);
88
- border-top-color: var(--accent);
89
- border-radius: 50%;
90
- animation: spin 0.8s linear infinite;
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
- @keyframes spin { to { transform: rotate(360deg); } }
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="spinner"></div>
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>