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.
- package/README.md +31 -6
- package/dist/chunk-BFI6P5DO.js +278 -0
- package/dist/chunk-BFI6P5DO.js.map +1 -0
- package/dist/chunk-DFXASIGV.js +431 -0
- package/dist/chunk-DFXASIGV.js.map +1 -0
- package/dist/{chunk-7IC2G3Z3.js → chunk-F3R3QL7V.js} +19 -252
- package/dist/chunk-F3R3QL7V.js.map +1 -0
- package/dist/{mcp-MOXTYXZU.js → chunk-FMZTT2MC.js} +72 -1160
- package/dist/chunk-FMZTT2MC.js.map +1 -0
- package/dist/chunk-MSV5OLSY.js +117 -0
- package/dist/chunk-MSV5OLSY.js.map +1 -0
- package/dist/chunk-P3CI4FTE.js +469 -0
- package/dist/chunk-P3CI4FTE.js.map +1 -0
- package/dist/{chunk-JXTUCMO2.js → chunk-QDA5FTUH.js} +5 -3
- package/dist/{chunk-JXTUCMO2.js.map → chunk-QDA5FTUH.js.map} +1 -1
- package/dist/chunk-XHSDRB3X.js +996 -0
- package/dist/chunk-XHSDRB3X.js.map +1 -0
- package/dist/cli.js +179 -22
- package/dist/cli.js.map +1 -1
- package/dist/cloud-DLLNKYND.js +34 -0
- package/dist/credentials-QHESDY4K.js +27 -0
- package/dist/doctor-ZAYOOCPT.js +13 -0
- package/dist/doctor-ZAYOOCPT.js.map +1 -0
- package/dist/files-UBPF7PKW.js +45 -0
- package/dist/files-UBPF7PKW.js.map +1 -0
- package/dist/mcp-HFIAQ6HP.js +133 -0
- package/dist/mcp-HFIAQ6HP.js.map +1 -0
- package/dist/open-browser-CTOGH5HQ.js +17 -0
- package/dist/open-browser-CTOGH5HQ.js.map +1 -0
- package/dist/run-MPIAR3LQ.js +20 -0
- package/dist/run-MPIAR3LQ.js.map +1 -0
- package/dist/runner-E45EJ76B.js +17 -0
- package/dist/runner-E45EJ76B.js.map +1 -0
- package/dist/studio-FBC66BER.js +360 -0
- package/dist/studio-FBC66BER.js.map +1 -0
- package/dist/studio.js +1621 -0
- package/dist/ui-server-C7CFJWRD.js +12 -0
- package/dist/ui-server-C7CFJWRD.js.map +1 -0
- package/dist/ui.html +100 -56
- package/package.json +2 -2
- package/dist/chunk-7IC2G3Z3.js.map +0 -1
- package/dist/doctor-7PW2QD3M.js +0 -12
- package/dist/mcp-MOXTYXZU.js.map +0 -1
- package/dist/ui-server-ZUJ5WJ7Q.js +0 -9
- /package/dist/{doctor-7PW2QD3M.js.map → cloud-DLLNKYND.js.map} +0 -0
- /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
|
+
})();
|