stuntdouble 0.1.1 → 0.1.3
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 +7 -4
- package/dist/{chunk-JAB46QV7.js → chunk-7IC2G3Z3.js} +298 -9
- package/dist/chunk-7IC2G3Z3.js.map +1 -0
- package/dist/chunk-JXTUCMO2.js +77 -0
- package/dist/chunk-JXTUCMO2.js.map +1 -0
- package/dist/cli.js +18 -63
- package/dist/cli.js.map +1 -1
- package/dist/doctor-7PW2QD3M.js +12 -0
- package/dist/{mcp-O2LGDSU7.js → mcp-MOXTYXZU.js} +122 -21
- package/dist/mcp-MOXTYXZU.js.map +1 -0
- package/dist/ui-server-ZUJ5WJ7Q.js +9 -0
- package/dist/ui-server-ZUJ5WJ7Q.js.map +1 -0
- package/dist/ui.html +2209 -388
- package/package.json +1 -1
- package/dist/chunk-JAB46QV7.js.map +0 -1
- package/dist/chunk-VWTYG7G4.js +0 -238
- package/dist/chunk-VWTYG7G4.js.map +0 -1
- package/dist/mcp-O2LGDSU7.js.map +0 -1
- package/dist/ui-server-AYIR7DTN.js +0 -10
- /package/dist/{ui-server-AYIR7DTN.js.map → doctor-7PW2QD3M.js.map} +0 -0
package/dist/ui.html
CHANGED
|
@@ -1,265 +1,1346 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en-GB">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Stunt Double Local</title>
|
|
7
7
|
<style>
|
|
8
|
+
/* Continuity, ported from Stunt Double's @stuntdouble/ui (globals.css and
|
|
9
|
+
motion.css): the semantic tokens, radii, type stacks and motion tiers
|
|
10
|
+
this page uses, under the same names. Untitled Sans is a licensed face
|
|
11
|
+
that cannot ship in an open source package, and this page loads
|
|
12
|
+
nothing from the network, so the sans is the token's own system
|
|
13
|
+
fallback. Geist Mono and Space Grotesk are used when installed. */
|
|
8
14
|
:root {
|
|
9
15
|
color-scheme: light dark;
|
|
10
16
|
--background: oklch(0.985 0.005 250);
|
|
11
17
|
--foreground: oklch(0.18 0.016 268);
|
|
12
18
|
--card: oklch(1 0 0);
|
|
19
|
+
--secondary: oklch(0.945 0.009 255);
|
|
20
|
+
--secondary-foreground: oklch(0.22 0.016 268);
|
|
13
21
|
--muted: oklch(0.955 0.007 255);
|
|
14
22
|
--muted-foreground: oklch(0.46 0.017 268);
|
|
15
|
-
--
|
|
23
|
+
--accent: oklch(0.945 0.02 255);
|
|
24
|
+
--accent-foreground: oklch(0.22 0.016 268);
|
|
16
25
|
--primary: oklch(0.52 0.22 268);
|
|
17
26
|
--primary-foreground: oklch(0.985 0.005 250);
|
|
27
|
+
--destructive: oklch(0.53 0.215 22);
|
|
28
|
+
--border: oklch(0.88 0.01 255);
|
|
29
|
+
--input: oklch(0.86 0.01 255);
|
|
30
|
+
--ring: oklch(0.52 0.22 268);
|
|
18
31
|
--success: oklch(0.6 0.13 160);
|
|
32
|
+
--success-ink: oklch(0.52 0.12 160);
|
|
19
33
|
--warning: oklch(0.75 0.15 75);
|
|
20
|
-
--
|
|
21
|
-
|
|
34
|
+
--warning-ink: oklch(0.52 0.13 70);
|
|
35
|
+
|
|
36
|
+
--radius-md: 0.25rem;
|
|
37
|
+
--radius-lg: 0.375rem;
|
|
38
|
+
|
|
22
39
|
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
40
|
+
--font-mono:
|
|
41
|
+
'Geist Mono', ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
23
42
|
--font-display: 'Space Grotesk', var(--font-sans);
|
|
24
|
-
|
|
43
|
+
|
|
44
|
+
--ease-snap: cubic-bezier(0.23, 1, 0.32, 1);
|
|
45
|
+
--ease-glide: cubic-bezier(0.77, 0, 0.175, 1);
|
|
46
|
+
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
|
47
|
+
--duration-press: 140ms;
|
|
48
|
+
--duration-quick: 180ms;
|
|
49
|
+
--duration-base: 250ms;
|
|
50
|
+
--duration-slow: 360ms;
|
|
51
|
+
|
|
52
|
+
/* The primary button's light (.button-arc). */
|
|
53
|
+
--button-fill-lit: color-mix(in oklab, var(--primary) 92%, var(--foreground));
|
|
54
|
+
--button-lamp: oklch(1 0 0 / 0.24);
|
|
55
|
+
--button-specular: oklch(1 0 0 / 0.28);
|
|
56
|
+
--button-specular-lit: oklch(1 0 0 / 0.46);
|
|
57
|
+
--button-shade: oklch(0.255 0.11 268 / 0.38);
|
|
58
|
+
--button-rim: oklch(0.32 0.15 268 / 0.5);
|
|
59
|
+
--button-shadow:
|
|
60
|
+
0 1px 1px oklch(0.25 0.03 265 / 0.14), 0 2px 5px -2px oklch(0.25 0.03 265 / 0.22);
|
|
61
|
+
--button-shadow-lit:
|
|
62
|
+
0 1px 1px oklch(0.25 0.03 265 / 0.16), 0 5px 12px -5px oklch(0.25 0.03 265 / 0.34);
|
|
25
63
|
}
|
|
26
64
|
@media (prefers-color-scheme: dark) {
|
|
27
65
|
:root {
|
|
28
66
|
--background: oklch(0.14 0.014 265);
|
|
29
67
|
--foreground: oklch(0.95 0.006 250);
|
|
30
68
|
--card: oklch(0.17 0.014 265);
|
|
69
|
+
--secondary: oklch(0.22 0.014 265);
|
|
70
|
+
--secondary-foreground: oklch(0.95 0.006 250);
|
|
31
71
|
--muted: oklch(0.22 0.014 265);
|
|
32
72
|
--muted-foreground: oklch(0.68 0.014 250);
|
|
33
|
-
--
|
|
73
|
+
--accent: oklch(0.24 0.018 265);
|
|
74
|
+
--accent-foreground: oklch(0.95 0.006 250);
|
|
34
75
|
--primary: oklch(0.68 0.165 276);
|
|
35
76
|
--primary-foreground: oklch(0.14 0.014 265);
|
|
77
|
+
--destructive: oklch(0.64 0.2 22);
|
|
78
|
+
--border: oklch(0.95 0.006 250 / 0.1);
|
|
79
|
+
--input: oklch(0.95 0.006 250 / 0.12);
|
|
80
|
+
--ring: oklch(0.68 0.165 276);
|
|
36
81
|
--success: oklch(0.68 0.14 160);
|
|
82
|
+
--success-ink: oklch(0.72 0.14 160);
|
|
37
83
|
--warning: oklch(0.82 0.15 75);
|
|
38
|
-
--
|
|
84
|
+
--warning-ink: oklch(0.82 0.15 75);
|
|
85
|
+
|
|
86
|
+
--button-lamp: oklch(1 0 0 / 0.3);
|
|
87
|
+
--button-specular: oklch(1 0 0 / 0.4);
|
|
88
|
+
--button-specular-lit: oklch(1 0 0 / 0.6);
|
|
89
|
+
--button-shade: oklch(0.2 0.08 272 / 0.3);
|
|
90
|
+
--button-rim: oklch(1 0 0 / 0.1);
|
|
91
|
+
--button-shadow: 0 1px 1px oklch(0 0 0 / 0.5), 0 2px 6px -2px oklch(0 0 0 / 0.5);
|
|
92
|
+
--button-shadow-lit: 0 1px 1px oklch(0 0 0 / 0.5), 0 5px 14px -5px oklch(0 0 0 / 0.65);
|
|
39
93
|
}
|
|
40
94
|
}
|
|
95
|
+
|
|
96
|
+
/* ── Base ─────────────────────────────────────────────── */
|
|
41
97
|
* {
|
|
42
98
|
box-sizing: border-box;
|
|
43
99
|
}
|
|
100
|
+
[hidden] {
|
|
101
|
+
display: none !important;
|
|
102
|
+
}
|
|
103
|
+
html {
|
|
104
|
+
-webkit-text-size-adjust: 100%;
|
|
105
|
+
}
|
|
44
106
|
body {
|
|
45
107
|
margin: 0;
|
|
108
|
+
min-height: 100vh;
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
46
111
|
background: var(--background);
|
|
47
112
|
color: var(--foreground);
|
|
48
|
-
font: 14px/1.5 var(--font-sans);
|
|
113
|
+
font: 400 14px/1.5 var(--font-sans);
|
|
114
|
+
-webkit-font-smoothing: antialiased;
|
|
115
|
+
-moz-osx-font-smoothing: grayscale;
|
|
116
|
+
}
|
|
117
|
+
h1,
|
|
118
|
+
h2,
|
|
119
|
+
h3 {
|
|
120
|
+
margin: 0;
|
|
121
|
+
font-weight: 500;
|
|
122
|
+
text-wrap: balance;
|
|
123
|
+
}
|
|
124
|
+
p,
|
|
125
|
+
li {
|
|
126
|
+
text-wrap: pretty;
|
|
127
|
+
}
|
|
128
|
+
p {
|
|
129
|
+
margin: 0;
|
|
49
130
|
}
|
|
50
131
|
a {
|
|
51
132
|
color: inherit;
|
|
52
133
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
134
|
+
ul,
|
|
135
|
+
ol {
|
|
136
|
+
list-style: none;
|
|
137
|
+
margin: 0;
|
|
138
|
+
padding: 0;
|
|
139
|
+
}
|
|
140
|
+
:focus-visible {
|
|
141
|
+
outline: 2px solid var(--ring);
|
|
142
|
+
outline-offset: 2px;
|
|
143
|
+
}
|
|
144
|
+
::selection {
|
|
145
|
+
background-color: var(--primary);
|
|
146
|
+
color: var(--primary-foreground);
|
|
147
|
+
}
|
|
148
|
+
/* A full-bleed row inside a clipped group draws its ring inside. */
|
|
149
|
+
.group :focus-visible {
|
|
150
|
+
outline-offset: -2px;
|
|
151
|
+
}
|
|
152
|
+
code,
|
|
153
|
+
.mono {
|
|
154
|
+
font-family: var(--font-mono);
|
|
155
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
156
|
+
}
|
|
157
|
+
svg.icon {
|
|
158
|
+
width: 1rem;
|
|
159
|
+
height: 1rem;
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
}
|
|
162
|
+
.muted {
|
|
163
|
+
color: var(--muted-foreground);
|
|
164
|
+
}
|
|
165
|
+
.sr-only {
|
|
166
|
+
position: absolute;
|
|
167
|
+
width: 1px;
|
|
168
|
+
height: 1px;
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
clip: rect(0 0 0 0);
|
|
171
|
+
white-space: nowrap;
|
|
172
|
+
}
|
|
173
|
+
.truncate {
|
|
174
|
+
min-width: 0;
|
|
175
|
+
overflow: hidden;
|
|
176
|
+
text-overflow: ellipsis;
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ── Chrome ───────────────────────────────────────────── */
|
|
181
|
+
.container {
|
|
182
|
+
width: 100%;
|
|
56
183
|
max-width: 72rem;
|
|
57
184
|
margin: 0 auto;
|
|
58
|
-
padding:
|
|
185
|
+
padding-inline: 1rem;
|
|
59
186
|
}
|
|
60
|
-
|
|
187
|
+
/* The narrow tier, kept on the chrome's left edge. */
|
|
188
|
+
.container.narrow > * {
|
|
189
|
+
max-width: 48rem;
|
|
190
|
+
}
|
|
191
|
+
@media (min-width: 40rem) {
|
|
192
|
+
.container {
|
|
193
|
+
padding-inline: 1.5rem;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
@media (min-width: 64rem) {
|
|
197
|
+
.container {
|
|
198
|
+
padding-inline: 2rem;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
.bar {
|
|
202
|
+
border-bottom: 1px solid var(--border);
|
|
203
|
+
background: var(--background);
|
|
204
|
+
}
|
|
205
|
+
.bar .container {
|
|
61
206
|
display: flex;
|
|
62
207
|
flex-wrap: wrap;
|
|
63
208
|
align-items: center;
|
|
64
|
-
gap: 0.5rem
|
|
209
|
+
gap: 0.5rem 2rem;
|
|
65
210
|
min-height: 3.5rem;
|
|
66
211
|
padding-block: 0.5rem;
|
|
67
|
-
border-bottom: 1px solid var(--border);
|
|
68
212
|
}
|
|
69
|
-
|
|
70
|
-
font-
|
|
213
|
+
.product {
|
|
214
|
+
font-weight: 500;
|
|
71
215
|
letter-spacing: -0.012em;
|
|
72
216
|
white-space: nowrap;
|
|
73
217
|
}
|
|
74
|
-
|
|
75
|
-
|
|
218
|
+
.bar nav {
|
|
219
|
+
display: flex;
|
|
220
|
+
gap: 0.25rem;
|
|
221
|
+
margin-inline: -0.5rem;
|
|
222
|
+
}
|
|
223
|
+
.bar nav a {
|
|
224
|
+
padding: 0.25rem 0.5rem;
|
|
225
|
+
border-radius: var(--radius-md);
|
|
76
226
|
color: var(--muted-foreground);
|
|
77
227
|
text-decoration: none;
|
|
78
228
|
white-space: nowrap;
|
|
229
|
+
transition: color var(--duration-quick) ease;
|
|
230
|
+
}
|
|
231
|
+
.bar nav a:hover {
|
|
232
|
+
color: var(--foreground);
|
|
79
233
|
}
|
|
80
|
-
|
|
234
|
+
.bar nav a[aria-current='page'] {
|
|
81
235
|
color: var(--foreground);
|
|
236
|
+
background: var(--accent);
|
|
82
237
|
}
|
|
83
238
|
main {
|
|
84
|
-
|
|
85
|
-
padding-
|
|
239
|
+
flex: 1;
|
|
240
|
+
padding-block: 2rem 4rem;
|
|
241
|
+
}
|
|
242
|
+
@media (min-width: 40rem) {
|
|
243
|
+
main {
|
|
244
|
+
padding-block: 2.5rem 5rem;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
footer {
|
|
248
|
+
border-top: 1px solid var(--border);
|
|
249
|
+
padding-block: 1.5rem 2rem;
|
|
250
|
+
font-size: 0.75rem;
|
|
251
|
+
color: var(--muted-foreground);
|
|
252
|
+
}
|
|
253
|
+
footer p {
|
|
254
|
+
max-width: 42rem;
|
|
255
|
+
}
|
|
256
|
+
footer a,
|
|
257
|
+
.link {
|
|
258
|
+
color: var(--foreground);
|
|
259
|
+
text-underline-offset: 3px;
|
|
260
|
+
text-decoration-color: var(--border);
|
|
261
|
+
transition: text-decoration-color var(--duration-quick) ease;
|
|
262
|
+
}
|
|
263
|
+
footer a:hover,
|
|
264
|
+
.link:hover {
|
|
265
|
+
text-decoration-color: currentColor;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ── Page header (PageHeader, PageTitle) ──────────────── */
|
|
269
|
+
.page-header {
|
|
270
|
+
margin-bottom: 2rem;
|
|
271
|
+
}
|
|
272
|
+
@media (min-width: 40rem) {
|
|
273
|
+
.page-header {
|
|
274
|
+
margin-bottom: 2.5rem;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
.back {
|
|
278
|
+
display: flex;
|
|
279
|
+
min-width: 0;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 0.5rem;
|
|
282
|
+
margin-bottom: 1rem;
|
|
283
|
+
font-size: 0.875rem;
|
|
284
|
+
color: var(--muted-foreground);
|
|
285
|
+
}
|
|
286
|
+
.back a {
|
|
287
|
+
display: inline-flex;
|
|
288
|
+
flex-shrink: 0;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: 0.375rem;
|
|
291
|
+
text-decoration: none;
|
|
292
|
+
transition: color var(--duration-quick) ease;
|
|
293
|
+
}
|
|
294
|
+
.back a:hover {
|
|
295
|
+
color: var(--foreground);
|
|
296
|
+
}
|
|
297
|
+
.back .slash {
|
|
298
|
+
color: var(--border);
|
|
299
|
+
}
|
|
300
|
+
.header-grid {
|
|
301
|
+
display: flex;
|
|
302
|
+
flex-wrap: wrap;
|
|
303
|
+
align-items: flex-start;
|
|
304
|
+
justify-content: space-between;
|
|
305
|
+
gap: 1rem 1.5rem;
|
|
306
|
+
}
|
|
307
|
+
.header-main {
|
|
308
|
+
min-width: 0;
|
|
309
|
+
display: grid;
|
|
310
|
+
gap: 0.5rem;
|
|
311
|
+
}
|
|
312
|
+
.title-row {
|
|
313
|
+
display: flex;
|
|
314
|
+
min-width: 0;
|
|
315
|
+
flex-wrap: wrap;
|
|
316
|
+
align-items: center;
|
|
317
|
+
gap: 0.375rem 0.75rem;
|
|
318
|
+
}
|
|
319
|
+
.page-title {
|
|
320
|
+
font-family: var(--font-display);
|
|
321
|
+
font-size: 1.875rem;
|
|
322
|
+
font-weight: 500;
|
|
323
|
+
letter-spacing: -0.035em;
|
|
324
|
+
line-height: 1.1;
|
|
325
|
+
overflow-wrap: anywhere;
|
|
326
|
+
}
|
|
327
|
+
@media (min-width: 40rem) {
|
|
328
|
+
.page-title {
|
|
329
|
+
font-size: 2.25rem;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
.description {
|
|
333
|
+
max-width: 42rem;
|
|
334
|
+
color: var(--muted-foreground);
|
|
335
|
+
}
|
|
336
|
+
@media (min-width: 40rem) {
|
|
337
|
+
.description {
|
|
338
|
+
font-size: 1rem;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
.actions {
|
|
342
|
+
display: flex;
|
|
343
|
+
max-width: 100%;
|
|
344
|
+
flex-shrink: 0;
|
|
345
|
+
flex-wrap: wrap;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 0.5rem;
|
|
348
|
+
}
|
|
349
|
+
.chips {
|
|
350
|
+
display: flex;
|
|
351
|
+
max-width: 42rem;
|
|
352
|
+
flex-wrap: wrap;
|
|
353
|
+
align-items: center;
|
|
354
|
+
gap: 0.375rem;
|
|
355
|
+
padding-top: 0.25rem;
|
|
356
|
+
}
|
|
357
|
+
.chips > li {
|
|
358
|
+
min-width: 0;
|
|
359
|
+
max-width: 100%;
|
|
360
|
+
}
|
|
361
|
+
/* The outline badge at its large size, in the quiet ink: a fact, not a state. */
|
|
362
|
+
.chip {
|
|
363
|
+
display: inline-flex;
|
|
364
|
+
max-width: 100%;
|
|
365
|
+
height: 1.5rem;
|
|
366
|
+
align-items: center;
|
|
367
|
+
gap: 0.25rem;
|
|
368
|
+
padding: 0.125rem 0.625rem;
|
|
369
|
+
border: 1px solid var(--border);
|
|
370
|
+
border-radius: 9999px;
|
|
371
|
+
font-size: 0.6875rem;
|
|
372
|
+
color: var(--muted-foreground);
|
|
373
|
+
text-decoration: none;
|
|
374
|
+
white-space: nowrap;
|
|
375
|
+
transition:
|
|
376
|
+
color var(--duration-quick) var(--ease-snap),
|
|
377
|
+
border-color var(--duration-quick) var(--ease-snap);
|
|
378
|
+
}
|
|
379
|
+
.chip svg.icon {
|
|
380
|
+
width: 0.75rem;
|
|
381
|
+
height: 0.75rem;
|
|
382
|
+
}
|
|
383
|
+
a.chip:hover {
|
|
384
|
+
color: var(--foreground);
|
|
385
|
+
border-color: color-mix(in oklch, var(--foreground) 25%, transparent);
|
|
386
|
+
}
|
|
387
|
+
@media (min-width: 40rem) {
|
|
388
|
+
.chips {
|
|
389
|
+
gap: 0.5rem;
|
|
390
|
+
}
|
|
391
|
+
.chip {
|
|
392
|
+
height: 1.75rem;
|
|
393
|
+
gap: 0.375rem;
|
|
394
|
+
padding: 0.25rem 0.75rem;
|
|
395
|
+
font-size: 0.75rem;
|
|
396
|
+
}
|
|
397
|
+
.chip svg.icon {
|
|
398
|
+
width: 0.875rem;
|
|
399
|
+
height: 0.875rem;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* ── Badge ────────────────────────────────────────────── */
|
|
404
|
+
.badge {
|
|
405
|
+
display: inline-flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
gap: 0.25rem;
|
|
408
|
+
padding: 0.125rem 0.625rem;
|
|
409
|
+
border: 1px solid transparent;
|
|
410
|
+
border-radius: 9999px;
|
|
411
|
+
font-size: 0.6875rem;
|
|
412
|
+
font-weight: 500;
|
|
413
|
+
line-height: 1.4;
|
|
414
|
+
white-space: nowrap;
|
|
415
|
+
}
|
|
416
|
+
.badge svg.icon {
|
|
417
|
+
width: 0.75rem;
|
|
418
|
+
height: 0.75rem;
|
|
419
|
+
}
|
|
420
|
+
.badge.lg {
|
|
421
|
+
gap: 0.375rem;
|
|
422
|
+
padding: 0.25rem 0.75rem;
|
|
423
|
+
font-size: 0.75rem;
|
|
424
|
+
}
|
|
425
|
+
.badge.lg svg.icon {
|
|
426
|
+
width: 0.875rem;
|
|
427
|
+
height: 0.875rem;
|
|
428
|
+
}
|
|
429
|
+
.badge.sm {
|
|
430
|
+
padding: 1px 0.5rem;
|
|
431
|
+
font-size: 0.625rem;
|
|
432
|
+
}
|
|
433
|
+
.badge.outline {
|
|
434
|
+
border-color: var(--border);
|
|
435
|
+
color: var(--foreground);
|
|
436
|
+
}
|
|
437
|
+
.badge.secondary {
|
|
438
|
+
background: var(--secondary);
|
|
439
|
+
color: var(--secondary-foreground);
|
|
440
|
+
}
|
|
441
|
+
/* Skipped, waiting and stopped: the product's muted override. */
|
|
442
|
+
.badge.quiet {
|
|
443
|
+
background: var(--muted);
|
|
444
|
+
color: var(--muted-foreground);
|
|
445
|
+
}
|
|
446
|
+
.badge.success {
|
|
447
|
+
border-color: color-mix(in oklch, var(--success) 35%, transparent);
|
|
448
|
+
background: color-mix(in oklch, var(--success) 8%, transparent);
|
|
449
|
+
color: var(--success-ink);
|
|
450
|
+
}
|
|
451
|
+
.badge.warning {
|
|
452
|
+
border-color: color-mix(in oklch, var(--warning) 40%, transparent);
|
|
453
|
+
background: color-mix(in oklch, var(--warning) 10%, transparent);
|
|
454
|
+
color: var(--warning-ink);
|
|
455
|
+
}
|
|
456
|
+
.badge.destructive {
|
|
457
|
+
border-color: color-mix(in oklch, var(--destructive) 30%, transparent);
|
|
458
|
+
background: color-mix(in oklch, var(--destructive) 8%, transparent);
|
|
459
|
+
color: var(--destructive);
|
|
460
|
+
}
|
|
461
|
+
.badge.arc {
|
|
462
|
+
border-color: color-mix(in oklch, var(--primary) 25%, transparent);
|
|
463
|
+
background: color-mix(in oklch, var(--primary) 8%, transparent);
|
|
464
|
+
color: var(--primary);
|
|
465
|
+
}
|
|
466
|
+
/* The `.stamp` motif pressed off square, in the colour it is given. */
|
|
467
|
+
.badge.stamp {
|
|
468
|
+
padding: 0.25rem 0.75rem;
|
|
469
|
+
border: 1.25px solid currentColor;
|
|
470
|
+
background: transparent;
|
|
471
|
+
font-family: var(--font-mono);
|
|
472
|
+
font-size: 0.75rem;
|
|
473
|
+
font-weight: 400;
|
|
474
|
+
letter-spacing: 0.04em;
|
|
475
|
+
text-transform: uppercase;
|
|
476
|
+
transform: rotate(-1.5deg);
|
|
477
|
+
opacity: 0.9;
|
|
86
478
|
}
|
|
87
|
-
|
|
88
|
-
|
|
479
|
+
.bulb {
|
|
480
|
+
display: inline-block;
|
|
481
|
+
width: 0.375rem;
|
|
482
|
+
height: 0.375rem;
|
|
483
|
+
flex-shrink: 0;
|
|
484
|
+
border-radius: 9999px;
|
|
485
|
+
background: currentColor;
|
|
486
|
+
}
|
|
487
|
+
/* Ambient status loops (this and .chase) run past the 360ms ceiling on
|
|
488
|
+
purpose: they are motion.css's --animate-tally and .marquee-chase, not
|
|
489
|
+
transitions, and they stop under prefers-reduced-motion. */
|
|
490
|
+
.bulb.live {
|
|
491
|
+
animation: marquee-dot 1.6s ease-in-out infinite;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* ── Button ───────────────────────────────────────────── */
|
|
495
|
+
.btn {
|
|
496
|
+
position: relative;
|
|
497
|
+
display: inline-flex;
|
|
498
|
+
height: 2rem;
|
|
499
|
+
flex-shrink: 0;
|
|
500
|
+
align-items: center;
|
|
501
|
+
justify-content: center;
|
|
502
|
+
gap: 0.375rem;
|
|
503
|
+
padding: 0 0.75rem;
|
|
504
|
+
border: 1px solid transparent;
|
|
505
|
+
border-radius: var(--radius-md);
|
|
506
|
+
font: 500 0.8125rem/1 var(--font-sans);
|
|
507
|
+
letter-spacing: -0.003em;
|
|
508
|
+
text-decoration: none;
|
|
509
|
+
white-space: nowrap;
|
|
510
|
+
cursor: pointer;
|
|
511
|
+
transition:
|
|
512
|
+
transform var(--duration-press) var(--ease-snap),
|
|
513
|
+
background-color var(--duration-quick) ease,
|
|
514
|
+
border-color var(--duration-quick) ease,
|
|
515
|
+
color var(--duration-quick) ease,
|
|
516
|
+
box-shadow var(--duration-quick) ease;
|
|
517
|
+
}
|
|
518
|
+
.btn:has(> svg) {
|
|
519
|
+
padding-inline: 0.625rem 0.75rem;
|
|
520
|
+
}
|
|
521
|
+
/* The shared press: 1px into the page, never for a keyboard press. */
|
|
522
|
+
.btn:active:not(:focus-visible) {
|
|
523
|
+
transform: translateY(1px) scale(0.985);
|
|
524
|
+
}
|
|
525
|
+
.btn:disabled {
|
|
526
|
+
pointer-events: none;
|
|
527
|
+
cursor: default;
|
|
528
|
+
opacity: 0.45;
|
|
529
|
+
}
|
|
530
|
+
/* Pending stays readable: its label is the only word that Stop took. */
|
|
531
|
+
.btn[data-pending] {
|
|
532
|
+
cursor: default;
|
|
533
|
+
box-shadow: none;
|
|
534
|
+
color: var(--muted-foreground);
|
|
535
|
+
}
|
|
536
|
+
.btn.outline {
|
|
537
|
+
border-color: var(--border);
|
|
538
|
+
background: var(--background);
|
|
539
|
+
color: var(--foreground);
|
|
540
|
+
}
|
|
541
|
+
.btn.outline:hover {
|
|
542
|
+
border-color: color-mix(in oklch, var(--foreground) 30%, transparent);
|
|
543
|
+
background: var(--accent);
|
|
544
|
+
color: var(--accent-foreground);
|
|
545
|
+
}
|
|
546
|
+
@media (prefers-color-scheme: dark) {
|
|
547
|
+
.btn.outline {
|
|
548
|
+
background: color-mix(in oklab, var(--input) 15%, transparent);
|
|
549
|
+
}
|
|
550
|
+
.btn.outline:hover {
|
|
551
|
+
background: color-mix(in oklab, var(--input) 30%, transparent);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/* Arc: the one light. A fill lit from the top left, a specular edge,
|
|
555
|
+
a shaded foot and a short cast. Hover brings the lamp up. */
|
|
556
|
+
.btn.primary {
|
|
557
|
+
isolation: isolate;
|
|
558
|
+
overflow: hidden;
|
|
559
|
+
background-color: var(--primary);
|
|
560
|
+
color: var(--primary-foreground);
|
|
561
|
+
box-shadow: var(--button-shadow);
|
|
562
|
+
}
|
|
563
|
+
.btn.primary::before {
|
|
564
|
+
content: '';
|
|
565
|
+
position: absolute;
|
|
566
|
+
inset: 0;
|
|
567
|
+
z-index: -1;
|
|
568
|
+
border-radius: inherit;
|
|
569
|
+
pointer-events: none;
|
|
570
|
+
background-image: radial-gradient(
|
|
571
|
+
130% 80% at 0% 0%,
|
|
572
|
+
var(--button-lamp) 0%,
|
|
573
|
+
color-mix(in oklab, var(--button-lamp) 88%, transparent) 10%,
|
|
574
|
+
color-mix(in oklab, var(--button-lamp) 56%, transparent) 25%,
|
|
575
|
+
color-mix(in oklab, var(--button-lamp) 22%, transparent) 50%,
|
|
576
|
+
color-mix(in oklab, var(--button-lamp) 6%, transparent) 65%,
|
|
577
|
+
transparent 80%
|
|
578
|
+
);
|
|
579
|
+
opacity: 0.4;
|
|
580
|
+
transition: opacity var(--duration-quick) ease;
|
|
581
|
+
}
|
|
582
|
+
.btn.primary::after {
|
|
583
|
+
content: '';
|
|
584
|
+
position: absolute;
|
|
585
|
+
inset: 0;
|
|
586
|
+
border-radius: inherit;
|
|
587
|
+
pointer-events: none;
|
|
588
|
+
box-shadow:
|
|
589
|
+
inset 1px 1px 0 var(--button-specular),
|
|
590
|
+
inset -1px -1px 0 var(--button-shade),
|
|
591
|
+
inset 0 0 0 1px var(--button-rim);
|
|
592
|
+
transition: box-shadow var(--duration-quick) ease;
|
|
593
|
+
}
|
|
594
|
+
@media (hover: hover) {
|
|
595
|
+
.btn.primary:hover {
|
|
596
|
+
background-color: var(--button-fill-lit);
|
|
597
|
+
box-shadow: var(--button-shadow-lit);
|
|
598
|
+
}
|
|
599
|
+
.btn.primary:hover::before {
|
|
600
|
+
opacity: 1;
|
|
601
|
+
transition: opacity var(--duration-quick) var(--ease-snap);
|
|
602
|
+
}
|
|
603
|
+
.btn.primary:hover::after {
|
|
604
|
+
box-shadow:
|
|
605
|
+
inset 1px 1px 0 var(--button-specular-lit),
|
|
606
|
+
inset -1px -1px 0 var(--button-shade),
|
|
607
|
+
inset 0 0 0 1px var(--button-rim);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* ── Sections, groups and rows ────────────────────────── */
|
|
612
|
+
.stack > * + * {
|
|
613
|
+
margin-top: 2.5rem;
|
|
614
|
+
}
|
|
615
|
+
@media (min-width: 40rem) {
|
|
616
|
+
.stack > * + * {
|
|
617
|
+
margin-top: 3rem;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
.section > * + * {
|
|
621
|
+
margin-top: 0.75rem;
|
|
622
|
+
}
|
|
623
|
+
.section-title {
|
|
624
|
+
display: flex;
|
|
625
|
+
align-items: center;
|
|
626
|
+
gap: 0.5rem;
|
|
627
|
+
font-size: 0.875rem;
|
|
89
628
|
letter-spacing: -0.025em;
|
|
90
|
-
margin: 0 0 0.25rem;
|
|
91
629
|
}
|
|
92
|
-
|
|
630
|
+
.section-title.large {
|
|
93
631
|
font-size: 1rem;
|
|
94
|
-
|
|
632
|
+
letter-spacing: -0.012em;
|
|
95
633
|
}
|
|
96
|
-
|
|
634
|
+
@media (min-width: 40rem) {
|
|
635
|
+
.section-title.large {
|
|
636
|
+
font-size: 1.125rem;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
.count {
|
|
640
|
+
font-family: var(--font-mono);
|
|
641
|
+
font-size: 0.75rem;
|
|
642
|
+
font-weight: 400;
|
|
643
|
+
letter-spacing: 0;
|
|
644
|
+
font-variant-numeric: tabular-nums;
|
|
97
645
|
color: var(--muted-foreground);
|
|
98
646
|
}
|
|
99
|
-
.
|
|
647
|
+
.section-note {
|
|
648
|
+
font-size: 0.875rem;
|
|
649
|
+
color: var(--muted-foreground);
|
|
650
|
+
}
|
|
651
|
+
/* One group, rows split by hairlines at 60%: never a card per row. */
|
|
652
|
+
.group {
|
|
653
|
+
overflow: hidden;
|
|
654
|
+
border: 1px solid var(--border);
|
|
655
|
+
border-radius: var(--radius-lg);
|
|
656
|
+
}
|
|
657
|
+
.group.card {
|
|
100
658
|
background: var(--card);
|
|
659
|
+
}
|
|
660
|
+
.group > li + li,
|
|
661
|
+
.group > a + a,
|
|
662
|
+
.group > .row + .row {
|
|
663
|
+
border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
|
|
664
|
+
}
|
|
665
|
+
.card {
|
|
101
666
|
border: 1px solid var(--border);
|
|
102
|
-
border-radius: var(--radius);
|
|
667
|
+
border-radius: var(--radius-lg);
|
|
668
|
+
background: var(--card);
|
|
669
|
+
}
|
|
670
|
+
.overline {
|
|
671
|
+
font-size: 0.6875rem;
|
|
672
|
+
font-weight: 500;
|
|
673
|
+
line-height: 1;
|
|
674
|
+
letter-spacing: 0.12em;
|
|
675
|
+
text-transform: uppercase;
|
|
676
|
+
color: var(--muted-foreground);
|
|
103
677
|
}
|
|
104
|
-
|
|
105
|
-
|
|
678
|
+
|
|
679
|
+
/* Runs: a table at width, stacked on a phone. */
|
|
680
|
+
.runs-head,
|
|
681
|
+
.run-row {
|
|
106
682
|
display: grid;
|
|
107
|
-
grid-template-columns: 1fr auto;
|
|
683
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
108
684
|
gap: 0.25rem 1rem;
|
|
685
|
+
align-items: center;
|
|
109
686
|
padding: 0.75rem 1rem;
|
|
110
|
-
|
|
687
|
+
}
|
|
688
|
+
.runs-head {
|
|
689
|
+
display: none;
|
|
690
|
+
}
|
|
691
|
+
.run-row {
|
|
111
692
|
text-decoration: none;
|
|
693
|
+
transition: background-color var(--duration-quick) ease;
|
|
112
694
|
}
|
|
113
|
-
.
|
|
114
|
-
|
|
695
|
+
.run-row:hover {
|
|
696
|
+
background: color-mix(in oklch, var(--muted) 60%, transparent);
|
|
115
697
|
}
|
|
116
|
-
.
|
|
117
|
-
|
|
698
|
+
.run-name {
|
|
699
|
+
display: flex;
|
|
700
|
+
min-width: 0;
|
|
701
|
+
align-items: center;
|
|
702
|
+
gap: 0.5rem;
|
|
703
|
+
}
|
|
704
|
+
.run-name strong {
|
|
705
|
+
font-weight: 500;
|
|
706
|
+
}
|
|
707
|
+
.run-name > svg {
|
|
708
|
+
color: var(--muted-foreground);
|
|
118
709
|
}
|
|
119
|
-
.
|
|
710
|
+
.run-name > div {
|
|
711
|
+
min-width: 0;
|
|
712
|
+
}
|
|
713
|
+
.run-id {
|
|
714
|
+
font-size: 0.75rem;
|
|
715
|
+
color: var(--muted-foreground);
|
|
716
|
+
}
|
|
717
|
+
.result {
|
|
120
718
|
display: flex;
|
|
121
719
|
flex-wrap: wrap;
|
|
122
|
-
gap: 0.5rem;
|
|
123
720
|
align-items: center;
|
|
124
|
-
|
|
125
|
-
|
|
721
|
+
justify-content: flex-end;
|
|
722
|
+
gap: 0.25rem 0.5rem;
|
|
126
723
|
}
|
|
127
|
-
.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
font-size: 12px;
|
|
132
|
-
line-height: 1.5rem;
|
|
133
|
-
border: 1px solid var(--border);
|
|
724
|
+
.tally {
|
|
725
|
+
font-family: var(--font-mono);
|
|
726
|
+
font-size: 0.75rem;
|
|
727
|
+
font-variant-numeric: tabular-nums;
|
|
134
728
|
color: var(--muted-foreground);
|
|
135
729
|
white-space: nowrap;
|
|
136
730
|
}
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
731
|
+
.facts {
|
|
732
|
+
grid-column: 1 / -1;
|
|
733
|
+
display: flex;
|
|
734
|
+
flex-wrap: wrap;
|
|
735
|
+
align-items: center;
|
|
736
|
+
gap: 0.25rem 1rem;
|
|
737
|
+
padding-left: 1.5rem;
|
|
738
|
+
font-size: 0.75rem;
|
|
739
|
+
color: var(--muted-foreground);
|
|
140
740
|
}
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
741
|
+
.fact {
|
|
742
|
+
display: inline-flex;
|
|
743
|
+
min-width: 0;
|
|
744
|
+
align-items: center;
|
|
745
|
+
gap: 0.375rem;
|
|
144
746
|
}
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
747
|
+
.fact svg.icon {
|
|
748
|
+
width: 0.875rem;
|
|
749
|
+
height: 0.875rem;
|
|
148
750
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
751
|
+
@media (min-width: 48rem) {
|
|
752
|
+
.runs-head,
|
|
753
|
+
.run-row {
|
|
754
|
+
grid-template-columns: minmax(0, 1fr) 12rem 9rem 10rem 7.5rem;
|
|
755
|
+
}
|
|
756
|
+
.runs-head {
|
|
757
|
+
display: grid;
|
|
758
|
+
height: 2.5rem;
|
|
759
|
+
padding-block: 0;
|
|
760
|
+
border-bottom: 1px solid var(--border);
|
|
761
|
+
font-size: 0.6875rem;
|
|
762
|
+
font-weight: 600;
|
|
763
|
+
letter-spacing: 0.14em;
|
|
764
|
+
text-transform: uppercase;
|
|
765
|
+
color: var(--muted-foreground);
|
|
766
|
+
}
|
|
767
|
+
.group > .runs-head + .run-row {
|
|
768
|
+
border-top: 0;
|
|
769
|
+
}
|
|
770
|
+
.result {
|
|
771
|
+
justify-content: flex-start;
|
|
772
|
+
}
|
|
773
|
+
.facts {
|
|
774
|
+
display: contents;
|
|
775
|
+
}
|
|
152
776
|
}
|
|
153
|
-
|
|
777
|
+
|
|
778
|
+
/* A check, as the run report draws it. */
|
|
779
|
+
.check {
|
|
780
|
+
padding: 0.75rem 1rem;
|
|
781
|
+
}
|
|
782
|
+
.check-head {
|
|
783
|
+
display: flex;
|
|
784
|
+
width: 100%;
|
|
785
|
+
align-items: flex-start;
|
|
786
|
+
gap: 0.75rem;
|
|
787
|
+
padding: 0;
|
|
788
|
+
border: 0;
|
|
789
|
+
background: none;
|
|
790
|
+
color: inherit;
|
|
154
791
|
font: inherit;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
background: var(--card);
|
|
159
|
-
color: var(--foreground);
|
|
792
|
+
text-align: left;
|
|
793
|
+
}
|
|
794
|
+
button.check-head {
|
|
160
795
|
cursor: pointer;
|
|
161
796
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
border-color: var(--primary);
|
|
165
|
-
color: var(--primary-foreground);
|
|
797
|
+
.check-head > svg.icon {
|
|
798
|
+
margin-top: 0.125rem;
|
|
166
799
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
800
|
+
.check-body {
|
|
801
|
+
display: block;
|
|
802
|
+
min-width: 0;
|
|
803
|
+
flex: 1;
|
|
170
804
|
}
|
|
171
|
-
.
|
|
805
|
+
.check-desc {
|
|
806
|
+
display: block;
|
|
807
|
+
font-weight: 500;
|
|
808
|
+
line-height: 1.375;
|
|
809
|
+
}
|
|
810
|
+
.check.waiting .check-desc {
|
|
811
|
+
font-weight: 400;
|
|
812
|
+
color: var(--muted-foreground);
|
|
813
|
+
}
|
|
814
|
+
.check-meta {
|
|
172
815
|
display: flex;
|
|
816
|
+
flex-wrap: wrap;
|
|
817
|
+
align-items: center;
|
|
818
|
+
gap: 0.375rem;
|
|
819
|
+
margin-top: 0.375rem;
|
|
820
|
+
}
|
|
821
|
+
.check-meta code {
|
|
822
|
+
font-size: 0.6875rem;
|
|
823
|
+
color: var(--muted-foreground);
|
|
824
|
+
}
|
|
825
|
+
.check-meta .badge {
|
|
826
|
+
font-size: 0.625rem;
|
|
827
|
+
}
|
|
828
|
+
.check-side {
|
|
829
|
+
display: flex;
|
|
830
|
+
flex-shrink: 0;
|
|
831
|
+
align-items: center;
|
|
173
832
|
gap: 0.5rem;
|
|
174
|
-
margin-top: 1rem;
|
|
175
833
|
}
|
|
176
|
-
.
|
|
834
|
+
.chevron {
|
|
835
|
+
color: var(--muted-foreground);
|
|
836
|
+
}
|
|
837
|
+
.open .chevron {
|
|
838
|
+
transform: rotate(180deg);
|
|
839
|
+
}
|
|
840
|
+
.check-detail {
|
|
177
841
|
display: grid;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
842
|
+
gap: 0.75rem;
|
|
843
|
+
margin-top: 0.5rem;
|
|
844
|
+
padding-left: 1.75rem;
|
|
181
845
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
grid-template-columns: 1fr;
|
|
185
|
-
}
|
|
846
|
+
.evidence {
|
|
847
|
+
color: var(--muted-foreground);
|
|
186
848
|
}
|
|
187
|
-
.
|
|
188
|
-
|
|
849
|
+
.evidence.observed {
|
|
850
|
+
color: var(--foreground);
|
|
851
|
+
}
|
|
852
|
+
.cited {
|
|
853
|
+
font-size: 0.75rem;
|
|
854
|
+
color: var(--muted-foreground);
|
|
855
|
+
overflow-wrap: anywhere;
|
|
856
|
+
}
|
|
857
|
+
.shot {
|
|
189
858
|
display: block;
|
|
190
|
-
|
|
859
|
+
width: fit-content;
|
|
860
|
+
max-width: min(100%, 36rem);
|
|
861
|
+
overflow: hidden;
|
|
191
862
|
border: 1px solid var(--border);
|
|
863
|
+
border-radius: var(--radius-md);
|
|
192
864
|
background: var(--muted);
|
|
193
865
|
}
|
|
194
|
-
.
|
|
866
|
+
.shot img {
|
|
867
|
+
display: block;
|
|
868
|
+
max-width: 100%;
|
|
869
|
+
max-height: 20rem;
|
|
870
|
+
width: auto;
|
|
871
|
+
object-fit: contain;
|
|
872
|
+
}
|
|
873
|
+
.tone-passed {
|
|
874
|
+
color: var(--success-ink);
|
|
875
|
+
}
|
|
876
|
+
.tone-failed {
|
|
877
|
+
color: var(--destructive);
|
|
878
|
+
}
|
|
879
|
+
.tone-error {
|
|
880
|
+
color: var(--warning-ink);
|
|
881
|
+
}
|
|
882
|
+
.tone-informational {
|
|
883
|
+
color: var(--primary);
|
|
884
|
+
}
|
|
885
|
+
.tone-skipped,
|
|
886
|
+
.tone-waiting {
|
|
887
|
+
color: var(--muted-foreground);
|
|
888
|
+
}
|
|
889
|
+
.tone-waiting {
|
|
890
|
+
opacity: 0.6;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/* The run's score, as RunSummary draws it. */
|
|
894
|
+
.summary {
|
|
195
895
|
padding: 1rem;
|
|
196
|
-
border-top: 1px solid var(--border);
|
|
197
896
|
}
|
|
198
|
-
|
|
199
|
-
|
|
897
|
+
@media (min-width: 40rem) {
|
|
898
|
+
.summary {
|
|
899
|
+
padding: 1.5rem;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
.summary-top {
|
|
903
|
+
display: flex;
|
|
904
|
+
flex-wrap: wrap;
|
|
905
|
+
align-items: flex-end;
|
|
906
|
+
justify-content: space-between;
|
|
907
|
+
gap: 0.75rem 1.5rem;
|
|
908
|
+
}
|
|
909
|
+
.figure {
|
|
910
|
+
margin-top: 0.25rem;
|
|
911
|
+
font-family: var(--font-mono);
|
|
912
|
+
font-size: 2.25rem;
|
|
913
|
+
line-height: 1;
|
|
914
|
+
letter-spacing: -0.012em;
|
|
915
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
200
916
|
}
|
|
201
|
-
|
|
202
|
-
|
|
917
|
+
@media (min-width: 40rem) {
|
|
918
|
+
.figure {
|
|
919
|
+
font-size: 3rem;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
.figure .of {
|
|
923
|
+
color: var(--muted-foreground);
|
|
924
|
+
}
|
|
925
|
+
.summary-line {
|
|
926
|
+
margin-top: 0.5rem;
|
|
927
|
+
font-size: 0.875rem;
|
|
928
|
+
color: var(--muted-foreground);
|
|
929
|
+
}
|
|
930
|
+
.verdict {
|
|
203
931
|
display: flex;
|
|
932
|
+
flex-wrap: wrap;
|
|
933
|
+
align-items: center;
|
|
204
934
|
gap: 0.5rem;
|
|
205
|
-
|
|
935
|
+
}
|
|
936
|
+
.meter {
|
|
937
|
+
display: flex;
|
|
938
|
+
height: 0.5rem;
|
|
939
|
+
margin-top: 1.25rem;
|
|
940
|
+
overflow: hidden;
|
|
941
|
+
border-radius: 9999px;
|
|
942
|
+
background: var(--muted);
|
|
943
|
+
}
|
|
944
|
+
.meter .passed {
|
|
945
|
+
background: var(--success);
|
|
946
|
+
}
|
|
947
|
+
.meter .failed {
|
|
948
|
+
background: var(--destructive);
|
|
949
|
+
}
|
|
950
|
+
.meter .error {
|
|
951
|
+
background: var(--warning);
|
|
952
|
+
}
|
|
953
|
+
.counts {
|
|
954
|
+
display: flex;
|
|
955
|
+
flex-wrap: wrap;
|
|
956
|
+
gap: 0.5rem;
|
|
957
|
+
margin-top: 1rem;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* Actor speech, verbatim: the sans, upright, behind a rule. */
|
|
961
|
+
.quote {
|
|
962
|
+
padding-left: 0.75rem;
|
|
963
|
+
border-left: 2px solid var(--border);
|
|
964
|
+
overflow-wrap: anywhere;
|
|
965
|
+
}
|
|
966
|
+
.notice {
|
|
967
|
+
display: flex;
|
|
968
|
+
align-items: flex-start;
|
|
969
|
+
gap: 0.5rem;
|
|
970
|
+
padding: 0.75rem;
|
|
971
|
+
border: 1px solid var(--border);
|
|
972
|
+
border-radius: var(--radius-lg);
|
|
973
|
+
font-size: 0.875rem;
|
|
974
|
+
overflow-wrap: anywhere;
|
|
975
|
+
}
|
|
976
|
+
.notice > svg.icon {
|
|
977
|
+
margin-top: 0.125rem;
|
|
978
|
+
}
|
|
979
|
+
.notice.destructive {
|
|
980
|
+
border-color: color-mix(in oklch, var(--destructive) 25%, transparent);
|
|
981
|
+
background: color-mix(in oklch, var(--destructive) 5%, transparent);
|
|
982
|
+
color: var(--destructive);
|
|
983
|
+
}
|
|
984
|
+
.notice.warning {
|
|
985
|
+
border-color: color-mix(in oklch, var(--warning) 40%, transparent);
|
|
986
|
+
background: color-mix(in oklch, var(--warning) 10%, transparent);
|
|
987
|
+
color: var(--warning-ink);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/* A run in progress. */
|
|
991
|
+
.live-card {
|
|
992
|
+
display: grid;
|
|
993
|
+
gap: 0.75rem;
|
|
994
|
+
padding: 1rem;
|
|
995
|
+
}
|
|
996
|
+
.live-head {
|
|
997
|
+
display: flex;
|
|
998
|
+
flex-wrap: wrap;
|
|
999
|
+
align-items: center;
|
|
206
1000
|
justify-content: space-between;
|
|
1001
|
+
gap: 0.5rem 0.75rem;
|
|
207
1002
|
}
|
|
208
|
-
.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
1003
|
+
.live-step {
|
|
1004
|
+
display: flex;
|
|
1005
|
+
min-width: 0;
|
|
1006
|
+
flex: 1 1 12rem;
|
|
1007
|
+
align-items: center;
|
|
1008
|
+
gap: 0.5rem;
|
|
1009
|
+
font-size: 0.875rem;
|
|
1010
|
+
color: var(--muted-foreground);
|
|
1011
|
+
}
|
|
1012
|
+
.live-side {
|
|
1013
|
+
display: flex;
|
|
1014
|
+
flex-shrink: 0;
|
|
1015
|
+
flex-wrap: wrap;
|
|
1016
|
+
align-items: center;
|
|
1017
|
+
justify-content: flex-end;
|
|
1018
|
+
gap: 0.5rem 0.75rem;
|
|
1019
|
+
}
|
|
1020
|
+
.stop-error {
|
|
1021
|
+
flex-basis: 100%;
|
|
1022
|
+
font-size: 0.75rem;
|
|
1023
|
+
color: var(--destructive);
|
|
1024
|
+
text-align: right;
|
|
1025
|
+
}
|
|
1026
|
+
.chase {
|
|
1027
|
+
display: inline-flex;
|
|
1028
|
+
flex-shrink: 0;
|
|
1029
|
+
align-items: center;
|
|
1030
|
+
gap: 0.3em;
|
|
1031
|
+
}
|
|
1032
|
+
.chase > span {
|
|
1033
|
+
display: block;
|
|
1034
|
+
width: 0.4em;
|
|
1035
|
+
height: 0.4em;
|
|
1036
|
+
border-radius: 9999px;
|
|
1037
|
+
background: currentColor;
|
|
1038
|
+
animation: sd-chase 1.1s var(--ease-glide) infinite both;
|
|
1039
|
+
animation-delay: calc(var(--bulb, 0) * 110ms);
|
|
1040
|
+
}
|
|
1041
|
+
.chase > span:nth-child(2) {
|
|
1042
|
+
--bulb: 1;
|
|
1043
|
+
}
|
|
1044
|
+
.chase > span:nth-child(3) {
|
|
1045
|
+
--bulb: 2;
|
|
1046
|
+
}
|
|
1047
|
+
.chase > span:nth-child(4) {
|
|
1048
|
+
--bulb: 3;
|
|
1049
|
+
}
|
|
1050
|
+
.chase > span:nth-child(5) {
|
|
1051
|
+
--bulb: 4;
|
|
1052
|
+
}
|
|
1053
|
+
.progress {
|
|
1054
|
+
height: 2px;
|
|
1055
|
+
overflow: hidden;
|
|
1056
|
+
border-radius: 9999px;
|
|
1057
|
+
background: var(--muted);
|
|
1058
|
+
}
|
|
1059
|
+
.progress > span {
|
|
1060
|
+
display: block;
|
|
1061
|
+
height: 100%;
|
|
1062
|
+
background: var(--primary);
|
|
1063
|
+
}
|
|
1064
|
+
.frame {
|
|
1065
|
+
display: block;
|
|
1066
|
+
width: 100%;
|
|
1067
|
+
max-height: 32rem;
|
|
1068
|
+
object-fit: contain;
|
|
1069
|
+
object-position: top;
|
|
213
1070
|
border: 1px solid var(--border);
|
|
1071
|
+
border-radius: var(--radius-md);
|
|
1072
|
+
background: var(--muted);
|
|
214
1073
|
}
|
|
215
|
-
|
|
216
|
-
.
|
|
1074
|
+
|
|
1075
|
+
/* Activity: the tool calls, in order. */
|
|
1076
|
+
.activity li {
|
|
1077
|
+
display: grid;
|
|
1078
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
1079
|
+
gap: 0.125rem 0.75rem;
|
|
1080
|
+
padding: 0.5rem 1rem;
|
|
1081
|
+
font-size: 0.8125rem;
|
|
1082
|
+
}
|
|
1083
|
+
.activity time {
|
|
217
1084
|
font-family: var(--font-mono);
|
|
218
|
-
font-size:
|
|
1085
|
+
font-size: 0.75rem;
|
|
1086
|
+
font-variant-numeric: tabular-nums;
|
|
1087
|
+
color: var(--muted-foreground);
|
|
1088
|
+
white-space: nowrap;
|
|
219
1089
|
}
|
|
220
|
-
.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
1090
|
+
.activity .text {
|
|
1091
|
+
grid-column: 1 / -1;
|
|
1092
|
+
white-space: pre-wrap;
|
|
1093
|
+
overflow-wrap: anywhere;
|
|
1094
|
+
color: var(--muted-foreground);
|
|
1095
|
+
}
|
|
1096
|
+
.activity summary {
|
|
1097
|
+
cursor: pointer;
|
|
1098
|
+
white-space: normal;
|
|
225
1099
|
}
|
|
226
|
-
|
|
1100
|
+
@media (min-width: 40rem) {
|
|
1101
|
+
.activity li {
|
|
1102
|
+
grid-template-columns: 5.5rem 8rem minmax(0, 1fr);
|
|
1103
|
+
}
|
|
1104
|
+
.activity .text {
|
|
1105
|
+
grid-column: auto;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/* Definitions. */
|
|
1110
|
+
.def {
|
|
227
1111
|
display: grid;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
1112
|
+
gap: 0.5rem;
|
|
1113
|
+
padding: 1rem;
|
|
1114
|
+
}
|
|
1115
|
+
.def-head {
|
|
1116
|
+
display: flex;
|
|
1117
|
+
flex-wrap: wrap;
|
|
1118
|
+
align-items: center;
|
|
1119
|
+
justify-content: space-between;
|
|
1120
|
+
gap: 0.5rem;
|
|
1121
|
+
}
|
|
1122
|
+
.def-name {
|
|
1123
|
+
display: flex;
|
|
1124
|
+
min-width: 0;
|
|
1125
|
+
align-items: center;
|
|
1126
|
+
gap: 0.5rem;
|
|
1127
|
+
font-weight: 500;
|
|
232
1128
|
}
|
|
233
|
-
.
|
|
234
|
-
|
|
1129
|
+
.def-name > svg {
|
|
1130
|
+
color: var(--muted-foreground);
|
|
235
1131
|
}
|
|
236
|
-
.
|
|
1132
|
+
.def-facts {
|
|
1133
|
+
display: flex;
|
|
1134
|
+
flex-wrap: wrap;
|
|
1135
|
+
align-items: center;
|
|
1136
|
+
gap: 0.25rem 1rem;
|
|
1137
|
+
padding-left: 1.5rem;
|
|
1138
|
+
font-size: 0.75rem;
|
|
1139
|
+
color: var(--muted-foreground);
|
|
237
1140
|
overflow-wrap: anywhere;
|
|
1141
|
+
}
|
|
1142
|
+
.def-body {
|
|
1143
|
+
padding-left: 1.5rem;
|
|
1144
|
+
color: var(--muted-foreground);
|
|
1145
|
+
}
|
|
1146
|
+
.def-checks {
|
|
1147
|
+
margin-left: 1.5rem;
|
|
1148
|
+
margin-top: 0.25rem;
|
|
1149
|
+
border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
|
|
1150
|
+
}
|
|
1151
|
+
.def-checks li {
|
|
1152
|
+
display: flex;
|
|
1153
|
+
flex-wrap: wrap;
|
|
1154
|
+
align-items: baseline;
|
|
1155
|
+
gap: 0.25rem 0.5rem;
|
|
1156
|
+
padding-block: 0.5rem;
|
|
1157
|
+
border-bottom: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
|
|
1158
|
+
}
|
|
1159
|
+
.def-checks li:last-child {
|
|
1160
|
+
border-bottom: 0;
|
|
1161
|
+
padding-bottom: 0;
|
|
1162
|
+
}
|
|
1163
|
+
.def-checks code {
|
|
1164
|
+
font-size: 0.6875rem;
|
|
1165
|
+
color: var(--muted-foreground);
|
|
1166
|
+
}
|
|
1167
|
+
.error-text {
|
|
1168
|
+
font-family: var(--font-mono);
|
|
1169
|
+
font-size: 0.75rem;
|
|
1170
|
+
color: var(--muted-foreground);
|
|
238
1171
|
white-space: pre-wrap;
|
|
1172
|
+
overflow-wrap: anywhere;
|
|
239
1173
|
}
|
|
1174
|
+
|
|
1175
|
+
/* EmptyState: a framed medallion, a display title, one action. */
|
|
240
1176
|
.empty {
|
|
241
|
-
|
|
1177
|
+
display: flex;
|
|
1178
|
+
min-height: 52vh;
|
|
1179
|
+
align-items: center;
|
|
1180
|
+
justify-content: center;
|
|
1181
|
+
padding-inline: 1.5rem;
|
|
242
1182
|
text-align: center;
|
|
243
1183
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
1184
|
+
.frame-marks {
|
|
1185
|
+
position: relative;
|
|
1186
|
+
width: 100%;
|
|
1187
|
+
max-width: 28rem;
|
|
1188
|
+
padding: 2.5rem 1.5rem;
|
|
1189
|
+
}
|
|
1190
|
+
.frame-marks::after {
|
|
1191
|
+
content: '';
|
|
1192
|
+
position: absolute;
|
|
1193
|
+
inset: 0.75rem;
|
|
1194
|
+
pointer-events: none;
|
|
1195
|
+
background:
|
|
1196
|
+
linear-gradient(currentColor, currentColor) top left / 1.25rem 1.25px no-repeat,
|
|
1197
|
+
linear-gradient(currentColor, currentColor) top left / 1.25px 1.25rem no-repeat,
|
|
1198
|
+
linear-gradient(currentColor, currentColor) bottom right / 1.25rem 1.25px no-repeat,
|
|
1199
|
+
linear-gradient(currentColor, currentColor) bottom right / 1.25px 1.25rem no-repeat;
|
|
1200
|
+
color: color-mix(in oklch, var(--muted-foreground) 45%, transparent);
|
|
1201
|
+
}
|
|
1202
|
+
.medallion {
|
|
1203
|
+
display: flex;
|
|
1204
|
+
width: 3.5rem;
|
|
1205
|
+
height: 3.5rem;
|
|
1206
|
+
align-items: center;
|
|
1207
|
+
justify-content: center;
|
|
1208
|
+
margin: 0 auto 1.25rem;
|
|
1209
|
+
border: 1px solid var(--border);
|
|
1210
|
+
border-radius: 9999px;
|
|
1211
|
+
background: color-mix(in oklch, var(--muted) 60%, transparent);
|
|
1212
|
+
color: var(--muted-foreground);
|
|
1213
|
+
}
|
|
1214
|
+
.medallion svg.icon {
|
|
1215
|
+
width: 1.5rem;
|
|
1216
|
+
height: 1.5rem;
|
|
1217
|
+
}
|
|
1218
|
+
.empty h2 {
|
|
1219
|
+
font-family: var(--font-display);
|
|
1220
|
+
font-size: 1.125rem;
|
|
1221
|
+
letter-spacing: -0.035em;
|
|
1222
|
+
line-height: 1.1;
|
|
1223
|
+
}
|
|
1224
|
+
.empty p {
|
|
1225
|
+
max-width: 24rem;
|
|
1226
|
+
margin: 0.5rem auto 0;
|
|
1227
|
+
font-size: 0.875rem;
|
|
1228
|
+
color: var(--muted-foreground);
|
|
1229
|
+
}
|
|
1230
|
+
.empty code {
|
|
1231
|
+
font-size: 0.8125rem;
|
|
1232
|
+
color: var(--foreground);
|
|
1233
|
+
}
|
|
1234
|
+
.inline-empty {
|
|
1235
|
+
display: flex;
|
|
1236
|
+
flex-direction: column;
|
|
1237
|
+
align-items: center;
|
|
1238
|
+
gap: 0.75rem;
|
|
1239
|
+
padding: 3rem 1rem;
|
|
1240
|
+
text-align: center;
|
|
1241
|
+
font-size: 0.875rem;
|
|
1242
|
+
color: var(--muted-foreground);
|
|
1243
|
+
}
|
|
1244
|
+
.inline-empty svg.icon {
|
|
1245
|
+
width: 2.5rem;
|
|
1246
|
+
height: 2.5rem;
|
|
1247
|
+
opacity: 0.5;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/* ── Motion (motion.css tokens and keyframes) ─────────── */
|
|
1251
|
+
.animate-page-in {
|
|
1252
|
+
animation: sd-rise var(--duration-slow) var(--ease-drawer) both;
|
|
1253
|
+
}
|
|
1254
|
+
.animate-rise {
|
|
1255
|
+
animation: sd-rise var(--duration-slow) var(--ease-snap) backwards;
|
|
1256
|
+
}
|
|
1257
|
+
.animate-stamp {
|
|
1258
|
+
animation: sd-stamp var(--duration-slow) var(--ease-snap) backwards;
|
|
1259
|
+
}
|
|
1260
|
+
@keyframes sd-rise {
|
|
1261
|
+
from {
|
|
1262
|
+
opacity: 0;
|
|
1263
|
+
transform: translateY(0.5rem);
|
|
1264
|
+
}
|
|
1265
|
+
to {
|
|
1266
|
+
opacity: 1;
|
|
1267
|
+
transform: none;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
@keyframes sd-stamp {
|
|
1271
|
+
0% {
|
|
1272
|
+
opacity: 0;
|
|
1273
|
+
transform: rotate(-6deg) scale(1.35);
|
|
1274
|
+
}
|
|
1275
|
+
60% {
|
|
1276
|
+
opacity: 0.9;
|
|
1277
|
+
transform: rotate(-1.5deg) scale(0.97);
|
|
1278
|
+
}
|
|
1279
|
+
100% {
|
|
1280
|
+
opacity: 0.9;
|
|
1281
|
+
transform: rotate(-1.5deg) scale(1);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
@keyframes sd-chase {
|
|
1285
|
+
0%,
|
|
1286
|
+
100% {
|
|
1287
|
+
opacity: 0.18;
|
|
1288
|
+
transform: scale(0.8);
|
|
1289
|
+
}
|
|
1290
|
+
20% {
|
|
1291
|
+
opacity: 1;
|
|
1292
|
+
transform: none;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
@keyframes marquee-dot {
|
|
1296
|
+
0%,
|
|
1297
|
+
100% {
|
|
1298
|
+
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 22%, transparent);
|
|
1299
|
+
opacity: 1;
|
|
1300
|
+
}
|
|
1301
|
+
50% {
|
|
1302
|
+
box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent);
|
|
1303
|
+
opacity: 0.7;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1307
|
+
*,
|
|
1308
|
+
*::before,
|
|
1309
|
+
*::after {
|
|
1310
|
+
animation-duration: 0.01ms !important;
|
|
1311
|
+
animation-iteration-count: 1 !important;
|
|
1312
|
+
animation-delay: 0s !important;
|
|
1313
|
+
transition-duration: 0.01ms !important;
|
|
1314
|
+
}
|
|
1315
|
+
.chase > span {
|
|
1316
|
+
opacity: 0.6;
|
|
1317
|
+
}
|
|
247
1318
|
}
|
|
248
1319
|
</style>
|
|
249
1320
|
</head>
|
|
250
1321
|
<body>
|
|
251
|
-
<header>
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
1322
|
+
<header class="bar">
|
|
1323
|
+
<div class="container">
|
|
1324
|
+
<span class="product">Stunt Double Local</span>
|
|
1325
|
+
<nav aria-label="Viewer">
|
|
1326
|
+
<a href="/" data-nav>Runs</a>
|
|
1327
|
+
<a href="/definitions" data-nav>Checklists and actors</a>
|
|
1328
|
+
</nav>
|
|
1329
|
+
</div>
|
|
257
1330
|
</header>
|
|
258
|
-
<main
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
1331
|
+
<main>
|
|
1332
|
+
<div class="container" id="app"></div>
|
|
1333
|
+
<p class="sr-only" id="status" role="status"></p>
|
|
1334
|
+
</main>
|
|
1335
|
+
<footer>
|
|
1336
|
+
<div class="container">
|
|
1337
|
+
<p>
|
|
1338
|
+
Local runs use the account Claude Code is signed in with and a browser on this machine,
|
|
1339
|
+
one at a time while your session is open. The cloud runs on schedules and in parallel,
|
|
1340
|
+
records every run and shares reports with your team.
|
|
1341
|
+
<a href="https://stuntdouble.io/pricing">See plans</a>
|
|
1342
|
+
</p>
|
|
1343
|
+
</div>
|
|
263
1344
|
</footer>
|
|
264
1345
|
<script>
|
|
265
1346
|
// Every value from a file is set as text, never as markup: verdicts and
|
|
@@ -275,45 +1356,238 @@
|
|
|
275
1356
|
if (child == null || child === false) continue;
|
|
276
1357
|
el.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
277
1358
|
}
|
|
278
|
-
return el;
|
|
1359
|
+
return el;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Continuity icons from StuntKit (@stunt-double/icons, svg/*.svg): the
|
|
1363
|
+
// drawing inside each 24 unit frame. Constant markup written here, never data.
|
|
1364
|
+
const ICONS = {
|
|
1365
|
+
actor:
|
|
1366
|
+
'<path d="M3 8V3h5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M8 21H3v-5"/><circle cx="12" cy="9.5" r="2.5"/><path d="M7.5 17.5a4.5 4.5 0 0 1 9 0"/>',
|
|
1367
|
+
'alert-triangle':
|
|
1368
|
+
'<path d="M12 3.5l9.5 16.5h-19z"/><path d="M12 9.5v4.5"/><rect x="10.75" y="16" width="2.5" height="2.5" fill="currentColor" stroke="none"/>',
|
|
1369
|
+
'arrow-left': '<path d="M20 12H4"/><path d="M10 6l-6 6 6 6"/>',
|
|
1370
|
+
calendar:
|
|
1371
|
+
'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18"/><path d="M8 3v4"/><path d="M16 3v4"/>',
|
|
1372
|
+
'check-circle': '<circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-5"/>',
|
|
1373
|
+
checklist:
|
|
1374
|
+
'<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8.5l1.5 1.5 3-3"/><path d="M14.5 9h2"/><rect x="8" y="13.5" width="3" height="3"/><path d="M14.5 15h2"/>',
|
|
1375
|
+
'chevron-down': '<path d="M5 9.5l7 7 7-7"/>',
|
|
1376
|
+
'circle-dashed':
|
|
1377
|
+
'<path d="M13.1 3.07A9 9 0 0 1 17.54 4.91"/><path d="M19.09 6.46A9 9 0 0 1 20.93 10.9"/><path d="M20.93 13.1A9 9 0 0 1 19.09 17.54"/><path d="M17.54 19.09A9 9 0 0 1 13.1 20.93"/><path d="M10.9 20.93A9 9 0 0 1 6.46 19.09"/><path d="M4.91 17.54A9 9 0 0 1 3.07 13.1"/><path d="M3.07 10.9A9 9 0 0 1 4.91 6.46"/><path d="M6.46 4.91A9 9 0 0 1 10.9 3.07"/>',
|
|
1378
|
+
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
|
|
1379
|
+
'external-link':
|
|
1380
|
+
'<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/><path d="M14 4h6v6"/><path d="M20 4l-9 9"/>',
|
|
1381
|
+
'git-branch':
|
|
1382
|
+
'<path d="M6 3v12.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="6" r="2.5"/><path d="M18 8.5A9.5 9.5 0 0 1 8.5 18"/>',
|
|
1383
|
+
globe:
|
|
1384
|
+
'<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
|
1385
|
+
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><rect x="10.75" y="6.75" width="2.5" height="2.5" fill="currentColor" stroke="none"/>',
|
|
1386
|
+
knowledge:
|
|
1387
|
+
'<rect x="6" y="3" width="14" height="18" rx="2"/><path d="M3.5 7.5h5"/><path d="M3.5 12h5"/><path d="M3.5 16.5h5"/><path d="M12 8h5"/><path d="M12 12h5"/>',
|
|
1388
|
+
'minus-circle': '<circle cx="12" cy="12" r="9"/><path d="M8 12h8"/>',
|
|
1389
|
+
monitor:
|
|
1390
|
+
'<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M12 16v4"/><path d="M8 20h8"/>',
|
|
1391
|
+
'octagon-x':
|
|
1392
|
+
'<path d="M8.25 3h7.5L21 8.25v7.5L15.75 21h-7.5L3 15.75v-7.5z"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/>',
|
|
1393
|
+
pending:
|
|
1394
|
+
'<rect x="3.75" y="10.75" width="2.5" height="2.5" fill="currentColor" stroke="none"/><rect x="10.75" y="10.75" width="2.5" height="2.5" fill="currentColor" stroke="none"/><rect x="17.75" y="10.75" width="2.5" height="2.5" fill="currentColor" stroke="none" opacity="0.35"/>',
|
|
1395
|
+
run: '<path d="M9 4.5l12 7.5-12 7.5z"/><path d="M3 9h3"/><path d="M3 15h3"/>',
|
|
1396
|
+
scale:
|
|
1397
|
+
'<path d="M12 4v17"/><path d="M8 21h8"/><path d="M5 7h14"/><path d="M5 7l-2.5 6h5z"/><path d="M19 7l-2.5 6h5z"/><path d="M2.5 13a2.5 2.5 0 0 0 5 0"/><path d="M16.5 13a2.5 2.5 0 0 0 5 0"/>',
|
|
1398
|
+
server:
|
|
1399
|
+
'<rect x="3" y="3" width="18" height="8" rx="2"/><rect x="3" y="13" width="18" height="8" rx="2"/><rect x="5.75" y="5.75" width="2.5" height="2.5" fill="currentColor" stroke="none"/><rect x="5.75" y="15.75" width="2.5" height="2.5" fill="currentColor" stroke="none"/>',
|
|
1400
|
+
stop: '<rect x="5" y="5" width="14" height="14"/>',
|
|
1401
|
+
timer:
|
|
1402
|
+
'<circle cx="12" cy="13.5" r="7.5"/><path d="M12 13.5l3-3"/><path d="M10 3h4"/><path d="M12 3v3"/>',
|
|
1403
|
+
'x-circle': '<circle cx="12" cy="12" r="9"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/>',
|
|
1404
|
+
};
|
|
1405
|
+
function icon(name, className) {
|
|
1406
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1407
|
+
for (const [key, value] of Object.entries({
|
|
1408
|
+
viewBox: '0 0 24 24',
|
|
1409
|
+
fill: 'none',
|
|
1410
|
+
stroke: 'currentColor',
|
|
1411
|
+
'stroke-width': '1.5',
|
|
1412
|
+
'stroke-linecap': 'square',
|
|
1413
|
+
'stroke-linejoin': 'miter',
|
|
1414
|
+
'aria-hidden': 'true',
|
|
1415
|
+
class: 'icon' + (className ? ' ' + className : ''),
|
|
1416
|
+
})) {
|
|
1417
|
+
svg.setAttribute(key, value);
|
|
1418
|
+
}
|
|
1419
|
+
svg.innerHTML = ICONS[name];
|
|
1420
|
+
return svg;
|
|
279
1421
|
}
|
|
280
1422
|
|
|
281
1423
|
const app = document.getElementById('app');
|
|
282
|
-
const when = (iso) => (iso ? new Date(iso).toLocaleString() : '');
|
|
283
1424
|
const fileUrl = (path) => '/files/' + path.split('/').map(encodeURIComponent).join('/');
|
|
284
1425
|
// A committed run.json is someone else's file: only web addresses become links.
|
|
285
1426
|
const webUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : null);
|
|
1427
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1428
|
+
|
|
1429
|
+
// Times in the reader's locale and zone, the way the product's
|
|
1430
|
+
// TimeDisplay and RelativeTime render them.
|
|
1431
|
+
const dateTime = new Intl.DateTimeFormat(undefined, {
|
|
1432
|
+
dateStyle: 'medium',
|
|
1433
|
+
timeStyle: 'short',
|
|
1434
|
+
});
|
|
1435
|
+
const clock = new Intl.DateTimeFormat(undefined, {
|
|
1436
|
+
hour: '2-digit',
|
|
1437
|
+
minute: '2-digit',
|
|
1438
|
+
second: '2-digit',
|
|
1439
|
+
});
|
|
1440
|
+
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
1441
|
+
const DIVISIONS = [
|
|
1442
|
+
[60, 'seconds'],
|
|
1443
|
+
[60, 'minutes'],
|
|
1444
|
+
[24, 'hours'],
|
|
1445
|
+
[7, 'days'],
|
|
1446
|
+
[4.34524, 'weeks'],
|
|
1447
|
+
[12, 'months'],
|
|
1448
|
+
[Infinity, 'years'],
|
|
1449
|
+
];
|
|
1450
|
+
function ago(date) {
|
|
1451
|
+
let amount = (date.getTime() - Date.now()) / 1000;
|
|
1452
|
+
if (Math.abs(amount) < 45) return 'just now';
|
|
1453
|
+
for (const [size, unit] of DIVISIONS) {
|
|
1454
|
+
if (Math.abs(amount) < size) return relative.format(Math.round(amount), unit);
|
|
1455
|
+
amount /= size;
|
|
1456
|
+
}
|
|
1457
|
+
return '';
|
|
1458
|
+
}
|
|
1459
|
+
function time(iso, style) {
|
|
1460
|
+
const date = new Date(iso);
|
|
1461
|
+
if (!iso || Number.isNaN(date.getTime())) return null;
|
|
1462
|
+
const exact = dateTime.format(date);
|
|
1463
|
+
if (style === 'relative') return h('time', { datetime: iso, title: exact }, ago(date));
|
|
1464
|
+
return h('time', { datetime: iso }, style === 'clock' ? clock.format(date) : exact);
|
|
1465
|
+
}
|
|
1466
|
+
const between = (start, end) => (new Date(end) - new Date(start)) / 1000;
|
|
1467
|
+
function took(seconds) {
|
|
1468
|
+
seconds = Math.round(seconds);
|
|
1469
|
+
if (!(seconds >= 0)) return null;
|
|
1470
|
+
if (seconds < 60) return seconds + 's';
|
|
1471
|
+
const minutes = Math.floor(seconds / 60);
|
|
1472
|
+
if (minutes < 60) return minutes + 'm ' + pad(seconds % 60) + 's';
|
|
1473
|
+
return Math.floor(minutes / 60) + 'h ' + pad(minutes % 60) + 'm';
|
|
1474
|
+
}
|
|
286
1475
|
|
|
287
|
-
|
|
288
|
-
|
|
1476
|
+
// The run's one headline badge: its verdict once it has one, otherwise
|
|
1477
|
+
// where the run got to.
|
|
1478
|
+
function runState(run) {
|
|
1479
|
+
if (run.status === 'running') return { label: 'Running', tone: 'arc', live: true };
|
|
289
1480
|
if (run.status === 'interrupted')
|
|
290
|
-
return
|
|
291
|
-
if (run.status === 'failed')
|
|
1481
|
+
return { label: 'Interrupted', tone: 'warning', icon: 'alert-triangle' };
|
|
1482
|
+
if (run.status === 'failed')
|
|
1483
|
+
return { label: 'Run failed', tone: 'destructive', icon: 'octagon-x' };
|
|
292
1484
|
const outcome = run.score && run.score.outcome;
|
|
293
1485
|
if (!outcome)
|
|
294
|
-
return
|
|
295
|
-
|
|
296
|
-
|
|
1486
|
+
return {
|
|
1487
|
+
label: run.status === 'stopped' ? 'Stopped' : run.status,
|
|
1488
|
+
tone: 'quiet',
|
|
1489
|
+
icon: run.status === 'stopped' ? 'stop' : null,
|
|
1490
|
+
};
|
|
1491
|
+
const [label, tone, mark] = {
|
|
1492
|
+
passed: ['Passed', 'success', 'check-circle'],
|
|
1493
|
+
failed: ['Failed', 'destructive', 'x-circle'],
|
|
1494
|
+
inconclusive: ['Inconclusive', 'warning', 'alert-triangle'],
|
|
1495
|
+
}[outcome];
|
|
1496
|
+
return {
|
|
1497
|
+
label: run.status === 'stopped' ? label + ', stopped' : label,
|
|
1498
|
+
tone,
|
|
1499
|
+
icon: mark,
|
|
1500
|
+
outcome,
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
function stateBadge(run, size) {
|
|
1504
|
+
const state = runState(run);
|
|
297
1505
|
return h(
|
|
298
1506
|
'span',
|
|
299
|
-
{ class: '
|
|
300
|
-
|
|
1507
|
+
{ class: 'badge ' + state.tone + (size ? ' ' + size : '') },
|
|
1508
|
+
state.live && h('span', { class: 'bulb live', 'aria-hidden': 'true' }),
|
|
1509
|
+
state.icon && icon(state.icon),
|
|
1510
|
+
state.label
|
|
301
1511
|
);
|
|
302
1512
|
}
|
|
303
1513
|
|
|
304
1514
|
const inUnit = (value, unit) => (unit == null ? value + '%' : value + ' ' + unit);
|
|
305
|
-
|
|
306
|
-
|
|
1515
|
+
// A checklist with no weights has no score, so the run reads as "3 of 4
|
|
1516
|
+
// passed", the same count the finish_run reply gives. Informational and
|
|
1517
|
+
// skipped checks are left out, as the scorer leaves them out.
|
|
1518
|
+
const tally = (run) => {
|
|
1519
|
+
if (run.tally) return run.tally;
|
|
1520
|
+
const checks = (run.checklist_snapshot && run.checklist_snapshot.checks) || [];
|
|
1521
|
+
const status = Object.fromEntries((run.results || []).map((r) => [r.check, r.status]));
|
|
1522
|
+
const scored = checks.filter(
|
|
1523
|
+
(c) => c.type !== 'informational' && status[c.key] !== 'skipped'
|
|
1524
|
+
);
|
|
1525
|
+
return {
|
|
1526
|
+
passed: scored.filter((c) => status[c.key] === 'passed').length,
|
|
1527
|
+
total: scored.length,
|
|
1528
|
+
};
|
|
1529
|
+
};
|
|
1530
|
+
const scoreText = (score, run) => {
|
|
1531
|
+
if (score && score.score != null) return inUnit(score.score, score.unit);
|
|
1532
|
+
if (!score || !run || !run.checklist_snapshot) return '';
|
|
1533
|
+
const t = tally(run);
|
|
1534
|
+
return t.total ? t.passed + ' of ' + t.total + ' passed' : '';
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
const runner = (run) =>
|
|
1538
|
+
run.runner.kind === 'cloud'
|
|
1539
|
+
? { icon: 'server', label: 'Cloud' }
|
|
1540
|
+
: { icon: 'monitor', label: 'Local, self-judged' };
|
|
1541
|
+
const checklistName = (run) =>
|
|
1542
|
+
run.checklist_name ||
|
|
1543
|
+
(run.checklist_snapshot && run.checklist_snapshot.name) ||
|
|
1544
|
+
run.checklist;
|
|
1545
|
+
const actorName = (run) => run.actor_name || run.actor;
|
|
1546
|
+
|
|
1547
|
+
// What each tool call did, in words. Page reads fold away: they are the
|
|
1548
|
+
// page's own text, long and full of refs.
|
|
1549
|
+
const ACTIVITY = {
|
|
1550
|
+
start_run: 'Started',
|
|
1551
|
+
navigate: 'Opened a page',
|
|
1552
|
+
read_page: 'Read the page',
|
|
1553
|
+
get_page_text: 'Read the page text',
|
|
1554
|
+
find: 'Looked for',
|
|
1555
|
+
click: 'Clicked',
|
|
1556
|
+
type: 'Typed',
|
|
1557
|
+
form_input: 'Filled a field',
|
|
1558
|
+
key: 'Pressed a key',
|
|
1559
|
+
scroll: 'Scrolled',
|
|
1560
|
+
screenshot: 'Took a screenshot',
|
|
1561
|
+
evaluate_check: 'Judged a check',
|
|
1562
|
+
finish_run: 'Finished',
|
|
1563
|
+
stop: 'Stopped',
|
|
1564
|
+
};
|
|
1565
|
+
const PAGE_READS = new Set(['read_page', 'get_page_text', 'find']);
|
|
307
1566
|
|
|
308
|
-
|
|
309
|
-
|
|
1567
|
+
// How long the actor worked: a resumed run's idle gap left out. Each
|
|
1568
|
+
// start_run opens a session that ends at its last line.
|
|
1569
|
+
function activeSeconds(run, activity) {
|
|
1570
|
+
let total = 0;
|
|
1571
|
+
let from = null;
|
|
1572
|
+
let last = null;
|
|
1573
|
+
for (const line of activity) {
|
|
1574
|
+
if (line.tool === 'start_run') {
|
|
1575
|
+
if (from && last) total += between(from, last);
|
|
1576
|
+
from = line.at;
|
|
1577
|
+
}
|
|
1578
|
+
last = line.at;
|
|
1579
|
+
}
|
|
1580
|
+
const end = run.finished_at || last;
|
|
1581
|
+
if (from && end) total += between(from, end);
|
|
1582
|
+
return activity.length ? total : between(run.started_at, run.finished_at);
|
|
1583
|
+
}
|
|
310
1584
|
|
|
311
1585
|
const RESULT = {
|
|
312
|
-
passed: ['Passed', '
|
|
313
|
-
failed: ['Failed', '
|
|
314
|
-
informational: ['Observed', ''],
|
|
315
|
-
error: ['Not judged', '
|
|
316
|
-
skipped: ['Skipped', ''],
|
|
1586
|
+
passed: ['Passed', 'success', 'check-circle'],
|
|
1587
|
+
failed: ['Failed', 'destructive', 'x-circle'],
|
|
1588
|
+
informational: ['Observed', 'arc', 'info'],
|
|
1589
|
+
error: ['Not judged', 'warning', 'alert-triangle'],
|
|
1590
|
+
skipped: ['Skipped', 'quiet', 'minus-circle'],
|
|
317
1591
|
};
|
|
318
1592
|
|
|
319
1593
|
async function getJson(url) {
|
|
@@ -322,59 +1596,196 @@
|
|
|
322
1596
|
return res.json();
|
|
323
1597
|
}
|
|
324
1598
|
|
|
1599
|
+
// A live region outside #app, so a redraw never re-announces the page.
|
|
1600
|
+
const status = document.getElementById('status');
|
|
1601
|
+
const announce = (text) => {
|
|
1602
|
+
if (status.textContent !== text) status.textContent = text;
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
// The label stays short so the header does not shift; the command is
|
|
1606
|
+
// the button's title.
|
|
325
1607
|
async function copy(text, button) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
1608
|
+
const label = button.lastChild;
|
|
1609
|
+
label.dataset.label ??= label.textContent;
|
|
1610
|
+
let done = 'Copied';
|
|
1611
|
+
try {
|
|
1612
|
+
await navigator.clipboard.writeText(text);
|
|
1613
|
+
} catch {
|
|
1614
|
+
done = 'Copy failed';
|
|
1615
|
+
}
|
|
1616
|
+
label.textContent = done;
|
|
1617
|
+
announce(done === 'Copied' ? 'Copied ' + text : done);
|
|
1618
|
+
clearTimeout(button.resetLabel);
|
|
1619
|
+
button.resetLabel = setTimeout(() => (label.textContent = label.dataset.label), 1600);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Set when a Stop is refused, shown beside the button on the next draw.
|
|
1623
|
+
let stopError = null;
|
|
1624
|
+
|
|
1625
|
+
function pageHeader({ back, title, badge, description, chips, actions }) {
|
|
1626
|
+
return h(
|
|
1627
|
+
'header',
|
|
1628
|
+
{ class: 'page-header' },
|
|
1629
|
+
back &&
|
|
1630
|
+
h(
|
|
1631
|
+
'div',
|
|
1632
|
+
{ class: 'back' },
|
|
1633
|
+
h('a', { href: back.href, 'data-nav': true }, icon('arrow-left'), back.label),
|
|
1634
|
+
back.trail && [
|
|
1635
|
+
h('span', { class: 'slash', 'aria-hidden': 'true' }, '/'),
|
|
1636
|
+
h('span', { class: 'truncate mono' }, back.trail),
|
|
1637
|
+
]
|
|
1638
|
+
),
|
|
1639
|
+
h(
|
|
1640
|
+
'div',
|
|
1641
|
+
{ class: 'header-grid' },
|
|
1642
|
+
h(
|
|
1643
|
+
'div',
|
|
1644
|
+
{ class: 'header-main' },
|
|
1645
|
+
h('div', { class: 'title-row' }, h('h1', { class: 'page-title' }, title), badge),
|
|
1646
|
+
description && h('p', { class: 'description' }, description),
|
|
1647
|
+
chips &&
|
|
1648
|
+
chips.length > 0 &&
|
|
1649
|
+
h(
|
|
1650
|
+
'ul',
|
|
1651
|
+
{ class: 'chips' },
|
|
1652
|
+
chips.map((chip) =>
|
|
1653
|
+
h(
|
|
1654
|
+
'li',
|
|
1655
|
+
{},
|
|
1656
|
+
h(
|
|
1657
|
+
chip.href ? 'a' : 'span',
|
|
1658
|
+
{
|
|
1659
|
+
class: 'chip',
|
|
1660
|
+
title: chip.title,
|
|
1661
|
+
href: chip.href,
|
|
1662
|
+
target: chip.href && '_blank',
|
|
1663
|
+
rel: chip.href && 'noopener noreferrer',
|
|
1664
|
+
},
|
|
1665
|
+
icon(chip.icon),
|
|
1666
|
+
h('span', { class: 'truncate' + (chip.mono ? ' mono' : '') }, chip.label)
|
|
1667
|
+
)
|
|
1668
|
+
)
|
|
1669
|
+
)
|
|
1670
|
+
)
|
|
1671
|
+
),
|
|
1672
|
+
actions && h('div', { class: 'actions' }, actions)
|
|
1673
|
+
)
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function emptyState(mark, title, ...description) {
|
|
1678
|
+
return h(
|
|
1679
|
+
'div',
|
|
1680
|
+
{ class: 'empty' },
|
|
1681
|
+
h(
|
|
1682
|
+
'div',
|
|
1683
|
+
{ class: 'frame-marks' },
|
|
1684
|
+
h('div', { class: 'medallion' }, icon(mark)),
|
|
1685
|
+
h('h2', {}, title),
|
|
1686
|
+
h('p', {}, description)
|
|
1687
|
+
)
|
|
1688
|
+
);
|
|
330
1689
|
}
|
|
331
1690
|
|
|
332
1691
|
async function renderRuns() {
|
|
333
1692
|
const runs = await getJson('/api/runs');
|
|
334
1693
|
if (runs.length === 0) {
|
|
335
1694
|
return [
|
|
336
|
-
h('h1', {}, 'Runs'),
|
|
337
|
-
|
|
338
|
-
'
|
|
339
|
-
|
|
340
|
-
'
|
|
1695
|
+
h('h1', { class: 'sr-only' }, 'Runs'),
|
|
1696
|
+
emptyState(
|
|
1697
|
+
'checklist',
|
|
1698
|
+
'No runs yet',
|
|
1699
|
+
'Type ',
|
|
341
1700
|
h('code', {}, '/stuntdouble run <checklist>'),
|
|
342
1701
|
' in Claude Code to start one.'
|
|
343
1702
|
),
|
|
344
1703
|
];
|
|
345
1704
|
}
|
|
346
1705
|
return [
|
|
347
|
-
|
|
348
|
-
|
|
1706
|
+
pageHeader({
|
|
1707
|
+
title: 'Runs',
|
|
1708
|
+
description: 'Local and cloud runs of this project, newest first.',
|
|
1709
|
+
}),
|
|
349
1710
|
h(
|
|
350
1711
|
'div',
|
|
351
|
-
{ class: 'card
|
|
1712
|
+
{ class: 'group card' },
|
|
1713
|
+
h(
|
|
1714
|
+
'div',
|
|
1715
|
+
{ class: 'runs-head', 'aria-hidden': 'true' },
|
|
1716
|
+
h('span', {}, 'Checklist'),
|
|
1717
|
+
h('span', {}, 'Result'),
|
|
1718
|
+
h('span', {}, 'Actor'),
|
|
1719
|
+
h('span', {}, 'Runner'),
|
|
1720
|
+
h('span', {}, 'Started')
|
|
1721
|
+
),
|
|
352
1722
|
runs.map((entry) =>
|
|
353
1723
|
'error' in entry
|
|
354
1724
|
? h(
|
|
355
1725
|
'div',
|
|
356
|
-
{},
|
|
357
|
-
h(
|
|
358
|
-
|
|
359
|
-
|
|
1726
|
+
{ class: 'run-row' },
|
|
1727
|
+
h(
|
|
1728
|
+
'span',
|
|
1729
|
+
{ class: 'run-name' },
|
|
1730
|
+
icon('alert-triangle', 'tone-error'),
|
|
1731
|
+
h('span', { class: 'truncate mono' }, entry.id)
|
|
1732
|
+
),
|
|
1733
|
+
h(
|
|
1734
|
+
'span',
|
|
1735
|
+
{ class: 'result' },
|
|
1736
|
+
h('span', { class: 'badge warning' }, 'Unreadable')
|
|
1737
|
+
),
|
|
1738
|
+
h('span', { class: 'facts error-text' }, entry.error)
|
|
360
1739
|
)
|
|
361
1740
|
: h(
|
|
362
1741
|
'a',
|
|
363
|
-
{
|
|
364
|
-
|
|
1742
|
+
{
|
|
1743
|
+
class: 'run-row',
|
|
1744
|
+
href: '/runs/' + encodeURIComponent(entry.id),
|
|
1745
|
+
'data-nav': true,
|
|
1746
|
+
},
|
|
365
1747
|
h(
|
|
366
1748
|
'span',
|
|
367
|
-
{},
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1749
|
+
{ class: 'run-name' },
|
|
1750
|
+
icon('checklist'),
|
|
1751
|
+
h(
|
|
1752
|
+
'div',
|
|
1753
|
+
{},
|
|
1754
|
+
h(
|
|
1755
|
+
'strong',
|
|
1756
|
+
{ class: 'truncate', style: 'display:block' },
|
|
1757
|
+
checklistName(entry)
|
|
1758
|
+
),
|
|
1759
|
+
h(
|
|
1760
|
+
'span',
|
|
1761
|
+
{ class: 'run-id mono truncate', style: 'display:block' },
|
|
1762
|
+
entry.id
|
|
1763
|
+
)
|
|
1764
|
+
)
|
|
371
1765
|
),
|
|
372
1766
|
h(
|
|
373
1767
|
'span',
|
|
374
|
-
{ class: '
|
|
375
|
-
entry
|
|
376
|
-
|
|
377
|
-
|
|
1768
|
+
{ class: 'result' },
|
|
1769
|
+
stateBadge(entry),
|
|
1770
|
+
scoreText(entry.score, entry) &&
|
|
1771
|
+
h('span', { class: 'tally' }, scoreText(entry.score, entry))
|
|
1772
|
+
),
|
|
1773
|
+
h(
|
|
1774
|
+
'span',
|
|
1775
|
+
{ class: 'facts' },
|
|
1776
|
+
h(
|
|
1777
|
+
'span',
|
|
1778
|
+
{ class: 'fact' },
|
|
1779
|
+
icon('actor'),
|
|
1780
|
+
h('span', { class: 'truncate' }, actorName(entry))
|
|
1781
|
+
),
|
|
1782
|
+
h(
|
|
1783
|
+
'span',
|
|
1784
|
+
{ class: 'fact' },
|
|
1785
|
+
icon(runner(entry).icon),
|
|
1786
|
+
h('span', { class: 'truncate' }, runner(entry).label)
|
|
1787
|
+
),
|
|
1788
|
+
h('span', { class: 'fact' }, time(entry.started_at, 'relative'))
|
|
378
1789
|
)
|
|
379
1790
|
)
|
|
380
1791
|
)
|
|
@@ -382,166 +1793,552 @@
|
|
|
382
1793
|
];
|
|
383
1794
|
}
|
|
384
1795
|
|
|
385
|
-
|
|
386
|
-
|
|
1796
|
+
// How a check is judged, beside its key. Silent for a plain pass/fail
|
|
1797
|
+
// check, an unconditional one and one that lets the run continue: those
|
|
1798
|
+
// are the defaults, and a badge on every row would bury the rest.
|
|
1799
|
+
function checkMeta(check) {
|
|
1800
|
+
const weight = check.weight ?? 1;
|
|
387
1801
|
return h(
|
|
1802
|
+
'span',
|
|
1803
|
+
{ class: 'check-meta' },
|
|
1804
|
+
h('code', {}, check.key),
|
|
1805
|
+
check.type === 'informational' &&
|
|
1806
|
+
h('span', { class: 'badge outline sm' }, icon('info'), 'Observation'),
|
|
1807
|
+
check.type === 'weighted' &&
|
|
1808
|
+
h(
|
|
1809
|
+
'span',
|
|
1810
|
+
{ class: 'badge outline sm' },
|
|
1811
|
+
icon(weight < 0 ? 'minus-circle' : 'scale'),
|
|
1812
|
+
h('span', { class: 'mono' }, weight < 0 ? weight + ' penalty' : weight + 'x')
|
|
1813
|
+
),
|
|
1814
|
+
check.run_condition &&
|
|
1815
|
+
check.run_condition !== 'always' &&
|
|
1816
|
+
h(
|
|
1817
|
+
'span',
|
|
1818
|
+
{ class: 'badge outline sm' },
|
|
1819
|
+
icon('git-branch'),
|
|
1820
|
+
check.run_condition.replaceAll('_', ' ')
|
|
1821
|
+
),
|
|
1822
|
+
check.on_failure === 'stop' &&
|
|
1823
|
+
h('span', { class: 'badge outline sm' }, icon('octagon-x'), 'Stops the run if it fails')
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Per page view: which rows a reader opened or closed, and what this
|
|
1828
|
+
// tab had already seen of each run, so a redraw keeps the rows as they
|
|
1829
|
+
// were and only something that just landed moves.
|
|
1830
|
+
const toggled = new Map();
|
|
1831
|
+
const seen = new Map();
|
|
1832
|
+
// Bumped only when the live frame changes, so a redraw reuses the
|
|
1833
|
+
// picture already loaded rather than fetching it again.
|
|
1834
|
+
let frameVersion = Date.now();
|
|
1835
|
+
|
|
1836
|
+
function checkRow(run, check, result, opts) {
|
|
1837
|
+
if (!result) {
|
|
1838
|
+
return h(
|
|
1839
|
+
'li',
|
|
1840
|
+
{ class: 'check waiting' },
|
|
1841
|
+
h(
|
|
1842
|
+
'div',
|
|
1843
|
+
{ class: 'check-head' },
|
|
1844
|
+
icon(opts.running ? 'pending' : 'circle-dashed', 'tone-waiting'),
|
|
1845
|
+
h(
|
|
1846
|
+
'span',
|
|
1847
|
+
{ class: 'check-body' },
|
|
1848
|
+
h('span', { class: 'check-desc' }, check.description),
|
|
1849
|
+
checkMeta(check)
|
|
1850
|
+
),
|
|
1851
|
+
h('span', { class: 'badge quiet sm' }, opts.running ? 'Waiting' : 'Not reached')
|
|
1852
|
+
)
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
const [label, tone, mark] = RESULT[result.status];
|
|
1856
|
+
const shot = result.screenshot && fileUrl('runs/' + run.id + '/' + result.screenshot);
|
|
1857
|
+
const key = run.id + ':' + check.key;
|
|
1858
|
+
const open = toggled.has(key) ? toggled.get(key) : opts.openByDefault;
|
|
1859
|
+
const detail = h(
|
|
388
1860
|
'div',
|
|
389
|
-
{ class: 'check' },
|
|
1861
|
+
{ class: 'check-detail', id: 'detail-' + check.key, hidden: !open },
|
|
390
1862
|
h(
|
|
391
|
-
'
|
|
392
|
-
{},
|
|
393
|
-
|
|
394
|
-
h('span', { class: 'pill ' + tone }, label)
|
|
1863
|
+
'p',
|
|
1864
|
+
{ class: 'evidence' + (result.status === 'informational' ? ' observed' : '') },
|
|
1865
|
+
result.evidence
|
|
395
1866
|
),
|
|
396
|
-
result &&
|
|
397
|
-
result &&
|
|
398
|
-
result.locator &&
|
|
1867
|
+
result.locator &&
|
|
399
1868
|
h(
|
|
400
|
-
'
|
|
401
|
-
{ class: '
|
|
1869
|
+
'p',
|
|
1870
|
+
{ class: 'cited mono' },
|
|
402
1871
|
'Cited: ' + (result.locator.text || result.locator.selector)
|
|
403
1872
|
),
|
|
404
|
-
|
|
405
|
-
result.screenshot &&
|
|
1873
|
+
shot &&
|
|
406
1874
|
h(
|
|
407
1875
|
'a',
|
|
408
|
-
{
|
|
1876
|
+
{
|
|
1877
|
+
class: 'shot',
|
|
1878
|
+
href: shot,
|
|
1879
|
+
target: '_blank',
|
|
1880
|
+
title: 'Open the full-size screenshot',
|
|
1881
|
+
},
|
|
409
1882
|
h('img', {
|
|
410
|
-
src:
|
|
1883
|
+
src: shot,
|
|
411
1884
|
alt: 'The page when ' + check.key + ' was judged',
|
|
412
1885
|
loading: 'lazy',
|
|
413
1886
|
})
|
|
414
1887
|
)
|
|
415
1888
|
);
|
|
1889
|
+
const row = h(
|
|
1890
|
+
'li',
|
|
1891
|
+
{ class: 'check' + (open ? ' open' : '') + (opts.arrived ? ' animate-rise' : '') },
|
|
1892
|
+
h(
|
|
1893
|
+
'button',
|
|
1894
|
+
{
|
|
1895
|
+
type: 'button',
|
|
1896
|
+
class: 'check-head',
|
|
1897
|
+
'aria-expanded': String(open),
|
|
1898
|
+
'aria-controls': 'detail-' + check.key,
|
|
1899
|
+
'aria-labelledby': 'desc-' + check.key + ' label-' + check.key,
|
|
1900
|
+
'data-focus-key': 'check:' + check.key,
|
|
1901
|
+
onclick: () => {
|
|
1902
|
+
const now = detail.hidden;
|
|
1903
|
+
toggled.set(key, now);
|
|
1904
|
+
detail.hidden = !now;
|
|
1905
|
+
row.classList.toggle('open', now);
|
|
1906
|
+
row.firstChild.setAttribute('aria-expanded', String(now));
|
|
1907
|
+
},
|
|
1908
|
+
},
|
|
1909
|
+
icon(mark, 'tone-' + result.status),
|
|
1910
|
+
h(
|
|
1911
|
+
'span',
|
|
1912
|
+
{ class: 'check-body' },
|
|
1913
|
+
h('span', { class: 'check-desc', id: 'desc-' + check.key }, check.description),
|
|
1914
|
+
checkMeta(check)
|
|
1915
|
+
),
|
|
1916
|
+
h(
|
|
1917
|
+
'span',
|
|
1918
|
+
{ class: 'check-side' },
|
|
1919
|
+
opts.earlier &&
|
|
1920
|
+
h(
|
|
1921
|
+
'span',
|
|
1922
|
+
{ class: 'badge outline sm', title: 'Judged before the run was resumed' },
|
|
1923
|
+
'Earlier'
|
|
1924
|
+
),
|
|
1925
|
+
h('span', { class: 'badge sm ' + tone, id: 'label-' + check.key }, label),
|
|
1926
|
+
icon('chevron-down', 'chevron')
|
|
1927
|
+
)
|
|
1928
|
+
),
|
|
1929
|
+
detail
|
|
1930
|
+
);
|
|
1931
|
+
return row;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function summaryCard(run, fresh) {
|
|
1935
|
+
const score = run.score;
|
|
1936
|
+
const state = runState(run);
|
|
1937
|
+
if (!score || !state.outcome) return null;
|
|
1938
|
+
const weighted = score.score != null;
|
|
1939
|
+
const t = tally(run);
|
|
1940
|
+
const counts = { passed: 0, failed: 0, error: 0, informational: 0, skipped: 0 };
|
|
1941
|
+
for (const r of run.results) counts[r.status]++;
|
|
1942
|
+
const judged = counts.passed + counts.failed + counts.error;
|
|
1943
|
+
const share = (n) => (judged ? (n / judged) * 100 : 0) + '%';
|
|
1944
|
+
return h(
|
|
1945
|
+
'section',
|
|
1946
|
+
{ class: 'card summary', 'aria-label': 'Score' },
|
|
1947
|
+
h(
|
|
1948
|
+
'div',
|
|
1949
|
+
{ class: 'summary-top' },
|
|
1950
|
+
h(
|
|
1951
|
+
'div',
|
|
1952
|
+
{},
|
|
1953
|
+
h('p', { class: 'overline' }, weighted ? 'Weighted score' : 'Checks passed'),
|
|
1954
|
+
h(
|
|
1955
|
+
'p',
|
|
1956
|
+
{
|
|
1957
|
+
class:
|
|
1958
|
+
'figure tone-' + (state.outcome === 'inconclusive' ? 'error' : state.outcome),
|
|
1959
|
+
},
|
|
1960
|
+
weighted
|
|
1961
|
+
? inUnit(score.score, score.unit)
|
|
1962
|
+
: [String(t.passed), h('span', { class: 'of' }, '/' + t.total)]
|
|
1963
|
+
),
|
|
1964
|
+
// Unweighted, there is no score: say how the outcome was decided,
|
|
1965
|
+
// in the words finish_run uses.
|
|
1966
|
+
h(
|
|
1967
|
+
'p',
|
|
1968
|
+
{ class: 'summary-line' },
|
|
1969
|
+
!weighted
|
|
1970
|
+
? 'No score: with no weighted checks, it passes only when every pass/fail check passes'
|
|
1971
|
+
: score.pass_threshold == null
|
|
1972
|
+
? 'No pass threshold set'
|
|
1973
|
+
: 'Passes at ' + inUnit(score.pass_threshold, score.unit)
|
|
1974
|
+
)
|
|
1975
|
+
),
|
|
1976
|
+
h(
|
|
1977
|
+
'div',
|
|
1978
|
+
{ class: 'verdict' },
|
|
1979
|
+
score.grade && h('span', { class: 'badge outline lg' }, score.grade),
|
|
1980
|
+
h(
|
|
1981
|
+
'span',
|
|
1982
|
+
{
|
|
1983
|
+
class:
|
|
1984
|
+
'badge stamp tone-' +
|
|
1985
|
+
(state.outcome === 'inconclusive' ? 'error' : state.outcome) +
|
|
1986
|
+
(fresh ? ' animate-stamp' : ''),
|
|
1987
|
+
},
|
|
1988
|
+
state.label
|
|
1989
|
+
)
|
|
1990
|
+
)
|
|
1991
|
+
),
|
|
1992
|
+
h(
|
|
1993
|
+
'div',
|
|
1994
|
+
{ class: 'meter', 'aria-hidden': 'true' },
|
|
1995
|
+
['passed', 'failed', 'error'].map(
|
|
1996
|
+
(s) => counts[s] > 0 && h('span', { class: s, style: 'width:' + share(counts[s]) })
|
|
1997
|
+
)
|
|
1998
|
+
),
|
|
1999
|
+
h(
|
|
2000
|
+
'ul',
|
|
2001
|
+
{ class: 'counts' },
|
|
2002
|
+
Object.entries(counts)
|
|
2003
|
+
.filter(([, n]) => n > 0)
|
|
2004
|
+
.map(([s, n]) =>
|
|
2005
|
+
h(
|
|
2006
|
+
'li',
|
|
2007
|
+
{},
|
|
2008
|
+
h(
|
|
2009
|
+
'span',
|
|
2010
|
+
{ class: 'badge lg ' + RESULT[s][1] },
|
|
2011
|
+
icon(RESULT[s][2]),
|
|
2012
|
+
h('span', { class: 'mono' }, n),
|
|
2013
|
+
RESULT[s][0]
|
|
2014
|
+
)
|
|
2015
|
+
)
|
|
2016
|
+
)
|
|
2017
|
+
)
|
|
2018
|
+
);
|
|
416
2019
|
}
|
|
417
2020
|
|
|
418
2021
|
async function renderRun(id) {
|
|
419
2022
|
const data = await getJson('/api/runs/' + encodeURIComponent(id));
|
|
420
|
-
if (data.error)
|
|
2023
|
+
if (data.error) {
|
|
2024
|
+
return [
|
|
2025
|
+
pageHeader({ back: { href: '/', label: 'Runs' }, title: id }),
|
|
2026
|
+
h('div', { class: 'notice warning' }, icon('alert-triangle'), h('p', {}, data.error)),
|
|
2027
|
+
];
|
|
2028
|
+
}
|
|
421
2029
|
const run = data.run;
|
|
422
2030
|
const results = new Map(run.results.map((r) => [r.check, r]));
|
|
423
2031
|
const checks = run.checklist_snapshot.checks;
|
|
424
2032
|
const running = run.status === 'running';
|
|
425
2033
|
const resumable = run.status === 'interrupted' || run.status === 'stopped';
|
|
426
2034
|
|
|
2035
|
+
const before = seen.get(run.id);
|
|
2036
|
+
seen.set(run.id, { status: run.status, results: new Set(results.keys()) });
|
|
2037
|
+
const fresh = before && before.status === 'running' && !running;
|
|
2038
|
+
const arrived = (key) => before && !before.results.has(key);
|
|
2039
|
+
|
|
2040
|
+
// Stop sits in the live card, beside the tally, as CancelRunButton does.
|
|
2041
|
+
const pending = data.stop_requested;
|
|
2042
|
+
const stopButton =
|
|
2043
|
+
running &&
|
|
2044
|
+
h(
|
|
2045
|
+
'button',
|
|
2046
|
+
{
|
|
2047
|
+
type: 'button',
|
|
2048
|
+
class: 'btn outline',
|
|
2049
|
+
'aria-disabled': pending && 'true',
|
|
2050
|
+
'data-pending': pending,
|
|
2051
|
+
'data-focus-key': 'stop',
|
|
2052
|
+
onclick: async (event) => {
|
|
2053
|
+
const button = event.currentTarget;
|
|
2054
|
+
if (button.hasAttribute('data-pending')) return;
|
|
2055
|
+
button.setAttribute('aria-disabled', 'true');
|
|
2056
|
+
button.setAttribute('data-pending', '');
|
|
2057
|
+
button.lastChild.textContent = 'Stopping at the next step';
|
|
2058
|
+
stopError = null;
|
|
2059
|
+
let res = null;
|
|
2060
|
+
try {
|
|
2061
|
+
res = await fetch('/api/stop', {
|
|
2062
|
+
method: 'POST',
|
|
2063
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2064
|
+
body: JSON.stringify({ run: run.id }),
|
|
2065
|
+
});
|
|
2066
|
+
} catch {
|
|
2067
|
+
res = null;
|
|
2068
|
+
}
|
|
2069
|
+
if (res && (res.ok || res.status === 409)) return; // 409: it already finished
|
|
2070
|
+
stopError =
|
|
2071
|
+
res && res.status === 403
|
|
2072
|
+
? 'Stop was refused. Open the viewer from the link Claude Code gave you.'
|
|
2073
|
+
: 'The run could not be stopped. Try again.';
|
|
2074
|
+
announce(stopError);
|
|
2075
|
+
render(false);
|
|
2076
|
+
},
|
|
2077
|
+
},
|
|
2078
|
+
icon('stop'),
|
|
2079
|
+
h('span', {}, pending ? 'Stopping at the next step' : 'Stop')
|
|
2080
|
+
);
|
|
2081
|
+
|
|
2082
|
+
const rerun = '/stuntdouble run ' + run.checklist + (resumable ? ' resume' : '');
|
|
2083
|
+
const cloud = '/stuntdouble cloud ' + run.checklist;
|
|
427
2084
|
const actions = running
|
|
428
|
-
?
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
method: 'POST',
|
|
440
|
-
headers: { 'Content-Type': 'application/json' },
|
|
441
|
-
body: JSON.stringify({ run: run.id }),
|
|
442
|
-
});
|
|
2085
|
+
? null
|
|
2086
|
+
: run.runner.kind === 'local'
|
|
2087
|
+
? [
|
|
2088
|
+
h(
|
|
2089
|
+
'button',
|
|
2090
|
+
{
|
|
2091
|
+
type: 'button',
|
|
2092
|
+
class: 'btn outline',
|
|
2093
|
+
title: rerun,
|
|
2094
|
+
'data-focus-key': 'rerun',
|
|
2095
|
+
onclick: (event) => copy(rerun, event.currentTarget),
|
|
443
2096
|
},
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
2097
|
+
icon('copy'),
|
|
2098
|
+
h('span', {}, resumable ? 'Copy resume command' : 'Copy re-run command')
|
|
2099
|
+
),
|
|
2100
|
+
h(
|
|
2101
|
+
'button',
|
|
2102
|
+
{
|
|
2103
|
+
type: 'button',
|
|
2104
|
+
class: 'btn outline',
|
|
2105
|
+
title: cloud,
|
|
2106
|
+
'data-focus-key': 'cloud',
|
|
2107
|
+
onclick: (event) => copy(cloud, event.currentTarget),
|
|
2108
|
+
},
|
|
2109
|
+
icon('copy'),
|
|
2110
|
+
h('span', {}, 'Copy cloud command')
|
|
2111
|
+
),
|
|
2112
|
+
]
|
|
2113
|
+
: webUrl(run.runner.url)
|
|
2114
|
+
? [
|
|
2115
|
+
h(
|
|
2116
|
+
'a',
|
|
2117
|
+
{
|
|
2118
|
+
class: 'btn outline',
|
|
2119
|
+
href: run.runner.url,
|
|
2120
|
+
target: '_blank',
|
|
2121
|
+
rel: 'noopener noreferrer',
|
|
2122
|
+
},
|
|
2123
|
+
icon('external-link'),
|
|
2124
|
+
h('span', {}, 'Open this run in Stunt Double')
|
|
2125
|
+
),
|
|
2126
|
+
]
|
|
2127
|
+
: null;
|
|
2128
|
+
|
|
2129
|
+
const starts = data.activity.filter((line) => line.tool === 'start_run');
|
|
2130
|
+
const sessions = starts.length;
|
|
2131
|
+
const duration =
|
|
2132
|
+
run.finished_at &&
|
|
2133
|
+
took(activeSeconds(run, data.activity)) +
|
|
2134
|
+
(sessions > 1 ? ' over ' + sessions + ' sessions' : '');
|
|
2135
|
+
// Resumed: the verdicts from before the last start_run stood.
|
|
2136
|
+
const resumedAt = sessions > 1 ? new Date(starts[sessions - 1].at) : null;
|
|
2137
|
+
const earlier = (result) => resumedAt && result.at && new Date(result.at) < resumedAt;
|
|
2138
|
+
const header = pageHeader({
|
|
2139
|
+
back: { href: '/', label: 'Runs', trail: run.id },
|
|
2140
|
+
title: checklistName(run),
|
|
2141
|
+
// A finished run's verdict is the score card's stamp: once is enough.
|
|
2142
|
+
badge: run.score && runState(run).outcome ? null : stateBadge(run, 'lg'),
|
|
2143
|
+
chips: [
|
|
2144
|
+
{ icon: runner(run).icon, label: runner(run).label },
|
|
2145
|
+
{ icon: 'actor', label: actorName(run), title: 'Actor' },
|
|
2146
|
+
{ icon: 'globe', label: run.url, href: webUrl(run.url), title: 'Target' },
|
|
2147
|
+
{ icon: 'calendar', label: time(run.started_at), title: 'Started' },
|
|
2148
|
+
duration && {
|
|
2149
|
+
icon: 'timer',
|
|
2150
|
+
label: duration,
|
|
2151
|
+
mono: true,
|
|
2152
|
+
title: 'How long the actor worked, idle time between sessions left out',
|
|
2153
|
+
},
|
|
2154
|
+
].filter(Boolean),
|
|
2155
|
+
actions,
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
const judged = run.results.length;
|
|
2159
|
+
const checked = pad(judged) + ' of ' + pad(checks.length) + ' checked';
|
|
2160
|
+
if (running) announce(stopError || judged + ' of ' + checks.length + ' checked');
|
|
2161
|
+
else if (fresh) announce(runState(run).label);
|
|
2162
|
+
if (!running) stopError = null;
|
|
2163
|
+
const live =
|
|
2164
|
+
running &&
|
|
2165
|
+
h(
|
|
2166
|
+
'section',
|
|
2167
|
+
{ class: 'card live-card', 'aria-label': 'Live' },
|
|
449
2168
|
h(
|
|
450
2169
|
'div',
|
|
451
|
-
{ class: '
|
|
2170
|
+
{ class: 'live-head' },
|
|
452
2171
|
h(
|
|
453
|
-
'
|
|
454
|
-
{
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
2172
|
+
'p',
|
|
2173
|
+
{ class: 'live-step' },
|
|
2174
|
+
h('span', { class: 'chase', role: 'img', 'aria-label': 'Run in progress' }, [
|
|
2175
|
+
h('span'),
|
|
2176
|
+
h('span'),
|
|
2177
|
+
h('span'),
|
|
2178
|
+
h('span'),
|
|
2179
|
+
h('span'),
|
|
2180
|
+
]),
|
|
2181
|
+
h(
|
|
2182
|
+
'span',
|
|
2183
|
+
{ class: 'truncate' },
|
|
2184
|
+
data.activity.length > 0
|
|
2185
|
+
? data.activity[data.activity.length - 1].text.split('\n')[0]
|
|
2186
|
+
: 'Running'
|
|
2187
|
+
)
|
|
463
2188
|
),
|
|
464
2189
|
h(
|
|
465
|
-
'
|
|
466
|
-
{
|
|
467
|
-
|
|
2190
|
+
'span',
|
|
2191
|
+
{ class: 'live-side' },
|
|
2192
|
+
h(
|
|
2193
|
+
'span',
|
|
2194
|
+
{ class: 'badge arc' },
|
|
2195
|
+
h('span', { class: 'bulb live', 'aria-hidden': 'true' }),
|
|
2196
|
+
'Live'
|
|
2197
|
+
),
|
|
2198
|
+
h('span', { class: 'tally' }, checked),
|
|
2199
|
+
stopButton,
|
|
2200
|
+
stopError && h('span', { class: 'stop-error' }, stopError)
|
|
468
2201
|
)
|
|
469
|
-
)
|
|
2202
|
+
),
|
|
2203
|
+
h(
|
|
2204
|
+
'div',
|
|
2205
|
+
{
|
|
2206
|
+
class: 'progress',
|
|
2207
|
+
role: 'progressbar',
|
|
2208
|
+
'aria-label': 'Run progress',
|
|
2209
|
+
'aria-valuemin': 0,
|
|
2210
|
+
'aria-valuemax': checks.length,
|
|
2211
|
+
'aria-valuenow': judged,
|
|
2212
|
+
},
|
|
2213
|
+
h('span', {
|
|
2214
|
+
style: 'width:' + (checks.length ? (judged / checks.length) * 100 : 0) + '%',
|
|
2215
|
+
})
|
|
2216
|
+
),
|
|
2217
|
+
h('img', {
|
|
2218
|
+
class: 'frame',
|
|
2219
|
+
id: 'frame',
|
|
2220
|
+
src: fileUrl('.live/latest.png') + '?v=' + frameVersion,
|
|
2221
|
+
alt: 'The browser now',
|
|
2222
|
+
})
|
|
2223
|
+
);
|
|
470
2224
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
2225
|
+
// What went wrong first, since that is what somebody opens a report to
|
|
2226
|
+
// find, then what held, then what was only read off the page, then what
|
|
2227
|
+
// never ran.
|
|
2228
|
+
const groups = [
|
|
2229
|
+
['Needs attention', ['failed', 'error'], true],
|
|
2230
|
+
['Passed', ['passed'], false],
|
|
2231
|
+
['Observations', ['informational'], true],
|
|
2232
|
+
['Skipped', ['skipped'], false],
|
|
2233
|
+
]
|
|
2234
|
+
.map(([title, statuses, openByDefault]) => ({
|
|
2235
|
+
title,
|
|
2236
|
+
openByDefault,
|
|
2237
|
+
checks: checks.filter(
|
|
2238
|
+
(c) => results.has(c.key) && statuses.includes(results.get(c.key).status)
|
|
2239
|
+
),
|
|
2240
|
+
}))
|
|
2241
|
+
.concat({
|
|
2242
|
+
title: running ? 'Still to check' : 'Not reached',
|
|
2243
|
+
checks: checks.filter((c) => !results.has(c.key)),
|
|
2244
|
+
})
|
|
2245
|
+
.filter((g) => g.checks.length > 0);
|
|
2246
|
+
|
|
2247
|
+
const notice =
|
|
2248
|
+
run.summary &&
|
|
2249
|
+
(run.status === 'failed'
|
|
2250
|
+
? h('div', { class: 'notice destructive' }, icon('octagon-x'), h('p', {}, run.summary))
|
|
2251
|
+
: h('p', { class: 'quote' }, run.summary));
|
|
476
2252
|
|
|
477
2253
|
return [
|
|
478
|
-
|
|
479
|
-
h('h1', {}, run.checklist),
|
|
2254
|
+
header,
|
|
480
2255
|
h(
|
|
481
2256
|
'div',
|
|
482
|
-
{ class: '
|
|
483
|
-
|
|
484
|
-
|
|
2257
|
+
{ class: 'stack' },
|
|
2258
|
+
live,
|
|
2259
|
+
notice,
|
|
2260
|
+
summaryCard(run, fresh),
|
|
2261
|
+
groups.map((group) =>
|
|
485
2262
|
h(
|
|
486
|
-
'
|
|
487
|
-
{},
|
|
488
|
-
scoreText(run.score) +
|
|
489
|
-
(run.score.pass_threshold == null
|
|
490
|
-
? ''
|
|
491
|
-
: ', passes at ' + inUnit(run.score.pass_threshold, run.score.unit))
|
|
492
|
-
),
|
|
493
|
-
runnerPill(run),
|
|
494
|
-
h('span', {}, run.actor),
|
|
495
|
-
webUrl(run.url) ? h('a', { href: run.url }, run.url) : h('span', {}, run.url),
|
|
496
|
-
h('span', {}, when(run.started_at))
|
|
497
|
-
),
|
|
498
|
-
run.summary && h('p', {}, run.summary),
|
|
499
|
-
webUrl(run.runner.url) &&
|
|
500
|
-
h('p', {}, h('a', { href: run.runner.url }, 'Open this run in Stunt Double')),
|
|
501
|
-
actions,
|
|
502
|
-
running
|
|
503
|
-
? h(
|
|
504
|
-
'div',
|
|
505
|
-
{ class: 'split' },
|
|
2263
|
+
'section',
|
|
2264
|
+
{ class: 'section' },
|
|
506
2265
|
h(
|
|
507
|
-
'
|
|
508
|
-
{},
|
|
509
|
-
|
|
510
|
-
h('
|
|
511
|
-
class: 'frame',
|
|
512
|
-
id: 'frame',
|
|
513
|
-
src: fileUrl('.live/latest.png') + '?v=' + Date.now(),
|
|
514
|
-
alt: 'The browser now',
|
|
515
|
-
})
|
|
2266
|
+
'h2',
|
|
2267
|
+
{ class: 'section-title' },
|
|
2268
|
+
group.title,
|
|
2269
|
+
h('span', { class: 'count' }, pad(group.checks.length))
|
|
516
2270
|
),
|
|
517
|
-
h('div', {}, h('h2', {}, 'Checks'), verdicts)
|
|
518
|
-
)
|
|
519
|
-
: [h('h2', {}, 'Checks'), verdicts],
|
|
520
|
-
data.activity.length > 0 && [
|
|
521
|
-
h('h2', {}, 'Activity'),
|
|
522
|
-
h(
|
|
523
|
-
'ol',
|
|
524
|
-
{ class: 'card timeline' },
|
|
525
|
-
data.activity.map((line) =>
|
|
526
2271
|
h(
|
|
527
|
-
'
|
|
528
|
-
{},
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
2272
|
+
'ul',
|
|
2273
|
+
{ class: 'group' },
|
|
2274
|
+
group.checks.map((check) =>
|
|
2275
|
+
checkRow(run, check, results.get(check.key), {
|
|
2276
|
+
running,
|
|
2277
|
+
openByDefault: group.openByDefault,
|
|
2278
|
+
arrived: results.has(check.key) && arrived(check.key),
|
|
2279
|
+
earlier: results.has(check.key) && earlier(results.get(check.key)),
|
|
2280
|
+
})
|
|
2281
|
+
)
|
|
532
2282
|
)
|
|
533
2283
|
)
|
|
534
2284
|
),
|
|
535
|
-
|
|
2285
|
+
data.activity.length > 0 &&
|
|
2286
|
+
h(
|
|
2287
|
+
'section',
|
|
2288
|
+
{ class: 'section' },
|
|
2289
|
+
h(
|
|
2290
|
+
'h2',
|
|
2291
|
+
{ class: 'section-title' },
|
|
2292
|
+
'Activity',
|
|
2293
|
+
h('span', { class: 'count' }, pad(data.activity.length))
|
|
2294
|
+
),
|
|
2295
|
+
h(
|
|
2296
|
+
'ol',
|
|
2297
|
+
{ class: 'group activity' },
|
|
2298
|
+
data.activity.map((line) =>
|
|
2299
|
+
h(
|
|
2300
|
+
'li',
|
|
2301
|
+
{},
|
|
2302
|
+
time(line.at, 'clock'),
|
|
2303
|
+
h('span', { title: line.tool }, ACTIVITY[line.tool] || line.tool),
|
|
2304
|
+
PAGE_READS.has(line.tool)
|
|
2305
|
+
? h(
|
|
2306
|
+
'details',
|
|
2307
|
+
{ class: 'text' },
|
|
2308
|
+
h(
|
|
2309
|
+
'summary',
|
|
2310
|
+
{},
|
|
2311
|
+
line.tool === 'find' && line.input && line.input.query
|
|
2312
|
+
? '"' + line.input.query + '": show matches'
|
|
2313
|
+
: 'Show page text'
|
|
2314
|
+
),
|
|
2315
|
+
line.text
|
|
2316
|
+
)
|
|
2317
|
+
: h('span', { class: 'text' }, line.text)
|
|
2318
|
+
)
|
|
2319
|
+
)
|
|
2320
|
+
)
|
|
2321
|
+
)
|
|
2322
|
+
),
|
|
536
2323
|
];
|
|
537
2324
|
}
|
|
538
2325
|
|
|
539
2326
|
function definitionError(entry) {
|
|
540
2327
|
return h(
|
|
541
|
-
'
|
|
542
|
-
{},
|
|
543
|
-
h(
|
|
544
|
-
|
|
2328
|
+
'li',
|
|
2329
|
+
{ class: 'def' },
|
|
2330
|
+
h(
|
|
2331
|
+
'div',
|
|
2332
|
+
{ class: 'def-head' },
|
|
2333
|
+
h(
|
|
2334
|
+
'span',
|
|
2335
|
+
{ class: 'def-name' },
|
|
2336
|
+
icon('alert-triangle', 'tone-error'),
|
|
2337
|
+
h('code', {}, entry.slug)
|
|
2338
|
+
),
|
|
2339
|
+
h('span', { class: 'badge warning' }, 'Unreadable')
|
|
2340
|
+
),
|
|
2341
|
+
h('p', { class: 'def-body error-text' }, entry.error)
|
|
545
2342
|
);
|
|
546
2343
|
}
|
|
547
2344
|
|
|
@@ -549,107 +2346,107 @@
|
|
|
549
2346
|
const { checklists, actors } = await getJson('/api/definitions');
|
|
550
2347
|
const cloudIds = (cloud) =>
|
|
551
2348
|
cloud && Object.values(cloud).some((v) => v && typeof v !== 'object')
|
|
552
|
-
? h('span', { class: '
|
|
553
|
-
: h('span', { class: '
|
|
554
|
-
|
|
555
|
-
h('h1', {}, 'Checklists and actors'),
|
|
2349
|
+
? h('span', { class: 'badge arc' }, 'Linked to the cloud')
|
|
2350
|
+
: h('span', { class: 'badge secondary' }, 'Local only');
|
|
2351
|
+
const section = (title, entries, mark, empty, row) =>
|
|
556
2352
|
h(
|
|
557
|
-
'
|
|
558
|
-
{ class: '
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
'div',
|
|
572
|
-
{ class: 'check' },
|
|
573
|
-
h(
|
|
574
|
-
'header',
|
|
575
|
-
{},
|
|
576
|
-
h('strong', {}, entry.data.name),
|
|
577
|
-
cloudIds(entry.data.cloud)
|
|
578
|
-
),
|
|
579
|
-
h(
|
|
580
|
-
'div',
|
|
581
|
-
{ class: 'meta' },
|
|
582
|
-
h('code', {}, entry.slug),
|
|
583
|
-
h('span', {}, 'run by ' + entry.data.actor),
|
|
584
|
-
h('span', {}, entry.data.url)
|
|
585
|
-
),
|
|
586
|
-
h('p', {}, entry.data.instructions),
|
|
587
|
-
h(
|
|
588
|
-
'ul',
|
|
589
|
-
{},
|
|
590
|
-
entry.data.checks.map((c) =>
|
|
591
|
-
h(
|
|
592
|
-
'li',
|
|
593
|
-
{},
|
|
594
|
-
h('code', {}, c.key),
|
|
595
|
-
' ',
|
|
596
|
-
c.description,
|
|
597
|
-
' ',
|
|
598
|
-
h(
|
|
599
|
-
'span',
|
|
600
|
-
{ class: 'muted' },
|
|
601
|
-
c.type === 'weighted'
|
|
602
|
-
? 'weighted x' + (c.weight ?? 1)
|
|
603
|
-
: c.type.replace('_', '/')
|
|
604
|
-
)
|
|
605
|
-
)
|
|
606
|
-
)
|
|
607
|
-
)
|
|
608
|
-
)
|
|
2353
|
+
'section',
|
|
2354
|
+
{ class: 'section' },
|
|
2355
|
+
h(
|
|
2356
|
+
'h2',
|
|
2357
|
+
{ class: 'section-title large' },
|
|
2358
|
+
title,
|
|
2359
|
+
h('span', { class: 'count' }, pad(entries.length))
|
|
2360
|
+
),
|
|
2361
|
+
entries.length === 0
|
|
2362
|
+
? h('div', { class: 'card inline-empty' }, icon(mark), h('p', {}, empty))
|
|
2363
|
+
: h(
|
|
2364
|
+
'ul',
|
|
2365
|
+
{ class: 'group card' },
|
|
2366
|
+
entries.map((entry) => (entry.error ? definitionError(entry) : row(entry)))
|
|
609
2367
|
)
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
:
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
2368
|
+
);
|
|
2369
|
+
return [
|
|
2370
|
+
pageHeader({
|
|
2371
|
+
title: 'Checklists and actors',
|
|
2372
|
+
description:
|
|
2373
|
+
'Read only. Edit them in Claude Code: they are the JSON files in .stuntdouble/.',
|
|
2374
|
+
}),
|
|
2375
|
+
h(
|
|
2376
|
+
'div',
|
|
2377
|
+
{ class: 'stack' },
|
|
2378
|
+
section('Checklists', checklists, 'checklist', 'No checklists yet.', (entry) =>
|
|
2379
|
+
h(
|
|
2380
|
+
'li',
|
|
2381
|
+
{ class: 'def' },
|
|
2382
|
+
h(
|
|
2383
|
+
'div',
|
|
2384
|
+
{ class: 'def-head' },
|
|
2385
|
+
h(
|
|
2386
|
+
'span',
|
|
2387
|
+
{ class: 'def-name' },
|
|
2388
|
+
icon('checklist'),
|
|
2389
|
+
h('span', { class: 'truncate' }, entry.data.name)
|
|
2390
|
+
),
|
|
2391
|
+
cloudIds(entry.data.cloud)
|
|
2392
|
+
),
|
|
2393
|
+
h(
|
|
2394
|
+
'div',
|
|
2395
|
+
{ class: 'def-facts' },
|
|
2396
|
+
h('code', {}, entry.slug),
|
|
2397
|
+
h('span', { class: 'fact' }, icon('actor'), 'Run by ' + entry.data.actor),
|
|
2398
|
+
h('span', { class: 'fact' }, icon('globe'), entry.data.url)
|
|
2399
|
+
),
|
|
2400
|
+
h('p', { class: 'def-body' }, entry.data.instructions),
|
|
2401
|
+
h(
|
|
2402
|
+
'ul',
|
|
2403
|
+
{ class: 'def-checks' },
|
|
2404
|
+
entry.data.checks.map((c) =>
|
|
2405
|
+
h('li', {}, h('span', {}, c.description), checkMeta(c))
|
|
2406
|
+
)
|
|
643
2407
|
)
|
|
644
|
-
)
|
|
2408
|
+
)
|
|
2409
|
+
),
|
|
2410
|
+
section('Actors', actors, 'actor', 'No actors yet.', (entry) =>
|
|
2411
|
+
h(
|
|
2412
|
+
'li',
|
|
2413
|
+
{ class: 'def' },
|
|
2414
|
+
h(
|
|
2415
|
+
'div',
|
|
2416
|
+
{ class: 'def-head' },
|
|
2417
|
+
h(
|
|
2418
|
+
'span',
|
|
2419
|
+
{ class: 'def-name' },
|
|
2420
|
+
icon('actor'),
|
|
2421
|
+
h('span', { class: 'truncate' }, entry.data.name)
|
|
2422
|
+
),
|
|
2423
|
+
cloudIds(entry.data.cloud)
|
|
2424
|
+
),
|
|
2425
|
+
h(
|
|
2426
|
+
'div',
|
|
2427
|
+
{ class: 'def-facts' },
|
|
2428
|
+
h('code', {}, entry.slug),
|
|
2429
|
+
entry.data.persist_login && h('span', {}, 'Keeps its logins')
|
|
2430
|
+
),
|
|
2431
|
+
entry.data.description && h('p', { class: 'def-body' }, entry.data.description),
|
|
2432
|
+
entry.data.knowledge.length > 0 &&
|
|
2433
|
+
h(
|
|
2434
|
+
'p',
|
|
2435
|
+
{ class: 'def-body fact' },
|
|
2436
|
+
icon('knowledge'),
|
|
2437
|
+
'Knows: ' + entry.data.knowledge.map((k) => k.title).join(', ')
|
|
2438
|
+
)
|
|
2439
|
+
)
|
|
2440
|
+
)
|
|
2441
|
+
),
|
|
645
2442
|
];
|
|
646
2443
|
}
|
|
647
2444
|
|
|
648
2445
|
let rendering = Promise.resolve();
|
|
649
|
-
function render() {
|
|
2446
|
+
function render(enter) {
|
|
650
2447
|
rendering = rendering.then(async () => {
|
|
651
2448
|
const path = location.pathname;
|
|
652
|
-
for (const link of document.querySelectorAll('
|
|
2449
|
+
for (const link of document.querySelectorAll('.bar nav a')) {
|
|
653
2450
|
const current = link.getAttribute('href') === (path.startsWith('/runs/') ? '/' : path);
|
|
654
2451
|
if (current) link.setAttribute('aria-current', 'page');
|
|
655
2452
|
else link.removeAttribute('aria-current');
|
|
@@ -662,9 +2459,27 @@
|
|
|
662
2459
|
? await renderDefinitions()
|
|
663
2460
|
: await renderRuns();
|
|
664
2461
|
} catch (err) {
|
|
665
|
-
view = [
|
|
2462
|
+
view = [
|
|
2463
|
+
h('div', { class: 'notice destructive' }, icon('octagon-x'), h('p', {}, err.message)),
|
|
2464
|
+
];
|
|
666
2465
|
}
|
|
2466
|
+
app.classList.toggle('narrow', path === '/definitions');
|
|
2467
|
+
// A live redraw replaces the view: keep keyboard focus where it was.
|
|
2468
|
+
const focused = document.activeElement?.closest?.('[data-focus-key]');
|
|
2469
|
+
const focusKey = focused && app.contains(focused) && focused.dataset.focusKey;
|
|
667
2470
|
app.replaceChildren(...[view].flat(2).filter(Boolean));
|
|
2471
|
+
if (focusKey) {
|
|
2472
|
+
app
|
|
2473
|
+
.querySelector('[data-focus-key="' + CSS.escape(focusKey) + '"]')
|
|
2474
|
+
?.focus({ preventScroll: true });
|
|
2475
|
+
}
|
|
2476
|
+
// A view the reader went to rises in once. Redraws for live updates,
|
|
2477
|
+
// and anything reached from the keyboard, cut.
|
|
2478
|
+
if (enter) {
|
|
2479
|
+
app.classList.remove('animate-page-in');
|
|
2480
|
+
void app.offsetWidth;
|
|
2481
|
+
app.classList.add('animate-page-in');
|
|
2482
|
+
}
|
|
668
2483
|
});
|
|
669
2484
|
}
|
|
670
2485
|
|
|
@@ -673,26 +2488,32 @@
|
|
|
673
2488
|
if (!link || event.metaKey || event.ctrlKey) return;
|
|
674
2489
|
event.preventDefault();
|
|
675
2490
|
history.pushState(null, '', link.getAttribute('href'));
|
|
676
|
-
|
|
2491
|
+
window.scrollTo(0, 0);
|
|
2492
|
+
render(event.detail > 0);
|
|
677
2493
|
});
|
|
678
|
-
window.addEventListener('popstate', render);
|
|
2494
|
+
window.addEventListener('popstate', () => render(false));
|
|
679
2495
|
|
|
680
|
-
// Live updates: a new frame swaps the picture, anything else redraws the
|
|
2496
|
+
// Live updates: a new frame swaps the picture, anything else redraws the
|
|
2497
|
+
// page. The server already coalesces changes, so a redraw waits only long
|
|
2498
|
+
// enough to fold a burst into one.
|
|
681
2499
|
let queued = false;
|
|
682
2500
|
new EventSource('/api/events').onmessage = (event) => {
|
|
683
2501
|
const { paths } = JSON.parse(event.data);
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
frame
|
|
2502
|
+
if (paths.includes('.live/latest.png')) {
|
|
2503
|
+
frameVersion++;
|
|
2504
|
+
const frame = document.getElementById('frame');
|
|
2505
|
+
if (frame) frame.src = fileUrl('.live/latest.png') + '?v=' + frameVersion;
|
|
687
2506
|
}
|
|
688
2507
|
if (paths.every((p) => p.startsWith('.live/')) || queued) return;
|
|
689
2508
|
queued = true;
|
|
690
2509
|
setTimeout(() => {
|
|
691
2510
|
queued = false;
|
|
692
|
-
render();
|
|
693
|
-
},
|
|
2511
|
+
render(false);
|
|
2512
|
+
}, 50);
|
|
694
2513
|
};
|
|
695
|
-
|
|
2514
|
+
// Relative times on the runs list stay current.
|
|
2515
|
+
setInterval(() => location.pathname === '/' && render(false), 60_000);
|
|
2516
|
+
render(false);
|
|
696
2517
|
</script>
|
|
697
2518
|
</body>
|
|
698
2519
|
</html>
|