stuntdouble 0.1.3 → 0.2.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.
Files changed (46) hide show
  1. package/README.md +31 -6
  2. package/dist/chunk-BFI6P5DO.js +278 -0
  3. package/dist/chunk-BFI6P5DO.js.map +1 -0
  4. package/dist/chunk-DFXASIGV.js +431 -0
  5. package/dist/chunk-DFXASIGV.js.map +1 -0
  6. package/dist/{chunk-7IC2G3Z3.js → chunk-F3R3QL7V.js} +19 -252
  7. package/dist/chunk-F3R3QL7V.js.map +1 -0
  8. package/dist/{mcp-MOXTYXZU.js → chunk-FMZTT2MC.js} +72 -1160
  9. package/dist/chunk-FMZTT2MC.js.map +1 -0
  10. package/dist/chunk-MSV5OLSY.js +117 -0
  11. package/dist/chunk-MSV5OLSY.js.map +1 -0
  12. package/dist/chunk-P3CI4FTE.js +469 -0
  13. package/dist/chunk-P3CI4FTE.js.map +1 -0
  14. package/dist/{chunk-JXTUCMO2.js → chunk-QDA5FTUH.js} +5 -3
  15. package/dist/{chunk-JXTUCMO2.js.map → chunk-QDA5FTUH.js.map} +1 -1
  16. package/dist/chunk-XHSDRB3X.js +996 -0
  17. package/dist/chunk-XHSDRB3X.js.map +1 -0
  18. package/dist/cli.js +179 -22
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cloud-DLLNKYND.js +34 -0
  21. package/dist/credentials-QHESDY4K.js +27 -0
  22. package/dist/doctor-ZAYOOCPT.js +13 -0
  23. package/dist/doctor-ZAYOOCPT.js.map +1 -0
  24. package/dist/files-UBPF7PKW.js +45 -0
  25. package/dist/files-UBPF7PKW.js.map +1 -0
  26. package/dist/mcp-HFIAQ6HP.js +133 -0
  27. package/dist/mcp-HFIAQ6HP.js.map +1 -0
  28. package/dist/open-browser-CTOGH5HQ.js +17 -0
  29. package/dist/open-browser-CTOGH5HQ.js.map +1 -0
  30. package/dist/run-MPIAR3LQ.js +20 -0
  31. package/dist/run-MPIAR3LQ.js.map +1 -0
  32. package/dist/runner-E45EJ76B.js +17 -0
  33. package/dist/runner-E45EJ76B.js.map +1 -0
  34. package/dist/studio-FBC66BER.js +360 -0
  35. package/dist/studio-FBC66BER.js.map +1 -0
  36. package/dist/studio.js +1621 -0
  37. package/dist/ui-server-C7CFJWRD.js +12 -0
  38. package/dist/ui-server-C7CFJWRD.js.map +1 -0
  39. package/dist/ui.html +100 -56
  40. package/package.json +2 -2
  41. package/dist/chunk-7IC2G3Z3.js.map +0 -1
  42. package/dist/doctor-7PW2QD3M.js +0 -12
  43. package/dist/mcp-MOXTYXZU.js.map +0 -1
  44. package/dist/ui-server-ZUJ5WJ7Q.js +0 -9
  45. /package/dist/{doctor-7PW2QD3M.js.map → cloud-DLLNKYND.js.map} +0 -0
  46. /package/dist/{ui-server-ZUJ5WJ7Q.js.map → credentials-QHESDY4K.js.map} +0 -0
package/dist/studio.js ADDED
@@ -0,0 +1,1621 @@
1
+ // Stunt Double Studio: the views `stuntdouble studio` adds to the viewer
2
+ // (ui.html), served only by studio.ts. Editing actors and checklists, starting
3
+ // a run on the person's own connection or in the cloud, and settings.
4
+ //
5
+ // Every value from a file or a server reply is set as text through h(), never
6
+ // as markup, and only http(s) addresses become links. The page never sees a
7
+ // key: the server says only whether one is set and where from.
8
+
9
+ /* global window, document, history, location, CSS */
10
+
11
+ (() => {
12
+ const sd = window.stuntdouble;
13
+ const { h, icon, pageHeader, emptyState, getJson, announce } = sd;
14
+
15
+ Object.assign(sd.ICONS, {
16
+ settings:
17
+ '<path d="M10.25 5.48V3.17a9 9 0 0 1 3.5 0v2.31a6.75 6.75 0 0 1 1.62.67l1.64-1.63a9 9 0 0 1 2.47 2.47l-1.63 1.64a6.75 6.75 0 0 1 .67 1.62h2.31a9 9 0 0 1 0 3.5h-2.31a6.75 6.75 0 0 1-.67 1.62l1.63 1.64a9 9 0 0 1-2.47 2.47l-1.64-1.63a6.75 6.75 0 0 1-1.62.67v2.31a9 9 0 0 1-3.5 0v-2.31a6.75 6.75 0 0 1-1.62-.67l-1.64 1.63a9 9 0 0 1-2.47-2.47l1.63-1.64a6.75 6.75 0 0 1-.67-1.62H3.17a9 9 0 0 1 0-3.5h2.31a6.75 6.75 0 0 1 .67-1.62L4.52 6.99a9 9 0 0 1 2.47-2.47l1.64 1.63a6.75 6.75 0 0 1 1.62-.67z"/><circle cx="12" cy="12" r="2.75"/>',
18
+ plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
19
+ pencil: '<path d="M4 20v-4L16 4l4 4L8 20z"/><path d="M13.5 6.5l4 4"/>',
20
+ 'arrow-up': '<path d="M12 20V4"/><path d="M6 10l6-6 6 6"/>',
21
+ 'arrow-down': '<path d="M12 4v16"/><path d="M6 14l6 6 6-6"/>',
22
+ key: '<circle cx="7.5" cy="16.5" r="4.5"/><path d="M10.68 13.32L20 4"/><path d="M16.5 7.5l2.5 2.5"/><path d="M14 10l2 2"/>',
23
+ 'log-out':
24
+ '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M10 12h10"/><path d="M16 8l4 4-4 4"/>',
25
+ trash:
26
+ '<path d="M4 6h16"/><path d="M9 6V3h6v3"/><path d="M6 6v13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/><path d="M10 10v7"/><path d="M14 10v7"/>',
27
+ coins: '<circle cx="9" cy="9" r="6"/><path d="M15 9a6 6 0 1 1-6 6"/>',
28
+ play: '<path d="M7 4.5l12 7.5-12 7.5z"/>',
29
+ sparkles:
30
+ '<path d="M10 5q1 7 8 8-7 1-8 8-1-7-8-8 7-1 8-8z"/><path d="M18 3v4"/><path d="M16 5h4"/>',
31
+ });
32
+
33
+ // ── Styles the forms need, on the viewer's tokens ─────────────────────────
34
+ const style = document.createElement('style');
35
+ style.textContent = `
36
+ .form { display: grid; gap: 2rem; max-width: 42rem; }
37
+ .form-section { display: grid; gap: 1rem; }
38
+ .form-section > h2 { font-size: 0.9375rem; }
39
+ .form-section > .section-note { margin-top: -0.5rem; }
40
+ .fields { display: grid; gap: 1rem; }
41
+ @media (min-width: 40rem) { .fields.two { grid-template-columns: 1fr 1fr; } }
42
+ .field { display: grid; gap: 0.375rem; min-width: 0; align-content: start; }
43
+ .field > label, .field > .label { font-size: 0.8125rem; font-weight: 500; }
44
+ .field .hint { font-size: 0.75rem; color: var(--muted-foreground); }
45
+ .field-error { font-size: 0.75rem; color: var(--destructive); }
46
+ .field-error:empty { display: none; }
47
+ .input {
48
+ width: 100%; min-width: 0; height: 2.25rem; padding: 0.25rem 0.75rem;
49
+ border: 1px solid var(--input); border-radius: var(--radius-md);
50
+ background: var(--background); color: var(--foreground);
51
+ font: 400 16px/1.4 var(--font-sans);
52
+ transition: border-color var(--duration-quick) ease, box-shadow var(--duration-quick) ease;
53
+ }
54
+ @media (min-width: 48rem) { .input { font-size: 14px; } }
55
+ textarea.input { height: auto; min-height: 5.5rem; padding-block: 0.5rem; resize: vertical; }
56
+ select.input { padding-right: 2rem; }
57
+ .input::placeholder { color: color-mix(in oklch, var(--muted-foreground) 80%, transparent); }
58
+ .input:hover { border-color: color-mix(in oklch, var(--foreground) 30%, transparent); }
59
+ .input:focus-visible {
60
+ outline: none; border-color: var(--ring);
61
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 20%, transparent);
62
+ }
63
+ .input[aria-invalid='true'] {
64
+ border-color: var(--destructive);
65
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 18%, transparent);
66
+ }
67
+ .input.mono { font-family: var(--font-mono); }
68
+ .check-label { display: flex; gap: 0.5rem; align-items: flex-start; font-size: 0.8125rem; }
69
+ .check-label input { margin-top: 0.2rem; accent-color: var(--primary); }
70
+ .form-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
71
+ .form-actions .note { font-size: 0.8125rem; color: var(--muted-foreground); }
72
+ .rows { display: grid; }
73
+ .rows > .row-card { padding: 1rem; display: grid; gap: 0.75rem; }
74
+ .rows > .row-card + .row-card { border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent); }
75
+ .row-top { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; }
76
+ .row-top .overline { margin: 0; }
77
+ .row-tools { display: flex; gap: 0.25rem; }
78
+ .row-fields { display: grid; gap: 0.75rem; }
79
+ @media (min-width: 40rem) {
80
+ .row-fields.check-row { grid-template-columns: 12rem 1fr; }
81
+ .row-fields.check-row .wide { grid-column: 1 / -1; }
82
+ .row-fields.knowledge-row { grid-template-columns: 10rem 1fr; }
83
+ .row-fields.knowledge-row .wide { grid-column: 1 / -1; }
84
+ .row-fields.rules { grid-template-columns: repeat(3, 1fr); }
85
+ }
86
+ .btn.ghost { border-color: transparent; background: transparent; color: var(--muted-foreground); }
87
+ .btn.ghost:hover { background: var(--accent); color: var(--accent-foreground); }
88
+ .btn.icon-only { width: 2rem; padding: 0; }
89
+ .btn.icon-only:has(> svg) { padding: 0; }
90
+ .choice { display: grid; gap: 0.5rem; }
91
+ .choice label {
92
+ display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.75rem; align-items: start;
93
+ padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: var(--radius-lg);
94
+ background: var(--card); cursor: pointer;
95
+ }
96
+ .choice label:has(input:checked) { border-color: var(--ring); box-shadow: 0 0 0 1px var(--ring); }
97
+ .choice label:has(input:disabled) { cursor: default; opacity: 0.6; }
98
+ .choice input { margin-top: 0.2rem; accent-color: var(--primary); }
99
+ .choice strong { font-weight: 500; }
100
+ .choice .hint { grid-column: 2; font-size: 0.8125rem; color: var(--muted-foreground); }
101
+ .def-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; padding-left: 1.5rem; }
102
+ .usage { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; padding: 0.75rem 1rem; font-size: 0.8125rem; color: var(--muted-foreground); }
103
+ .usage .fact { display: inline-flex; gap: 0.375rem; align-items: center; }
104
+ .usage .mono { font-variant-numeric: tabular-nums; color: var(--foreground); }
105
+ .presets { display: flex; flex-wrap: wrap; gap: 0.375rem; }
106
+ .settings-card { padding: 1.25rem; display: grid; gap: 1rem; }
107
+ .result-text { font-size: 0.8125rem; }
108
+ .result-text.ok { color: var(--success-ink); }
109
+ .result-text.bad { color: var(--destructive); }
110
+ `;
111
+ document.head.append(style);
112
+
113
+ // ── Chrome ────────────────────────────────────────────────────────────────
114
+ document.querySelector('.bar .product').textContent = 'Stunt Double Studio';
115
+ document.title = 'Stunt Double Studio';
116
+ const nav = document.querySelector('.bar nav');
117
+ nav.setAttribute('aria-label', 'Studio');
118
+ nav.replaceChildren(
119
+ ...[
120
+ ['/', 'Runs'],
121
+ ['/checklists', 'Checklists'],
122
+ ['/actors', 'Actors'],
123
+ ['/settings', 'Settings'],
124
+ ].map(([href, label]) => h('a', { href, 'data-nav': true }, label))
125
+ );
126
+ document
127
+ .querySelector('footer p')
128
+ .replaceChildren(
129
+ 'Studio runs use your own model connection and a browser on this machine, one at a time. The cloud runs on schedules and in parallel, records every run and shares reports with your team. ',
130
+ h('a', { href: 'https://stuntdouble.io/pricing' }, 'See plans')
131
+ );
132
+
133
+ // ── Helpers ───────────────────────────────────────────────────────────────
134
+ let status = null;
135
+ async function refresh() {
136
+ status = await getJson('/api/studio');
137
+ return status;
138
+ }
139
+
140
+ function go(path) {
141
+ history.pushState(null, '', path);
142
+ window.scrollTo(0, 0);
143
+ sd.render(true);
144
+ }
145
+
146
+ /** A write to the Studio's API. Resolves to {ok, status, data}. */
147
+ async function send(method, url, body) {
148
+ let res;
149
+ try {
150
+ res = await fetch(url, {
151
+ method,
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify(body ?? {}),
154
+ });
155
+ } catch {
156
+ return {
157
+ ok: false,
158
+ status: 0,
159
+ data: { error: 'The Studio is not answering. Is it still running?' },
160
+ };
161
+ }
162
+ const data = await res.json().catch(() => ({}));
163
+ return { ok: res.ok, status: res.status, data };
164
+ }
165
+
166
+ let fieldSeq = 0;
167
+ /** A labelled control with its hint and its error line, found by `path`. */
168
+ function field(label, control, opts = {}) {
169
+ const id = control.id || 'f' + ++fieldSeq;
170
+ control.id = id;
171
+ const errorId = id + '-error';
172
+ const hintId = opts.hint ? id + '-hint' : null;
173
+ control.setAttribute('aria-describedby', [hintId, errorId].filter(Boolean).join(' '));
174
+ return h(
175
+ 'div',
176
+ { class: 'field' + (opts.className ? ' ' + opts.className : ''), 'data-path': opts.path },
177
+ h('label', { for: id }, label),
178
+ control,
179
+ opts.hint && h('p', { class: 'hint', id: hintId }, opts.hint),
180
+ h('p', { class: 'field-error', id: errorId, role: 'alert' })
181
+ );
182
+ }
183
+ const input = (attrs) => h('input', { class: 'input', type: 'text', ...attrs });
184
+ const textarea = (attrs, value) => {
185
+ const el = h('textarea', { class: 'input', ...attrs });
186
+ el.value = value ?? '';
187
+ return el;
188
+ };
189
+ function select(attrs, options, value) {
190
+ const el = h(
191
+ 'select',
192
+ { class: 'input', ...attrs },
193
+ options.map(([v, label]) => h('option', { value: v }, label))
194
+ );
195
+ el.value = value ?? options[0][0];
196
+ return el;
197
+ }
198
+
199
+ /**
200
+ * Show the server's field errors beside their fields. An error on a path
201
+ * with no field of its own lands on the nearest field above it, or in the
202
+ * notice at the top of the form.
203
+ */
204
+ function showErrors(form, issues, notice) {
205
+ for (const el of form.querySelectorAll('.field-error')) el.textContent = '';
206
+ for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid');
207
+ const loose = [];
208
+ for (const issue of issues) {
209
+ const parts = issue.path ? issue.path.split('.') : [];
210
+ let target = null;
211
+ for (let n = parts.length; n > 0 && !target; n--) {
212
+ target = form.querySelector(
213
+ '[data-path="' + CSS.escape(parts.slice(0, n).join('.')) + '"]'
214
+ );
215
+ }
216
+ if (!target) {
217
+ loose.push(issue.message);
218
+ continue;
219
+ }
220
+ const line = target.querySelector('.field-error');
221
+ line.textContent = line.textContent ? line.textContent + ' ' + issue.message : issue.message;
222
+ target.querySelector('.input')?.setAttribute('aria-invalid', 'true');
223
+ }
224
+ notice.replaceChildren();
225
+ notice.hidden = issues.length === 0;
226
+ if (issues.length) {
227
+ notice.append(
228
+ icon('alert-triangle'),
229
+ h(
230
+ 'div',
231
+ {},
232
+ h(
233
+ 'p',
234
+ {},
235
+ issues.length === 1
236
+ ? 'Fix the field marked below.'
237
+ : 'Fix the ' + issues.length + ' fields marked below.'
238
+ ),
239
+ loose.map((m) => h('p', {}, m))
240
+ )
241
+ );
242
+ const first = form.querySelector('[aria-invalid="true"]');
243
+ (first || notice).focus?.();
244
+ announce(
245
+ issues.length === 1 ? 'One field needs fixing' : issues.length + ' fields need fixing'
246
+ );
247
+ }
248
+ }
249
+ const noticeBox = () => h('div', { class: 'notice warning', hidden: true, tabindex: '-1' });
250
+
251
+ function pending(button, label) {
252
+ button.disabled = true;
253
+ const text = button.lastChild;
254
+ const before = text.textContent;
255
+ text.textContent = label;
256
+ return () => {
257
+ button.disabled = false;
258
+ text.textContent = before;
259
+ };
260
+ }
261
+
262
+ // ── Onboarding ────────────────────────────────────────────────────────────
263
+ function renderWelcome() {
264
+ const notice = noticeBox();
265
+ const url = input({
266
+ name: 'url',
267
+ type: 'url',
268
+ placeholder: 'http://localhost:3000',
269
+ autocomplete: 'url',
270
+ required: true,
271
+ });
272
+ const submit = h(
273
+ 'button',
274
+ { type: 'submit', class: 'btn primary' },
275
+ h('span', {}, 'Create the project')
276
+ );
277
+ const form = h(
278
+ 'form',
279
+ {
280
+ class: 'form',
281
+ novalidate: true,
282
+ onsubmit: async (event) => {
283
+ event.preventDefault();
284
+ const done = pending(submit, 'Creating');
285
+ const res = await send('POST', '/api/studio/init', { url: url.value.trim() });
286
+ done();
287
+ if (res.ok) {
288
+ await refresh();
289
+ go('/checklists/' + res.data.checklist);
290
+ } else
291
+ showErrors(form, res.data.issues || [{ path: '', message: res.data.error }], notice);
292
+ },
293
+ },
294
+ notice,
295
+ h(
296
+ 'div',
297
+ { class: 'form-section' },
298
+ field('Address to test', url, {
299
+ path: 'url',
300
+ hint: 'Localhost works: the browser runs on this machine.',
301
+ })
302
+ ),
303
+ h('div', { class: 'form-actions' }, submit)
304
+ );
305
+ return [
306
+ pageHeader({
307
+ title: 'Test your product with an AI actor',
308
+ description:
309
+ 'Studio keeps actors, checklists and runs as files in .stuntdouble/, in ' +
310
+ status.project +
311
+ '. Give it an address and it writes a first-time visitor and a first checklist to start from.',
312
+ }),
313
+ h('div', { class: 'stack' }, form),
314
+ ];
315
+ }
316
+
317
+ // ── Lists ─────────────────────────────────────────────────────────────────
318
+ function cloudBadge(cloud) {
319
+ return cloud && Object.values(cloud).some((v) => v && typeof v !== 'object')
320
+ ? h('span', { class: 'badge arc' }, 'Linked to the cloud')
321
+ : h('span', { class: 'badge secondary' }, 'Local only');
322
+ }
323
+ function unreadable(entry) {
324
+ return h(
325
+ 'li',
326
+ { class: 'def' },
327
+ h(
328
+ 'div',
329
+ { class: 'def-head' },
330
+ h(
331
+ 'span',
332
+ { class: 'def-name' },
333
+ icon('alert-triangle', 'tone-error'),
334
+ h('code', {}, entry.slug)
335
+ ),
336
+ h('span', { class: 'badge warning' }, 'Unreadable')
337
+ ),
338
+ h('p', { class: 'def-body error-text' }, entry.error)
339
+ );
340
+ }
341
+
342
+ async function renderChecklists() {
343
+ const { checklists } = await getJson('/api/definitions');
344
+ return [
345
+ pageHeader({
346
+ title: 'Checklists',
347
+ description:
348
+ 'What an actor checks on each run. Each one is a file in .stuntdouble/checklists/.',
349
+ actions: h(
350
+ 'a',
351
+ { class: 'btn primary', href: '/checklists/new', 'data-nav': true },
352
+ icon('plus'),
353
+ h('span', {}, 'New checklist')
354
+ ),
355
+ }),
356
+ checklists.length === 0
357
+ ? emptyState(
358
+ 'checklist',
359
+ 'No checklists yet',
360
+ 'A checklist names the page to start on, what to do and the checks to judge.'
361
+ )
362
+ : h(
363
+ 'ul',
364
+ { class: 'group card' },
365
+ checklists.map((entry) =>
366
+ entry.error
367
+ ? unreadable(entry)
368
+ : h(
369
+ 'li',
370
+ { class: 'def' },
371
+ h(
372
+ 'div',
373
+ { class: 'def-head' },
374
+ h(
375
+ 'span',
376
+ { class: 'def-name' },
377
+ icon('checklist'),
378
+ h('span', { class: 'truncate' }, entry.data.name)
379
+ ),
380
+ cloudBadge(entry.data.cloud)
381
+ ),
382
+ h(
383
+ 'div',
384
+ { class: 'def-facts' },
385
+ h('code', {}, entry.slug),
386
+ h('span', { class: 'fact' }, icon('actor'), entry.data.actor),
387
+ h('span', { class: 'fact truncate' }, icon('globe'), entry.data.url),
388
+ h(
389
+ 'span',
390
+ { class: 'fact mono' },
391
+ String(entry.data.checks.length).padStart(2, '0') + ' checks'
392
+ )
393
+ ),
394
+ h('p', { class: 'def-body' }, entry.data.instructions),
395
+ h(
396
+ 'div',
397
+ { class: 'def-actions' },
398
+ h(
399
+ 'a',
400
+ {
401
+ class: 'btn outline',
402
+ href: '/checklists/' + encodeURIComponent(entry.slug) + '/run',
403
+ 'data-nav': true,
404
+ },
405
+ icon('play'),
406
+ h('span', {}, 'Run')
407
+ ),
408
+ h(
409
+ 'a',
410
+ {
411
+ class: 'btn ghost',
412
+ href: '/checklists/' + encodeURIComponent(entry.slug),
413
+ 'data-nav': true,
414
+ },
415
+ icon('pencil'),
416
+ h('span', {}, 'Edit')
417
+ )
418
+ )
419
+ )
420
+ )
421
+ ),
422
+ ];
423
+ }
424
+
425
+ async function renderActors() {
426
+ const { actors } = await getJson('/api/definitions');
427
+ return [
428
+ pageHeader({
429
+ title: 'Actors',
430
+ description:
431
+ 'The people your checklists are run as. Each one is a file in .stuntdouble/actors/.',
432
+ actions: h(
433
+ 'a',
434
+ { class: 'btn primary', href: '/actors/new', 'data-nav': true },
435
+ icon('plus'),
436
+ h('span', {}, 'New actor')
437
+ ),
438
+ }),
439
+ actors.length === 0
440
+ ? emptyState(
441
+ 'actor',
442
+ 'No actors yet',
443
+ 'An actor is a persona described by role, such as a first-time visitor or a busy finance manager.'
444
+ )
445
+ : h(
446
+ 'ul',
447
+ { class: 'group card' },
448
+ actors.map((entry) =>
449
+ entry.error
450
+ ? unreadable(entry)
451
+ : h(
452
+ 'li',
453
+ { class: 'def' },
454
+ h(
455
+ 'div',
456
+ { class: 'def-head' },
457
+ h(
458
+ 'span',
459
+ { class: 'def-name' },
460
+ icon('actor'),
461
+ h('span', { class: 'truncate' }, entry.data.name)
462
+ ),
463
+ cloudBadge(entry.data.cloud)
464
+ ),
465
+ h(
466
+ 'div',
467
+ { class: 'def-facts' },
468
+ h('code', {}, entry.slug),
469
+ entry.data.persist_login && h('span', {}, 'Keeps its logins')
470
+ ),
471
+ entry.data.description && h('p', { class: 'def-body' }, entry.data.description),
472
+ entry.data.knowledge.length > 0 &&
473
+ h(
474
+ 'p',
475
+ { class: 'def-body fact' },
476
+ icon('knowledge'),
477
+ 'Knows: ' + entry.data.knowledge.map((k) => k.title).join(', ')
478
+ ),
479
+ h(
480
+ 'div',
481
+ { class: 'def-actions' },
482
+ h(
483
+ 'a',
484
+ {
485
+ class: 'btn ghost',
486
+ href: '/actors/' + encodeURIComponent(entry.slug),
487
+ 'data-nav': true,
488
+ },
489
+ icon('pencil'),
490
+ h('span', {}, 'Edit')
491
+ )
492
+ )
493
+ )
494
+ )
495
+ ),
496
+ ];
497
+ }
498
+
499
+ // ── Editors ───────────────────────────────────────────────────────────────
500
+ const slugify = (text) =>
501
+ text
502
+ .toLowerCase()
503
+ .replace(/[^a-z0-9]+/g, '-')
504
+ .replace(/^-+|-+$/g, '')
505
+ .slice(0, 60);
506
+
507
+ /** A list of rows, each a set of inputs, that can be added to, moved and removed. */
508
+ function rowEditor({ items, blank, build, label, addLabel, path }) {
509
+ const list = h('div', { class: 'rows' });
510
+ const rows = items.map((item) => ({ item }));
511
+ function draw() {
512
+ list.replaceChildren(
513
+ ...rows.map((row, i) => {
514
+ row.read = null;
515
+ const { node, read } = build(row.item, i);
516
+ row.read = read;
517
+ const tool = (mark, text, onclick, disabled) =>
518
+ h(
519
+ 'button',
520
+ {
521
+ type: 'button',
522
+ class: 'btn ghost icon-only',
523
+ 'aria-label': text + ' ' + (i + 1),
524
+ title: text,
525
+ disabled,
526
+ onclick,
527
+ },
528
+ icon(mark)
529
+ );
530
+ return h(
531
+ 'div',
532
+ { class: 'row-card', 'data-path': path + '.' + i },
533
+ h(
534
+ 'div',
535
+ { class: 'row-top' },
536
+ h('p', { class: 'overline' }, label + ' ' + String(i + 1).padStart(2, '0')),
537
+ h(
538
+ 'div',
539
+ { class: 'row-tools' },
540
+ tool('arrow-up', 'Move up', () => move(i, -1), i === 0),
541
+ tool('arrow-down', 'Move down', () => move(i, 1), i === rows.length - 1),
542
+ tool('trash', 'Remove', () => {
543
+ collect();
544
+ rows.splice(i, 1);
545
+ draw();
546
+ })
547
+ )
548
+ ),
549
+ node
550
+ );
551
+ })
552
+ );
553
+ }
554
+ function collect() {
555
+ for (const row of rows) row.item = row.read();
556
+ }
557
+ function move(i, by) {
558
+ collect();
559
+ const [row] = rows.splice(i, 1);
560
+ rows.splice(i + by, 0, row);
561
+ draw();
562
+ }
563
+ draw();
564
+ const add = h(
565
+ 'button',
566
+ {
567
+ type: 'button',
568
+ class: 'btn outline',
569
+ onclick: () => {
570
+ collect();
571
+ rows.push({ item: blank() });
572
+ draw();
573
+ list.lastChild?.querySelector('.input')?.focus();
574
+ },
575
+ },
576
+ icon('plus'),
577
+ h('span', {}, addLabel)
578
+ );
579
+ return {
580
+ node: h(
581
+ 'div',
582
+ { class: 'card', 'data-path': path },
583
+ list,
584
+ h(
585
+ 'div',
586
+ {
587
+ style:
588
+ 'padding: 1rem; border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent)',
589
+ },
590
+ add,
591
+ h('p', { class: 'field-error', role: 'alert' })
592
+ )
593
+ ),
594
+ values: () => {
595
+ collect();
596
+ return rows.map((r) => r.item);
597
+ },
598
+ };
599
+ }
600
+
601
+ const number = (value) => (value === '' || value == null ? undefined : Number(value));
602
+ /** Drop empty strings and undefined, so an untouched optional field stays out of the file. */
603
+ const tidy = (object) =>
604
+ Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined && v !== ''));
605
+
606
+ async function renderChecklistEditor(slug) {
607
+ const isNew = slug === 'new';
608
+ const [{ checklists, actors }] = await Promise.all([getJson('/api/definitions')]);
609
+ const entry = checklists.find((c) => c.slug === slug);
610
+ if (!isNew && !entry)
611
+ return [
612
+ pageHeader({
613
+ back: { href: '/checklists', label: 'Checklists' },
614
+ title: 'No checklist called ' + slug,
615
+ }),
616
+ ];
617
+ if (entry && entry.error) {
618
+ return [
619
+ pageHeader({
620
+ back: { href: '/checklists', label: 'Checklists', trail: slug },
621
+ title: slug,
622
+ }),
623
+ h(
624
+ 'div',
625
+ { class: 'notice warning' },
626
+ icon('alert-triangle'),
627
+ h('p', {}, entry.error + ' Fix it in the file, then come back.')
628
+ ),
629
+ ];
630
+ }
631
+ const data = entry ? entry.data : { checks: [] };
632
+ const notice = noticeBox();
633
+ const name = input({ name: 'name', value: data.name ?? '', maxlength: 200 });
634
+ const slugInput =
635
+ isNew &&
636
+ input({
637
+ name: 'slug',
638
+ class: 'input mono',
639
+ placeholder: 'guest-checkout',
640
+ spellcheck: 'false',
641
+ });
642
+ if (slugInput)
643
+ name.addEventListener(
644
+ 'input',
645
+ () => !slugInput.dataset.touched && (slugInput.value = slugify(name.value))
646
+ );
647
+ if (slugInput) slugInput.addEventListener('input', () => (slugInput.dataset.touched = '1'));
648
+ const actor = select(
649
+ { name: 'actor' },
650
+ actors
651
+ .filter((a) => a.data)
652
+ .map((a) => [a.slug, a.data.name + ' (' + a.slug + ')'])
653
+ .concat(actors.length ? [] : [['', 'No actors yet']]),
654
+ data.actor
655
+ );
656
+ const url = input({
657
+ name: 'url',
658
+ type: 'url',
659
+ value: data.url ?? '',
660
+ placeholder: 'http://localhost:3000',
661
+ });
662
+ const instructions = textarea(
663
+ {
664
+ name: 'instructions',
665
+ rows: 3,
666
+ placeholder: 'Buy the cheapest mug as a guest. Stop at the payment step.',
667
+ },
668
+ data.instructions
669
+ );
670
+
671
+ const checks = rowEditor({
672
+ items: data.checks.length ? data.checks : [{}],
673
+ blank: () => ({}),
674
+ label: 'Check',
675
+ addLabel: 'Add a check',
676
+ path: 'checks',
677
+ build: (check, i) => {
678
+ const p = 'checks.' + i + '.';
679
+ const description = textarea(
680
+ { rows: 2, placeholder: 'Shipping cost is shown before payment' },
681
+ check.description
682
+ );
683
+ const key = input({
684
+ class: 'input mono',
685
+ value: check.key ?? '',
686
+ placeholder: 'shipping-early',
687
+ spellcheck: 'false',
688
+ });
689
+ description.addEventListener(
690
+ 'input',
691
+ () =>
692
+ !key.dataset.touched &&
693
+ !check.key &&
694
+ (key.value = slugify(description.value).slice(0, 40))
695
+ );
696
+ key.addEventListener('input', () => (key.dataset.touched = '1'));
697
+ const type = select(
698
+ {},
699
+ [
700
+ ['pass_fail', 'Pass or fail'],
701
+ ['weighted', 'Weighted'],
702
+ ['informational', 'Observation'],
703
+ ],
704
+ check.type
705
+ );
706
+ const weight = input({
707
+ type: 'number',
708
+ step: '1',
709
+ value: check.weight ?? '',
710
+ placeholder: '1',
711
+ });
712
+ const weightField = field('Weight', weight, {
713
+ path: p + 'weight',
714
+ hint: 'Negative is a penalty.',
715
+ });
716
+ const syncWeight = () => (weightField.hidden = type.value !== 'weighted');
717
+ type.addEventListener('change', syncWeight);
718
+ syncWeight();
719
+ const condition = select(
720
+ {},
721
+ [
722
+ ['always', 'Always'],
723
+ ['if_previous_passed', 'If the check before passed'],
724
+ ['if_previous_failed', 'If the check before failed'],
725
+ ],
726
+ check.run_condition
727
+ );
728
+ const onFailure = select(
729
+ {},
730
+ [
731
+ ['continue', 'Carry on'],
732
+ ['stop', 'Stop the run'],
733
+ ],
734
+ check.on_failure
735
+ );
736
+ return {
737
+ node: h(
738
+ 'div',
739
+ { class: 'row-fields check-row' },
740
+ field('Key', key, { path: p + 'key', hint: 'Stays the same across runs.' }),
741
+ field('What must be true', description, { path: p + 'description' }),
742
+ h(
743
+ 'div',
744
+ { class: 'row-fields rules wide' },
745
+ field('Type', type, { path: p + 'type' }),
746
+ field('Runs', condition, { path: p + 'run_condition' }),
747
+ field('If it fails', onFailure, { path: p + 'on_failure' })
748
+ ),
749
+ h('div', { class: 'wide' }, weightField)
750
+ ),
751
+ read: () =>
752
+ tidy({
753
+ ...check,
754
+ key: key.value.trim(),
755
+ description: description.value.trim(),
756
+ type: type.value === 'pass_fail' && !check.type ? undefined : type.value,
757
+ weight: type.value === 'weighted' ? number(weight.value) : undefined,
758
+ run_condition:
759
+ condition.value === 'always' && !check.run_condition ? undefined : condition.value,
760
+ on_failure:
761
+ onFailure.value === 'continue' && !check.on_failure ? undefined : onFailure.value,
762
+ }),
763
+ };
764
+ },
765
+ });
766
+
767
+ const threshold = input({
768
+ type: 'number',
769
+ min: 0,
770
+ step: '1',
771
+ value: data.pass_threshold ?? '',
772
+ placeholder: '100',
773
+ });
774
+ const scoreType = select(
775
+ {},
776
+ [
777
+ ['percent', 'Percent'],
778
+ ['points', 'Points'],
779
+ ],
780
+ data.score_type
781
+ );
782
+ const preset = select(
783
+ {},
784
+ [
785
+ ['', 'Desktop (default)'],
786
+ ['tablet', 'Tablet'],
787
+ ['mobile', 'Phone'],
788
+ ],
789
+ data.browser_config?.preset ?? ''
790
+ );
791
+
792
+ const save = h(
793
+ 'button',
794
+ { type: 'submit', class: 'btn primary' },
795
+ h('span', {}, 'Save checklist')
796
+ );
797
+ const saved = h('span', { class: 'note', role: 'status' });
798
+ const form = h(
799
+ 'form',
800
+ {
801
+ class: 'form',
802
+ novalidate: true,
803
+ onsubmit: async (event) => {
804
+ event.preventDefault();
805
+ const browser = { ...(data.browser_config || {}) };
806
+ if (preset.value) browser.preset = preset.value;
807
+ else delete browser.preset;
808
+ const body = tidy({
809
+ ...data,
810
+ name: name.value.trim(),
811
+ actor: actor.value,
812
+ url: url.value.trim(),
813
+ instructions: instructions.value.trim(),
814
+ checks: checks.values(),
815
+ pass_threshold: number(threshold.value),
816
+ score_type:
817
+ scoreType.value === 'percent' && !data.score_type ? undefined : scoreType.value,
818
+ browser_config: Object.keys(browser).length ? browser : undefined,
819
+ });
820
+ const target = isNew ? slugInput.value.trim() : slug;
821
+ const done = pending(save, 'Saving');
822
+ const res = await send(
823
+ 'PUT',
824
+ '/api/studio/checklists/' + encodeURIComponent(target || '-'),
825
+ { data: body, create: isNew }
826
+ );
827
+ done();
828
+ if (!res.ok)
829
+ return showErrors(
830
+ form,
831
+ res.data.issues || [{ path: '', message: res.data.error }],
832
+ notice
833
+ );
834
+ showErrors(form, [], notice);
835
+ if (isNew) return go('/checklists/' + encodeURIComponent(res.data.slug));
836
+ saved.textContent = 'Saved to .stuntdouble/checklists/' + slug + '.json';
837
+ announce('Saved');
838
+ },
839
+ },
840
+ notice,
841
+ h(
842
+ 'section',
843
+ { class: 'form-section' },
844
+ h('h2', {}, 'What the actor does'),
845
+ h(
846
+ 'div',
847
+ { class: 'fields two' },
848
+ field('Name', name, { path: 'name' }),
849
+ slugInput &&
850
+ field('File name', slugInput, {
851
+ path: 'slug',
852
+ hint: 'checklists/<name>.json, and the name you run it by.',
853
+ })
854
+ ),
855
+ h(
856
+ 'div',
857
+ { class: 'fields two' },
858
+ field('Actor', actor, { path: 'actor' }),
859
+ field('Start at', url, {
860
+ path: 'url',
861
+ hint: 'A local run can swap the origin for local_base_url.',
862
+ })
863
+ ),
864
+ field('Instructions', instructions, {
865
+ path: 'instructions',
866
+ hint: 'Where to go, what to do and any test data to use.',
867
+ })
868
+ ),
869
+ h(
870
+ 'section',
871
+ { class: 'form-section' },
872
+ h('h2', {}, 'Checks'),
873
+ h(
874
+ 'p',
875
+ { class: 'section-note' },
876
+ 'Each one is judged with evidence from the page, in this order.'
877
+ ),
878
+ checks.node
879
+ ),
880
+ h(
881
+ 'section',
882
+ { class: 'form-section' },
883
+ h('h2', {}, 'Scoring and browser'),
884
+ h(
885
+ 'div',
886
+ { class: 'fields two' },
887
+ field('Pass threshold', threshold, {
888
+ path: 'pass_threshold',
889
+ hint: 'For weighted checks only. Leave empty for every point.',
890
+ }),
891
+ field('Score in', scoreType, { path: 'score_type' })
892
+ ),
893
+ field('Browser', preset, { path: 'browser_config' })
894
+ ),
895
+ h(
896
+ 'div',
897
+ { class: 'form-actions' },
898
+ save,
899
+ !isNew &&
900
+ h(
901
+ 'a',
902
+ {
903
+ class: 'btn outline',
904
+ href: '/checklists/' + encodeURIComponent(slug) + '/run',
905
+ 'data-nav': true,
906
+ },
907
+ icon('play'),
908
+ h('span', {}, 'Run')
909
+ ),
910
+ saved
911
+ )
912
+ );
913
+ return [
914
+ pageHeader({
915
+ back: { href: '/checklists', label: 'Checklists', trail: isNew ? null : slug },
916
+ title: isNew ? 'New checklist' : data.name,
917
+ description: isNew
918
+ ? 'Saved as a file in .stuntdouble/checklists/, which the cloud takes as it is.'
919
+ : null,
920
+ }),
921
+ form,
922
+ ];
923
+ }
924
+
925
+ async function renderActorEditor(slug) {
926
+ const isNew = slug === 'new';
927
+ const { actors } = await getJson('/api/definitions');
928
+ const entry = actors.find((a) => a.slug === slug);
929
+ if (!isNew && !entry)
930
+ return [
931
+ pageHeader({
932
+ back: { href: '/actors', label: 'Actors' },
933
+ title: 'No actor called ' + slug,
934
+ }),
935
+ ];
936
+ if (entry && entry.error) {
937
+ return [
938
+ pageHeader({ back: { href: '/actors', label: 'Actors', trail: slug }, title: slug }),
939
+ h(
940
+ 'div',
941
+ { class: 'notice warning' },
942
+ icon('alert-triangle'),
943
+ h('p', {}, entry.error + ' Fix it in the file, then come back.')
944
+ ),
945
+ ];
946
+ }
947
+ const data = entry ? entry.data : { knowledge: [] };
948
+ const notice = noticeBox();
949
+ const name = input({ value: data.name ?? '', placeholder: 'First-time shopper' });
950
+ const slugInput =
951
+ isNew &&
952
+ input({ class: 'input mono', placeholder: 'first-time-shopper', spellcheck: 'false' });
953
+ if (slugInput)
954
+ name.addEventListener(
955
+ 'input',
956
+ () => !slugInput.dataset.touched && (slugInput.value = slugify(name.value))
957
+ );
958
+ if (slugInput) slugInput.addEventListener('input', () => (slugInput.dataset.touched = '1'));
959
+ const description = textarea(
960
+ { rows: 2, placeholder: 'Price-sensitive, on a phone, new to the site' },
961
+ data.description
962
+ );
963
+ const prompt = textarea({ rows: 3, maxlength: 1000 }, data.system_prompt);
964
+ const persist = h('input', { type: 'checkbox', id: 'persist-login' });
965
+ persist.checked = Boolean(data.persist_login);
966
+ const knowledge = rowEditor({
967
+ items: data.knowledge,
968
+ blank: () => ({ kind: 'reference' }),
969
+ label: 'Entry',
970
+ addLabel: 'Add knowledge',
971
+ path: 'knowledge',
972
+ build: (k, i) => {
973
+ const p = 'knowledge.' + i + '.';
974
+ const kind = select(
975
+ {},
976
+ [
977
+ ['reference', 'Reference'],
978
+ ['account', 'Test account'],
979
+ ['process', 'Process'],
980
+ ['expertise', 'Expertise'],
981
+ ],
982
+ k.kind
983
+ );
984
+ const title = input({ value: k.title ?? '', maxlength: 120 });
985
+ const content = textarea({ rows: 3, maxlength: 8000 }, k.content);
986
+ return {
987
+ node: h(
988
+ 'div',
989
+ { class: 'row-fields knowledge-row' },
990
+ field('Kind', kind, { path: p + 'kind' }),
991
+ field('Title', title, { path: p + 'title' }),
992
+ field('What it knows', content, {
993
+ path: p + 'content',
994
+ className: 'wide',
995
+ hint: 'Test accounts only, never real credentials: this file is committed.',
996
+ })
997
+ ),
998
+ read: () => ({
999
+ kind: kind.value,
1000
+ title: title.value.trim(),
1001
+ content: content.value.trim(),
1002
+ }),
1003
+ };
1004
+ },
1005
+ });
1006
+ const save = h('button', { type: 'submit', class: 'btn primary' }, h('span', {}, 'Save actor'));
1007
+ const saved = h('span', { class: 'note', role: 'status' });
1008
+ const form = h(
1009
+ 'form',
1010
+ {
1011
+ class: 'form',
1012
+ novalidate: true,
1013
+ onsubmit: async (event) => {
1014
+ event.preventDefault();
1015
+ const body = tidy({
1016
+ ...data,
1017
+ name: name.value.trim(),
1018
+ description: description.value.trim() || undefined,
1019
+ system_prompt: prompt.value.trim() || undefined,
1020
+ knowledge: knowledge.values(),
1021
+ persist_login: persist.checked,
1022
+ });
1023
+ const target = isNew ? slugInput.value.trim() : slug;
1024
+ const done = pending(save, 'Saving');
1025
+ const res = await send('PUT', '/api/studio/actors/' + encodeURIComponent(target || '-'), {
1026
+ data: body,
1027
+ create: isNew,
1028
+ });
1029
+ done();
1030
+ if (!res.ok)
1031
+ return showErrors(
1032
+ form,
1033
+ res.data.issues || [{ path: '', message: res.data.error }],
1034
+ notice
1035
+ );
1036
+ showErrors(form, [], notice);
1037
+ if (isNew) return go('/actors/' + encodeURIComponent(res.data.slug));
1038
+ saved.textContent = 'Saved to .stuntdouble/actors/' + slug + '.json';
1039
+ announce('Saved');
1040
+ },
1041
+ },
1042
+ notice,
1043
+ h(
1044
+ 'section',
1045
+ { class: 'form-section' },
1046
+ h('h2', {}, 'Who they are'),
1047
+ h(
1048
+ 'div',
1049
+ { class: 'fields two' },
1050
+ field('Name', name, { path: 'name', hint: 'A role, never a person’s name.' }),
1051
+ slugInput && field('File name', slugInput, { path: 'slug' })
1052
+ ),
1053
+ field('Description', description, {
1054
+ path: 'description',
1055
+ hint: 'Background, goals and how comfortable they are with technology.',
1056
+ }),
1057
+ field('Extra instructions', prompt, {
1058
+ path: 'system_prompt',
1059
+ hint: 'Up to 1,000 characters, layered on the persona.',
1060
+ }),
1061
+ h(
1062
+ 'label',
1063
+ { class: 'check-label', for: 'persist-login' },
1064
+ persist,
1065
+ h('span', {}, 'Keep this actor’s logins between runs, in ~/.stuntdouble/profiles/')
1066
+ )
1067
+ ),
1068
+ h('section', { class: 'form-section' }, h('h2', {}, 'What they know'), knowledge.node),
1069
+ h('div', { class: 'form-actions' }, save, saved)
1070
+ );
1071
+ return [
1072
+ pageHeader({
1073
+ back: { href: '/actors', label: 'Actors', trail: isNew ? null : slug },
1074
+ title: isNew ? 'New actor' : data.name,
1075
+ }),
1076
+ form,
1077
+ ];
1078
+ }
1079
+
1080
+ // ── Run ───────────────────────────────────────────────────────────────────
1081
+ async function startRun(checklist, where, resume, button) {
1082
+ const done = button && pending(button, 'Starting');
1083
+ const res = await send('POST', '/api/studio/run', { checklist, where, resume });
1084
+ done?.();
1085
+ if (res.ok) {
1086
+ await refresh();
1087
+ go('/runs/' + encodeURIComponent(res.data.run_id));
1088
+ return null;
1089
+ }
1090
+ return res.data.error || 'The run did not start.';
1091
+ }
1092
+
1093
+ async function renderRunPicker(slug) {
1094
+ await refresh();
1095
+ const { checklists } = await getJson('/api/definitions');
1096
+ const entry = checklists.find((c) => c.slug === slug && c.data);
1097
+ if (!entry)
1098
+ return [
1099
+ pageHeader({
1100
+ back: { href: '/checklists', label: 'Checklists' },
1101
+ title: 'No checklist called ' + slug,
1102
+ }),
1103
+ ];
1104
+ const c = status.connection;
1105
+ const cloud = status.cloud;
1106
+ const local = c.configured && !(c.key === 'none' && c.provider === 'anthropic');
1107
+ const option = (value, title, hint, enabled) =>
1108
+ h(
1109
+ 'label',
1110
+ {},
1111
+ h('input', { type: 'radio', name: 'where', value, disabled: !enabled }),
1112
+ h('strong', {}, title),
1113
+ h('span', { class: 'hint' }, hint)
1114
+ );
1115
+ const choice = h(
1116
+ 'div',
1117
+ { class: 'choice', role: 'radiogroup', 'aria-label': 'Where to run it' },
1118
+ option(
1119
+ 'local',
1120
+ 'On this machine',
1121
+ local
1122
+ ? 'A Chrome window on this machine, driven by ' +
1123
+ c.model +
1124
+ ' on your own connection. Reaches localhost.'
1125
+ : 'Connect a model in Settings first: an Anthropic API key or an OpenAI-compatible endpoint.',
1126
+ local
1127
+ ),
1128
+ option(
1129
+ 'cloud',
1130
+ 'In the cloud',
1131
+ cloud.signed_in && cloud.workspace
1132
+ ? 'Stunt Double’s browsers and judge, billed to ' +
1133
+ cloud.workspace.name +
1134
+ '. Needs a public or preview address.'
1135
+ : 'Sign in to Stunt Double in Settings, on a paid plan.',
1136
+ cloud.signed_in && Boolean(cloud.workspace)
1137
+ )
1138
+ );
1139
+ const first = choice.querySelector('input:not(:disabled)');
1140
+ if (first) first.checked = true;
1141
+ const error = h('p', { class: 'result-text bad', role: 'alert' });
1142
+ const start = h(
1143
+ 'button',
1144
+ { type: 'submit', class: 'btn primary', disabled: !first },
1145
+ icon('play'),
1146
+ h('span', {}, 'Start run')
1147
+ );
1148
+ const form = h(
1149
+ 'form',
1150
+ {
1151
+ class: 'form',
1152
+ onsubmit: async (event) => {
1153
+ event.preventDefault();
1154
+ const where = choice.querySelector('input:checked')?.value;
1155
+ if (!where) return;
1156
+ error.textContent = (await startRun(slug, where, false, start)) || '';
1157
+ },
1158
+ },
1159
+ choice,
1160
+ h(
1161
+ 'div',
1162
+ { class: 'form-actions' },
1163
+ start,
1164
+ !first &&
1165
+ h(
1166
+ 'a',
1167
+ { class: 'btn outline', href: '/settings', 'data-nav': true },
1168
+ icon('settings'),
1169
+ h('span', {}, 'Settings')
1170
+ )
1171
+ ),
1172
+ error
1173
+ );
1174
+ return [
1175
+ pageHeader({
1176
+ back: { href: '/checklists/' + encodeURIComponent(slug), label: entry.data.name },
1177
+ title: 'Run ' + entry.data.name,
1178
+ description:
1179
+ entry.data.checks.length +
1180
+ ' checks, as ' +
1181
+ entry.data.actor +
1182
+ ', starting at ' +
1183
+ entry.data.url +
1184
+ '.',
1185
+ }),
1186
+ form,
1187
+ ];
1188
+ }
1189
+
1190
+ /** On a run's page: what a Studio run has used so far, from the server. */
1191
+ function usageLine(run) {
1192
+ const job = status && status.jobs.find((j) => j.run_id === run.id);
1193
+ const usage = (job && job.usage) || run.runner.usage;
1194
+ if (!usage || !run.runner.model) return null;
1195
+ const tokens = usage.input_tokens + usage.output_tokens;
1196
+ return h(
1197
+ 'section',
1198
+ { class: 'card usage', id: 'usage', 'aria-label': 'Model use' },
1199
+ h(
1200
+ 'span',
1201
+ { class: 'fact' },
1202
+ icon('sparkles'),
1203
+ h('span', { class: 'mono' }, run.runner.model.id)
1204
+ ),
1205
+ h(
1206
+ 'span',
1207
+ { class: 'fact' },
1208
+ h('span', { class: 'mono' }, String(usage.steps)),
1209
+ ' model calls'
1210
+ ),
1211
+ h(
1212
+ 'span',
1213
+ { class: 'fact' },
1214
+ h('span', { class: 'mono' }, tokens.toLocaleString()),
1215
+ ' tokens'
1216
+ ),
1217
+ usage.cost_usd != null &&
1218
+ h(
1219
+ 'span',
1220
+ { class: 'fact' },
1221
+ icon('coins'),
1222
+ 'about ',
1223
+ h('span', { class: 'mono' }, '$' + usage.cost_usd.toFixed(2))
1224
+ ),
1225
+ job &&
1226
+ job.usage &&
1227
+ h('span', { class: 'fact' }, 'of at most ' + status.limits.maxSteps + ' calls')
1228
+ );
1229
+ }
1230
+ sd.runExtras = usageLine;
1231
+
1232
+ // A finished run's one primary action is running it again, here.
1233
+ sd.runActions = (run, running, resumable) => {
1234
+ if (running) return null;
1235
+ const error = h('span', { class: 'stop-error', role: 'alert' });
1236
+ const again = h(
1237
+ 'button',
1238
+ {
1239
+ type: 'button',
1240
+ class: 'btn primary',
1241
+ 'data-focus-key': 'again',
1242
+ onclick: async (event) => {
1243
+ error.textContent =
1244
+ (await startRun(
1245
+ run.checklist,
1246
+ run.runner.kind === 'cloud' ? 'cloud' : 'local',
1247
+ resumable && run.runner.kind === 'local',
1248
+ event.currentTarget
1249
+ )) || '';
1250
+ },
1251
+ },
1252
+ icon('play'),
1253
+ h('span', {}, resumable && run.runner.kind === 'local' ? 'Resume' : 'Run again')
1254
+ );
1255
+ return [
1256
+ sd.webUrl(run.runner.url) &&
1257
+ h(
1258
+ 'a',
1259
+ {
1260
+ class: 'btn outline',
1261
+ href: run.runner.url,
1262
+ target: '_blank',
1263
+ rel: 'noopener noreferrer',
1264
+ },
1265
+ icon('external-link'),
1266
+ h('span', {}, 'Open in Stunt Double')
1267
+ ),
1268
+ again,
1269
+ error,
1270
+ ].filter(Boolean);
1271
+ };
1272
+
1273
+ // While a run is going, the usage line follows it without redrawing the page.
1274
+ setInterval(async () => {
1275
+ if (!status || status.jobs.length === 0) return;
1276
+ await refresh().catch(() => null);
1277
+ const box = document.getElementById('usage');
1278
+ const id = decodeURIComponent(location.pathname.slice('/runs/'.length));
1279
+ const run = location.pathname.startsWith('/runs/') && {
1280
+ id,
1281
+ runner: { model: status.jobs.find((j) => j.run_id === id)?.model },
1282
+ };
1283
+ if (box && run && run.runner.model) {
1284
+ const line = usageLine(run);
1285
+ if (line) box.replaceWith(line);
1286
+ }
1287
+ }, 2000);
1288
+
1289
+ // ── Settings ──────────────────────────────────────────────────────────────
1290
+ const PRESETS = [
1291
+ ['OpenAI', 'https://api.openai.com/v1', 'gpt-5'],
1292
+ ['OpenRouter', 'https://openrouter.ai/api/v1', 'anthropic/claude-sonnet-5.5'],
1293
+ ['Ollama', 'http://localhost:11434/v1', 'qwen3.5:9b'],
1294
+ ['LM Studio', 'http://localhost:1234/v1', ''],
1295
+ ];
1296
+
1297
+ async function renderSettings() {
1298
+ await refresh();
1299
+ const c = status.connection;
1300
+ const notice = noticeBox();
1301
+ const provider = select(
1302
+ { name: 'provider' },
1303
+ [
1304
+ ['anthropic', 'Anthropic API'],
1305
+ ['openai', 'OpenAI-compatible endpoint'],
1306
+ ],
1307
+ c.configured ? c.provider : 'anthropic'
1308
+ );
1309
+ const model = input({
1310
+ name: 'model',
1311
+ value: c.configured ? c.model : 'claude-opus-5-5',
1312
+ spellcheck: 'false',
1313
+ class: 'input mono',
1314
+ });
1315
+ const baseUrl = input({
1316
+ name: 'base_url',
1317
+ type: 'url',
1318
+ value: c.configured && c.provider === 'openai' ? c.base_url : '',
1319
+ placeholder: 'https://api.openai.com/v1',
1320
+ class: 'input mono',
1321
+ });
1322
+ const key = input({
1323
+ name: 'api_key',
1324
+ type: 'password',
1325
+ autocomplete: 'off',
1326
+ spellcheck: 'false',
1327
+ });
1328
+ const forget = h('input', { type: 'checkbox', id: 'forget-key' });
1329
+ const keyState = () => {
1330
+ if (!c.configured || c.provider !== provider.value) return 'No key yet.';
1331
+ if (c.key === 'stored')
1332
+ return 'A key is stored on this machine. Leave this empty to keep it.';
1333
+ if (c.key === 'env')
1334
+ return 'Using ' + c.key_env + ' from the environment. A key entered here takes its place.';
1335
+ return 'No key yet.';
1336
+ };
1337
+ const keyHint = h('span', {});
1338
+ const forgetRow = h(
1339
+ 'label',
1340
+ { class: 'check-label', for: 'forget-key' },
1341
+ forget,
1342
+ h('span', {}, 'Forget the stored key')
1343
+ );
1344
+ const presets = h(
1345
+ 'div',
1346
+ { class: 'presets', role: 'group', 'aria-label': 'Fill in a known endpoint' },
1347
+ PRESETS.map(([label, url, suggested]) =>
1348
+ h(
1349
+ 'button',
1350
+ {
1351
+ type: 'button',
1352
+ class: 'btn outline',
1353
+ onclick: () => {
1354
+ baseUrl.value = url;
1355
+ if (suggested) model.value = suggested;
1356
+ key.focus();
1357
+ },
1358
+ },
1359
+ label
1360
+ )
1361
+ )
1362
+ );
1363
+ const baseField = field('Endpoint', baseUrl, {
1364
+ path: 'base_url',
1365
+ hint: 'The address before /chat/completions. Ollama and LM Studio need no key.',
1366
+ });
1367
+ const presetRow = h(
1368
+ 'div',
1369
+ { class: 'field' },
1370
+ h('span', { class: 'label' }, 'Known endpoints'),
1371
+ presets
1372
+ );
1373
+ const sync = () => {
1374
+ const openai = provider.value === 'openai';
1375
+ baseField.hidden = !openai;
1376
+ presetRow.hidden = !openai;
1377
+ keyHint.textContent = keyState();
1378
+ forgetRow.hidden = !(c.configured && c.key === 'stored' && c.provider === provider.value);
1379
+ if (!openai && !/^claude-/.test(model.value)) model.value = 'claude-opus-5-5';
1380
+ };
1381
+ provider.addEventListener('change', sync);
1382
+ const keyField = field('API key', key, { path: 'api_key' });
1383
+ keyField.querySelector('label').after(keyHint);
1384
+ keyHint.className = 'hint';
1385
+ sync();
1386
+
1387
+ const result = h('p', { class: 'result-text', role: 'status' });
1388
+ const save = h(
1389
+ 'button',
1390
+ { type: 'submit', class: 'btn primary' },
1391
+ h('span', {}, 'Save connection')
1392
+ );
1393
+ const test = h(
1394
+ 'button',
1395
+ {
1396
+ type: 'button',
1397
+ class: 'btn outline',
1398
+ onclick: async (event) => {
1399
+ const done = pending(event.currentTarget, 'Testing');
1400
+ const res = await send('POST', '/api/studio/connection/test');
1401
+ done();
1402
+ result.className = 'result-text ' + (res.data.ok ? 'ok' : 'bad');
1403
+ result.textContent = res.data.message || res.data.error || '';
1404
+ },
1405
+ },
1406
+ h('span', {}, 'Test connection')
1407
+ );
1408
+ const form = h(
1409
+ 'form',
1410
+ {
1411
+ class: 'card settings-card',
1412
+ novalidate: true,
1413
+ onsubmit: async (event) => {
1414
+ event.preventDefault();
1415
+ const done = pending(save, 'Saving');
1416
+ const res = await send('POST', '/api/studio/connection', {
1417
+ provider: provider.value,
1418
+ model: model.value.trim(),
1419
+ base_url: provider.value === 'openai' ? baseUrl.value.trim() : null,
1420
+ api_key: key.value,
1421
+ clear_key: forget.checked,
1422
+ });
1423
+ done();
1424
+ if (!res.ok)
1425
+ return showErrors(
1426
+ form,
1427
+ res.data.issues || [{ path: '', message: res.data.error }],
1428
+ notice
1429
+ );
1430
+ showErrors(form, [], notice);
1431
+ key.value = '';
1432
+ forget.checked = false;
1433
+ await refresh();
1434
+ Object.assign(c, status.connection);
1435
+ sync();
1436
+ result.className = 'result-text ok';
1437
+ result.textContent = 'Saved. Test it to make sure the key works.';
1438
+ },
1439
+ },
1440
+ notice,
1441
+ h(
1442
+ 'div',
1443
+ { class: 'fields two' },
1444
+ field('Provider', provider, { path: 'provider' }),
1445
+ field('Model', model, { path: 'model' })
1446
+ ),
1447
+ presetRow,
1448
+ baseField,
1449
+ keyField,
1450
+ forgetRow,
1451
+ h('div', { class: 'form-actions' }, save, test),
1452
+ result
1453
+ );
1454
+
1455
+ const cloud = status.cloud;
1456
+ const cloudBody = h('div', { class: 'card settings-card' });
1457
+ const cloudError = h('p', { class: 'result-text bad', role: 'alert' });
1458
+ if (!cloud.signed_in) {
1459
+ const signIn = h(
1460
+ 'button',
1461
+ {
1462
+ type: 'button',
1463
+ class: 'btn outline',
1464
+ onclick: async (event) => {
1465
+ const done = pending(event.currentTarget, 'Opening Stunt Double');
1466
+ const res = await send('POST', '/api/studio/cloud/login');
1467
+ if (res.ok && sd.webUrl(res.data.url)) location.assign(res.data.url);
1468
+ else {
1469
+ done();
1470
+ cloudError.textContent = res.data.error || 'Sign-in could not start.';
1471
+ }
1472
+ },
1473
+ },
1474
+ icon('external-link'),
1475
+ h('span', {}, 'Sign in to Stunt Double')
1476
+ );
1477
+ cloudBody.append(
1478
+ h(
1479
+ 'p',
1480
+ {},
1481
+ 'Sign in and a checklist can also run in the cloud: on Stunt Double’s browsers, judged independently, billed to your workspace on a paid plan.'
1482
+ ),
1483
+ h('div', { class: 'form-actions' }, signIn),
1484
+ cloudError
1485
+ );
1486
+ } else {
1487
+ const picker = select({}, [['', 'Loading workspaces']], '');
1488
+ picker.disabled = true;
1489
+ getJson('/api/studio/cloud/workspaces').then(
1490
+ ({ workspaces }) => {
1491
+ picker.replaceChildren(
1492
+ h('option', { value: '' }, 'Choose a workspace'),
1493
+ ...workspaces.map((w) => h('option', { value: w.id }, w.name + ' (' + w.slug + ')'))
1494
+ );
1495
+ picker.value = cloud.workspace ? cloud.workspace.id : '';
1496
+ picker.disabled = false;
1497
+ },
1498
+ (err) => (cloudError.textContent = err.message)
1499
+ );
1500
+ picker.addEventListener('change', async () => {
1501
+ if (!picker.value) return;
1502
+ const res = await send('POST', '/api/studio/cloud/workspace', { id: picker.value });
1503
+ cloudError.textContent = res.ok ? '' : res.data.error;
1504
+ if (res.ok) announce('Cloud runs go to ' + res.data.workspace.name);
1505
+ });
1506
+ const signOut = h(
1507
+ 'button',
1508
+ {
1509
+ type: 'button',
1510
+ class: 'btn outline',
1511
+ onclick: async () => {
1512
+ await send('POST', '/api/studio/cloud/logout');
1513
+ sd.render(false);
1514
+ },
1515
+ },
1516
+ icon('log-out'),
1517
+ h('span', {}, 'Sign out')
1518
+ );
1519
+ cloudBody.append(
1520
+ field('Workspace for cloud runs', picker, {
1521
+ hint: 'Cloud runs and the checklists they push go here.',
1522
+ }),
1523
+ h('div', { class: 'form-actions' }, signOut),
1524
+ cloudError
1525
+ );
1526
+ }
1527
+
1528
+ return [
1529
+ pageHeader({
1530
+ title: 'Settings',
1531
+ description:
1532
+ 'Kept in ~/.stuntdouble/credentials.json on this machine, never in the project and never shown here.',
1533
+ }),
1534
+ h(
1535
+ 'div',
1536
+ { class: 'stack' },
1537
+ h(
1538
+ 'section',
1539
+ { class: 'section' },
1540
+ h('h2', { class: 'section-title' }, 'Connection'),
1541
+ h(
1542
+ 'p',
1543
+ { class: 'section-note' },
1544
+ 'Studio runs use only this: your own Anthropic API key, or an OpenAI-compatible endpoint such as OpenAI, OpenRouter, Ollama or LM Studio. Never a Claude plan.'
1545
+ ),
1546
+ form
1547
+ ),
1548
+ h(
1549
+ 'section',
1550
+ { class: 'section' },
1551
+ h('h2', { class: 'section-title' }, 'Stunt Double'),
1552
+ cloudBody
1553
+ )
1554
+ ),
1555
+ ];
1556
+ }
1557
+
1558
+ // ── Runs, with the Studio's empty state ───────────────────────────────────
1559
+ async function renderHome() {
1560
+ const runs = await getJson('/api/runs');
1561
+ if (runs.length) return sd.renderRuns();
1562
+ return [
1563
+ h('h1', { class: 'sr-only' }, 'Runs'),
1564
+ emptyState(
1565
+ 'checklist',
1566
+ 'No runs yet',
1567
+ 'Run a checklist and its verdicts, screenshots and activity land here as it goes.'
1568
+ ),
1569
+ h(
1570
+ 'div',
1571
+ { class: 'form-actions', style: 'justify-content: center; margin-top: 1rem' },
1572
+ h(
1573
+ 'a',
1574
+ { class: 'btn primary', href: '/checklists', 'data-nav': true },
1575
+ icon('play'),
1576
+ h('span', {}, 'Choose a checklist')
1577
+ )
1578
+ ),
1579
+ ];
1580
+ }
1581
+
1582
+ // ── Routes ────────────────────────────────────────────────────────────────
1583
+ const views = [
1584
+ [/^\/$/, renderHome],
1585
+ [/^\/definitions$/, renderChecklists],
1586
+ [/^\/checklists$/, renderChecklists],
1587
+ [
1588
+ /^\/checklists\/([^/]+)\/run$/,
1589
+ (m) => renderRunPicker(decodeURIComponent(m[1])),
1590
+ { narrow: true },
1591
+ ],
1592
+ [
1593
+ /^\/checklists\/([^/]+)$/,
1594
+ (m) => renderChecklistEditor(decodeURIComponent(m[1])),
1595
+ { keep: true, narrow: true },
1596
+ ],
1597
+ [/^\/actors$/, renderActors],
1598
+ [
1599
+ /^\/actors\/([^/]+)$/,
1600
+ (m) => renderActorEditor(decodeURIComponent(m[1])),
1601
+ { keep: true, narrow: true },
1602
+ ],
1603
+ [/^\/settings$/, renderSettings, { keep: true, narrow: true }],
1604
+ ];
1605
+ for (const [pattern, view, opts] of views) {
1606
+ sd.routes.push({
1607
+ match: (path) => pattern.test(path),
1608
+ ...opts,
1609
+ render: async (path) => {
1610
+ if (!status) await refresh();
1611
+ // No project yet: everything but Settings starts with setting one up.
1612
+ if (!status.initialised && path !== '/settings') {
1613
+ await refresh();
1614
+ if (!status.initialised) return renderWelcome();
1615
+ }
1616
+ return view(path.match(pattern));
1617
+ },
1618
+ });
1619
+ }
1620
+ refresh().catch(() => null);
1621
+ })();