chat-glass 1.0.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.
@@ -0,0 +1,500 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>chat-glass</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ html, body {
11
+ height: 100%;
12
+ overflow: hidden;
13
+ background: #1a1a2e;
14
+ color: #e0e0e0;
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
16
+ }
17
+
18
+ #app {
19
+ display: flex;
20
+ flex-direction: column;
21
+ height: 100vh;
22
+ }
23
+
24
+ /* Title bar */
25
+ #title-bar {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 12px;
29
+ padding: 8px 16px;
30
+ background: #0f0f23;
31
+ border-bottom: 1px solid #2a2a4a;
32
+ flex-shrink: 0;
33
+ min-height: 40px;
34
+ }
35
+
36
+ #title-bar .brand {
37
+ font-size: 13px;
38
+ font-weight: 700;
39
+ color: #00d4ff;
40
+ letter-spacing: 0.5px;
41
+ white-space: nowrap;
42
+ }
43
+
44
+ #title-bar .separator {
45
+ width: 1px;
46
+ height: 16px;
47
+ background: #2a2a4a;
48
+ flex-shrink: 0;
49
+ }
50
+
51
+ #title-bar .page-title {
52
+ font-size: 13px;
53
+ color: #e0e0e0;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ white-space: nowrap;
57
+ flex: 1;
58
+ min-width: 0;
59
+ }
60
+
61
+ #title-bar .timestamp {
62
+ font-size: 11px;
63
+ color: #888;
64
+ white-space: nowrap;
65
+ flex-shrink: 0;
66
+ }
67
+
68
+ #ws-indicator {
69
+ width: 8px;
70
+ height: 8px;
71
+ border-radius: 50%;
72
+ background: #00d4ff;
73
+ flex-shrink: 0;
74
+ transition: background 0.3s;
75
+ }
76
+
77
+ #ws-indicator.disconnected {
78
+ background: #ff4444;
79
+ }
80
+
81
+ /* Content area */
82
+ #content {
83
+ flex: 1;
84
+ position: relative;
85
+ min-height: 0;
86
+ }
87
+
88
+ #viewer {
89
+ width: 100%;
90
+ height: 100%;
91
+ border: none;
92
+ display: block;
93
+ background: #1a1a2e;
94
+ }
95
+
96
+ /* Empty state */
97
+ #empty-state {
98
+ position: absolute;
99
+ inset: 0;
100
+ display: flex;
101
+ flex-direction: column;
102
+ align-items: center;
103
+ justify-content: center;
104
+ gap: 16px;
105
+ color: #888;
106
+ }
107
+
108
+ #empty-state .icon {
109
+ font-size: 48px;
110
+ opacity: 0.3;
111
+ }
112
+
113
+ #empty-state .message {
114
+ font-size: 16px;
115
+ }
116
+
117
+ #empty-state .hint {
118
+ font-size: 12px;
119
+ color: #555;
120
+ }
121
+
122
+ /* Error state */
123
+ #error-state {
124
+ position: absolute;
125
+ inset: 0;
126
+ display: none;
127
+ flex-direction: column;
128
+ align-items: center;
129
+ justify-content: center;
130
+ gap: 12px;
131
+ color: #ff6b6b;
132
+ }
133
+
134
+ #error-state .message {
135
+ font-size: 14px;
136
+ }
137
+
138
+ /* Gallery strip */
139
+ #gallery-strip {
140
+ display: flex;
141
+ align-items: stretch;
142
+ gap: 0;
143
+ background: #16213e;
144
+ border-top: 1px solid #2a2a4a;
145
+ flex-shrink: 0;
146
+ overflow-x: auto;
147
+ overflow-y: hidden;
148
+ scrollbar-width: thin;
149
+ scrollbar-color: #2a2a4a #16213e;
150
+ }
151
+
152
+ #gallery-strip::-webkit-scrollbar {
153
+ height: 4px;
154
+ }
155
+
156
+ #gallery-strip::-webkit-scrollbar-track {
157
+ background: #16213e;
158
+ }
159
+
160
+ #gallery-strip::-webkit-scrollbar-thumb {
161
+ background: #2a2a4a;
162
+ border-radius: 2px;
163
+ }
164
+
165
+ .gallery-item {
166
+ display: flex;
167
+ flex-direction: column;
168
+ justify-content: center;
169
+ padding: 8px 16px;
170
+ min-width: 160px;
171
+ max-width: 240px;
172
+ cursor: pointer;
173
+ border-top: 2px solid transparent;
174
+ transition: background 0.15s, border-color 0.15s;
175
+ flex-shrink: 0;
176
+ user-select: none;
177
+ }
178
+
179
+ .gallery-item:hover {
180
+ background: #1a2744;
181
+ }
182
+
183
+ .gallery-item.active {
184
+ border-top-color: #00d4ff;
185
+ background: #1a2744;
186
+ }
187
+
188
+ .gallery-item .item-title {
189
+ font-size: 12px;
190
+ color: #e0e0e0;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
194
+ line-height: 1.4;
195
+ }
196
+
197
+ .gallery-item.active .item-title {
198
+ color: #00d4ff;
199
+ }
200
+
201
+ .gallery-item .item-time {
202
+ font-size: 10px;
203
+ color: #666;
204
+ line-height: 1.4;
205
+ }
206
+
207
+ /* Hidden utility */
208
+ .hidden { display: none !important; }
209
+ </style>
210
+ </head>
211
+ <body>
212
+ <div id="app">
213
+ <div id="title-bar">
214
+ <span class="brand">chat-glass</span>
215
+ <span class="separator"></span>
216
+ <span class="page-title" id="current-title">Loading...</span>
217
+ <span class="timestamp" id="current-timestamp"></span>
218
+ <div id="ws-indicator" title="WebSocket connected"></div>
219
+ </div>
220
+
221
+ <div id="content">
222
+ <iframe id="viewer" class="hidden" sandbox="allow-scripts allow-same-origin"></iframe>
223
+ <div id="empty-state">
224
+ <div class="icon">&#9672;</div>
225
+ <div class="message">Waiting for Claude to create a visualization...</div>
226
+ <div class="hint">Use chat-glass show &lt;file.html&gt; to display content</div>
227
+ </div>
228
+ <div id="error-state">
229
+ <div class="message" id="error-message">Failed to load visualization</div>
230
+ </div>
231
+ </div>
232
+
233
+ <div id="gallery-strip"></div>
234
+ </div>
235
+
236
+ <script>
237
+ const viewer = document.getElementById('viewer');
238
+ const emptyState = document.getElementById('empty-state');
239
+ const errorState = document.getElementById('error-state');
240
+ const errorMessage = document.getElementById('error-message');
241
+ const titleEl = document.getElementById('current-title');
242
+ const timestampEl = document.getElementById('current-timestamp');
243
+ const galleryStrip = document.getElementById('gallery-strip');
244
+ const wsIndicator = document.getElementById('ws-indicator');
245
+
246
+ let pages = [];
247
+ let currentIndex = -1;
248
+
249
+ // -- Page loading --
250
+
251
+ function formatTimestamp(ts) {
252
+ if (!ts) return '';
253
+ // ts is like "2026-02-18T14-30-00-123" — parse into readable form
254
+ const cleaned = ts.replace(/T/, ' ').replace(/-(\d{2})-(\d{2})-(\d+)$/, ':$1:$2');
255
+ return cleaned;
256
+ }
257
+
258
+ function loadPage(index) {
259
+ if (pages.length === 0) {
260
+ showEmpty();
261
+ return;
262
+ }
263
+ if (index < 0 || index >= pages.length) return;
264
+
265
+ currentIndex = index;
266
+ const page = pages[index];
267
+
268
+ // Update title bar
269
+ titleEl.textContent = page.title || page.filename;
270
+ timestampEl.textContent = formatTimestamp(page.timestamp);
271
+
272
+ // Update iframe
273
+ emptyState.classList.add('hidden');
274
+ errorState.style.display = 'none';
275
+ viewer.classList.remove('hidden');
276
+ viewer.src = '/pages/' + encodeURIComponent(page.filename);
277
+
278
+ // Update gallery strip highlights
279
+ updateGalleryHighlight();
280
+
281
+ // Scroll active item into view
282
+ const activeItem = galleryStrip.querySelector('.gallery-item.active');
283
+ if (activeItem) {
284
+ activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
285
+ }
286
+
287
+ // Update URL without reload
288
+ const url = new URL(window.location);
289
+ url.searchParams.set('page', page.filename);
290
+ history.replaceState(null, '', url);
291
+ }
292
+
293
+ function showEmpty() {
294
+ currentIndex = -1;
295
+ viewer.classList.add('hidden');
296
+ errorState.style.display = 'none';
297
+ emptyState.classList.remove('hidden');
298
+ titleEl.textContent = 'No visualizations';
299
+ timestampEl.textContent = '';
300
+ galleryStrip.innerHTML = '';
301
+ }
302
+
303
+ function showError(msg) {
304
+ viewer.classList.add('hidden');
305
+ emptyState.classList.add('hidden');
306
+ errorState.style.display = 'flex';
307
+ errorMessage.textContent = msg;
308
+ }
309
+
310
+ // -- Gallery strip --
311
+
312
+ function renderGalleryStrip() {
313
+ galleryStrip.innerHTML = '';
314
+
315
+ // Pages are sorted newest-first from API, but strip shows most recent on the right
316
+ // So we reverse for display
317
+ const displayOrder = [...pages].reverse();
318
+
319
+ displayOrder.forEach((page, displayIdx) => {
320
+ const realIndex = pages.length - 1 - displayIdx;
321
+ const item = document.createElement('div');
322
+ item.className = 'gallery-item';
323
+ item.dataset.index = realIndex;
324
+ if (realIndex === currentIndex) item.classList.add('active');
325
+
326
+ const titleSpan = document.createElement('div');
327
+ titleSpan.className = 'item-title';
328
+ titleSpan.textContent = page.title || page.filename;
329
+
330
+ const timeSpan = document.createElement('div');
331
+ timeSpan.className = 'item-time';
332
+ timeSpan.textContent = formatTimestamp(page.timestamp);
333
+
334
+ item.appendChild(titleSpan);
335
+ item.appendChild(timeSpan);
336
+
337
+ item.addEventListener('click', () => loadPage(realIndex));
338
+ galleryStrip.appendChild(item);
339
+ });
340
+ }
341
+
342
+ function updateGalleryHighlight() {
343
+ galleryStrip.querySelectorAll('.gallery-item').forEach(item => {
344
+ const idx = parseInt(item.dataset.index, 10);
345
+ item.classList.toggle('active', idx === currentIndex);
346
+ });
347
+ }
348
+
349
+ // -- Data fetching --
350
+
351
+ async function fetchPages() {
352
+ try {
353
+ const res = await fetch('/api/pages');
354
+ if (!res.ok) throw new Error('Failed to fetch pages');
355
+ pages = await res.json();
356
+ } catch {
357
+ pages = [];
358
+ }
359
+ }
360
+
361
+ async function init() {
362
+ await fetchPages();
363
+
364
+ if (pages.length === 0) {
365
+ showEmpty();
366
+ return;
367
+ }
368
+
369
+ renderGalleryStrip();
370
+
371
+ // Check for ?page= query param
372
+ const params = new URLSearchParams(window.location.search);
373
+ const requestedPage = params.get('page');
374
+
375
+ if (requestedPage) {
376
+ const idx = pages.findIndex(p => p.filename === requestedPage);
377
+ if (idx !== -1) {
378
+ loadPage(idx);
379
+ return;
380
+ }
381
+ }
382
+
383
+ // Default: load the latest (index 0 since sorted newest-first)
384
+ loadPage(0);
385
+ }
386
+
387
+ async function onReload() {
388
+ const previousFilename = currentIndex >= 0 ? pages[currentIndex]?.filename : null;
389
+ await fetchPages();
390
+
391
+ if (pages.length === 0) {
392
+ showEmpty();
393
+ return;
394
+ }
395
+
396
+ renderGalleryStrip();
397
+
398
+ // Load the newest page (a reload means new content arrived)
399
+ loadPage(0);
400
+ }
401
+
402
+ // -- Keyboard navigation --
403
+
404
+ document.addEventListener('keydown', (e) => {
405
+ // Don't capture if user is typing in an input
406
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
407
+
408
+ switch (e.key) {
409
+ case 'ArrowLeft':
410
+ e.preventDefault();
411
+ if (currentIndex < pages.length - 1) loadPage(currentIndex + 1);
412
+ break;
413
+ case 'ArrowRight':
414
+ e.preventDefault();
415
+ if (currentIndex > 0) loadPage(currentIndex - 1);
416
+ break;
417
+ case 'Home':
418
+ e.preventDefault();
419
+ if (pages.length > 0) loadPage(pages.length - 1);
420
+ break;
421
+ case 'End':
422
+ e.preventDefault();
423
+ if (pages.length > 0) loadPage(0);
424
+ break;
425
+ case 'g':
426
+ case 'G':
427
+ window.location.href = '/gallery';
428
+ break;
429
+ }
430
+ });
431
+
432
+ // -- Iframe load / error handling --
433
+
434
+ viewer.addEventListener('load', () => {
435
+ // Ignore the initial about:blank load — only signal for real pages
436
+ if (!viewer.src || viewer.src === 'about:blank') return;
437
+ // Wait a short tick for async renderers (Mermaid, D3, Chart.js) to finish
438
+ setTimeout(() => {
439
+ if (ws && ws.readyState === WebSocket.OPEN) {
440
+ ws.send(JSON.stringify({ type: 'render-complete' }));
441
+ }
442
+ }, 500);
443
+ });
444
+
445
+ viewer.addEventListener('error', () => {
446
+ showError('Failed to load visualization');
447
+ });
448
+
449
+ // -- WebSocket with auto-reconnect --
450
+
451
+ let ws = null;
452
+ let wsReconnectDelay = 500;
453
+ const WS_MAX_DELAY = 10000;
454
+
455
+ function connectWebSocket() {
456
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
457
+ ws = new WebSocket(`${protocol}//${location.host}/ws`);
458
+
459
+ ws.onopen = () => {
460
+ wsReconnectDelay = 500;
461
+ wsIndicator.classList.remove('disconnected');
462
+ wsIndicator.title = 'WebSocket connected';
463
+ };
464
+
465
+ ws.onmessage = (e) => {
466
+ try {
467
+ const msg = JSON.parse(e.data);
468
+ if (msg.type === 'reload') {
469
+ onReload();
470
+ }
471
+ } catch {
472
+ // ignore malformed messages
473
+ }
474
+ };
475
+
476
+ ws.onclose = () => {
477
+ wsIndicator.classList.add('disconnected');
478
+ wsIndicator.title = 'WebSocket disconnected - reconnecting...';
479
+ scheduleReconnect();
480
+ };
481
+
482
+ ws.onerror = () => {
483
+ // onclose will fire after this
484
+ };
485
+ }
486
+
487
+ function scheduleReconnect() {
488
+ setTimeout(() => {
489
+ connectWebSocket();
490
+ wsReconnectDelay = Math.min(wsReconnectDelay * 2, WS_MAX_DELAY);
491
+ }, wsReconnectDelay);
492
+ }
493
+
494
+ // -- Boot --
495
+
496
+ connectWebSocket();
497
+ init();
498
+ </script>
499
+ </body>
500
+ </html>
@@ -0,0 +1,31 @@
1
+ import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { configPath, pagesDir } from "./paths.js";
4
+
5
+ const DEFAULTS = {
6
+ port: null,
7
+ pid: null,
8
+ lastActivity: null,
9
+ createdAt: null,
10
+ };
11
+
12
+ export async function readConfig(projectDir) {
13
+ try {
14
+ const raw = await readFile(configPath(projectDir), "utf8");
15
+ return { ...DEFAULTS, ...JSON.parse(raw) };
16
+ } catch {
17
+ return { ...DEFAULTS };
18
+ }
19
+ }
20
+
21
+ export async function writeConfig(projectDir, config) {
22
+ const filePath = configPath(projectDir);
23
+ await mkdir(dirname(filePath), { recursive: true });
24
+ const tmp = filePath + ".tmp";
25
+ await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
26
+ await rename(tmp, filePath);
27
+ }
28
+
29
+ export async function ensureDirs(projectDir) {
30
+ await mkdir(pagesDir(projectDir), { recursive: true });
31
+ }
@@ -0,0 +1,27 @@
1
+ import { access } from "node:fs/promises";
2
+
3
+ const CANDIDATES = {
4
+ darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
5
+ linux: [
6
+ "/usr/bin/google-chrome",
7
+ "/usr/bin/chromium-browser",
8
+ "/usr/bin/chromium",
9
+ ],
10
+ win32: [
11
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
12
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
13
+ ],
14
+ };
15
+
16
+ export async function findChrome() {
17
+ const paths = CANDIDATES[process.platform] || [];
18
+ for (const p of paths) {
19
+ try {
20
+ await access(p);
21
+ return p;
22
+ } catch {
23
+ // not found, try next
24
+ }
25
+ }
26
+ return null;
27
+ }
@@ -0,0 +1,17 @@
1
+ import { join } from "node:path";
2
+
3
+ export function pagesDir(projectDir) {
4
+ return join(projectDir, ".chat-glass", "pages");
5
+ }
6
+
7
+ export function configPath(projectDir) {
8
+ return join(projectDir, ".chat-glass", "config.json");
9
+ }
10
+
11
+ export function latestPath(projectDir) {
12
+ return join(projectDir, ".chat-glass", "pages", "latest.html");
13
+ }
14
+
15
+ export function getProjectDir() {
16
+ return process.cwd();
17
+ }
@@ -0,0 +1,24 @@
1
+ import { createServer } from "node:net";
2
+
3
+ const PORT_MIN = 3737;
4
+ const PORT_MAX = 3747;
5
+
6
+ function tryPort(port) {
7
+ return new Promise((resolve) => {
8
+ const server = createServer();
9
+ server.unref();
10
+ server.on("error", () => resolve(false));
11
+ server.listen(port, "127.0.0.1", () => {
12
+ server.close(() => resolve(true));
13
+ });
14
+ });
15
+ }
16
+
17
+ export async function findFreePort() {
18
+ for (let port = PORT_MIN; port <= PORT_MAX; port++) {
19
+ if (await tryPort(port)) return port;
20
+ }
21
+ throw new Error(
22
+ `No free port found in range ${PORT_MIN}–${PORT_MAX}. Close an existing chat-glass instance or free a port.`
23
+ );
24
+ }
@@ -0,0 +1,70 @@
1
+ import { findChrome } from "./find-chrome.js";
2
+
3
+ const CAPTURE_TIMEOUT_MS = 5000;
4
+
5
+ export async function captureScreenshot(port, outputPath) {
6
+ let timer;
7
+ try {
8
+ const result = await Promise.race([
9
+ doCapture(port, outputPath),
10
+ new Promise((_, reject) => {
11
+ timer = setTimeout(
12
+ () => reject(new Error("Screenshot timeout")),
13
+ CAPTURE_TIMEOUT_MS
14
+ );
15
+ }),
16
+ ]);
17
+ return result ?? null;
18
+ } catch {
19
+ return null;
20
+ } finally {
21
+ clearTimeout(timer);
22
+ }
23
+ }
24
+
25
+ async function doCapture(port, outputPath) {
26
+ let browser = null;
27
+ try {
28
+ let puppeteer;
29
+ try {
30
+ const mod = await import("puppeteer-core");
31
+ puppeteer = mod.default ?? mod;
32
+ } catch {
33
+ return null;
34
+ }
35
+
36
+ const chromePath = await findChrome();
37
+ if (!chromePath) return null;
38
+
39
+ browser = await puppeteer.launch({
40
+ headless: true,
41
+ executablePath: chromePath,
42
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
43
+ });
44
+
45
+ const page = await browser.newPage();
46
+ await page.setViewport({ width: 1200, height: 800 });
47
+ await page.goto(`http://127.0.0.1:${port}`, {
48
+ waitUntil: "networkidle0",
49
+ timeout: 4000,
50
+ });
51
+
52
+ // Short settle time for async renderers
53
+ await new Promise((r) => setTimeout(r, 500));
54
+
55
+ await page.screenshot({ path: outputPath, fullPage: true });
56
+ await browser.close();
57
+ browser = null;
58
+
59
+ return outputPath;
60
+ } catch {
61
+ if (browser) {
62
+ try {
63
+ await browser.close();
64
+ } catch {
65
+ // ignore cleanup errors
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ }