@lioneltay/component-shot 0.1.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,1939 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { watch } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import http from 'node:http';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { createRspackBuild } from './rspack.js';
8
+ const defaultGalleryOptions = {
9
+ host: '127.0.0.1',
10
+ open: true,
11
+ port: 0,
12
+ scenarioDir: 'component-shot/scenarios',
13
+ };
14
+ const scenarioExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
15
+ const setupFilenames = ['setup.tsx', 'setup.ts', 'setup.jsx', 'setup.js'];
16
+ const contentTypes = {
17
+ '.css': 'text/css; charset=utf-8',
18
+ '.gif': 'image/gif',
19
+ '.html': 'text/html; charset=utf-8',
20
+ '.jpeg': 'image/jpeg',
21
+ '.jpg': 'image/jpeg',
22
+ '.js': 'text/javascript; charset=utf-8',
23
+ '.json': 'application/json; charset=utf-8',
24
+ '.map': 'application/json; charset=utf-8',
25
+ '.png': 'image/png',
26
+ '.svg': 'image/svg+xml',
27
+ '.webp': 'image/webp',
28
+ };
29
+ const readFlagValue = (args, index, flag) => {
30
+ const inlineValue = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : undefined;
31
+ if (inlineValue) {
32
+ return [inlineValue, index];
33
+ }
34
+ const value = args[index + 1];
35
+ if (!value || value.startsWith('--')) {
36
+ throw new Error(`Missing value for ${flag}`);
37
+ }
38
+ return [value, index + 1];
39
+ };
40
+ const createGalleryUsage = (usageCommand) => `Usage:
41
+ ${usageCommand}
42
+
43
+ Options:
44
+ --scenario-dir <path> Scenario directory. Defaults to component-shot/scenarios.
45
+ --screenshots-dir <path> Screenshot history directory. Defaults to sibling component-shot/screenshots.
46
+ --cwd <path> Working directory. Defaults to the current directory.
47
+ --host <host> Host to bind. Defaults to 127.0.0.1.
48
+ --port <port> Port to bind. Defaults to an ephemeral port.
49
+ --no-open Print the gallery URL without opening a browser.
50
+ --json Print machine-readable startup JSON.
51
+ --help Show this help message.`;
52
+ const parseGalleryCliArgs = ({ argv, usageCommand, }) => {
53
+ const options = { ...defaultGalleryOptions };
54
+ const usage = createGalleryUsage(usageCommand);
55
+ for (let index = 0; index < argv.length; index += 1) {
56
+ const arg = argv[index];
57
+ const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
58
+ switch (flag) {
59
+ case '--':
60
+ break;
61
+ case '--cwd': {
62
+ const [value, nextIndex] = readFlagValue(argv, index, arg);
63
+ options.cwd = value;
64
+ index = nextIndex;
65
+ break;
66
+ }
67
+ case '--help':
68
+ case '-h':
69
+ process.stdout.write(`${usage}\n`);
70
+ process.exit(0);
71
+ break;
72
+ case '--host': {
73
+ const [value, nextIndex] = readFlagValue(argv, index, arg);
74
+ options.host = value;
75
+ index = nextIndex;
76
+ break;
77
+ }
78
+ case '--json':
79
+ options.json = true;
80
+ break;
81
+ case '--no-open':
82
+ options.open = false;
83
+ break;
84
+ case '--open':
85
+ options.open = true;
86
+ break;
87
+ case '--port': {
88
+ const [value, nextIndex] = readFlagValue(argv, index, arg);
89
+ options.port = Number(value);
90
+ index = nextIndex;
91
+ break;
92
+ }
93
+ case '--scenario-dir': {
94
+ const [value, nextIndex] = readFlagValue(argv, index, arg);
95
+ options.scenarioDir = value;
96
+ index = nextIndex;
97
+ break;
98
+ }
99
+ case '--screenshots-dir': {
100
+ const [value, nextIndex] = readFlagValue(argv, index, arg);
101
+ options.screenshotsDir = value;
102
+ index = nextIndex;
103
+ break;
104
+ }
105
+ default:
106
+ throw new Error(`Unknown gallery option "${arg}"\n\n${usage}`);
107
+ }
108
+ }
109
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65_535) {
110
+ throw new Error('--port must be an integer from 0 to 65535');
111
+ }
112
+ return options;
113
+ };
114
+ const encodeId = (value) => Buffer.from(value).toString('base64url');
115
+ const escapeHtml = (value) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
116
+ const escapeAttribute = (value) => escapeHtml(value).replace(/'/g, '&#39;');
117
+ const toInlineJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
118
+ const toPosixPath = (value) => value.split(path.sep).join('/');
119
+ const sanitizeFilename = (value) => value
120
+ .replace(/[^a-z0-9_.-]+/gi, '-')
121
+ .replace(/^-+|-+$/g, '')
122
+ .toLowerCase() || 'component-shot';
123
+ const getScenarioName = (scenarioPath) => {
124
+ const basename = path.basename(scenarioPath, path.extname(scenarioPath));
125
+ return basename === 'index' ? path.basename(path.dirname(scenarioPath)) : basename;
126
+ };
127
+ const isScenarioFile = (filePath) => scenarioExtensions.has(path.extname(filePath).toLowerCase()) && !filePath.endsWith('.d.ts');
128
+ const pathExists = async (filePath) => {
129
+ try {
130
+ await fs.access(filePath);
131
+ return true;
132
+ }
133
+ catch (error) {
134
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
135
+ if (code === 'ENOENT') {
136
+ return false;
137
+ }
138
+ throw error;
139
+ }
140
+ };
141
+ const readDirOrEmpty = async (dir) => {
142
+ try {
143
+ return await fs.readdir(dir, { withFileTypes: true });
144
+ }
145
+ catch (error) {
146
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
147
+ if (code === 'ENOENT') {
148
+ return [];
149
+ }
150
+ throw error;
151
+ }
152
+ };
153
+ const walkScenarioFiles = async (dir) => {
154
+ const entries = await readDirOrEmpty(dir);
155
+ const files = await Promise.all(entries.map(async (entry) => {
156
+ const entryPath = path.join(dir, entry.name);
157
+ if (entry.isDirectory()) {
158
+ return walkScenarioFiles(entryPath);
159
+ }
160
+ if (entry.isFile() && isScenarioFile(entryPath)) {
161
+ return [entryPath];
162
+ }
163
+ return [];
164
+ }));
165
+ return files.flat();
166
+ };
167
+ const findComponentShotDir = (dir) => {
168
+ let current = path.resolve(dir);
169
+ while (true) {
170
+ if (path.basename(current) === 'component-shot') {
171
+ return current;
172
+ }
173
+ const parent = path.dirname(current);
174
+ if (parent === current) {
175
+ return undefined;
176
+ }
177
+ current = parent;
178
+ }
179
+ };
180
+ const findSetupPath = async (componentShotDir) => {
181
+ for (const filename of setupFilenames) {
182
+ const candidate = path.join(componentShotDir, filename);
183
+ if (await pathExists(candidate)) {
184
+ return candidate;
185
+ }
186
+ }
187
+ return undefined;
188
+ };
189
+ const resolveSetupPath = async ({ cwd, scenarioDir, }) => {
190
+ const componentShotDir = findComponentShotDir(scenarioDir);
191
+ if (componentShotDir) {
192
+ const setupPath = await findSetupPath(componentShotDir);
193
+ if (setupPath) {
194
+ return setupPath;
195
+ }
196
+ }
197
+ for (const candidate of setupFilenames.map((filename) => path.join('component-shot', filename))) {
198
+ if (await pathExists(path.resolve(cwd, candidate))) {
199
+ return candidate;
200
+ }
201
+ }
202
+ return undefined;
203
+ };
204
+ const resolveGalleryOptions = (options) => {
205
+ const cwd = path.resolve(process.cwd(), options.cwd ?? '.');
206
+ const scenarioDir = path.resolve(cwd, options.scenarioDir ?? defaultGalleryOptions.scenarioDir);
207
+ const componentShotDir = findComponentShotDir(scenarioDir);
208
+ return {
209
+ cwd,
210
+ host: options.host ?? defaultGalleryOptions.host,
211
+ open: options.open ?? defaultGalleryOptions.open,
212
+ port: options.port ?? defaultGalleryOptions.port,
213
+ scenarioDir,
214
+ screenshotsDir: options.screenshotsDir
215
+ ? path.resolve(cwd, options.screenshotsDir)
216
+ : componentShotDir
217
+ ? path.join(componentShotDir, 'screenshots')
218
+ : path.resolve(cwd, 'component-shot/screenshots'),
219
+ };
220
+ };
221
+ const getScenarioHistoryDir = (screenshotsDir, scenario) => path.join(screenshotsDir, sanitizeFilename(scenario.name), 'history');
222
+ const getHistoryCount = async (historyDir) => {
223
+ const entries = await readDirOrEmpty(historyDir);
224
+ return entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png')).length;
225
+ };
226
+ const listHistoryShots = async (index, scenario) => {
227
+ const historyDir = getScenarioHistoryDir(index.screenshotsDir, scenario);
228
+ const entries = await readDirOrEmpty(historyDir);
229
+ const shots = await Promise.all(entries
230
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
231
+ .map(async (entry) => {
232
+ const filePath = path.join(historyDir, entry.name);
233
+ const stats = await fs.stat(filePath);
234
+ return {
235
+ filename: entry.name,
236
+ updatedAt: stats.mtime.toISOString(),
237
+ url: `/history/${scenario.id}/${encodeURIComponent(entry.name)}`,
238
+ };
239
+ }));
240
+ shots.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
241
+ return shots;
242
+ };
243
+ export const createComponentShotGalleryIndex = async (options = {}) => {
244
+ const resolved = resolveGalleryOptions(options);
245
+ const scenarioPaths = await walkScenarioFiles(resolved.scenarioDir);
246
+ const scenarios = await Promise.all(scenarioPaths.map(async (scenarioPath) => {
247
+ const relativeScenarioPath = toPosixPath(path.relative(resolved.cwd, scenarioPath));
248
+ const id = encodeId(relativeScenarioPath);
249
+ const name = getScenarioName(scenarioPath);
250
+ return {
251
+ detailUrl: `/scenario/${id}/`,
252
+ historyCount: await getHistoryCount(getScenarioHistoryDir(resolved.screenshotsDir, { name })),
253
+ id,
254
+ name,
255
+ previewUrl: `/render/${id}/?embed=preview`,
256
+ relativeScenarioPath,
257
+ renderUrl: `/render/${id}/`,
258
+ scenarioPath,
259
+ };
260
+ }));
261
+ scenarios.sort((left, right) => left.relativeScenarioPath.localeCompare(right.relativeScenarioPath));
262
+ return {
263
+ cwd: resolved.cwd,
264
+ scenarioDir: resolved.scenarioDir,
265
+ scenarios,
266
+ screenshotsDir: resolved.screenshotsDir,
267
+ };
268
+ };
269
+ const pinIcon = `<svg aria-hidden="true" viewBox="0 0 20 20"><path d="M7 3h6l-1 5 3 3v2H5v-2l3-3-1-5Z"/><path d="M10 13v4"/></svg>`;
270
+ const trashIcon = `<svg aria-hidden="true" viewBox="0 0 20 20"><path d="M4 6h12"/><path d="M8 6V4h4v2"/><path d="M6 6l1 10h6l1-10"/><path d="M9 9v4"/><path d="M11 9v4"/></svg>`;
271
+ const openIcon = `<svg aria-hidden="true" viewBox="0 0 20 20"><path d="M8 5H5v10h10v-3"/><path d="M11 5h4v4"/><path d="M10 10l5-5"/></svg>`;
272
+ const createScenarioCard = (scenario) => `<article class="scenario-card" data-scenario-card data-scenario-id="${escapeAttribute(scenario.id)}" data-scenario-search="${escapeAttribute(`${scenario.name} ${scenario.relativeScenarioPath}`.toLowerCase())}">
273
+ <div class="render-frame" data-render-frame>
274
+ <iframe
275
+ title="${escapeAttribute(scenario.name)}"
276
+ src="${escapeAttribute(scenario.previewUrl)}"
277
+ scrolling="no"
278
+ ></iframe>
279
+ <div class="render-loading">Rendering...</div>
280
+ </div>
281
+ <div class="scenario-card__body">
282
+ <div class="scenario-card__meta">
283
+ <h2>${escapeHtml(scenario.name)}</h2>
284
+ <p class="path">${escapeHtml(scenario.relativeScenarioPath)}</p>
285
+ </div>
286
+ <div class="scenario-card__actions" aria-label="Scenario actions">
287
+ <button class="scenario-action pin-button" type="button" data-pin-button aria-label="Pin scenario" aria-pressed="false" title="Pin scenario">${pinIcon}</button>
288
+ <button
289
+ class="scenario-action delete-scenario"
290
+ type="button"
291
+ data-delete-scenario
292
+ data-scenario-name="${escapeAttribute(scenario.name)}"
293
+ data-scenario-path="${escapeAttribute(scenario.relativeScenarioPath)}"
294
+ aria-label="Delete scenario"
295
+ title="Delete scenario"
296
+ >${trashIcon}</button>
297
+ <a class="scenario-action open-render" href="${escapeAttribute(scenario.detailUrl)}" aria-label="Open scenario" title="Open scenario">${openIcon}</a>
298
+ </div>
299
+ </div>
300
+ </article>`;
301
+ const createGalleryHtml = (index) => {
302
+ const clearDisabledAttribute = index.scenarios.length === 0 ? ' disabled' : '';
303
+ const scenarioDirLabel = toPosixPath(path.relative(index.cwd, index.scenarioDir) || index.scenarioDir);
304
+ const cards = index.scenarios.length > 0
305
+ ? index.scenarios.map(createScenarioCard).join('\n')
306
+ : `<section class="empty-state">
307
+ <h2>No scenarios found</h2>
308
+ <p>${escapeHtml(scenarioDirLabel)}</p>
309
+ </section>`;
310
+ return `<!doctype html>
311
+ <html lang="en">
312
+ <head>
313
+ <meta charset="utf-8" />
314
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
315
+ <title>Component Shot Gallery</title>
316
+ <script>
317
+ try {
318
+ const savedColumns = localStorage.getItem('component-shot-gallery:columns') || 'auto'
319
+ if (['auto', '2', '3', '4'].includes(savedColumns)) {
320
+ document.documentElement.dataset.galleryColumns = savedColumns
321
+ }
322
+ } catch {}
323
+ </script>
324
+ <style>
325
+ :root {
326
+ --bg: #e9eef3;
327
+ --panel: #ffffff;
328
+ --panel-muted: #f6f8fb;
329
+ --border: #cbd5e1;
330
+ --border-strong: #aeb9c8;
331
+ --text: #111827;
332
+ --muted: #526173;
333
+ --subtle: #718096;
334
+ --accent: #0f766e;
335
+ --danger: #a11d33;
336
+ --ink: #101b2d;
337
+ color: var(--text);
338
+ background: var(--bg);
339
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
340
+ font-synthesis: none;
341
+ text-rendering: optimizeLegibility;
342
+ }
343
+
344
+ * {
345
+ box-sizing: border-box;
346
+ }
347
+
348
+ [hidden] {
349
+ display: none !important;
350
+ }
351
+
352
+ body {
353
+ min-width: 320px;
354
+ min-height: 100vh;
355
+ margin: 0;
356
+ }
357
+
358
+ .app-shell {
359
+ width: 100%;
360
+ margin: 0 auto;
361
+ padding: 0 14px 18px;
362
+ }
363
+
364
+ .gallery-header {
365
+ position: sticky;
366
+ top: 0;
367
+ z-index: 10;
368
+ display: grid;
369
+ grid-template-columns: minmax(280px, 1fr) auto;
370
+ align-items: center;
371
+ gap: 14px;
372
+ margin: 0 -14px 12px;
373
+ padding: 9px 14px;
374
+ border-bottom: 1px solid var(--border);
375
+ background: rgb(233 238 243 / 94%);
376
+ backdrop-filter: blur(8px);
377
+ }
378
+
379
+ .title-lockup {
380
+ display: flex;
381
+ min-width: 0;
382
+ align-items: center;
383
+ gap: 9px;
384
+ }
385
+
386
+ .brand-mark {
387
+ width: 30px;
388
+ height: 30px;
389
+ display: grid;
390
+ flex: 0 0 auto;
391
+ place-items: center;
392
+ border: 1px solid #233044;
393
+ border-radius: 6px;
394
+ background: var(--ink);
395
+ color: #ffffff;
396
+ font-size: 0.64rem;
397
+ font-weight: 900;
398
+ line-height: 1;
399
+ }
400
+
401
+ .eyebrow {
402
+ margin: 0 0 2px;
403
+ color: var(--accent);
404
+ font-size: 0.62rem;
405
+ font-weight: 800;
406
+ letter-spacing: 0;
407
+ text-transform: uppercase;
408
+ }
409
+
410
+ h1,
411
+ h2,
412
+ p {
413
+ margin-top: 0;
414
+ }
415
+
416
+ h1 {
417
+ margin-bottom: 0;
418
+ color: var(--text);
419
+ font-size: 1.02rem;
420
+ line-height: 1.1;
421
+ letter-spacing: 0;
422
+ }
423
+
424
+ .workspace-path {
425
+ margin: 3px 0 0;
426
+ color: var(--muted);
427
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
428
+ font-size: 0.72rem;
429
+ line-height: 1.25;
430
+ overflow: hidden;
431
+ text-overflow: ellipsis;
432
+ white-space: nowrap;
433
+ }
434
+
435
+ .summary {
436
+ display: flex;
437
+ align-items: center;
438
+ justify-content: flex-end;
439
+ gap: 6px;
440
+ color: var(--muted);
441
+ font-weight: 700;
442
+ flex-wrap: wrap;
443
+ }
444
+
445
+ .search-control {
446
+ position: relative;
447
+ display: inline-flex;
448
+ align-items: center;
449
+ }
450
+
451
+ .search-control span {
452
+ position: absolute;
453
+ left: 9px;
454
+ color: var(--subtle);
455
+ font-size: 0.72rem;
456
+ font-weight: 800;
457
+ pointer-events: none;
458
+ }
459
+
460
+ .search-control input {
461
+ width: clamp(190px, 18vw, 300px);
462
+ min-height: 32px;
463
+ padding: 0 10px 0 58px;
464
+ border: 1px solid var(--border);
465
+ border-radius: 6px;
466
+ background: var(--panel);
467
+ color: var(--text);
468
+ font: inherit;
469
+ font-size: 0.8rem;
470
+ font-weight: 650;
471
+ }
472
+
473
+ .search-control input:focus,
474
+ .layout-control select:focus,
475
+ .summary a:focus,
476
+ .summary button:focus,
477
+ .scenario-action:focus {
478
+ outline: 2px solid rgb(15 118 110 / 30%);
479
+ outline-offset: 1px;
480
+ }
481
+
482
+ .layout-control {
483
+ display: inline-flex;
484
+ align-items: center;
485
+ overflow: hidden;
486
+ min-height: 32px;
487
+ border: 1px solid var(--border);
488
+ border-radius: 6px;
489
+ background: var(--panel);
490
+ color: var(--muted);
491
+ font-size: 0.74rem;
492
+ font-weight: 800;
493
+ }
494
+
495
+ .layout-control span {
496
+ padding: 0 8px;
497
+ }
498
+
499
+ .layout-control select {
500
+ min-height: 30px;
501
+ padding: 0 26px 0 8px;
502
+ border: 0;
503
+ border-left: 1px solid var(--border);
504
+ background: var(--panel-muted);
505
+ color: var(--text);
506
+ font: inherit;
507
+ }
508
+
509
+ .summary-count {
510
+ min-height: 32px;
511
+ display: inline-flex;
512
+ align-items: center;
513
+ padding: 0 9px;
514
+ border: 1px solid var(--border);
515
+ border-radius: 999px;
516
+ background: #dde6ef;
517
+ color: #35465a;
518
+ font-size: 0.76rem;
519
+ white-space: nowrap;
520
+ }
521
+
522
+ .summary a,
523
+ .summary button {
524
+ min-height: 32px;
525
+ display: inline-flex;
526
+ align-items: center;
527
+ justify-content: center;
528
+ padding: 0 10px;
529
+ border: 1px solid var(--border);
530
+ border-radius: 6px;
531
+ background: var(--panel);
532
+ color: var(--text);
533
+ font: inherit;
534
+ font-size: 0.76rem;
535
+ font-weight: 800;
536
+ text-decoration: none;
537
+ }
538
+
539
+ .summary button {
540
+ cursor: pointer;
541
+ }
542
+
543
+ .summary button:disabled {
544
+ cursor: not-allowed;
545
+ opacity: 0.5;
546
+ }
547
+
548
+ .summary .danger-button {
549
+ border-color: #e4b8c0;
550
+ color: var(--danger);
551
+ }
552
+
553
+ .scenario-grid {
554
+ --auto-card-min: clamp(460px, 30vw, 640px);
555
+ display: grid;
556
+ grid-template-columns: repeat(auto-fill, minmax(min(var(--auto-card-min), 100%), 1fr));
557
+ align-items: start;
558
+ gap: 10px;
559
+ }
560
+
561
+ html[data-gallery-columns="2"] .scenario-grid {
562
+ grid-template-columns: repeat(2, minmax(0, 1fr));
563
+ }
564
+
565
+ html[data-gallery-columns="3"] .scenario-grid {
566
+ grid-template-columns: repeat(3, minmax(0, 1fr));
567
+ }
568
+
569
+ html[data-gallery-columns="4"] .scenario-grid {
570
+ grid-template-columns: repeat(4, minmax(0, 1fr));
571
+ }
572
+
573
+ .scenario-card {
574
+ display: grid;
575
+ overflow: hidden;
576
+ border: 1px solid var(--border);
577
+ border-radius: 6px;
578
+ background: var(--panel);
579
+ box-shadow: 0 1px 2px rgb(15 23 42 / 5%);
580
+ }
581
+
582
+ .scenario-card[data-pinned="true"] {
583
+ border-color: #6eaa9d;
584
+ box-shadow: inset 0 2px 0 #6eaa9d, 0 1px 2px rgb(15 23 42 / 6%);
585
+ }
586
+
587
+ .render-frame {
588
+ --preview-min-height: 220px;
589
+ --preview-max-height: 620px;
590
+ position: relative;
591
+ height: clamp(280px, 25vw, var(--preview-max-height));
592
+ overflow: hidden;
593
+ border-bottom: 1px solid var(--border);
594
+ background: #fbfcfe;
595
+ }
596
+
597
+ html[data-gallery-columns="2"] .render-frame {
598
+ --preview-max-height: 720px;
599
+ height: clamp(340px, 34vw, var(--preview-max-height));
600
+ }
601
+
602
+ html[data-gallery-columns="3"] .render-frame {
603
+ --preview-max-height: 620px;
604
+ height: clamp(300px, 26vw, var(--preview-max-height));
605
+ }
606
+
607
+ html[data-gallery-columns="4"] .render-frame {
608
+ --preview-max-height: 460px;
609
+ height: clamp(240px, 21vw, var(--preview-max-height));
610
+ }
611
+
612
+ .render-frame iframe {
613
+ width: 100%;
614
+ height: 100%;
615
+ border: 0;
616
+ background: var(--panel);
617
+ opacity: 0;
618
+ transition: opacity 160ms ease;
619
+ }
620
+
621
+ .render-frame.is-ready iframe {
622
+ opacity: 1;
623
+ }
624
+
625
+ .render-loading {
626
+ position: absolute;
627
+ inset: 0;
628
+ display: grid;
629
+ place-items: center;
630
+ background: var(--panel);
631
+ color: var(--subtle);
632
+ font-size: 0.8rem;
633
+ font-weight: 800;
634
+ }
635
+
636
+ .render-frame.is-ready .render-loading {
637
+ display: none;
638
+ }
639
+
640
+ .scenario-card__body {
641
+ display: grid;
642
+ align-items: center;
643
+ grid-template-columns: minmax(0, 1fr) auto;
644
+ gap: 10px;
645
+ min-height: 58px;
646
+ padding: 9px 10px 9px 12px;
647
+ }
648
+
649
+ .scenario-card__meta {
650
+ min-width: 0;
651
+ }
652
+
653
+ .scenario-card__actions {
654
+ display: inline-flex;
655
+ flex: 0 0 auto;
656
+ align-items: center;
657
+ gap: 4px;
658
+ }
659
+
660
+ .scenario-card h2 {
661
+ margin-bottom: 3px;
662
+ color: var(--text);
663
+ font-size: 0.92rem;
664
+ line-height: 1.15;
665
+ letter-spacing: 0;
666
+ }
667
+
668
+ .path {
669
+ margin-bottom: 0;
670
+ color: var(--muted);
671
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
672
+ font-size: 0.7rem;
673
+ line-height: 1.35;
674
+ overflow-wrap: anywhere;
675
+ }
676
+
677
+ .scenario-action {
678
+ flex: 0 0 auto;
679
+ width: 30px;
680
+ height: 30px;
681
+ display: inline-flex;
682
+ align-items: center;
683
+ justify-content: center;
684
+ padding: 0;
685
+ border: 1px solid var(--border);
686
+ border-radius: 5px;
687
+ background: var(--panel);
688
+ color: #314156;
689
+ font: inherit;
690
+ font-weight: 800;
691
+ line-height: 1;
692
+ text-decoration: none;
693
+ }
694
+
695
+ .scenario-action svg {
696
+ width: 15px;
697
+ height: 15px;
698
+ fill: none;
699
+ stroke: currentColor;
700
+ stroke-linecap: round;
701
+ stroke-linejoin: round;
702
+ stroke-width: 1.8;
703
+ }
704
+
705
+ .pin-button {
706
+ cursor: pointer;
707
+ }
708
+
709
+ .pin-button[aria-pressed="true"] {
710
+ border-color: #6eaa9d;
711
+ background: #e7f5f1;
712
+ color: #0f5f56;
713
+ }
714
+
715
+ .delete-scenario {
716
+ border-color: #e4b8c0;
717
+ background: #fff8fa;
718
+ color: var(--danger);
719
+ cursor: pointer;
720
+ }
721
+
722
+ .open-render {
723
+ border-color: var(--ink);
724
+ background: var(--ink);
725
+ color: #ffffff;
726
+ }
727
+
728
+ .empty-state {
729
+ padding: 24px;
730
+ border: 1px solid var(--border);
731
+ border-radius: 6px;
732
+ background: var(--panel);
733
+ }
734
+
735
+ .empty-state h2 {
736
+ margin-bottom: 8px;
737
+ color: var(--text);
738
+ }
739
+
740
+ .empty-state p {
741
+ margin-bottom: 0;
742
+ color: var(--muted);
743
+ }
744
+
745
+ .filter-empty {
746
+ padding: 18px;
747
+ border: 1px dashed var(--border-strong);
748
+ border-radius: 6px;
749
+ background: rgb(255 255 255 / 55%);
750
+ color: var(--muted);
751
+ font-size: 0.86rem;
752
+ font-weight: 750;
753
+ }
754
+
755
+ @media (max-width: 720px) {
756
+ .app-shell {
757
+ padding: 0 10px 14px;
758
+ }
759
+
760
+ .gallery-header {
761
+ grid-template-columns: 1fr;
762
+ align-items: stretch;
763
+ margin: 0 -10px 10px;
764
+ padding: 9px 10px;
765
+ }
766
+
767
+ h1 {
768
+ font-size: 1rem;
769
+ }
770
+
771
+ .summary {
772
+ justify-content: flex-start;
773
+ }
774
+
775
+ .search-control,
776
+ .search-control input {
777
+ width: 100%;
778
+ }
779
+
780
+ .scenario-grid,
781
+ html[data-gallery-columns="2"] .scenario-grid,
782
+ html[data-gallery-columns="3"] .scenario-grid,
783
+ html[data-gallery-columns="4"] .scenario-grid {
784
+ grid-template-columns: 1fr;
785
+ }
786
+
787
+ .render-frame,
788
+ html[data-gallery-columns="2"] .render-frame,
789
+ html[data-gallery-columns="3"] .render-frame,
790
+ html[data-gallery-columns="4"] .render-frame {
791
+ --preview-min-height: 200px;
792
+ --preview-max-height: 480px;
793
+ height: 280px;
794
+ }
795
+
796
+ .scenario-card__body {
797
+ grid-template-columns: 1fr;
798
+ }
799
+
800
+ .scenario-card__actions {
801
+ width: 100%;
802
+ justify-content: flex-end;
803
+ }
804
+
805
+ .scenario-action {
806
+ width: 34px;
807
+ height: 34px;
808
+ }
809
+ }
810
+ </style>
811
+ </head>
812
+ <body>
813
+ <main class="app-shell">
814
+ <header class="gallery-header">
815
+ <div class="title-lockup">
816
+ <div class="brand-mark" aria-hidden="true">CS</div>
817
+ <div>
818
+ <p class="eyebrow">Component Shot</p>
819
+ <h1>Scenario Gallery</h1>
820
+ <p class="workspace-path">${escapeHtml(scenarioDirLabel)}</p>
821
+ </div>
822
+ </div>
823
+ <div class="summary" aria-label="Gallery controls">
824
+ <label class="search-control">
825
+ <span>Search</span>
826
+ <input data-gallery-search type="search" autocomplete="off" spellcheck="false" />
827
+ </label>
828
+ <label class="layout-control">
829
+ <span>Columns</span>
830
+ <select data-layout-select aria-label="Gallery columns">
831
+ <option value="auto">Auto</option>
832
+ <option value="2">2</option>
833
+ <option value="3">3</option>
834
+ <option value="4">4</option>
835
+ </select>
836
+ </label>
837
+ <span class="summary-count" data-scenario-count>${index.scenarios.length} ${index.scenarios.length === 1 ? 'scenario' : 'scenarios'}</span>
838
+ <button class="danger-button" type="button" data-clear-scenarios${clearDisabledAttribute}>Clear all</button>
839
+ <a href="/api/scenarios">JSON</a>
840
+ </div>
841
+ </header>
842
+ <section class="scenario-grid" data-scenario-grid aria-label="Scenarios">
843
+ ${cards}
844
+ </section>
845
+ <section class="filter-empty" data-filter-empty hidden>No scenarios match the current search.</section>
846
+ </main>
847
+ <script>
848
+ const columnsKey = 'component-shot-gallery:columns'
849
+ const pinnedKey = 'component-shot-gallery:pinned'
850
+ const validColumns = new Set(['auto', '2', '3', '4'])
851
+
852
+ const readColumns = () => {
853
+ try {
854
+ const value = localStorage.getItem(columnsKey)
855
+ return validColumns.has(value) ? value : 'auto'
856
+ } catch {
857
+ return 'auto'
858
+ }
859
+ }
860
+
861
+ const applyColumns = (value) => {
862
+ const nextValue = validColumns.has(value) ? value : 'auto'
863
+ document.documentElement.dataset.galleryColumns = nextValue
864
+ const select = document.querySelector('[data-layout-select]')
865
+ if (select) {
866
+ select.value = nextValue
867
+ }
868
+ try {
869
+ localStorage.setItem(columnsKey, nextValue)
870
+ } catch {}
871
+ }
872
+
873
+ const readPinned = () => {
874
+ try {
875
+ const value = JSON.parse(localStorage.getItem(pinnedKey) || '[]')
876
+ return new Set(Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [])
877
+ } catch {
878
+ return new Set()
879
+ }
880
+ }
881
+
882
+ const writePinned = (pinned) => {
883
+ try {
884
+ localStorage.setItem(pinnedKey, JSON.stringify([...pinned]))
885
+ } catch {}
886
+ }
887
+
888
+ const scenarioGrid = document.querySelector('[data-scenario-grid]')
889
+ const cards = Array.from(document.querySelectorAll('[data-scenario-card]'))
890
+ const countNode = document.querySelector('[data-scenario-count]')
891
+ const filterEmpty = document.querySelector('[data-filter-empty]')
892
+ const searchInput = document.querySelector('[data-gallery-search]')
893
+ const formatScenarioCount = (count, total) => {
894
+ const label = count === 1 ? 'scenario' : 'scenarios'
895
+ return count === total ? count + ' ' + label : count + ' of ' + total + ' scenarios'
896
+ }
897
+ const updateScenarioCount = () => {
898
+ if (!countNode) {
899
+ return
900
+ }
901
+ const visibleCount = cards.filter((card) => !card.hidden).length
902
+ countNode.textContent = formatScenarioCount(visibleCount, cards.length)
903
+ }
904
+ const applySearch = () => {
905
+ const query = String(searchInput?.value || '').trim().toLowerCase()
906
+ let visibleCount = 0
907
+ for (const card of cards) {
908
+ const matches = !query || String(card.dataset.scenarioSearch || '').includes(query)
909
+ card.hidden = !matches
910
+ if (matches) {
911
+ visibleCount += 1
912
+ }
913
+ }
914
+ if (filterEmpty) {
915
+ filterEmpty.hidden = visibleCount > 0 || cards.length === 0
916
+ }
917
+ updateScenarioCount()
918
+ }
919
+ const applyPinned = () => {
920
+ const pinned = readPinned()
921
+ const cardStates = cards.map((card, index) => {
922
+ const id = card.dataset.scenarioId
923
+ const isPinned = typeof id === 'string' && pinned.has(id)
924
+ card.dataset.pinned = String(isPinned)
925
+
926
+ const button = card.querySelector('[data-pin-button]')
927
+ if (button) {
928
+ button.setAttribute('aria-pressed', String(isPinned))
929
+ button.setAttribute('aria-label', isPinned ? 'Unpin scenario' : 'Pin scenario')
930
+ button.title = isPinned ? 'Unpin scenario' : 'Pin scenario'
931
+ }
932
+
933
+ return { card, index, isPinned }
934
+ })
935
+
936
+ if (scenarioGrid) {
937
+ cardStates
938
+ .sort((left, right) => {
939
+ if (left.isPinned !== right.isPinned) {
940
+ return left.isPinned ? -1 : 1
941
+ }
942
+ return left.index - right.index
943
+ })
944
+ .forEach(({ card }) => scenarioGrid.appendChild(card))
945
+ }
946
+ applySearch()
947
+ }
948
+
949
+ const layoutSelect = document.querySelector('[data-layout-select]')
950
+ if (layoutSelect) {
951
+ applyColumns(readColumns())
952
+ layoutSelect.addEventListener('change', () => {
953
+ applyColumns(layoutSelect.value)
954
+ window.requestAnimationFrame(fitReadyFrames)
955
+ })
956
+ }
957
+
958
+ for (const button of document.querySelectorAll('[data-pin-button]')) {
959
+ button.addEventListener('click', () => {
960
+ const card = button.closest('[data-scenario-card]')
961
+ const id = card?.dataset.scenarioId
962
+ if (!id) {
963
+ return
964
+ }
965
+
966
+ const pinned = readPinned()
967
+ if (pinned.has(id)) {
968
+ pinned.delete(id)
969
+ } else {
970
+ pinned.add(id)
971
+ }
972
+ writePinned(pinned)
973
+ applyPinned()
974
+ window.requestAnimationFrame(() => {
975
+ fitReadyFrames()
976
+ window.setTimeout(fitReadyFrames, 80)
977
+ window.setTimeout(fitReadyFrames, 280)
978
+ })
979
+ })
980
+ }
981
+ applyPinned()
982
+ searchInput?.addEventListener('input', () => {
983
+ applySearch()
984
+ window.requestAnimationFrame(fitReadyFrames)
985
+ })
986
+
987
+ const requestDelete = async (url) => {
988
+ const response = await fetch(url, { method: 'DELETE' })
989
+ if (response.ok) {
990
+ return
991
+ }
992
+
993
+ let message = 'Delete failed'
994
+ try {
995
+ const data = await response.json()
996
+ message = data.error || message
997
+ } catch {
998
+ message = await response.text()
999
+ }
1000
+ throw new Error(message || 'Delete failed')
1001
+ }
1002
+
1003
+ for (const button of document.querySelectorAll('[data-delete-scenario]')) {
1004
+ button.addEventListener('click', async () => {
1005
+ const card = button.closest('[data-scenario-card]')
1006
+ const id = card?.dataset.scenarioId
1007
+ if (!id) {
1008
+ return
1009
+ }
1010
+
1011
+ const scenarioName = button.dataset.scenarioName || id
1012
+ const scenarioPath = button.dataset.scenarioPath || ''
1013
+ if (!window.confirm('Delete scenario "' + scenarioName + '"?\\n' + scenarioPath)) {
1014
+ return
1015
+ }
1016
+
1017
+ try {
1018
+ await requestDelete('/api/scenarios/' + encodeURIComponent(id))
1019
+ const pinned = readPinned()
1020
+ pinned.delete(id)
1021
+ writePinned(pinned)
1022
+ window.location.reload()
1023
+ } catch (error) {
1024
+ window.alert(error instanceof Error ? error.message : String(error))
1025
+ }
1026
+ })
1027
+ }
1028
+
1029
+ const clearButton = document.querySelector('[data-clear-scenarios]')
1030
+ clearButton?.addEventListener('click', async () => {
1031
+ if (cards.length === 0) {
1032
+ return
1033
+ }
1034
+
1035
+ if (!window.confirm('Delete all ' + cards.length + ' scenario files?\\nScreenshot history is not deleted.')) {
1036
+ return
1037
+ }
1038
+
1039
+ try {
1040
+ await requestDelete('/api/scenarios')
1041
+ writePinned(new Set())
1042
+ window.location.reload()
1043
+ } catch (error) {
1044
+ window.alert(error instanceof Error ? error.message : String(error))
1045
+ }
1046
+ })
1047
+
1048
+ const readPixelValue = (value, fallback) => {
1049
+ const parsed = Number.parseFloat(value)
1050
+ return Number.isFinite(parsed) ? parsed : fallback
1051
+ }
1052
+
1053
+ const fitRenderFrame = (frame) => {
1054
+ const iframe = frame.querySelector('iframe')
1055
+ const doc = iframe?.contentDocument
1056
+ const root = doc?.querySelector('[data-component-shot-root]')
1057
+ if (!iframe || !doc || !root) {
1058
+ return
1059
+ }
1060
+
1061
+ root.style.marginLeft = '0px'
1062
+ root.style.marginTop = '0px'
1063
+ root.style.transform = ''
1064
+ root.style.transformOrigin = 'top left'
1065
+
1066
+ const rect = root.getBoundingClientRect()
1067
+ const bodyStyle = doc.defaultView?.getComputedStyle(doc.body)
1068
+ const paddingX =
1069
+ Number.parseFloat(bodyStyle?.paddingLeft || '0') +
1070
+ Number.parseFloat(bodyStyle?.paddingRight || '0')
1071
+ const paddingY =
1072
+ Number.parseFloat(bodyStyle?.paddingTop || '0') +
1073
+ Number.parseFloat(bodyStyle?.paddingBottom || '0')
1074
+ const frameStyle = window.getComputedStyle(frame)
1075
+ const minFrameHeight = readPixelValue(frameStyle.getPropertyValue('--preview-min-height'), 240)
1076
+ const maxFrameHeight = readPixelValue(frameStyle.getPropertyValue('--preview-max-height'), 620)
1077
+ const availableWidth = Math.max(1, iframe.clientWidth - paddingX)
1078
+ const contentWidth = Math.max(1, root.scrollWidth, Math.ceil(rect.width))
1079
+ const contentHeight = Math.max(1, root.scrollHeight, Math.ceil(rect.height))
1080
+ const widthScale = Math.min(1, availableWidth / contentWidth)
1081
+ const desiredHeight = Math.min(
1082
+ maxFrameHeight,
1083
+ Math.max(minFrameHeight, Math.ceil(contentHeight * widthScale + paddingY)),
1084
+ )
1085
+ if (Math.abs(frame.getBoundingClientRect().height - desiredHeight) > 1) {
1086
+ frame.style.height = desiredHeight + 'px'
1087
+ }
1088
+ const availableHeight = Math.max(1, desiredHeight - paddingY)
1089
+ const scale = Math.max(
1090
+ 0.15,
1091
+ Math.min(1, availableWidth / contentWidth, availableHeight / contentHeight),
1092
+ )
1093
+
1094
+ if (scale < 0.995) {
1095
+ root.style.transform = 'scale(' + scale + ')'
1096
+ }
1097
+ root.style.marginLeft = Math.max(0, (availableWidth - contentWidth * scale) / 2) + 'px'
1098
+ root.style.marginTop = Math.max(0, (availableHeight - contentHeight * scale) / 2) + 'px'
1099
+ frame.dataset.previewScale = scale.toFixed(3)
1100
+ }
1101
+
1102
+ const fitFrameSoon = (frame) => {
1103
+ fitRenderFrame(frame)
1104
+ window.setTimeout(() => fitRenderFrame(frame), 80)
1105
+ window.setTimeout(() => fitRenderFrame(frame), 280)
1106
+ }
1107
+
1108
+ const fitReadyFrames = () => {
1109
+ for (const frame of document.querySelectorAll('[data-render-frame].is-ready')) {
1110
+ fitRenderFrame(frame)
1111
+ }
1112
+ }
1113
+
1114
+ window.addEventListener('resize', fitReadyFrames)
1115
+
1116
+ if ('ResizeObserver' in window) {
1117
+ const previewResizeObserver = new ResizeObserver((entries) => {
1118
+ for (const entry of entries) {
1119
+ fitRenderFrame(entry.target)
1120
+ }
1121
+ })
1122
+ for (const frame of document.querySelectorAll('[data-render-frame]')) {
1123
+ previewResizeObserver.observe(frame)
1124
+ }
1125
+ }
1126
+
1127
+ const markFrameReady = (frame) => {
1128
+ const iframe = frame.querySelector('iframe')
1129
+ let checkToken = 0
1130
+ const finish = () => {
1131
+ frame.classList.add('is-ready')
1132
+ fitFrameSoon(frame)
1133
+ }
1134
+ const startChecking = () => {
1135
+ const token = ++checkToken
1136
+ frame.classList.remove('is-ready')
1137
+ const check = () => {
1138
+ if (token !== checkToken) {
1139
+ return
1140
+ }
1141
+ try {
1142
+ const win = iframe?.contentWindow
1143
+ if (!win) {
1144
+ window.setTimeout(check, 100)
1145
+ return
1146
+ }
1147
+ if (win.__COMPONENT_SHOT_READY__ || win.__COMPONENT_SHOT_ERROR__) {
1148
+ finish()
1149
+ return
1150
+ }
1151
+ } catch {
1152
+ finish()
1153
+ return
1154
+ }
1155
+ window.setTimeout(check, 100)
1156
+ }
1157
+ check()
1158
+ }
1159
+
1160
+ iframe?.addEventListener('load', startChecking)
1161
+ startChecking()
1162
+ }
1163
+
1164
+ for (const frame of document.querySelectorAll('[data-render-frame]')) {
1165
+ markFrameReady(frame)
1166
+ }
1167
+
1168
+ let currentVersion
1169
+ const checkVersion = async () => {
1170
+ try {
1171
+ const response = await fetch('/api/version')
1172
+ const data = await response.json()
1173
+ if (currentVersion === undefined) {
1174
+ currentVersion = data.version
1175
+ return
1176
+ }
1177
+ if (data.version !== currentVersion) {
1178
+ window.location.reload()
1179
+ }
1180
+ } catch {}
1181
+ }
1182
+ window.setInterval(checkVersion, 1200)
1183
+ void checkVersion()
1184
+ </script>
1185
+ </body>
1186
+ </html>`;
1187
+ };
1188
+ const createHistoryGrid = (history) => {
1189
+ if (history.length === 0) {
1190
+ return `<div class="empty-history">No screenshot history</div>`;
1191
+ }
1192
+ return history
1193
+ .map((shot) => `<a class="history-shot" href="${escapeAttribute(shot.url)}" target="_blank" rel="noreferrer">
1194
+ <img src="${escapeAttribute(shot.url)}" alt="${escapeAttribute(shot.filename)}" />
1195
+ <span>${escapeHtml(new Date(shot.updatedAt).toLocaleString())}</span>
1196
+ </a>`)
1197
+ .join('\n');
1198
+ };
1199
+ const createScenarioDetailHtml = ({ history, scenario, }) => `<!doctype html>
1200
+ <html lang="en">
1201
+ <head>
1202
+ <meta charset="utf-8" />
1203
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1204
+ <title>${escapeHtml(scenario.name)} - Component Shot</title>
1205
+ <style>
1206
+ :root {
1207
+ color: #172033;
1208
+ background: #eef2f6;
1209
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1210
+ font-synthesis: none;
1211
+ text-rendering: optimizeLegibility;
1212
+ }
1213
+
1214
+ * {
1215
+ box-sizing: border-box;
1216
+ }
1217
+
1218
+ body {
1219
+ min-width: 320px;
1220
+ min-height: 100vh;
1221
+ margin: 0;
1222
+ }
1223
+
1224
+ .app-shell {
1225
+ width: calc(100% - 32px);
1226
+ margin: 0 auto;
1227
+ padding: 16px 0;
1228
+ }
1229
+
1230
+ header {
1231
+ display: flex;
1232
+ align-items: center;
1233
+ justify-content: space-between;
1234
+ gap: 16px;
1235
+ margin-bottom: 12px;
1236
+ }
1237
+
1238
+ h1,
1239
+ h2,
1240
+ p {
1241
+ margin-top: 0;
1242
+ }
1243
+
1244
+ h1 {
1245
+ margin-bottom: 6px;
1246
+ color: #111827;
1247
+ font-size: 1.8rem;
1248
+ line-height: 1.1;
1249
+ letter-spacing: 0;
1250
+ }
1251
+
1252
+ .path {
1253
+ margin-bottom: 0;
1254
+ color: #506070;
1255
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
1256
+ font-size: 0.86rem;
1257
+ overflow-wrap: anywhere;
1258
+ }
1259
+
1260
+ .back-link {
1261
+ min-height: 36px;
1262
+ display: inline-flex;
1263
+ align-items: center;
1264
+ justify-content: center;
1265
+ padding: 0 12px;
1266
+ border: 1px solid #ccd6e3;
1267
+ border-radius: 7px;
1268
+ background: #ffffff;
1269
+ color: #172033;
1270
+ font-weight: 800;
1271
+ text-decoration: none;
1272
+ }
1273
+
1274
+ .detail-actions {
1275
+ display: inline-flex;
1276
+ align-items: center;
1277
+ gap: 8px;
1278
+ }
1279
+
1280
+ .delete-scenario {
1281
+ min-height: 36px;
1282
+ display: inline-flex;
1283
+ align-items: center;
1284
+ justify-content: center;
1285
+ padding: 0 12px;
1286
+ border: 1px solid #e9bec5;
1287
+ border-radius: 7px;
1288
+ background: #ffffff;
1289
+ color: #9f1239;
1290
+ font: inherit;
1291
+ font-weight: 800;
1292
+ cursor: pointer;
1293
+ }
1294
+
1295
+ .detail-render {
1296
+ position: relative;
1297
+ height: 620px;
1298
+ overflow: hidden;
1299
+ border: 1px solid #d7dee8;
1300
+ border-radius: 8px;
1301
+ background: #ffffff;
1302
+ box-shadow: 0 10px 28px rgb(15 23 42 / 7%);
1303
+ }
1304
+
1305
+ .detail-render iframe {
1306
+ width: 100%;
1307
+ height: 100%;
1308
+ border: 0;
1309
+ background: #ffffff;
1310
+ opacity: 0;
1311
+ transition: opacity 160ms ease;
1312
+ }
1313
+
1314
+ .detail-render.is-ready iframe {
1315
+ opacity: 1;
1316
+ }
1317
+
1318
+ .render-loading {
1319
+ position: absolute;
1320
+ inset: 0;
1321
+ display: grid;
1322
+ place-items: center;
1323
+ background: #ffffff;
1324
+ color: #64748b;
1325
+ font-weight: 800;
1326
+ }
1327
+
1328
+ .detail-render.is-ready .render-loading {
1329
+ display: none;
1330
+ }
1331
+
1332
+ .history-section {
1333
+ margin-top: 16px;
1334
+ }
1335
+
1336
+ .history-section h2 {
1337
+ margin-bottom: 12px;
1338
+ color: #111827;
1339
+ font-size: 1.1rem;
1340
+ letter-spacing: 0;
1341
+ }
1342
+
1343
+ .history-grid {
1344
+ display: grid;
1345
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
1346
+ gap: 12px;
1347
+ }
1348
+
1349
+ .history-shot {
1350
+ display: grid;
1351
+ overflow: hidden;
1352
+ border: 1px solid #d7dee8;
1353
+ border-radius: 8px;
1354
+ background: #ffffff;
1355
+ color: #506070;
1356
+ text-decoration: none;
1357
+ }
1358
+
1359
+ .history-shot img {
1360
+ width: 100%;
1361
+ aspect-ratio: 16 / 10;
1362
+ object-fit: contain;
1363
+ background: #f8fbff;
1364
+ border-bottom: 1px solid #d7dee8;
1365
+ }
1366
+
1367
+ .history-shot span,
1368
+ .empty-history {
1369
+ padding: 10px 12px;
1370
+ color: #506070;
1371
+ font-size: 0.84rem;
1372
+ font-weight: 700;
1373
+ }
1374
+
1375
+ .empty-history {
1376
+ border: 1px solid #d7dee8;
1377
+ border-radius: 8px;
1378
+ background: #ffffff;
1379
+ }
1380
+
1381
+ @media (max-width: 720px) {
1382
+ .app-shell {
1383
+ width: calc(100% - 24px);
1384
+ padding: 12px 0;
1385
+ }
1386
+
1387
+ header {
1388
+ align-items: stretch;
1389
+ flex-direction: column;
1390
+ }
1391
+
1392
+ .detail-actions {
1393
+ justify-content: flex-end;
1394
+ }
1395
+
1396
+ .detail-render {
1397
+ height: 520px;
1398
+ }
1399
+ }
1400
+ </style>
1401
+ </head>
1402
+ <body>
1403
+ <main class="app-shell">
1404
+ <header>
1405
+ <div>
1406
+ <h1>${escapeHtml(scenario.name)}</h1>
1407
+ <p class="path">${escapeHtml(scenario.relativeScenarioPath)}</p>
1408
+ </div>
1409
+ <div class="detail-actions">
1410
+ <button class="delete-scenario" type="button" data-delete-scenario>Delete</button>
1411
+ <a class="back-link" href="/">Gallery</a>
1412
+ </div>
1413
+ </header>
1414
+ <section class="detail-render" data-render-frame aria-label="${escapeAttribute(scenario.name)} live render">
1415
+ <iframe title="${escapeAttribute(scenario.name)}" src="${escapeAttribute(scenario.renderUrl)}"></iframe>
1416
+ <div class="render-loading">Rendering...</div>
1417
+ </section>
1418
+ <section class="history-section" aria-label="Screenshot history">
1419
+ <h2>History</h2>
1420
+ <div class="history-grid">
1421
+ ${createHistoryGrid(history)}
1422
+ </div>
1423
+ </section>
1424
+ </main>
1425
+ <script>
1426
+ const scenarioId = ${toInlineJson(scenario.id)}
1427
+ const scenarioName = ${toInlineJson(scenario.name)}
1428
+ const scenarioPath = ${toInlineJson(scenario.relativeScenarioPath)}
1429
+ const deleteButton = document.querySelector('[data-delete-scenario]')
1430
+ deleteButton?.addEventListener('click', async () => {
1431
+ if (!window.confirm('Delete scenario "' + scenarioName + '"?\\n' + scenarioPath)) {
1432
+ return
1433
+ }
1434
+
1435
+ try {
1436
+ const response = await fetch('/api/scenarios/' + encodeURIComponent(scenarioId), {
1437
+ method: 'DELETE',
1438
+ })
1439
+ if (!response.ok) {
1440
+ let message = 'Delete failed'
1441
+ try {
1442
+ const data = await response.json()
1443
+ message = data.error || message
1444
+ } catch {
1445
+ message = await response.text()
1446
+ }
1447
+ throw new Error(message || 'Delete failed')
1448
+ }
1449
+ try {
1450
+ const pinnedKey = 'component-shot-gallery:pinned'
1451
+ const pinned = JSON.parse(localStorage.getItem(pinnedKey) || '[]')
1452
+ if (Array.isArray(pinned)) {
1453
+ localStorage.setItem(
1454
+ pinnedKey,
1455
+ JSON.stringify(pinned.filter((entry) => entry !== scenarioId)),
1456
+ )
1457
+ }
1458
+ } catch {}
1459
+ window.location.href = '/'
1460
+ } catch (error) {
1461
+ window.alert(error instanceof Error ? error.message : String(error))
1462
+ }
1463
+ })
1464
+
1465
+ const frame = document.querySelector('[data-render-frame]')
1466
+ const finish = () => frame?.classList.add('is-ready')
1467
+ const check = () => {
1468
+ try {
1469
+ const win = frame?.querySelector('iframe')?.contentWindow
1470
+ if (!win) {
1471
+ window.setTimeout(check, 100)
1472
+ return
1473
+ }
1474
+ if (win.__COMPONENT_SHOT_READY__ || win.__COMPONENT_SHOT_ERROR__) {
1475
+ finish()
1476
+ return
1477
+ }
1478
+ } catch {
1479
+ finish()
1480
+ return
1481
+ }
1482
+ window.setTimeout(check, 100)
1483
+ }
1484
+ check()
1485
+
1486
+ let currentVersion
1487
+ const checkVersion = async () => {
1488
+ try {
1489
+ const response = await fetch('/api/version')
1490
+ const data = await response.json()
1491
+ if (currentVersion === undefined) {
1492
+ currentVersion = data.version
1493
+ return
1494
+ }
1495
+ if (data.version !== currentVersion) {
1496
+ window.location.reload()
1497
+ }
1498
+ } catch {}
1499
+ }
1500
+ window.setInterval(checkVersion, 1200)
1501
+ void checkVersion()
1502
+ </script>
1503
+ </body>
1504
+ </html>`;
1505
+ const sendContent = ({ content, contentType, response, }) => {
1506
+ response.statusCode = 200;
1507
+ response.setHeader('Content-Type', contentType);
1508
+ response.end(content);
1509
+ };
1510
+ const sendJson = ({ body, response, statusCode = 200, }) => {
1511
+ response.statusCode = statusCode;
1512
+ response.setHeader('Content-Type', 'application/json; charset=utf-8');
1513
+ response.end(`${JSON.stringify(body, null, 2)}\n`);
1514
+ };
1515
+ const sendNotFound = (response) => {
1516
+ response.statusCode = 404;
1517
+ response.end('Not found');
1518
+ };
1519
+ const sendMethodNotAllowed = (response, methods) => {
1520
+ response.statusCode = 405;
1521
+ response.setHeader('Allow', methods.join(', '));
1522
+ response.end('Method not allowed');
1523
+ };
1524
+ const sendFile = async ({ filePath, response, }) => {
1525
+ try {
1526
+ const content = await fs.readFile(filePath);
1527
+ response.statusCode = 200;
1528
+ response.setHeader('Content-Type', contentTypes[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream');
1529
+ response.end(content);
1530
+ }
1531
+ catch (error) {
1532
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
1533
+ if (code === 'ENOENT' || code === 'EISDIR') {
1534
+ sendNotFound(response);
1535
+ return;
1536
+ }
1537
+ throw error;
1538
+ }
1539
+ };
1540
+ const sendRenderFile = async ({ embed, filePath, response, }) => {
1541
+ if (embed === 'preview' && path.basename(filePath) === 'index.html') {
1542
+ const html = await fs.readFile(filePath, 'utf8');
1543
+ sendContent({
1544
+ content: html.replace('</head>', '<style data-component-shot-gallery-embed>html,body{width:100%;height:100%;overflow:hidden;}body{box-sizing:border-box;margin:0!important;padding:16px!important;}[data-component-shot-root]{transform-origin:top left;}</style></head>'),
1545
+ contentType: 'text/html; charset=utf-8',
1546
+ response,
1547
+ });
1548
+ return;
1549
+ }
1550
+ await sendFile({ filePath, response });
1551
+ };
1552
+ const assertPathWithin = ({ candidate, root }) => {
1553
+ const resolvedCandidate = path.resolve(candidate);
1554
+ const resolvedRoot = path.resolve(root);
1555
+ return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(`${resolvedRoot}${path.sep}`);
1556
+ };
1557
+ const removeEmptyParentDirs = async ({ rootDir, startDir, }) => {
1558
+ const root = path.resolve(rootDir);
1559
+ let current = path.resolve(startDir);
1560
+ while (current !== root && assertPathWithin({ candidate: current, root })) {
1561
+ try {
1562
+ await fs.rmdir(current);
1563
+ }
1564
+ catch (error) {
1565
+ const code = typeof error === 'object' && error && 'code' in error ? error.code : undefined;
1566
+ if (code === 'ENOENT') {
1567
+ current = path.dirname(current);
1568
+ continue;
1569
+ }
1570
+ if (code === 'ENOTEMPTY' || code === 'EEXIST') {
1571
+ return;
1572
+ }
1573
+ throw error;
1574
+ }
1575
+ current = path.dirname(current);
1576
+ }
1577
+ };
1578
+ const canDeleteScenarioFile = (index, scenario) => assertPathWithin({ candidate: scenario.scenarioPath, root: index.scenarioDir }) &&
1579
+ isScenarioFile(scenario.scenarioPath);
1580
+ const deleteScenarioFiles = async (index, scenarios) => {
1581
+ const scenarioPaths = uniquePaths(scenarios.map((scenario) => scenario.scenarioPath));
1582
+ const deleted = [];
1583
+ for (const scenarioPath of scenarioPaths) {
1584
+ const scenario = scenarios.find((entry) => path.resolve(entry.scenarioPath) === path.resolve(scenarioPath));
1585
+ if (!scenario || !canDeleteScenarioFile(index, scenario)) {
1586
+ throw new Error(`Refusing to delete path outside scenario directory: ${scenarioPath}`);
1587
+ }
1588
+ await fs.rm(scenarioPath, { force: true });
1589
+ await removeEmptyParentDirs({
1590
+ rootDir: index.scenarioDir,
1591
+ startDir: path.dirname(scenarioPath),
1592
+ });
1593
+ deleted.push(toPosixPath(path.relative(index.cwd, scenarioPath)));
1594
+ }
1595
+ return deleted;
1596
+ };
1597
+ const resolveBuildCommand = (build, context) => typeof build === 'function' ? build(context) : build;
1598
+ const buildBundle = async ({ build, context }) => {
1599
+ const command = await resolveBuildCommand(build, context);
1600
+ if (!command) {
1601
+ return;
1602
+ }
1603
+ await new Promise((resolve, reject) => {
1604
+ const child = spawn(command.command, command.args ?? [], {
1605
+ cwd: command.cwd ?? context.cwd,
1606
+ env: {
1607
+ ...process.env,
1608
+ ...command.env,
1609
+ },
1610
+ shell: command.shell,
1611
+ stdio: ['ignore', 'pipe', 'pipe'],
1612
+ });
1613
+ const output = [];
1614
+ child.stdout?.on('data', (chunk) => output.push(String(chunk)));
1615
+ child.stderr?.on('data', (chunk) => output.push(String(chunk)));
1616
+ child.on('error', reject);
1617
+ child.on('exit', (code) => {
1618
+ if (code === 0) {
1619
+ resolve();
1620
+ return;
1621
+ }
1622
+ reject(new Error(output.join('').trim() || `${command.command} exited with code ${code}`));
1623
+ });
1624
+ });
1625
+ };
1626
+ const createRequestHandler = ({ buildCache, getIndex, getVersion, options, refreshIndex, tempRoot, }) => {
1627
+ const buildScenario = async (scenario) => {
1628
+ const cached = buildCache.get(scenario.id);
1629
+ if (cached) {
1630
+ return cached;
1631
+ }
1632
+ const buildPromise = (async () => {
1633
+ const publicDir = path.join(tempRoot, scenario.id, 'public');
1634
+ await fs.mkdir(publicDir, { recursive: true });
1635
+ const setup = await resolveSetupPath({
1636
+ cwd: options.cwd,
1637
+ scenarioDir: options.scenarioDir,
1638
+ });
1639
+ const build = createRspackBuild({
1640
+ publicPath: `/render/${scenario.id}/`,
1641
+ setup,
1642
+ });
1643
+ await buildBundle({
1644
+ build,
1645
+ context: {
1646
+ cwd: options.cwd,
1647
+ debug: false,
1648
+ publicDir,
1649
+ scenarioPath: scenario.scenarioPath,
1650
+ },
1651
+ });
1652
+ return {
1653
+ publicDir,
1654
+ scenario,
1655
+ };
1656
+ })().catch((error) => {
1657
+ buildCache.delete(scenario.id);
1658
+ throw error;
1659
+ });
1660
+ buildCache.set(scenario.id, buildPromise);
1661
+ return buildPromise;
1662
+ };
1663
+ return async (request, response) => {
1664
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
1665
+ const index = getIndex();
1666
+ if (url.pathname === '/favicon.ico') {
1667
+ response.statusCode = 204;
1668
+ response.end();
1669
+ return;
1670
+ }
1671
+ if (url.pathname === '/') {
1672
+ sendContent({
1673
+ content: createGalleryHtml(index),
1674
+ contentType: 'text/html; charset=utf-8',
1675
+ response,
1676
+ });
1677
+ return;
1678
+ }
1679
+ if (url.pathname === '/api/version') {
1680
+ sendJson({ body: { version: getVersion() }, response });
1681
+ return;
1682
+ }
1683
+ if (url.pathname === '/api/scenarios') {
1684
+ if (request.method === 'DELETE') {
1685
+ const deleted = await deleteScenarioFiles(index, index.scenarios);
1686
+ const nextIndex = await refreshIndex();
1687
+ sendJson({
1688
+ body: {
1689
+ deleted,
1690
+ scenarioCount: nextIndex.scenarios.length,
1691
+ },
1692
+ response,
1693
+ });
1694
+ return;
1695
+ }
1696
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
1697
+ sendMethodNotAllowed(response, ['GET', 'HEAD', 'DELETE']);
1698
+ return;
1699
+ }
1700
+ sendJson({ body: index, response });
1701
+ return;
1702
+ }
1703
+ const scenarioApiMatch = url.pathname.match(/^\/api\/scenarios\/([^/]+)\/?$/);
1704
+ if (scenarioApiMatch) {
1705
+ if (request.method !== 'DELETE') {
1706
+ sendMethodNotAllowed(response, ['DELETE']);
1707
+ return;
1708
+ }
1709
+ const scenario = index.scenarios.find((entry) => entry.id === scenarioApiMatch[1]);
1710
+ if (!scenario) {
1711
+ sendJson({ body: { error: 'Scenario not found' }, response, statusCode: 404 });
1712
+ return;
1713
+ }
1714
+ if (!canDeleteScenarioFile(index, scenario)) {
1715
+ sendJson({ body: { error: 'Refusing to delete scenario outside scenario directory' }, response, statusCode: 403 });
1716
+ return;
1717
+ }
1718
+ const deleted = await deleteScenarioFiles(index, [scenario]);
1719
+ const nextIndex = await refreshIndex();
1720
+ sendJson({
1721
+ body: {
1722
+ deleted,
1723
+ scenarioCount: nextIndex.scenarios.length,
1724
+ },
1725
+ response,
1726
+ });
1727
+ return;
1728
+ }
1729
+ const detailMatch = url.pathname.match(/^\/scenario\/([^/]+)\/?$/);
1730
+ if (detailMatch) {
1731
+ const scenario = index.scenarios.find((entry) => entry.id === detailMatch[1]);
1732
+ if (!scenario) {
1733
+ sendNotFound(response);
1734
+ return;
1735
+ }
1736
+ sendContent({
1737
+ content: createScenarioDetailHtml({
1738
+ history: await listHistoryShots(index, scenario),
1739
+ scenario,
1740
+ }),
1741
+ contentType: 'text/html; charset=utf-8',
1742
+ response,
1743
+ });
1744
+ return;
1745
+ }
1746
+ const historyMatch = url.pathname.match(/^\/history\/([^/]+)\/(.+)$/);
1747
+ if (historyMatch) {
1748
+ const scenario = index.scenarios.find((entry) => entry.id === historyMatch[1]);
1749
+ if (!scenario) {
1750
+ sendNotFound(response);
1751
+ return;
1752
+ }
1753
+ const filename = decodeURIComponent(historyMatch[2]);
1754
+ if (filename !== path.basename(filename)) {
1755
+ response.statusCode = 403;
1756
+ response.end('Forbidden');
1757
+ return;
1758
+ }
1759
+ const historyDir = getScenarioHistoryDir(index.screenshotsDir, scenario);
1760
+ const filePath = path.resolve(historyDir, filename);
1761
+ if (!assertPathWithin({ candidate: filePath, root: historyDir })) {
1762
+ response.statusCode = 403;
1763
+ response.end('Forbidden');
1764
+ return;
1765
+ }
1766
+ await sendFile({ filePath, response });
1767
+ return;
1768
+ }
1769
+ const renderMatch = url.pathname.match(/^\/render\/([^/]+)\/?(.*)$/);
1770
+ if (renderMatch) {
1771
+ const scenario = index.scenarios.find((entry) => entry.id === renderMatch[1]);
1772
+ if (!scenario) {
1773
+ sendNotFound(response);
1774
+ return;
1775
+ }
1776
+ const renderBuild = await buildScenario(scenario);
1777
+ const relativePath = decodeURIComponent(renderMatch[2] || 'index.html');
1778
+ const filePath = path.resolve(renderBuild.publicDir, relativePath);
1779
+ if (!assertPathWithin({ candidate: filePath, root: renderBuild.publicDir })) {
1780
+ response.statusCode = 403;
1781
+ response.end('Forbidden');
1782
+ return;
1783
+ }
1784
+ await sendRenderFile({
1785
+ embed: url.searchParams.get('embed'),
1786
+ filePath,
1787
+ response,
1788
+ });
1789
+ return;
1790
+ }
1791
+ sendNotFound(response);
1792
+ };
1793
+ };
1794
+ const closeHttpServer = (server) => new Promise((resolve, reject) => {
1795
+ server.close((error) => {
1796
+ if (error) {
1797
+ reject(error);
1798
+ return;
1799
+ }
1800
+ resolve();
1801
+ });
1802
+ });
1803
+ const uniquePaths = (paths) => [...new Set(paths.map((entry) => path.resolve(entry)))];
1804
+ const startGalleryWatchers = async ({ onChange, options, }) => {
1805
+ const componentShotDir = findComponentShotDir(options.scenarioDir);
1806
+ const watchRoots = uniquePaths([componentShotDir ?? options.scenarioDir, options.screenshotsDir]);
1807
+ const watchers = [];
1808
+ for (const root of watchRoots) {
1809
+ if (!(await pathExists(root))) {
1810
+ continue;
1811
+ }
1812
+ try {
1813
+ watchers.push(watch(root, {
1814
+ recursive: true,
1815
+ }, onChange));
1816
+ }
1817
+ catch {
1818
+ watchers.push(watch(root, onChange));
1819
+ }
1820
+ }
1821
+ return () => {
1822
+ for (const watcher of watchers) {
1823
+ watcher.close();
1824
+ }
1825
+ };
1826
+ };
1827
+ export const startComponentShotGallery = async (optionsInput = {}) => {
1828
+ const options = resolveGalleryOptions(optionsInput);
1829
+ let index = await createComponentShotGalleryIndex(options);
1830
+ let version = 0;
1831
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'component-shot-gallery-'));
1832
+ const buildCache = new Map();
1833
+ const refreshIndex = async () => {
1834
+ index = await createComponentShotGalleryIndex(options);
1835
+ buildCache.clear();
1836
+ version += 1;
1837
+ return index;
1838
+ };
1839
+ let refreshTimer;
1840
+ const refresh = () => {
1841
+ if (refreshTimer) {
1842
+ clearTimeout(refreshTimer);
1843
+ }
1844
+ refreshTimer = setTimeout(() => {
1845
+ void (async () => {
1846
+ await refreshIndex();
1847
+ })().catch((error) => {
1848
+ process.stderr.write(`component-shot gallery refresh failed: ${error instanceof Error ? error.message : String(error)}\n`);
1849
+ });
1850
+ }, 120);
1851
+ };
1852
+ const stopWatching = await startGalleryWatchers({ onChange: refresh, options });
1853
+ const handleRequest = createRequestHandler({
1854
+ buildCache,
1855
+ getIndex: () => index,
1856
+ getVersion: () => version,
1857
+ options,
1858
+ refreshIndex,
1859
+ tempRoot,
1860
+ });
1861
+ const server = http.createServer((request, response) => {
1862
+ void handleRequest(request, response).catch((error) => {
1863
+ response.statusCode = 500;
1864
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
1865
+ response.end(error instanceof Error ? error.stack ?? error.message : String(error));
1866
+ });
1867
+ });
1868
+ await new Promise((resolve, reject) => {
1869
+ const handleError = (error) => {
1870
+ server.off('listening', handleListening);
1871
+ reject(error);
1872
+ };
1873
+ const handleListening = () => {
1874
+ server.off('error', handleError);
1875
+ resolve();
1876
+ };
1877
+ server.once('error', handleError);
1878
+ server.once('listening', handleListening);
1879
+ server.listen(options.port, options.host);
1880
+ });
1881
+ const address = server.address();
1882
+ if (!address || typeof address === 'string') {
1883
+ throw new Error('Unable to read component-shot gallery server address');
1884
+ }
1885
+ return {
1886
+ close: async () => {
1887
+ stopWatching();
1888
+ if (refreshTimer) {
1889
+ clearTimeout(refreshTimer);
1890
+ }
1891
+ await closeHttpServer(server);
1892
+ await fs.rm(tempRoot, { force: true, recursive: true });
1893
+ },
1894
+ index,
1895
+ server,
1896
+ url: `http://${options.host}:${address.port}`,
1897
+ };
1898
+ };
1899
+ const openUrl = (url) => {
1900
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
1901
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
1902
+ const child = spawn(command, args, {
1903
+ detached: true,
1904
+ stdio: 'ignore',
1905
+ });
1906
+ child.on('error', () => { });
1907
+ child.unref();
1908
+ };
1909
+ const waitForShutdownSignal = () => new Promise((resolve) => {
1910
+ const cleanup = () => {
1911
+ process.off('SIGINT', cleanup);
1912
+ process.off('SIGTERM', cleanup);
1913
+ resolve();
1914
+ };
1915
+ process.once('SIGINT', cleanup);
1916
+ process.once('SIGTERM', cleanup);
1917
+ });
1918
+ export const runComponentShotGalleryCli = async ({ argv = process.argv.slice(2), usageCommand = 'component-shot gallery [options]', } = {}) => {
1919
+ const options = parseGalleryCliArgs({ argv, usageCommand });
1920
+ const { json: _json, ...galleryOptions } = options;
1921
+ const gallery = await startComponentShotGallery(galleryOptions);
1922
+ const startupDetails = {
1923
+ scenarioCount: gallery.index.scenarios.length,
1924
+ scenarioDir: gallery.index.scenarioDir,
1925
+ url: gallery.url,
1926
+ };
1927
+ if (options.json) {
1928
+ process.stdout.write(`${JSON.stringify(startupDetails)}\n`);
1929
+ }
1930
+ else {
1931
+ process.stdout.write(`Component Shot gallery: ${gallery.url}\n`);
1932
+ process.stdout.write('Press Ctrl+C to stop.\n');
1933
+ }
1934
+ if (options.open) {
1935
+ openUrl(gallery.url);
1936
+ }
1937
+ await waitForShutdownSignal();
1938
+ await gallery.close();
1939
+ };