claude-code-hub 0.20.1 → 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -35,10 +35,10 @@
35
35
  },
36
36
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
37
37
  "dependencies": {
38
- "claude-code-cost": "^0.10.0",
39
- "claude-code-kanban": "^4.14.1",
40
- "claude-code-marketplace": "^0.10.0",
41
- "claude-code-memory-explorer": "^0.7.0",
38
+ "claude-code-cost": "^0.12.0",
39
+ "claude-code-kanban": "^4.15.0",
40
+ "claude-code-marketplace": "^0.12.0",
41
+ "claude-code-memory-explorer": "^0.9.0",
42
42
  "express": "^4.21.0",
43
43
  "open": "^10.1.0"
44
44
  },
package/public/app.js CHANGED
@@ -6,6 +6,11 @@ let allowedOrigins = new Set();
6
6
  // {theme: 'dark'|'light', colorTheme: '<id>'} — survives hub reloads so
7
7
  // late-loading iframes and fresh sessions get the last chosen theme.
8
8
  const themeState = loadThemeState();
9
+ // {project, encoded, name} — the current project scope. Deliberately NOT persisted, unlike
10
+ // hub-theme: starting unset means there is nothing to race against each sub-app's own
11
+ // project self-restore on boot. Once set it never returns to null.
12
+ let projectState = null;
13
+ const palette = { open: false, projects: [], rows: [], sel: 0, query: '' };
9
14
 
10
15
  function loadThemeState() {
11
16
  try {
@@ -19,14 +24,92 @@ function themeMessage() {
19
24
  return { type: 'hub:theme', theme: themeState.theme, colorTheme: themeState.colorTheme };
20
25
  }
21
26
 
27
+ // {<themeId>: {dark, light}} from /api/config, which derives it from scripts/themes.json — the same
28
+ // registry that generates each sub-app's themes.css. Empty until config arrives, and empty if the
29
+ // registry is unreadable; either way the palette keeps the --accent from index.html.
30
+ let themeAccents = {};
31
+
32
+ // Paints the palette — the hub's only themed surface — in the active theme. The hub relays
33
+ // hub:theme to the sub-apps; this is what makes it apply to the hub's own chrome too.
34
+ function applyHubTheme() {
35
+ // themeState.theme is unset until a sub-app pushes one, so fall back to the OS preference.
36
+ const mode = themeState.theme ?? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
37
+ document.documentElement.classList.toggle('light', mode === 'light');
38
+ const pair = themeAccents[themeState.colorTheme];
39
+ if (pair) document.documentElement.style.setProperty('--accent', pair[mode]);
40
+ }
41
+
42
+ // Claude's on-disk project-directory name. Matches memory/server.js encodeProjectPath. The hub
43
+ // owns this transform so cost never has to convert anything; the inverse is lossy and not needed.
44
+ function encodeProjectPath(p) {
45
+ return p.replace(/\\/g, '/').replace(/:/g, '-').replace(/\//g, '-');
46
+ }
47
+
48
+ function basename(p) {
49
+ return p.split(/[/\\]/).filter(Boolean).pop() || p;
50
+ }
51
+
52
+ function parentOf(p) {
53
+ const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
54
+ return i <= 0 ? '' : p.slice(0, i);
55
+ }
56
+
57
+ function projectMessage() {
58
+ return {
59
+ type: 'hub:project',
60
+ project: projectState.project,
61
+ encoded: projectState.encoded,
62
+ name: projectState.name,
63
+ };
64
+ }
65
+
66
+ function originOf(appId) {
67
+ return new URL(apps[appId].url).origin;
68
+ }
69
+
70
+ function postTo(appId, message) {
71
+ iframes[appId]?.contentWindow?.postMessage(message, originOf(appId));
72
+ }
73
+
74
+ // exceptWindow lets an echo of a sub-app's own message skip that sub-app.
75
+ function broadcast(message, exceptWindow) {
76
+ for (const [id, iframe] of Object.entries(iframes)) {
77
+ if (exceptWindow && iframe.contentWindow === exceptWindow) continue;
78
+ postTo(id, message);
79
+ }
80
+ }
81
+
82
+ function setProject(absPath) {
83
+ projectState = { project: absPath, encoded: encodeProjectPath(absPath), name: basename(absPath) };
84
+ broadcast(projectMessage());
85
+ }
86
+
87
+ function postProjectTo(appId) {
88
+ if (!projectState) return;
89
+ postTo(appId, projectMessage());
90
+ }
91
+
92
+ // Sub-apps can't detect this themselves: inactive iframes are display:none, and a nested
93
+ // document's visibilityState still follows the top-level tab. Used for polling/auto-refresh.
94
+ function postActiveTo(appId) {
95
+ postTo(appId, { type: 'hub:active', active: appId === activeApp });
96
+ }
97
+
22
98
  async function init() {
99
+ // Twice: light/dark comes from localStorage and shouldn't wait on the fetch, the accent can't be
100
+ // resolved until the registry arrives with it.
101
+ applyHubTheme();
23
102
  const res = await fetch('/api/config');
24
- apps = (await res.json()).apps;
103
+ const config = await res.json();
104
+ apps = config.apps;
105
+ themeAccents = config.themeAccents ?? {};
106
+ applyHubTheme();
25
107
  allowedOrigins = new Set(Object.values(apps).map((a) => new URL(a.url).origin));
26
108
  buildIframes();
27
109
  switchTab(Object.keys(apps)[0]);
28
110
  listenMessages();
29
111
  listenKeys();
112
+ bindPalette();
30
113
  registerSW();
31
114
  }
32
115
 
@@ -50,6 +133,7 @@ function switchTab(appId) {
50
133
 
51
134
  for (const [id, iframe] of Object.entries(iframes)) {
52
135
  iframe.classList.toggle('hidden', id !== appId);
136
+ postActiveTo(id);
53
137
  }
54
138
 
55
139
  const overlay = document.getElementById('loading-overlay');
@@ -68,8 +152,17 @@ function onIframeLoad(appId) {
68
152
  document.getElementById('loading-overlay').classList.add('fade-out');
69
153
  }
70
154
  if (themeState.theme || themeState.colorTheme) {
71
- iframes[appId].contentWindow?.postMessage(themeMessage(), new URL(apps[appId].url).origin);
155
+ postTo(appId, themeMessage());
72
156
  }
157
+ postProjectTo(appId);
158
+ postActiveTo(appId);
159
+ // Posted twice: the shims gate their origin check on window.__HUB__, which they populate from
160
+ // an async /hub-config fetch that resolves after this load event, so the first post can be
161
+ // dropped. Safe to repeat — every shim's apply is idempotent.
162
+ setTimeout(() => {
163
+ postProjectTo(appId);
164
+ postActiveTo(appId);
165
+ }, 400);
73
166
  }
74
167
 
75
168
  function listenMessages() {
@@ -81,12 +174,7 @@ function listenMessages() {
81
174
  switchTab(data.app);
82
175
  if (data.url) iframes[data.app].src = apps[data.app].url + data.url;
83
176
  } else if (data.type === 'hub:keydown') {
84
- if (data.key === 'ArrowLeft') cycleTab(-1);
85
- else if (data.key === 'ArrowRight') cycleTab(1);
86
- else {
87
- const digit = parseInt(data.key, 10);
88
- if (digit >= 1 && digit <= 9) switchByIndex(digit - 1);
89
- }
177
+ handleForwardedKey(data);
90
178
  } else if (data.type === 'hub:theme') {
91
179
  // Legacy senders pass only {theme}; colorTheme is optional and sticky.
92
180
  if (data.theme !== 'light' && data.theme !== 'dark') return;
@@ -96,14 +184,28 @@ function listenMessages() {
96
184
  themeState.theme = data.theme;
97
185
  if (hasColor) themeState.colorTheme = data.colorTheme;
98
186
  localStorage.setItem('hub-theme', JSON.stringify(themeState));
99
- for (const [id, iframe] of Object.entries(iframes)) {
100
- if (iframe.contentWindow === e.source) continue;
101
- iframe.contentWindow?.postMessage(themeMessage(), new URL(apps[id].url).origin);
102
- }
187
+ applyHubTheme();
188
+ broadcast(themeMessage(), e.source);
103
189
  }
104
190
  });
105
191
  }
106
192
 
193
+ function handleForwardedKey(d) {
194
+ // The modifier fields are new. A sub-app running an older service-worker-cached bundle sends
195
+ // {key} only; it forwards nothing but the pre-existing bindings, and each of those is
196
+ // unambiguous from the key alone, so normalize instead of dispatching twice.
197
+ const alt = typeof d.alt === 'boolean' ? d.alt : true;
198
+ const ctrl = typeof d.alt === 'boolean' ? d.ctrl : d.key.startsWith('Arrow');
199
+ if (!alt || d.shift === true) return;
200
+ if (!ctrl) {
201
+ if (/^[1-9]$/.test(d.key)) switchByIndex(Number(d.key) - 1);
202
+ return;
203
+ }
204
+ if (d.key === 'ArrowLeft') cycleTab(-1);
205
+ else if (d.key === 'ArrowRight') cycleTab(1);
206
+ else if (d.key.toLowerCase() === 'p') togglePalette();
207
+ }
208
+
107
209
  function cycleTab(delta) {
108
210
  const ids = Object.keys(apps);
109
211
  const idx = ids.indexOf(activeApp);
@@ -118,6 +220,15 @@ function switchByIndex(idx) {
118
220
 
119
221
  function listenKeys() {
120
222
  document.addEventListener('keydown', (e) => {
223
+ // Matches both cases: with Ctrl+Alt held, some layouts report AltGr-shifted characters.
224
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'p') {
225
+ e.preventDefault();
226
+ togglePalette();
227
+ return;
228
+ }
229
+ // While the palette is open the input owns the keyboard — don't let tab shortcuts fire
230
+ // mid-path (Alt+digit especially, since Windows paths contain digits).
231
+ if (palette.open) return;
121
232
  if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
122
233
  const digit = parseInt(e.key, 10);
123
234
  if (digit >= 1 && digit <= 9) {
@@ -141,6 +252,257 @@ function listenKeys() {
141
252
  });
142
253
  }
143
254
 
255
+ async function loadProjects() {
256
+ const res = await fetch('/api/projects');
257
+ if (!res.ok) {
258
+ const body = await res.json().catch(() => ({}));
259
+ throw new Error(body.error || `HTTP ${res.status}`);
260
+ }
261
+ const list = await res.json();
262
+ // kanban sorts alphabetically; a picker wants most-recently-touched first.
263
+ palette.projects = list
264
+ .map((p) => ({
265
+ path: p.path,
266
+ name: basename(p.path),
267
+ parent: parentOf(p.path),
268
+ ts: p.modifiedAt ? Date.parse(p.modifiedAt) : 0,
269
+ }))
270
+ .sort((a, b) => b.ts - a.ts);
271
+ }
272
+
273
+ function subseq(text, q) {
274
+ let i = 0;
275
+ for (const ch of text) {
276
+ if (ch === q[i]) i++;
277
+ if (i === q.length) return true;
278
+ }
279
+ return false;
280
+ }
281
+
282
+ // Matches the full path so worktrees with opaque basenames stay reachable, but ranks basename
283
+ // hits above parent-path hits. Tier ties fall back to the recency order set in loadProjects.
284
+ // Returns {p, tier} so the renderer can mark the field that actually explains the match instead of
285
+ // re-deriving it.
286
+ function projectRows(projects, query) {
287
+ const q = query.trim().toLowerCase();
288
+ if (!q) return projects.slice(0, 100).map((p) => ({ p, tier: 0 }));
289
+ const tierOf = (p) => {
290
+ if (p.name.toLowerCase().includes(q)) return 0;
291
+ if (p.path.toLowerCase().includes(q)) return 1;
292
+ return -1;
293
+ };
294
+ let out = projects.map((p) => ({ p, tier: tierOf(p) })).filter((x) => x.tier >= 0);
295
+ if (out.length === 0) {
296
+ // Subsequence only as a fallback — it would otherwise swamp real substring matches.
297
+ out = projects
298
+ .map((p) => {
299
+ const tier = subseq(p.name.toLowerCase(), q) ? 0 : subseq(p.path.toLowerCase(), q) ? 1 : -1;
300
+ return { p, tier };
301
+ })
302
+ .filter((x) => x.tier >= 0);
303
+ }
304
+ out.sort((a, b) => a.tier - b.tier || b.p.ts - a.p.ts);
305
+ return out.slice(0, 100);
306
+ }
307
+
308
+ // The [start, end) slices of `lower` that q matched, mirroring the tiers in projectRows: one
309
+ // contiguous range for a substring hit, otherwise the subsequence positions, coalesced so adjacent
310
+ // letters share a range. Null when q is absent altogether.
311
+ function matchRanges(lower, q) {
312
+ const at = lower.indexOf(q);
313
+ if (at >= 0) return [[at, at + q.length]];
314
+ const ranges = [];
315
+ let qi = 0;
316
+ for (let i = 0; i < lower.length && qi < q.length; i++) {
317
+ if (lower[i] !== q[qi]) continue;
318
+ const last = ranges[ranges.length - 1];
319
+ if (last && last[1] === i) last[1] = i + 1;
320
+ else ranges.push([i, i + 1]);
321
+ qi++;
322
+ }
323
+ return qi === q.length ? ranges : null;
324
+ }
325
+
326
+ // Marks the letters that made this row match, so a hit on an opaque basename explains itself.
327
+ // Returns nodes, never HTML — a project path must never be interpolated.
328
+ function markMatch(text, q) {
329
+ const ranges = q ? matchRanges(text.toLowerCase(), q) : null;
330
+ if (!ranges) return [document.createTextNode(text)];
331
+ const nodes = [];
332
+ let at = 0;
333
+ for (const [from, to] of ranges) {
334
+ if (from > at) nodes.push(document.createTextNode(text.slice(at, from)));
335
+ nodes.push(el('mark', '', text.slice(from, to)));
336
+ at = to;
337
+ }
338
+ if (at < text.length) nodes.push(document.createTextNode(text.slice(at)));
339
+ return nodes;
340
+ }
341
+
342
+ function markedSpan(className, text, q) {
343
+ const node = el('span', className);
344
+ node.append(...markMatch(text, q));
345
+ return node;
346
+ }
347
+
348
+ function looksLikePath(q) {
349
+ return /[/\\:]/.test(q.trim());
350
+ }
351
+
352
+ function relAge(ts) {
353
+ if (!ts) return '';
354
+ const m = Math.round((Date.now() - ts) / 60000);
355
+ if (m < 60) return `${m}m`;
356
+ if (m < 1440) return `${Math.round(m / 60)}h`;
357
+ return `${Math.round(m / 1440)}d`;
358
+ }
359
+
360
+ function renderPalette() {
361
+ const list = document.getElementById('palette-list');
362
+ const q = palette.query.trim().toLowerCase();
363
+ const rows = projectRows(palette.projects, palette.query).map((x) => ({
364
+ kind: 'project',
365
+ path: x.p.path,
366
+ p: x.p,
367
+ tier: x.tier,
368
+ }));
369
+ if (looksLikePath(palette.query)) rows.push({ kind: 'literal', path: palette.query.trim() });
370
+ palette.rows = rows;
371
+ palette.sel = Math.min(palette.sel, Math.max(0, rows.length - 1));
372
+ if (rows.length === 0) {
373
+ list.replaceChildren(el('li', 'palette-empty', 'No matching project'));
374
+ return;
375
+ }
376
+ list.replaceChildren(
377
+ ...rows.map((row, i) => {
378
+ const li = el(
379
+ 'li',
380
+ `palette-row${row.kind === 'literal' ? ' literal' : ''}${i === palette.sel ? ' selected' : ''}`,
381
+ );
382
+ li.dataset.idx = String(i);
383
+ if (row.kind === 'literal') {
384
+ li.textContent = `Use literal path -- ${row.path}`;
385
+ } else {
386
+ // Only the field that ranked the row is marked — otherwise a one-letter query like "c"
387
+ // would light up the "C:" drive letter on every row. Tier 0 is a basename hit.
388
+ li.append(
389
+ markedSpan('name', row.p.name, row.tier === 0 ? q : ''),
390
+ markedSpan('parent', row.p.parent, row.tier === 0 ? '' : q),
391
+ el('span', 'age', relAge(row.p.ts)),
392
+ );
393
+ }
394
+ return li;
395
+ }),
396
+ );
397
+ list.querySelector('.selected')?.scrollIntoView({ block: 'nearest' });
398
+ }
399
+
400
+ // textContent throughout — paths are user data and the hub has no escaping helper.
401
+ function el(tag, className, text) {
402
+ const node = document.createElement(tag);
403
+ node.className = className;
404
+ if (text !== undefined) node.textContent = text;
405
+ return node;
406
+ }
407
+
408
+ function togglePalette() {
409
+ if (palette.open) closePalette();
410
+ else openPalette();
411
+ }
412
+
413
+ function openPalette() {
414
+ const input = document.getElementById('palette-input');
415
+ palette.open = true;
416
+ palette.query = '';
417
+ palette.sel = 0;
418
+ input.value = '';
419
+ document.getElementById('palette').hidden = false;
420
+ renderPalette();
421
+ input.focus();
422
+ // Stale-while-revalidate: the cached list renders instantly, recency refreshes when this lands.
423
+ loadProjects()
424
+ .then(() => {
425
+ if (palette.open) renderPalette();
426
+ })
427
+ .catch((err) => console.warn('project list unavailable:', err.message));
428
+ }
429
+
430
+ function closePalette() {
431
+ palette.open = false;
432
+ document.getElementById('palette').hidden = true;
433
+ iframes[activeApp]?.focus();
434
+ }
435
+
436
+ function movePaletteSel(delta) {
437
+ const n = palette.rows.length;
438
+ if (n === 0) return;
439
+ palette.sel = (palette.sel + delta + n) % n;
440
+ renderPalette();
441
+ }
442
+
443
+ async function commitPalette() {
444
+ const row = palette.rows[palette.sel];
445
+ if (!row) return;
446
+ // List rows broadcast verbatim: kanban handed us this exact string and matches it with strict
447
+ // ===. Only a typed path needs normalizing.
448
+ const absPath = row.kind === 'project' ? row.path : await resolveTypedPath(row.path);
449
+ if (!absPath) return;
450
+ closePalette();
451
+ setProject(absPath);
452
+ }
453
+
454
+ // Returns the real on-disk path, or null when it can't be resolved (the palette stays open).
455
+ async function resolveTypedPath(typed) {
456
+ const input = document.getElementById('palette-input');
457
+ input.disabled = true;
458
+ try {
459
+ const res = await fetch(`/api/resolve-path?path=${encodeURIComponent(typed)}`);
460
+ const data = await res.json().catch(() => ({}));
461
+ if (res.ok) return data.path;
462
+ console.warn('resolve-path failed:', data.error || res.status);
463
+ } catch (err) {
464
+ console.warn('resolve-path failed:', err.message);
465
+ } finally {
466
+ input.disabled = false;
467
+ if (palette.open) input.focus();
468
+ }
469
+ return null;
470
+ }
471
+
472
+ function bindPalette() {
473
+ const input = document.getElementById('palette-input');
474
+ input.addEventListener('input', () => {
475
+ palette.query = input.value;
476
+ palette.sel = 0;
477
+ renderPalette();
478
+ });
479
+ // Bound to the input rather than the document: the palette always owns focus while open.
480
+ input.addEventListener('keydown', (e) => {
481
+ if (e.key === 'Escape') {
482
+ e.preventDefault();
483
+ closePalette();
484
+ } else if (e.key === 'Enter') {
485
+ e.preventDefault();
486
+ commitPalette();
487
+ } else if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
488
+ e.preventDefault();
489
+ movePaletteSel(1);
490
+ } else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
491
+ e.preventDefault();
492
+ movePaletteSel(-1);
493
+ }
494
+ });
495
+ document.getElementById('palette-list').addEventListener('click', (e) => {
496
+ const li = e.target.closest('.palette-row');
497
+ if (!li) return;
498
+ palette.sel = Number(li.dataset.idx);
499
+ commitPalette();
500
+ });
501
+ document.getElementById('palette').addEventListener('mousedown', (e) => {
502
+ if (e.target.id === 'palette') closePalette();
503
+ });
504
+ }
505
+
144
506
  function registerSW() {
145
507
  if ('serviceWorker' in navigator) {
146
508
  navigator.serviceWorker.register('/sw.js');
package/public/index.html CHANGED
@@ -10,14 +10,28 @@
10
10
  :root {
11
11
  --bg: #1a1a2e;
12
12
  --border: #0f3460;
13
- --accent: #e94560;
13
+ /* Fallback until applyHubAccent() overrides it with the active theme's accent, and the value
14
+ that stands if /api/config carries no accents. Ember, every sub-app's :root default. */
15
+ --accent: #e86f33;
16
+ --surface: #16213e;
17
+ --surface-hover: #1f2a4a;
18
+ --text: #e8e8ef;
19
+ --text-dim: #8a8fa3;
20
+ --shadow: rgba(0, 0, 0, 0.55);
14
21
  }
15
22
 
16
- @media (prefers-color-scheme: light) {
17
- :root {
18
- --bg: #f4f4f5;
19
- --border: #e4e4e7;
20
- }
23
+ /* applyHubTheme() owns light/dark and stamps this class, resolving the pushed hub:theme first
24
+ and prefers-color-scheme only as its fallback. Deliberately not a @media block: the theme a
25
+ sub-app pushed has to win over the OS preference, and the only surface painted before JS runs
26
+ is a background the iframes immediately cover. */
27
+ :root.light {
28
+ --bg: #f4f4f5;
29
+ --border: #e4e4e7;
30
+ --surface: #ffffff;
31
+ --surface-hover: #f1f1f4;
32
+ --text: #18181b;
33
+ --text-dim: #71717a;
34
+ --shadow: rgba(0, 0, 0, 0.18);
21
35
  }
22
36
 
23
37
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -75,6 +89,100 @@
75
89
  }
76
90
 
77
91
  @keyframes spin { to { transform: rotate(360deg); } }
92
+
93
+ /* Project switcher palette — the hub's only chrome, summoned with Ctrl+Alt+P. */
94
+ .palette {
95
+ position: fixed;
96
+ inset: 0;
97
+ z-index: 20;
98
+ display: flex;
99
+ justify-content: center;
100
+ align-items: flex-start;
101
+ padding-top: 12vh;
102
+ background: color-mix(in srgb, var(--bg) 78%, transparent);
103
+ }
104
+
105
+ .palette[hidden] { display: none; }
106
+
107
+ .palette-box {
108
+ width: min(680px, 92vw);
109
+ background: var(--surface);
110
+ border: 1px solid var(--border);
111
+ border-radius: 10px;
112
+ box-shadow: 0 18px 48px var(--shadow);
113
+ overflow: hidden;
114
+ font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
115
+ }
116
+
117
+ .palette-input {
118
+ width: 100%;
119
+ padding: 12px 14px;
120
+ border: none;
121
+ border-bottom: 1px solid var(--border);
122
+ background: transparent;
123
+ color: var(--text);
124
+ font: inherit;
125
+ outline: none;
126
+ }
127
+
128
+ .palette-input::placeholder { color: var(--text-dim); }
129
+
130
+ .palette-list {
131
+ list-style: none;
132
+ max-height: 46vh;
133
+ overflow-y: auto;
134
+ }
135
+
136
+ .palette-row {
137
+ display: flex;
138
+ align-items: baseline;
139
+ gap: 10px;
140
+ padding: 8px 14px;
141
+ cursor: pointer;
142
+ color: var(--text);
143
+ white-space: nowrap;
144
+ }
145
+
146
+ /* Bar over the neutral hover background, no accent tint: the faintest theme accents
147
+ (monokai light #c08a00, vitesse light #1c6b48) barely register as a tint, but read
148
+ clearly as a 2px bar. */
149
+ .palette-row.selected {
150
+ background: var(--surface-hover);
151
+ box-shadow: inset 2px 0 0 var(--accent);
152
+ }
153
+
154
+ .palette-row .name { font-weight: 600; }
155
+
156
+ /* The letters that made this row match. Accent-coloured rather than block-highlighted so it
157
+ reads inside a bold name without fighting the selected row's bar. */
158
+ .palette-row mark {
159
+ background: transparent;
160
+ color: var(--accent);
161
+ font-weight: 700;
162
+ }
163
+
164
+ .palette-row .parent {
165
+ color: var(--text-dim);
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ flex: 1;
169
+ }
170
+
171
+ .palette-row .age {
172
+ color: var(--text-dim);
173
+ margin-left: auto;
174
+ padding-left: 10px;
175
+ }
176
+
177
+ .palette-row.literal {
178
+ border-top: 1px solid var(--border);
179
+ color: var(--accent);
180
+ }
181
+
182
+ .palette-empty {
183
+ padding: 10px 14px;
184
+ color: var(--text-dim);
185
+ }
78
186
  </style>
79
187
  </head>
80
188
  <body>
@@ -84,6 +192,14 @@
84
192
  </div>
85
193
  </div>
86
194
 
195
+ <div class="palette" id="palette" hidden>
196
+ <div class="palette-box" role="dialog" aria-modal="true" aria-label="Switch project">
197
+ <input class="palette-input" id="palette-input" type="text" spellcheck="false"
198
+ autocomplete="off" placeholder="Search projects or type a path...">
199
+ <ul class="palette-list" id="palette-list" role="listbox"></ul>
200
+ </div>
201
+ </div>
202
+
87
203
  <script src="/app.js"></script>
88
204
  </body>
89
205
  </html>
package/server.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { spawn } = require('child_process');
4
4
  const express = require('express');
5
+ const fs = require('fs');
5
6
  const http = require('http');
6
7
  const path = require('path');
7
8
 
@@ -113,8 +114,23 @@ spawnApp('memory', process.execPath, [NODE_HDR, memoryPath, `--port=${MEMORY_POR
113
114
 
114
115
  const app = express();
115
116
 
117
+ // The accent hex per color theme, read from the same registry generate-themes.mjs compiles into each
118
+ // sub-app's themes.css. Served rather than hand-copied into public/app.js so there is one source of
119
+ // truth; the palette is the hub's only themed surface, so only ember (the accent role) is needed.
120
+ // Read once — themes.json only changes when the generator is re-run, which restarts the hub anyway.
121
+ const themeAccents = (() => {
122
+ try {
123
+ const themes = JSON.parse(fs.readFileSync(path.join(__dirname, 'scripts/themes.json'), 'utf8'));
124
+ return Object.fromEntries(themes.map((t) => [t.id, { dark: t.dark.ember, light: t.light.ember }]));
125
+ } catch {
126
+ // Palette falls back to the --accent in index.html; not worth failing startup over.
127
+ return {};
128
+ }
129
+ })();
130
+
116
131
  app.get('/api/config', (_req, res) => {
117
132
  res.json({
133
+ themeAccents,
118
134
  apps: {
119
135
  kanban: { name: 'Kanban', url: `http://localhost:${actualPorts.kanban}`, icon: 'columns' },
120
136
  marketplace: { name: 'Marketplace', url: `http://localhost:${actualPorts.marketplace}`, icon: 'store' },
@@ -124,6 +140,51 @@ app.get('/api/config', (_req, res) => {
124
140
  });
125
141
  });
126
142
 
143
+ // Project list for the switcher palette, proxied from kanban — it is the only sub-app that
144
+ // enumerates projects. actualPorts is read per request because kanban's real port is only
145
+ // known once its banner has been scraped (see spawnApp).
146
+ app.get('/api/projects', async (_req, res) => {
147
+ res.setHeader('Cache-Control', 'no-store');
148
+ try {
149
+ // 127.0.0.1, not localhost — Node's verbatim DNS ordering tries ::1 first on Windows.
150
+ const upstream = await fetch(`http://127.0.0.1:${actualPorts.kanban}/api/projects`, {
151
+ signal: AbortSignal.timeout(4000),
152
+ });
153
+ if (!upstream.ok) {
154
+ res.status(502).json({ error: `kanban responded ${upstream.status}` });
155
+ return;
156
+ }
157
+ res.json(await upstream.json());
158
+ } catch (err) {
159
+ // Soft-fail so the palette still opens and offers literal-path entry.
160
+ res.status(502).json({ error: `kanban unavailable: ${err.message}` });
161
+ }
162
+ });
163
+
164
+ // Normalizes a typed path to its real on-disk form (true casing, native separators) so cck's
165
+ // strict === project match and cost's abs->encoded transform both hit. List-picked paths skip
166
+ // this: they are byte-exact copies of what kanban reported.
167
+ app.get('/api/resolve-path', (req, res) => {
168
+ res.setHeader('Cache-Control', 'no-store');
169
+ const input = typeof req.query.path === 'string' ? req.query.path.trim() : '';
170
+ if (!input) {
171
+ res.status(400).json({ error: 'path is required' });
172
+ return;
173
+ }
174
+ let resolved;
175
+ try {
176
+ resolved = fs.realpathSync.native(path.resolve(input));
177
+ } catch {
178
+ res.status(404).json({ error: 'Path not found' });
179
+ return;
180
+ }
181
+ if (!fs.statSync(resolved).isDirectory()) {
182
+ res.status(400).json({ error: 'Not a directory' });
183
+ return;
184
+ }
185
+ res.json({ path: resolved });
186
+ });
187
+
127
188
  app.use(express.static(path.join(__dirname, 'public')));
128
189
 
129
190
  const server = http.createServer({ maxHeaderSize: 65536 }, app);