stuntdouble 0.0.0-stage → 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.
package/dist/ui.html ADDED
@@ -0,0 +1,698 @@
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>Stunt Double Local</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --background: oklch(0.985 0.005 250);
11
+ --foreground: oklch(0.18 0.016 268);
12
+ --card: oklch(1 0 0);
13
+ --muted: oklch(0.955 0.007 255);
14
+ --muted-foreground: oklch(0.46 0.017 268);
15
+ --border: oklch(0.88 0.01 255);
16
+ --primary: oklch(0.52 0.22 268);
17
+ --primary-foreground: oklch(0.985 0.005 250);
18
+ --success: oklch(0.6 0.13 160);
19
+ --warning: oklch(0.75 0.15 75);
20
+ --destructive: oklch(0.53 0.215 22);
21
+ --radius: 0.5rem;
22
+ --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
23
+ --font-display: 'Space Grotesk', var(--font-sans);
24
+ --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
25
+ }
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --background: oklch(0.14 0.014 265);
29
+ --foreground: oklch(0.95 0.006 250);
30
+ --card: oklch(0.17 0.014 265);
31
+ --muted: oklch(0.22 0.014 265);
32
+ --muted-foreground: oklch(0.68 0.014 250);
33
+ --border: oklch(0.95 0.006 250 / 0.1);
34
+ --primary: oklch(0.68 0.165 276);
35
+ --primary-foreground: oklch(0.14 0.014 265);
36
+ --success: oklch(0.68 0.14 160);
37
+ --warning: oklch(0.82 0.15 75);
38
+ --destructive: oklch(0.64 0.2 22);
39
+ }
40
+ }
41
+ * {
42
+ box-sizing: border-box;
43
+ }
44
+ body {
45
+ margin: 0;
46
+ background: var(--background);
47
+ color: var(--foreground);
48
+ font: 14px/1.5 var(--font-sans);
49
+ }
50
+ a {
51
+ color: inherit;
52
+ }
53
+ header,
54
+ main,
55
+ footer {
56
+ max-width: 72rem;
57
+ margin: 0 auto;
58
+ padding: 0 1rem;
59
+ }
60
+ header {
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ align-items: center;
64
+ gap: 0.5rem 1.5rem;
65
+ min-height: 3.5rem;
66
+ padding-block: 0.5rem;
67
+ border-bottom: 1px solid var(--border);
68
+ }
69
+ header strong {
70
+ font-family: var(--font-display);
71
+ letter-spacing: -0.012em;
72
+ white-space: nowrap;
73
+ }
74
+ header nav a {
75
+ margin-right: 1rem;
76
+ color: var(--muted-foreground);
77
+ text-decoration: none;
78
+ white-space: nowrap;
79
+ }
80
+ header nav a[aria-current='page'] {
81
+ color: var(--foreground);
82
+ }
83
+ main {
84
+ padding-top: 1.5rem;
85
+ padding-bottom: 3rem;
86
+ }
87
+ h1 {
88
+ font: 600 1.5rem/1.2 var(--font-display);
89
+ letter-spacing: -0.025em;
90
+ margin: 0 0 0.25rem;
91
+ }
92
+ h2 {
93
+ font-size: 1rem;
94
+ margin: 2rem 0 0.75rem;
95
+ }
96
+ .muted {
97
+ color: var(--muted-foreground);
98
+ }
99
+ .card {
100
+ background: var(--card);
101
+ border: 1px solid var(--border);
102
+ border-radius: var(--radius);
103
+ }
104
+ .rows > a,
105
+ .rows > div {
106
+ display: grid;
107
+ grid-template-columns: 1fr auto;
108
+ gap: 0.25rem 1rem;
109
+ padding: 0.75rem 1rem;
110
+ border-top: 1px solid var(--border);
111
+ text-decoration: none;
112
+ }
113
+ .rows > :first-child {
114
+ border-top: 0;
115
+ }
116
+ .rows > a:hover {
117
+ background: var(--muted);
118
+ }
119
+ .meta {
120
+ display: flex;
121
+ flex-wrap: wrap;
122
+ gap: 0.5rem;
123
+ align-items: center;
124
+ color: var(--muted-foreground);
125
+ font-size: 13px;
126
+ }
127
+ .pill {
128
+ display: inline-block;
129
+ padding: 0 0.5rem;
130
+ border-radius: 999px;
131
+ font-size: 12px;
132
+ line-height: 1.5rem;
133
+ border: 1px solid var(--border);
134
+ color: var(--muted-foreground);
135
+ white-space: nowrap;
136
+ }
137
+ .positive {
138
+ color: var(--success);
139
+ border-color: color-mix(in oklch, var(--success) 40%, transparent);
140
+ }
141
+ .negative {
142
+ color: var(--destructive);
143
+ border-color: color-mix(in oklch, var(--destructive) 40%, transparent);
144
+ }
145
+ .caution {
146
+ color: var(--warning);
147
+ border-color: color-mix(in oklch, var(--warning) 40%, transparent);
148
+ }
149
+ .live {
150
+ color: var(--primary);
151
+ border-color: color-mix(in oklch, var(--primary) 40%, transparent);
152
+ }
153
+ button {
154
+ font: inherit;
155
+ padding: 0.4rem 0.9rem;
156
+ border-radius: var(--radius);
157
+ border: 1px solid var(--border);
158
+ background: var(--card);
159
+ color: var(--foreground);
160
+ cursor: pointer;
161
+ }
162
+ button.primary {
163
+ background: var(--primary);
164
+ border-color: var(--primary);
165
+ color: var(--primary-foreground);
166
+ }
167
+ button:disabled {
168
+ opacity: 0.6;
169
+ cursor: default;
170
+ }
171
+ .actions {
172
+ display: flex;
173
+ gap: 0.5rem;
174
+ margin-top: 1rem;
175
+ }
176
+ .split {
177
+ display: grid;
178
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
179
+ gap: 1.5rem;
180
+ align-items: start;
181
+ }
182
+ @media (max-width: 52rem) {
183
+ .split {
184
+ grid-template-columns: 1fr;
185
+ }
186
+ }
187
+ .frame {
188
+ width: 100%;
189
+ display: block;
190
+ border-radius: var(--radius);
191
+ border: 1px solid var(--border);
192
+ background: var(--muted);
193
+ }
194
+ .check {
195
+ padding: 1rem;
196
+ border-top: 1px solid var(--border);
197
+ }
198
+ .check:first-child {
199
+ border-top: 0;
200
+ }
201
+ .check header {
202
+ all: unset;
203
+ display: flex;
204
+ gap: 0.5rem;
205
+ align-items: baseline;
206
+ justify-content: space-between;
207
+ }
208
+ .check img {
209
+ margin-top: 0.75rem;
210
+ max-width: 100%;
211
+ max-height: 20rem;
212
+ border-radius: calc(var(--radius) - 2px);
213
+ border: 1px solid var(--border);
214
+ }
215
+ code,
216
+ .mono {
217
+ font-family: var(--font-mono);
218
+ font-size: 12px;
219
+ }
220
+ .timeline {
221
+ list-style: none;
222
+ margin: 0;
223
+ padding: 0;
224
+ font-size: 13px;
225
+ }
226
+ .timeline li {
227
+ display: grid;
228
+ grid-template-columns: 5.5rem 7rem 1fr;
229
+ gap: 0.75rem;
230
+ padding: 0.4rem 1rem;
231
+ border-top: 1px solid var(--border);
232
+ }
233
+ .timeline li:first-child {
234
+ border-top: 0;
235
+ }
236
+ .timeline span:last-child {
237
+ overflow-wrap: anywhere;
238
+ white-space: pre-wrap;
239
+ }
240
+ .empty {
241
+ padding: 2rem 1rem;
242
+ text-align: center;
243
+ }
244
+ footer {
245
+ padding-bottom: 2rem;
246
+ font-size: 13px;
247
+ }
248
+ </style>
249
+ </head>
250
+ <body>
251
+ <header>
252
+ <strong>Stunt Double Local</strong>
253
+ <nav>
254
+ <a href="/" data-nav>Runs</a>
255
+ <a href="/definitions" data-nav>Checklists and actors</a>
256
+ </nav>
257
+ </header>
258
+ <main id="app" aria-live="polite"></main>
259
+ <footer class="muted">
260
+ Local runs use your own Claude plan and a browser on this machine, one at a time while your
261
+ session is open. The cloud runs on schedules and in parallel, records every run and shares
262
+ reports with your team. <a href="https://stuntdouble.io/pricing">See plans</a>
263
+ </footer>
264
+ <script>
265
+ // Every value from a file is set as text, never as markup: verdicts and
266
+ // activity quote the pages the actor read.
267
+ function h(tag, attrs, ...children) {
268
+ const el = document.createElement(tag);
269
+ for (const [key, value] of Object.entries(attrs || {})) {
270
+ if (value === false || value == null) continue;
271
+ if (key.startsWith('on')) el.addEventListener(key.slice(2), value);
272
+ else el.setAttribute(key, value === true ? '' : String(value));
273
+ }
274
+ for (const child of children.flat()) {
275
+ if (child == null || child === false) continue;
276
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)));
277
+ }
278
+ return el;
279
+ }
280
+
281
+ const app = document.getElementById('app');
282
+ const when = (iso) => (iso ? new Date(iso).toLocaleString() : '');
283
+ const fileUrl = (path) => '/files/' + path.split('/').map(encodeURIComponent).join('/');
284
+ // A committed run.json is someone else's file: only web addresses become links.
285
+ const webUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : null);
286
+
287
+ function outcomePill(run) {
288
+ if (run.status === 'running') return h('span', { class: 'pill live' }, 'Running');
289
+ if (run.status === 'interrupted')
290
+ return h('span', { class: 'pill caution' }, 'Interrupted');
291
+ if (run.status === 'failed') return h('span', { class: 'pill negative' }, 'Run failed');
292
+ const outcome = run.score && run.score.outcome;
293
+ if (!outcome)
294
+ return h('span', { class: 'pill' }, run.status === 'stopped' ? 'Stopped' : run.status);
295
+ const label = { passed: 'Passed', failed: 'Failed', inconclusive: 'Inconclusive' }[outcome];
296
+ const tone = { passed: 'positive', failed: 'negative', inconclusive: '' }[outcome];
297
+ return h(
298
+ 'span',
299
+ { class: 'pill ' + tone },
300
+ run.status === 'stopped' ? label + ', stopped' : label
301
+ );
302
+ }
303
+
304
+ const inUnit = (value, unit) => (unit == null ? value + '%' : value + ' ' + unit);
305
+ const scoreText = (score) =>
306
+ score && score.score != null ? inUnit(score.score, score.unit) : '';
307
+
308
+ const runnerPill = (run) =>
309
+ h('span', { class: 'pill' }, run.runner.kind === 'cloud' ? 'Cloud' : 'Local, self-judged');
310
+
311
+ const RESULT = {
312
+ passed: ['Passed', 'positive'],
313
+ failed: ['Failed', 'negative'],
314
+ informational: ['Observed', ''],
315
+ error: ['Not judged', 'caution'],
316
+ skipped: ['Skipped', ''],
317
+ };
318
+
319
+ async function getJson(url) {
320
+ const res = await fetch(url);
321
+ if (!res.ok) throw new Error((await res.text()) || res.statusText);
322
+ return res.json();
323
+ }
324
+
325
+ async function copy(text, button) {
326
+ await navigator.clipboard.writeText(text);
327
+ const label = button.textContent;
328
+ button.textContent = 'Copied ' + text;
329
+ setTimeout(() => (button.textContent = label), 1600);
330
+ }
331
+
332
+ async function renderRuns() {
333
+ const runs = await getJson('/api/runs');
334
+ if (runs.length === 0) {
335
+ return [
336
+ h('h1', {}, 'Runs'),
337
+ h(
338
+ 'div',
339
+ { class: 'card empty muted' },
340
+ 'No runs yet. Type ',
341
+ h('code', {}, '/stuntdouble run <checklist>'),
342
+ ' in Claude Code to start one.'
343
+ ),
344
+ ];
345
+ }
346
+ return [
347
+ h('h1', {}, 'Runs'),
348
+ h('p', { class: 'muted' }, 'Local and cloud runs of this project, newest first.'),
349
+ h(
350
+ 'div',
351
+ { class: 'card rows' },
352
+ runs.map((entry) =>
353
+ 'error' in entry
354
+ ? h(
355
+ 'div',
356
+ {},
357
+ h('span', {}, entry.id),
358
+ h('span', { class: 'pill caution' }, 'Unreadable'),
359
+ h('span', { class: 'meta mono' }, entry.error)
360
+ )
361
+ : h(
362
+ 'a',
363
+ { href: '/runs/' + encodeURIComponent(entry.id), 'data-nav': true },
364
+ h('strong', {}, entry.checklist),
365
+ h(
366
+ 'span',
367
+ {},
368
+ outcomePill(entry),
369
+ ' ',
370
+ h('span', { class: 'muted' }, scoreText(entry.score))
371
+ ),
372
+ h(
373
+ 'span',
374
+ { class: 'meta' },
375
+ entry.actor,
376
+ runnerPill(entry),
377
+ when(entry.started_at)
378
+ )
379
+ )
380
+ )
381
+ ),
382
+ ];
383
+ }
384
+
385
+ function checkCard(run, check, result) {
386
+ const [label, tone] = result ? RESULT[result.status] : ['Waiting', ''];
387
+ return h(
388
+ 'div',
389
+ { class: 'check' },
390
+ h(
391
+ 'header',
392
+ {},
393
+ h('span', {}, h('code', {}, check.key), ' ', check.description),
394
+ h('span', { class: 'pill ' + tone }, label)
395
+ ),
396
+ result && h('p', {}, result.evidence),
397
+ result &&
398
+ result.locator &&
399
+ h(
400
+ 'div',
401
+ { class: 'meta mono' },
402
+ 'Cited: ' + (result.locator.text || result.locator.selector)
403
+ ),
404
+ result &&
405
+ result.screenshot &&
406
+ h(
407
+ 'a',
408
+ { href: fileUrl('runs/' + run.id + '/' + result.screenshot), target: '_blank' },
409
+ h('img', {
410
+ src: fileUrl('runs/' + run.id + '/' + result.screenshot),
411
+ alt: 'The page when ' + check.key + ' was judged',
412
+ loading: 'lazy',
413
+ })
414
+ )
415
+ );
416
+ }
417
+
418
+ async function renderRun(id) {
419
+ const data = await getJson('/api/runs/' + encodeURIComponent(id));
420
+ if (data.error) return [h('h1', {}, id), h('p', { class: 'mono' }, data.error)];
421
+ const run = data.run;
422
+ const results = new Map(run.results.map((r) => [r.check, r]));
423
+ const checks = run.checklist_snapshot.checks;
424
+ const running = run.status === 'running';
425
+ const resumable = run.status === 'interrupted' || run.status === 'stopped';
426
+
427
+ const actions = running
428
+ ? h(
429
+ 'div',
430
+ { class: 'actions' },
431
+ h(
432
+ 'button',
433
+ {
434
+ disabled: data.stop_requested,
435
+ onclick: async (event) => {
436
+ event.target.disabled = true;
437
+ event.target.textContent = 'Stopping at the next step';
438
+ await fetch('/api/stop', {
439
+ method: 'POST',
440
+ headers: { 'Content-Type': 'application/json' },
441
+ body: JSON.stringify({ run: run.id }),
442
+ });
443
+ },
444
+ },
445
+ data.stop_requested ? 'Stopping at the next step' : 'Stop'
446
+ )
447
+ )
448
+ : run.runner.kind === 'local' &&
449
+ h(
450
+ 'div',
451
+ { class: 'actions' },
452
+ h(
453
+ 'button',
454
+ {
455
+ class: 'primary',
456
+ onclick: (event) =>
457
+ copy(
458
+ '/stuntdouble run ' + run.checklist + (resumable ? ' resume' : ''),
459
+ event.target
460
+ ),
461
+ },
462
+ resumable ? 'Copy resume command' : 'Copy re-run command'
463
+ ),
464
+ h(
465
+ 'button',
466
+ { onclick: (event) => copy('/stuntdouble cloud ' + run.checklist, event.target) },
467
+ 'Run in the cloud'
468
+ )
469
+ );
470
+
471
+ const verdicts = h(
472
+ 'div',
473
+ { class: 'card' },
474
+ checks.map((check) => checkCard(run, check, results.get(check.key)))
475
+ );
476
+
477
+ return [
478
+ h('p', { class: 'meta' }, h('a', { href: '/', 'data-nav': true }, 'Runs'), '/', run.id),
479
+ h('h1', {}, run.checklist),
480
+ h(
481
+ 'div',
482
+ { class: 'meta' },
483
+ outcomePill(run),
484
+ scoreText(run.score) &&
485
+ h(
486
+ 'span',
487
+ {},
488
+ scoreText(run.score) +
489
+ (run.score.pass_threshold == null
490
+ ? ''
491
+ : ', passes at ' + inUnit(run.score.pass_threshold, run.score.unit))
492
+ ),
493
+ runnerPill(run),
494
+ h('span', {}, run.actor),
495
+ webUrl(run.url) ? h('a', { href: run.url }, run.url) : h('span', {}, run.url),
496
+ h('span', {}, when(run.started_at))
497
+ ),
498
+ run.summary && h('p', {}, run.summary),
499
+ webUrl(run.runner.url) &&
500
+ h('p', {}, h('a', { href: run.runner.url }, 'Open this run in Stunt Double')),
501
+ actions,
502
+ running
503
+ ? h(
504
+ 'div',
505
+ { class: 'split' },
506
+ h(
507
+ 'div',
508
+ {},
509
+ h('h2', {}, 'Live'),
510
+ h('img', {
511
+ class: 'frame',
512
+ id: 'frame',
513
+ src: fileUrl('.live/latest.png') + '?v=' + Date.now(),
514
+ alt: 'The browser now',
515
+ })
516
+ ),
517
+ h('div', {}, h('h2', {}, 'Checks'), verdicts)
518
+ )
519
+ : [h('h2', {}, 'Checks'), verdicts],
520
+ data.activity.length > 0 && [
521
+ h('h2', {}, 'Activity'),
522
+ h(
523
+ 'ol',
524
+ { class: 'card timeline' },
525
+ data.activity.map((line) =>
526
+ h(
527
+ 'li',
528
+ {},
529
+ h('span', { class: 'muted' }, new Date(line.at).toLocaleTimeString()),
530
+ h('code', {}, line.tool),
531
+ h('span', {}, line.text)
532
+ )
533
+ )
534
+ ),
535
+ ],
536
+ ];
537
+ }
538
+
539
+ function definitionError(entry) {
540
+ return h(
541
+ 'div',
542
+ {},
543
+ h('code', {}, entry.slug),
544
+ h('span', { class: 'meta mono' }, entry.error)
545
+ );
546
+ }
547
+
548
+ async function renderDefinitions() {
549
+ const { checklists, actors } = await getJson('/api/definitions');
550
+ const cloudIds = (cloud) =>
551
+ cloud && Object.values(cloud).some((v) => v && typeof v !== 'object')
552
+ ? h('span', { class: 'pill live' }, 'Linked to the cloud')
553
+ : h('span', { class: 'pill' }, 'Local only');
554
+ return [
555
+ h('h1', {}, 'Checklists and actors'),
556
+ h(
557
+ 'p',
558
+ { class: 'muted' },
559
+ 'Read only. Edit them in Claude Code: they are the JSON files in .stuntdouble/.'
560
+ ),
561
+ h('h2', {}, 'Checklists'),
562
+ checklists.length === 0
563
+ ? h('div', { class: 'card empty muted' }, 'No checklists yet.')
564
+ : h(
565
+ 'div',
566
+ { class: 'card' },
567
+ checklists.map((entry) =>
568
+ entry.error
569
+ ? h('div', { class: 'check' }, definitionError(entry))
570
+ : h(
571
+ 'div',
572
+ { class: 'check' },
573
+ h(
574
+ 'header',
575
+ {},
576
+ h('strong', {}, entry.data.name),
577
+ cloudIds(entry.data.cloud)
578
+ ),
579
+ h(
580
+ 'div',
581
+ { class: 'meta' },
582
+ h('code', {}, entry.slug),
583
+ h('span', {}, 'run by ' + entry.data.actor),
584
+ h('span', {}, entry.data.url)
585
+ ),
586
+ h('p', {}, entry.data.instructions),
587
+ h(
588
+ 'ul',
589
+ {},
590
+ entry.data.checks.map((c) =>
591
+ h(
592
+ 'li',
593
+ {},
594
+ h('code', {}, c.key),
595
+ ' ',
596
+ c.description,
597
+ ' ',
598
+ h(
599
+ 'span',
600
+ { class: 'muted' },
601
+ c.type === 'weighted'
602
+ ? 'weighted x' + (c.weight ?? 1)
603
+ : c.type.replace('_', '/')
604
+ )
605
+ )
606
+ )
607
+ )
608
+ )
609
+ )
610
+ ),
611
+ h('h2', {}, 'Actors'),
612
+ actors.length === 0
613
+ ? h('div', { class: 'card empty muted' }, 'No actors yet.')
614
+ : h(
615
+ 'div',
616
+ { class: 'card' },
617
+ actors.map((entry) =>
618
+ entry.error
619
+ ? h('div', { class: 'check' }, definitionError(entry))
620
+ : h(
621
+ 'div',
622
+ { class: 'check' },
623
+ h(
624
+ 'header',
625
+ {},
626
+ h('strong', {}, entry.data.name),
627
+ cloudIds(entry.data.cloud)
628
+ ),
629
+ h(
630
+ 'div',
631
+ { class: 'meta' },
632
+ h('code', {}, entry.slug),
633
+ entry.data.persist_login && 'keeps its logins'
634
+ ),
635
+ entry.data.description && h('p', {}, entry.data.description),
636
+ entry.data.knowledge.length > 0 &&
637
+ h(
638
+ 'p',
639
+ { class: 'muted' },
640
+ 'Knows: ' + entry.data.knowledge.map((k) => k.title).join(', ')
641
+ )
642
+ )
643
+ )
644
+ ),
645
+ ];
646
+ }
647
+
648
+ let rendering = Promise.resolve();
649
+ function render() {
650
+ rendering = rendering.then(async () => {
651
+ const path = location.pathname;
652
+ for (const link of document.querySelectorAll('header nav a')) {
653
+ const current = link.getAttribute('href') === (path.startsWith('/runs/') ? '/' : path);
654
+ if (current) link.setAttribute('aria-current', 'page');
655
+ else link.removeAttribute('aria-current');
656
+ }
657
+ let view;
658
+ try {
659
+ view = path.startsWith('/runs/')
660
+ ? await renderRun(decodeURIComponent(path.slice('/runs/'.length)))
661
+ : path === '/definitions'
662
+ ? await renderDefinitions()
663
+ : await renderRuns();
664
+ } catch (err) {
665
+ view = [h('p', { class: 'mono' }, err.message)];
666
+ }
667
+ app.replaceChildren(...[view].flat(2).filter(Boolean));
668
+ });
669
+ }
670
+
671
+ document.addEventListener('click', (event) => {
672
+ const link = event.target.closest('a[data-nav]');
673
+ if (!link || event.metaKey || event.ctrlKey) return;
674
+ event.preventDefault();
675
+ history.pushState(null, '', link.getAttribute('href'));
676
+ render();
677
+ });
678
+ window.addEventListener('popstate', render);
679
+
680
+ // Live updates: a new frame swaps the picture, anything else redraws the page.
681
+ let queued = false;
682
+ new EventSource('/api/events').onmessage = (event) => {
683
+ const { paths } = JSON.parse(event.data);
684
+ const frame = document.getElementById('frame');
685
+ if (frame && paths.includes('.live/latest.png')) {
686
+ frame.src = fileUrl('.live/latest.png') + '?v=' + Date.now();
687
+ }
688
+ if (paths.every((p) => p.startsWith('.live/')) || queued) return;
689
+ queued = true;
690
+ setTimeout(() => {
691
+ queued = false;
692
+ render();
693
+ }, 300);
694
+ };
695
+ render();
696
+ </script>
697
+ </body>
698
+ </html>