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/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
- --border: oklch(0.88 0.01 255);
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
- --destructive: oklch(0.53 0.215 22);
21
- --radius: 0.5rem;
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
- --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
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
- --border: oklch(0.95 0.006 250 / 0.1);
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
- --destructive: oklch(0.64 0.2 22);
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
- header,
54
- main,
55
- footer {
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: 0 1rem;
185
+ padding-inline: 1rem;
59
186
  }
60
- header {
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 1.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
- header strong {
70
- font-family: var(--font-display);
213
+ .product {
214
+ font-weight: 500;
71
215
  letter-spacing: -0.012em;
72
216
  white-space: nowrap;
73
217
  }
74
- header nav a {
75
- margin-right: 1rem;
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
- header nav a[aria-current='page'] {
234
+ .bar nav a[aria-current='page'] {
81
235
  color: var(--foreground);
236
+ background: var(--accent);
82
237
  }
83
238
  main {
84
- padding-top: 1.5rem;
85
- padding-bottom: 3rem;
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
- h1 {
88
- font: 600 1.5rem/1.2 var(--font-display);
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
- h2 {
630
+ .section-title.large {
93
631
  font-size: 1rem;
94
- margin: 2rem 0 0.75rem;
632
+ letter-spacing: -0.012em;
95
633
  }
96
- .muted {
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
- .card {
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
- .rows > a,
105
- .rows > div {
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
- border-top: 1px solid var(--border);
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
- .rows > :first-child {
114
- border-top: 0;
695
+ .run-row:hover {
696
+ background: color-mix(in oklch, var(--muted) 60%, transparent);
115
697
  }
116
- .rows > a:hover {
117
- background: var(--muted);
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
- .meta {
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
- color: var(--muted-foreground);
125
- font-size: 13px;
721
+ justify-content: flex-end;
722
+ gap: 0.25rem 0.5rem;
126
723
  }
127
- .pill {
128
- display: inline-block;
129
- padding: 0 0.5rem;
130
- border-radius: 999px;
131
- font-size: 12px;
132
- line-height: 1.5rem;
133
- border: 1px solid var(--border);
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
- .positive {
138
- color: var(--success);
139
- border-color: color-mix(in oklch, var(--success) 40%, transparent);
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
- .negative {
142
- color: var(--destructive);
143
- border-color: color-mix(in oklch, var(--destructive) 40%, transparent);
741
+ .fact {
742
+ display: inline-flex;
743
+ min-width: 0;
744
+ align-items: center;
745
+ gap: 0.375rem;
144
746
  }
145
- .caution {
146
- color: var(--warning);
147
- border-color: color-mix(in oklch, var(--warning) 40%, transparent);
747
+ .fact svg.icon {
748
+ width: 0.875rem;
749
+ height: 0.875rem;
148
750
  }
149
- .live {
150
- color: var(--primary);
151
- border-color: color-mix(in oklch, var(--primary) 40%, transparent);
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
- button {
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
- padding: 0.4rem 0.9rem;
156
- border-radius: var(--radius);
157
- border: 1px solid var(--border);
158
- background: var(--card);
159
- color: var(--foreground);
792
+ text-align: left;
793
+ }
794
+ button.check-head {
160
795
  cursor: pointer;
161
796
  }
162
- button.primary {
163
- background: var(--primary);
164
- border-color: var(--primary);
165
- color: var(--primary-foreground);
797
+ .check-head > svg.icon {
798
+ margin-top: 0.125rem;
166
799
  }
167
- button:disabled {
168
- opacity: 0.6;
169
- cursor: default;
800
+ .check-body {
801
+ display: block;
802
+ min-width: 0;
803
+ flex: 1;
170
804
  }
171
- .actions {
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
- .split {
834
+ .chevron {
835
+ color: var(--muted-foreground);
836
+ }
837
+ .open .chevron {
838
+ transform: rotate(180deg);
839
+ }
840
+ .check-detail {
177
841
  display: grid;
178
- grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
179
- gap: 1.5rem;
180
- align-items: start;
842
+ gap: 0.75rem;
843
+ margin-top: 0.5rem;
844
+ padding-left: 1.75rem;
181
845
  }
182
- @media (max-width: 52rem) {
183
- .split {
184
- grid-template-columns: 1fr;
185
- }
846
+ .evidence {
847
+ color: var(--muted-foreground);
186
848
  }
187
- .frame {
188
- width: 100%;
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
- border-radius: var(--radius);
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
- .check {
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
- .check:first-child {
199
- border-top: 0;
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
- .check header {
202
- all: unset;
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
- align-items: baseline;
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
- .check img {
209
- margin-top: 0.75rem;
210
- max-width: 100%;
211
- max-height: 20rem;
212
- border-radius: calc(var(--radius) - 2px);
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
- code,
216
- .mono {
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: 12px;
1085
+ font-size: 0.75rem;
1086
+ font-variant-numeric: tabular-nums;
1087
+ color: var(--muted-foreground);
1088
+ white-space: nowrap;
219
1089
  }
220
- .timeline {
221
- list-style: none;
222
- margin: 0;
223
- padding: 0;
224
- font-size: 13px;
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
- .timeline li {
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
- grid-template-columns: 5.5rem 7rem 1fr;
229
- gap: 0.75rem;
230
- padding: 0.4rem 1rem;
231
- border-top: 1px solid var(--border);
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
- .timeline li:first-child {
234
- border-top: 0;
1129
+ .def-name > svg {
1130
+ color: var(--muted-foreground);
235
1131
  }
236
- .timeline span:last-child {
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
- padding: 2rem 1rem;
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
- footer {
245
- padding-bottom: 2rem;
246
- font-size: 13px;
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
- <strong>Stunt Double Local</strong>
253
- <nav>
254
- <a href="/" data-nav>Runs</a>
255
- <a href="/definitions" data-nav>Checklists and actors</a>
256
- </nav>
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 id="app" aria-live="polite"></main>
259
- <footer class="muted">
260
- Local runs use your own Claude plan and a browser on this machine, one at a time while your
261
- session is open. The cloud runs on schedules and in parallel, records every run and shares
262
- reports with your team. <a href="https://stuntdouble.io/pricing">See plans</a>
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
- function outcomePill(run) {
288
- if (run.status === 'running') return h('span', { class: 'pill live' }, 'Running');
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 h('span', { class: 'pill caution' }, 'Interrupted');
291
- if (run.status === 'failed') return h('span', { class: 'pill negative' }, 'Run 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 h('span', { class: 'pill' }, run.status === 'stopped' ? 'Stopped' : run.status);
295
- const label = { passed: 'Passed', failed: 'Failed', inconclusive: 'Inconclusive' }[outcome];
296
- const tone = { passed: 'positive', failed: 'negative', inconclusive: '' }[outcome];
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: 'pill ' + tone },
300
- run.status === 'stopped' ? label + ', stopped' : label
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
- const scoreText = (score) =>
306
- score && score.score != null ? inUnit(score.score, score.unit) : '';
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
- const runnerPill = (run) =>
309
- h('span', { class: 'pill' }, run.runner.kind === 'cloud' ? 'Cloud' : 'Local, self-judged');
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', 'positive'],
313
- failed: ['Failed', 'negative'],
314
- informational: ['Observed', ''],
315
- error: ['Not judged', 'caution'],
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
- await navigator.clipboard.writeText(text);
327
- const label = button.textContent;
328
- button.textContent = 'Copied ' + text;
329
- setTimeout(() => (button.textContent = label), 1600);
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
- h(
338
- 'div',
339
- { class: 'card empty muted' },
340
- 'No runs yet. Type ',
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
- h('h1', {}, 'Runs'),
348
- h('p', { class: 'muted' }, 'Local and cloud runs of this project, newest first.'),
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 rows' },
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('span', {}, entry.id),
358
- h('span', { class: 'pill caution' }, 'Unreadable'),
359
- h('span', { class: 'meta mono' }, entry.error)
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
- { href: '/runs/' + encodeURIComponent(entry.id), 'data-nav': true },
364
- h('strong', {}, entry.checklist),
1742
+ {
1743
+ class: 'run-row',
1744
+ href: '/runs/' + encodeURIComponent(entry.id),
1745
+ 'data-nav': true,
1746
+ },
365
1747
  h(
366
1748
  'span',
367
- {},
368
- outcomePill(entry),
369
- ' ',
370
- h('span', { class: 'muted' }, scoreText(entry.score))
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: 'meta' },
375
- entry.actor,
376
- runnerPill(entry),
377
- when(entry.started_at)
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
- function checkCard(run, check, result) {
386
- const [label, tone] = result ? RESULT[result.status] : ['Waiting', ''];
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
- 'header',
392
- {},
393
- h('span', {}, h('code', {}, check.key), ' ', check.description),
394
- h('span', { class: 'pill ' + tone }, label)
1863
+ 'p',
1864
+ { class: 'evidence' + (result.status === 'informational' ? ' observed' : '') },
1865
+ result.evidence
395
1866
  ),
396
- result && h('p', {}, result.evidence),
397
- result &&
398
- result.locator &&
1867
+ result.locator &&
399
1868
  h(
400
- 'div',
401
- { class: 'meta mono' },
1869
+ 'p',
1870
+ { class: 'cited mono' },
402
1871
  'Cited: ' + (result.locator.text || result.locator.selector)
403
1872
  ),
404
- result &&
405
- result.screenshot &&
1873
+ shot &&
406
1874
  h(
407
1875
  'a',
408
- { href: fileUrl('runs/' + run.id + '/' + result.screenshot), target: '_blank' },
1876
+ {
1877
+ class: 'shot',
1878
+ href: shot,
1879
+ target: '_blank',
1880
+ title: 'Open the full-size screenshot',
1881
+ },
409
1882
  h('img', {
410
- src: fileUrl('runs/' + run.id + '/' + result.screenshot),
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) return [h('h1', {}, id), h('p', { class: 'mono' }, 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
- ? h(
429
- 'div',
430
- { class: 'actions' },
431
- h(
432
- 'button',
433
- {
434
- disabled: data.stop_requested,
435
- onclick: async (event) => {
436
- event.target.disabled = true;
437
- event.target.textContent = 'Stopping at the next step';
438
- await fetch('/api/stop', {
439
- method: 'POST',
440
- headers: { 'Content-Type': 'application/json' },
441
- body: JSON.stringify({ run: run.id }),
442
- });
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
- data.stop_requested ? 'Stopping at the next step' : 'Stop'
446
- )
447
- )
448
- : run.runner.kind === 'local' &&
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: 'actions' },
2170
+ { class: 'live-head' },
452
2171
  h(
453
- 'button',
454
- {
455
- class: 'primary',
456
- onclick: (event) =>
457
- copy(
458
- '/stuntdouble run ' + run.checklist + (resumable ? ' resume' : ''),
459
- event.target
460
- ),
461
- },
462
- resumable ? 'Copy resume command' : 'Copy re-run command'
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
- 'button',
466
- { onclick: (event) => copy('/stuntdouble cloud ' + run.checklist, event.target) },
467
- 'Run in the cloud'
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
- const verdicts = h(
472
- 'div',
473
- { class: 'card' },
474
- checks.map((check) => checkCard(run, check, results.get(check.key)))
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
- h('p', { class: 'meta' }, h('a', { href: '/', 'data-nav': true }, 'Runs'), '/', run.id),
479
- h('h1', {}, run.checklist),
2254
+ header,
480
2255
  h(
481
2256
  'div',
482
- { class: 'meta' },
483
- outcomePill(run),
484
- scoreText(run.score) &&
2257
+ { class: 'stack' },
2258
+ live,
2259
+ notice,
2260
+ summaryCard(run, fresh),
2261
+ groups.map((group) =>
485
2262
  h(
486
- 'span',
487
- {},
488
- scoreText(run.score) +
489
- (run.score.pass_threshold == null
490
- ? ''
491
- : ', passes at ' + inUnit(run.score.pass_threshold, run.score.unit))
492
- ),
493
- runnerPill(run),
494
- h('span', {}, run.actor),
495
- webUrl(run.url) ? h('a', { href: run.url }, run.url) : h('span', {}, run.url),
496
- h('span', {}, when(run.started_at))
497
- ),
498
- run.summary && h('p', {}, run.summary),
499
- webUrl(run.runner.url) &&
500
- h('p', {}, h('a', { href: run.runner.url }, 'Open this run in Stunt Double')),
501
- actions,
502
- running
503
- ? h(
504
- 'div',
505
- { class: 'split' },
2263
+ 'section',
2264
+ { class: 'section' },
506
2265
  h(
507
- 'div',
508
- {},
509
- h('h2', {}, 'Live'),
510
- h('img', {
511
- class: 'frame',
512
- id: 'frame',
513
- src: fileUrl('.live/latest.png') + '?v=' + Date.now(),
514
- alt: 'The browser now',
515
- })
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
- 'li',
528
- {},
529
- h('span', { class: 'muted' }, new Date(line.at).toLocaleTimeString()),
530
- h('code', {}, line.tool),
531
- h('span', {}, line.text)
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
- 'div',
542
- {},
543
- h('code', {}, entry.slug),
544
- h('span', { class: 'meta mono' }, entry.error)
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: 'pill live' }, 'Linked to the cloud')
553
- : h('span', { class: 'pill' }, 'Local only');
554
- return [
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
- 'p',
558
- { class: 'muted' },
559
- 'Read only. Edit them in Claude Code: they are the JSON files in .stuntdouble/.'
560
- ),
561
- h('h2', {}, 'Checklists'),
562
- checklists.length === 0
563
- ? h('div', { class: 'card empty muted' }, 'No checklists yet.')
564
- : h(
565
- 'div',
566
- { class: 'card' },
567
- checklists.map((entry) =>
568
- entry.error
569
- ? h('div', { class: 'check' }, definitionError(entry))
570
- : h(
571
- 'div',
572
- { class: 'check' },
573
- h(
574
- 'header',
575
- {},
576
- h('strong', {}, entry.data.name),
577
- cloudIds(entry.data.cloud)
578
- ),
579
- h(
580
- 'div',
581
- { class: 'meta' },
582
- h('code', {}, entry.slug),
583
- h('span', {}, 'run by ' + entry.data.actor),
584
- h('span', {}, entry.data.url)
585
- ),
586
- h('p', {}, entry.data.instructions),
587
- h(
588
- 'ul',
589
- {},
590
- entry.data.checks.map((c) =>
591
- h(
592
- 'li',
593
- {},
594
- h('code', {}, c.key),
595
- ' ',
596
- c.description,
597
- ' ',
598
- h(
599
- 'span',
600
- { class: 'muted' },
601
- c.type === 'weighted'
602
- ? 'weighted x' + (c.weight ?? 1)
603
- : c.type.replace('_', '/')
604
- )
605
- )
606
- )
607
- )
608
- )
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
- h('h2', {}, 'Actors'),
612
- actors.length === 0
613
- ? h('div', { class: 'card empty muted' }, 'No actors yet.')
614
- : h(
615
- 'div',
616
- { class: 'card' },
617
- actors.map((entry) =>
618
- entry.error
619
- ? h('div', { class: 'check' }, definitionError(entry))
620
- : h(
621
- 'div',
622
- { class: 'check' },
623
- h(
624
- 'header',
625
- {},
626
- h('strong', {}, entry.data.name),
627
- cloudIds(entry.data.cloud)
628
- ),
629
- h(
630
- 'div',
631
- { class: 'meta' },
632
- h('code', {}, entry.slug),
633
- entry.data.persist_login && 'keeps its logins'
634
- ),
635
- entry.data.description && h('p', {}, entry.data.description),
636
- entry.data.knowledge.length > 0 &&
637
- h(
638
- 'p',
639
- { class: 'muted' },
640
- 'Knows: ' + entry.data.knowledge.map((k) => k.title).join(', ')
641
- )
642
- )
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('header nav a')) {
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 = [h('p', { class: 'mono' }, err.message)];
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
- render();
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 page.
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
- const frame = document.getElementById('frame');
685
- if (frame && paths.includes('.live/latest.png')) {
686
- frame.src = fileUrl('.live/latest.png') + '?v=' + Date.now();
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
- }, 300);
2511
+ render(false);
2512
+ }, 50);
694
2513
  };
695
- render();
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>