stuntdouble 0.1.0 → 0.1.2

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,1359 @@
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;
79
230
  }
80
- header nav a[aria-current='page'] {
231
+ .bar nav a:hover {
81
232
  color: var(--foreground);
82
233
  }
234
+ .bar nav a[aria-current='page'] {
235
+ color: var(--foreground);
236
+ background: var(--accent);
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;
478
+ }
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);
86
562
  }
87
- h1 {
88
- font: 600 1.5rem/1.2 var(--font-display);
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);
103
669
  }
104
- .rows > a,
105
- .rows > div {
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);
677
+ }
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);
709
+ }
710
+ .run-name > div {
711
+ min-width: 0;
118
712
  }
119
- .meta {
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);
792
+ text-align: left;
793
+ }
794
+ button.check-head {
795
+ cursor: pointer;
796
+ }
797
+ .check-head > svg.icon {
798
+ margin-top: 0.125rem;
799
+ }
800
+ .check-body {
801
+ display: block;
802
+ min-width: 0;
803
+ flex: 1;
804
+ }
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 {
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;
832
+ gap: 0.5rem;
833
+ }
834
+ .thumb {
835
+ display: none;
836
+ width: 3rem;
837
+ height: 2rem;
157
838
  border: 1px solid var(--border);
158
- background: var(--card);
839
+ border-radius: var(--radius-md);
840
+ object-fit: cover;
841
+ object-position: top;
842
+ background: var(--muted);
843
+ }
844
+ @media (min-width: 40rem) {
845
+ .thumb {
846
+ display: block;
847
+ }
848
+ }
849
+ .chevron {
850
+ color: var(--muted-foreground);
851
+ }
852
+ .open .chevron {
853
+ transform: rotate(180deg);
854
+ }
855
+ .check-detail {
856
+ display: grid;
857
+ gap: 0.75rem;
858
+ margin-top: 0.5rem;
859
+ padding-left: 1.75rem;
860
+ }
861
+ .evidence {
862
+ color: var(--muted-foreground);
863
+ }
864
+ .evidence.observed {
159
865
  color: var(--foreground);
160
- cursor: pointer;
161
866
  }
162
- button.primary {
163
- background: var(--primary);
164
- border-color: var(--primary);
165
- color: var(--primary-foreground);
867
+ .cited {
868
+ font-size: 0.75rem;
869
+ color: var(--muted-foreground);
870
+ overflow-wrap: anywhere;
871
+ }
872
+ .shot {
873
+ display: block;
874
+ width: fit-content;
875
+ max-width: min(100%, 36rem);
876
+ overflow: hidden;
877
+ border: 1px solid var(--border);
878
+ border-radius: var(--radius-md);
879
+ background: var(--muted);
880
+ }
881
+ .shot img {
882
+ display: block;
883
+ max-width: 100%;
884
+ max-height: 20rem;
885
+ width: auto;
886
+ object-fit: contain;
887
+ }
888
+ .tone-passed {
889
+ color: var(--success-ink);
890
+ }
891
+ .tone-failed {
892
+ color: var(--destructive);
893
+ }
894
+ .tone-error {
895
+ color: var(--warning-ink);
896
+ }
897
+ .tone-informational {
898
+ color: var(--primary);
166
899
  }
167
- button:disabled {
900
+ .tone-skipped,
901
+ .tone-waiting {
902
+ color: var(--muted-foreground);
903
+ }
904
+ .tone-waiting {
168
905
  opacity: 0.6;
169
- cursor: default;
170
906
  }
171
- .actions {
907
+
908
+ /* The run's score, as RunSummary draws it. */
909
+ .summary {
910
+ padding: 1rem;
911
+ }
912
+ @media (min-width: 40rem) {
913
+ .summary {
914
+ padding: 1.5rem;
915
+ }
916
+ }
917
+ .summary-top {
918
+ display: flex;
919
+ flex-wrap: wrap;
920
+ align-items: flex-end;
921
+ justify-content: space-between;
922
+ gap: 0.75rem 1.5rem;
923
+ }
924
+ .figure {
925
+ margin-top: 0.25rem;
926
+ font-family: var(--font-mono);
927
+ font-size: 2.25rem;
928
+ line-height: 1;
929
+ letter-spacing: -0.012em;
930
+ font-variant-numeric: tabular-nums lining-nums;
931
+ }
932
+ @media (min-width: 40rem) {
933
+ .figure {
934
+ font-size: 3rem;
935
+ }
936
+ }
937
+ .figure .of {
938
+ color: var(--muted-foreground);
939
+ }
940
+ .summary-line {
941
+ margin-top: 0.5rem;
942
+ font-size: 0.875rem;
943
+ color: var(--muted-foreground);
944
+ }
945
+ .verdict {
172
946
  display: flex;
947
+ flex-wrap: wrap;
948
+ align-items: center;
949
+ gap: 0.5rem;
950
+ }
951
+ .meter {
952
+ display: flex;
953
+ height: 0.5rem;
954
+ margin-top: 1.25rem;
955
+ overflow: hidden;
956
+ border-radius: 9999px;
957
+ background: var(--muted);
958
+ }
959
+ .meter .passed {
960
+ background: var(--success);
961
+ }
962
+ .meter .failed {
963
+ background: var(--destructive);
964
+ }
965
+ .meter .error {
966
+ background: var(--warning);
967
+ }
968
+ .counts {
969
+ display: flex;
970
+ flex-wrap: wrap;
173
971
  gap: 0.5rem;
174
972
  margin-top: 1rem;
175
973
  }
176
- .split {
974
+
975
+ /* Actor speech, verbatim: the sans, upright, behind a rule. */
976
+ .quote {
977
+ padding-left: 0.75rem;
978
+ border-left: 2px solid var(--border);
979
+ overflow-wrap: anywhere;
980
+ }
981
+ .notice {
982
+ display: flex;
983
+ align-items: flex-start;
984
+ gap: 0.5rem;
985
+ padding: 0.75rem;
986
+ border: 1px solid var(--border);
987
+ border-radius: var(--radius-lg);
988
+ font-size: 0.875rem;
989
+ overflow-wrap: anywhere;
990
+ }
991
+ .notice > svg.icon {
992
+ margin-top: 0.125rem;
993
+ }
994
+ .notice.destructive {
995
+ border-color: color-mix(in oklch, var(--destructive) 25%, transparent);
996
+ background: color-mix(in oklch, var(--destructive) 5%, transparent);
997
+ color: var(--destructive);
998
+ }
999
+ .notice.warning {
1000
+ border-color: color-mix(in oklch, var(--warning) 40%, transparent);
1001
+ background: color-mix(in oklch, var(--warning) 10%, transparent);
1002
+ color: var(--warning-ink);
1003
+ }
1004
+
1005
+ /* A run in progress. */
1006
+ .live-card {
177
1007
  display: grid;
178
- grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
179
- gap: 1.5rem;
180
- align-items: start;
1008
+ gap: 0.75rem;
1009
+ padding: 1rem;
181
1010
  }
182
- @media (max-width: 52rem) {
183
- .split {
184
- grid-template-columns: 1fr;
185
- }
1011
+ .live-head {
1012
+ display: flex;
1013
+ flex-wrap: wrap;
1014
+ align-items: center;
1015
+ justify-content: space-between;
1016
+ gap: 0.5rem 0.75rem;
1017
+ }
1018
+ .live-step {
1019
+ display: flex;
1020
+ min-width: 0;
1021
+ flex: 1 1 12rem;
1022
+ align-items: center;
1023
+ gap: 0.5rem;
1024
+ font-size: 0.875rem;
1025
+ color: var(--muted-foreground);
1026
+ }
1027
+ .live-side {
1028
+ display: flex;
1029
+ flex-shrink: 0;
1030
+ flex-wrap: wrap;
1031
+ align-items: center;
1032
+ justify-content: flex-end;
1033
+ gap: 0.5rem 0.75rem;
1034
+ }
1035
+ .stop-error {
1036
+ flex-basis: 100%;
1037
+ font-size: 0.75rem;
1038
+ color: var(--destructive);
1039
+ text-align: right;
1040
+ }
1041
+ .chase {
1042
+ display: inline-flex;
1043
+ flex-shrink: 0;
1044
+ align-items: center;
1045
+ gap: 0.3em;
1046
+ }
1047
+ .chase > span {
1048
+ display: block;
1049
+ width: 0.4em;
1050
+ height: 0.4em;
1051
+ border-radius: 9999px;
1052
+ background: currentColor;
1053
+ animation: sd-chase 1.1s var(--ease-glide) infinite both;
1054
+ animation-delay: calc(var(--bulb, 0) * 110ms);
1055
+ }
1056
+ .chase > span:nth-child(2) {
1057
+ --bulb: 1;
1058
+ }
1059
+ .chase > span:nth-child(3) {
1060
+ --bulb: 2;
1061
+ }
1062
+ .chase > span:nth-child(4) {
1063
+ --bulb: 3;
1064
+ }
1065
+ .chase > span:nth-child(5) {
1066
+ --bulb: 4;
1067
+ }
1068
+ .progress {
1069
+ height: 2px;
1070
+ overflow: hidden;
1071
+ border-radius: 9999px;
1072
+ background: var(--muted);
1073
+ }
1074
+ .progress > span {
1075
+ display: block;
1076
+ height: 100%;
1077
+ background: var(--primary);
186
1078
  }
187
1079
  .frame {
188
- width: 100%;
189
1080
  display: block;
190
- border-radius: var(--radius);
1081
+ width: 100%;
1082
+ max-height: 32rem;
1083
+ object-fit: contain;
1084
+ object-position: top;
191
1085
  border: 1px solid var(--border);
1086
+ border-radius: var(--radius-md);
192
1087
  background: var(--muted);
193
1088
  }
194
- .check {
1089
+
1090
+ /* Activity: the tool calls, in order. */
1091
+ .activity li {
1092
+ display: grid;
1093
+ grid-template-columns: auto minmax(0, 1fr);
1094
+ gap: 0.125rem 0.75rem;
1095
+ padding: 0.5rem 1rem;
1096
+ font-size: 0.8125rem;
1097
+ }
1098
+ .activity time {
1099
+ font-family: var(--font-mono);
1100
+ font-size: 0.75rem;
1101
+ font-variant-numeric: tabular-nums;
1102
+ color: var(--muted-foreground);
1103
+ white-space: nowrap;
1104
+ }
1105
+ .activity code {
1106
+ font-size: 0.75rem;
1107
+ }
1108
+ .activity .text {
1109
+ grid-column: 1 / -1;
1110
+ white-space: pre-wrap;
1111
+ overflow-wrap: anywhere;
1112
+ color: var(--muted-foreground);
1113
+ }
1114
+ @media (min-width: 40rem) {
1115
+ .activity li {
1116
+ grid-template-columns: 5.5rem 8rem minmax(0, 1fr);
1117
+ }
1118
+ .activity .text {
1119
+ grid-column: auto;
1120
+ }
1121
+ }
1122
+
1123
+ /* Definitions. */
1124
+ .def {
1125
+ display: grid;
1126
+ gap: 0.5rem;
195
1127
  padding: 1rem;
196
- border-top: 1px solid var(--border);
197
1128
  }
198
- .check:first-child {
199
- border-top: 0;
1129
+ .def-head {
1130
+ display: flex;
1131
+ flex-wrap: wrap;
1132
+ align-items: center;
1133
+ justify-content: space-between;
1134
+ gap: 0.5rem;
200
1135
  }
201
- .check header {
202
- all: unset;
1136
+ .def-name {
203
1137
  display: flex;
1138
+ min-width: 0;
1139
+ align-items: center;
204
1140
  gap: 0.5rem;
1141
+ font-weight: 500;
1142
+ }
1143
+ .def-name > svg {
1144
+ color: var(--muted-foreground);
1145
+ }
1146
+ .def-facts {
1147
+ display: flex;
1148
+ flex-wrap: wrap;
1149
+ align-items: center;
1150
+ gap: 0.25rem 1rem;
1151
+ padding-left: 1.5rem;
1152
+ font-size: 0.75rem;
1153
+ color: var(--muted-foreground);
1154
+ overflow-wrap: anywhere;
1155
+ }
1156
+ .def-body {
1157
+ padding-left: 1.5rem;
1158
+ color: var(--muted-foreground);
1159
+ }
1160
+ .def-checks {
1161
+ margin-left: 1.5rem;
1162
+ margin-top: 0.25rem;
1163
+ border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
1164
+ }
1165
+ .def-checks li {
1166
+ display: flex;
1167
+ flex-wrap: wrap;
205
1168
  align-items: baseline;
206
- justify-content: space-between;
1169
+ gap: 0.25rem 0.5rem;
1170
+ padding-block: 0.5rem;
1171
+ border-bottom: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
207
1172
  }
208
- .check img {
209
- margin-top: 0.75rem;
210
- max-width: 100%;
211
- max-height: 20rem;
212
- border-radius: calc(var(--radius) - 2px);
213
- border: 1px solid var(--border);
1173
+ .def-checks li:last-child {
1174
+ border-bottom: 0;
1175
+ padding-bottom: 0;
214
1176
  }
215
- code,
216
- .mono {
1177
+ .def-checks code {
1178
+ font-size: 0.6875rem;
1179
+ color: var(--muted-foreground);
1180
+ }
1181
+ .error-text {
217
1182
  font-family: var(--font-mono);
218
- font-size: 12px;
1183
+ font-size: 0.75rem;
1184
+ color: var(--muted-foreground);
1185
+ white-space: pre-wrap;
1186
+ overflow-wrap: anywhere;
219
1187
  }
220
- .timeline {
221
- list-style: none;
222
- margin: 0;
223
- padding: 0;
224
- font-size: 13px;
1188
+
1189
+ /* EmptyState: a framed medallion, a display title, one action. */
1190
+ .empty {
1191
+ display: flex;
1192
+ min-height: 52vh;
1193
+ align-items: center;
1194
+ justify-content: center;
1195
+ padding-inline: 1.5rem;
1196
+ text-align: center;
225
1197
  }
226
- .timeline li {
227
- display: grid;
228
- grid-template-columns: 5.5rem 7rem 1fr;
1198
+ .frame-marks {
1199
+ position: relative;
1200
+ width: 100%;
1201
+ max-width: 28rem;
1202
+ padding: 2.5rem 1.5rem;
1203
+ }
1204
+ .frame-marks::after {
1205
+ content: '';
1206
+ position: absolute;
1207
+ inset: 0.75rem;
1208
+ pointer-events: none;
1209
+ background:
1210
+ linear-gradient(currentColor, currentColor) top left / 1.25rem 1.25px no-repeat,
1211
+ linear-gradient(currentColor, currentColor) top left / 1.25px 1.25rem no-repeat,
1212
+ linear-gradient(currentColor, currentColor) bottom right / 1.25rem 1.25px no-repeat,
1213
+ linear-gradient(currentColor, currentColor) bottom right / 1.25px 1.25rem no-repeat;
1214
+ color: color-mix(in oklch, var(--muted-foreground) 45%, transparent);
1215
+ }
1216
+ .medallion {
1217
+ display: flex;
1218
+ width: 3.5rem;
1219
+ height: 3.5rem;
1220
+ align-items: center;
1221
+ justify-content: center;
1222
+ margin: 0 auto 1.25rem;
1223
+ border: 1px solid var(--border);
1224
+ border-radius: 9999px;
1225
+ background: color-mix(in oklch, var(--muted) 60%, transparent);
1226
+ color: var(--muted-foreground);
1227
+ }
1228
+ .medallion svg.icon {
1229
+ width: 1.5rem;
1230
+ height: 1.5rem;
1231
+ }
1232
+ .empty h2 {
1233
+ font-family: var(--font-display);
1234
+ font-size: 1.125rem;
1235
+ letter-spacing: -0.035em;
1236
+ line-height: 1.1;
1237
+ }
1238
+ .empty p {
1239
+ max-width: 24rem;
1240
+ margin: 0.5rem auto 0;
1241
+ font-size: 0.875rem;
1242
+ color: var(--muted-foreground);
1243
+ }
1244
+ .empty code {
1245
+ font-size: 0.8125rem;
1246
+ color: var(--foreground);
1247
+ }
1248
+ .inline-empty {
1249
+ display: flex;
1250
+ flex-direction: column;
1251
+ align-items: center;
229
1252
  gap: 0.75rem;
230
- padding: 0.4rem 1rem;
231
- border-top: 1px solid var(--border);
1253
+ padding: 3rem 1rem;
1254
+ text-align: center;
1255
+ font-size: 0.875rem;
1256
+ color: var(--muted-foreground);
1257
+ }
1258
+ .inline-empty svg.icon {
1259
+ width: 2.5rem;
1260
+ height: 2.5rem;
1261
+ opacity: 0.5;
1262
+ }
1263
+
1264
+ /* ── Motion (motion.css tokens and keyframes) ─────────── */
1265
+ .animate-page-in {
1266
+ animation: sd-rise var(--duration-slow) var(--ease-drawer) both;
1267
+ }
1268
+ .animate-rise {
1269
+ animation: sd-rise var(--duration-slow) var(--ease-snap) backwards;
1270
+ }
1271
+ .animate-stamp {
1272
+ animation: sd-stamp var(--duration-slow) var(--ease-snap) backwards;
1273
+ }
1274
+ @keyframes sd-rise {
1275
+ from {
1276
+ opacity: 0;
1277
+ transform: translateY(0.5rem);
1278
+ }
1279
+ to {
1280
+ opacity: 1;
1281
+ transform: none;
1282
+ }
232
1283
  }
233
- .timeline li:first-child {
234
- border-top: 0;
1284
+ @keyframes sd-stamp {
1285
+ 0% {
1286
+ opacity: 0;
1287
+ transform: rotate(-6deg) scale(1.35);
1288
+ }
1289
+ 60% {
1290
+ opacity: 0.9;
1291
+ transform: rotate(-1.5deg) scale(0.97);
1292
+ }
1293
+ 100% {
1294
+ opacity: 0.9;
1295
+ transform: rotate(-1.5deg) scale(1);
1296
+ }
235
1297
  }
236
- .timeline span:last-child {
237
- overflow-wrap: anywhere;
238
- white-space: pre-wrap;
1298
+ @keyframes sd-chase {
1299
+ 0%,
1300
+ 100% {
1301
+ opacity: 0.18;
1302
+ transform: scale(0.8);
1303
+ }
1304
+ 20% {
1305
+ opacity: 1;
1306
+ transform: none;
1307
+ }
239
1308
  }
240
- .empty {
241
- padding: 2rem 1rem;
242
- text-align: center;
1309
+ @keyframes marquee-dot {
1310
+ 0%,
1311
+ 100% {
1312
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 22%, transparent);
1313
+ opacity: 1;
1314
+ }
1315
+ 50% {
1316
+ box-shadow: 0 0 0 6px color-mix(in oklch, var(--primary) 0%, transparent);
1317
+ opacity: 0.7;
1318
+ }
243
1319
  }
244
- footer {
245
- padding-bottom: 2rem;
246
- font-size: 13px;
1320
+ @media (prefers-reduced-motion: reduce) {
1321
+ *,
1322
+ *::before,
1323
+ *::after {
1324
+ animation-duration: 0.01ms !important;
1325
+ animation-iteration-count: 1 !important;
1326
+ animation-delay: 0s !important;
1327
+ transition-duration: 0.01ms !important;
1328
+ }
1329
+ .chase > span {
1330
+ opacity: 0.6;
1331
+ }
247
1332
  }
248
1333
  </style>
249
1334
  </head>
250
1335
  <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>
1336
+ <header class="bar">
1337
+ <div class="container">
1338
+ <span class="product">Stunt Double Local</span>
1339
+ <nav aria-label="Viewer">
1340
+ <a href="/" data-nav>Runs</a>
1341
+ <a href="/definitions" data-nav>Checklists and actors</a>
1342
+ </nav>
1343
+ </div>
257
1344
  </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>
1345
+ <main>
1346
+ <div class="container" id="app"></div>
1347
+ <p class="sr-only" id="status" role="status"></p>
1348
+ </main>
1349
+ <footer>
1350
+ <div class="container">
1351
+ <p>
1352
+ Local runs use your own Claude plan and a browser on this machine, one at a time while
1353
+ your session is open. The cloud runs on schedules and in parallel, records every run and
1354
+ shares reports with your team. <a href="https://stuntdouble.io/pricing">See plans</a>
1355
+ </p>
1356
+ </div>
263
1357
  </footer>
264
1358
  <script>
265
1359
  // Every value from a file is set as text, never as markup: verdicts and
@@ -278,42 +1372,192 @@
278
1372
  return el;
279
1373
  }
280
1374
 
1375
+ // Continuity icons from StuntKit (@stunt-double/icons, svg/*.svg): the
1376
+ // drawing inside each 24 unit frame. Constant markup written here, never data.
1377
+ const ICONS = {
1378
+ actor:
1379
+ '<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"/>',
1380
+ 'alert-triangle':
1381
+ '<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"/>',
1382
+ 'arrow-left': '<path d="M20 12H4"/><path d="M10 6l-6 6 6 6"/>',
1383
+ calendar:
1384
+ '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18"/><path d="M8 3v4"/><path d="M16 3v4"/>',
1385
+ 'check-circle': '<circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-5"/>',
1386
+ checklist:
1387
+ '<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"/>',
1388
+ 'chevron-down': '<path d="M5 9.5l7 7 7-7"/>',
1389
+ 'circle-dashed':
1390
+ '<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"/>',
1391
+ 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"/>',
1392
+ 'external-link':
1393
+ '<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"/>',
1394
+ 'git-branch':
1395
+ '<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"/>',
1396
+ globe:
1397
+ '<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"/>',
1398
+ 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"/>',
1399
+ knowledge:
1400
+ '<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"/>',
1401
+ 'minus-circle': '<circle cx="12" cy="12" r="9"/><path d="M8 12h8"/>',
1402
+ monitor:
1403
+ '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M12 16v4"/><path d="M8 20h8"/>',
1404
+ 'octagon-x':
1405
+ '<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"/>',
1406
+ pending:
1407
+ '<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"/>',
1408
+ run: '<path d="M9 4.5l12 7.5-12 7.5z"/><path d="M3 9h3"/><path d="M3 15h3"/>',
1409
+ scale:
1410
+ '<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"/>',
1411
+ server:
1412
+ '<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"/>',
1413
+ stop: '<rect x="5" y="5" width="14" height="14"/>',
1414
+ timer:
1415
+ '<circle cx="12" cy="13.5" r="7.5"/><path d="M12 13.5l3-3"/><path d="M10 3h4"/><path d="M12 3v3"/>',
1416
+ 'x-circle': '<circle cx="12" cy="12" r="9"/><path d="M9 9l6 6"/><path d="M15 9l-6 6"/>',
1417
+ };
1418
+ function icon(name, className) {
1419
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1420
+ for (const [key, value] of Object.entries({
1421
+ viewBox: '0 0 24 24',
1422
+ fill: 'none',
1423
+ stroke: 'currentColor',
1424
+ 'stroke-width': '1.5',
1425
+ 'stroke-linecap': 'square',
1426
+ 'stroke-linejoin': 'miter',
1427
+ 'aria-hidden': 'true',
1428
+ class: 'icon' + (className ? ' ' + className : ''),
1429
+ })) {
1430
+ svg.setAttribute(key, value);
1431
+ }
1432
+ svg.innerHTML = ICONS[name];
1433
+ return svg;
1434
+ }
1435
+
281
1436
  const app = document.getElementById('app');
282
- const when = (iso) => (iso ? new Date(iso).toLocaleString() : '');
283
1437
  const fileUrl = (path) => '/files/' + path.split('/').map(encodeURIComponent).join('/');
284
1438
  // A committed run.json is someone else's file: only web addresses become links.
285
1439
  const webUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : null);
1440
+ const pad = (n) => String(n).padStart(2, '0');
1441
+
1442
+ // Times in the reader's locale and zone, the way the product's
1443
+ // TimeDisplay and RelativeTime render them.
1444
+ const dateTime = new Intl.DateTimeFormat(undefined, {
1445
+ dateStyle: 'medium',
1446
+ timeStyle: 'short',
1447
+ });
1448
+ const clock = new Intl.DateTimeFormat(undefined, {
1449
+ hour: '2-digit',
1450
+ minute: '2-digit',
1451
+ second: '2-digit',
1452
+ });
1453
+ const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
1454
+ const DIVISIONS = [
1455
+ [60, 'seconds'],
1456
+ [60, 'minutes'],
1457
+ [24, 'hours'],
1458
+ [7, 'days'],
1459
+ [4.34524, 'weeks'],
1460
+ [12, 'months'],
1461
+ [Infinity, 'years'],
1462
+ ];
1463
+ function ago(date) {
1464
+ let amount = (date.getTime() - Date.now()) / 1000;
1465
+ if (Math.abs(amount) < 45) return 'just now';
1466
+ for (const [size, unit] of DIVISIONS) {
1467
+ if (Math.abs(amount) < size) return relative.format(Math.round(amount), unit);
1468
+ amount /= size;
1469
+ }
1470
+ return '';
1471
+ }
1472
+ function time(iso, style) {
1473
+ const date = new Date(iso);
1474
+ if (!iso || Number.isNaN(date.getTime())) return null;
1475
+ const exact = dateTime.format(date);
1476
+ if (style === 'relative') return h('time', { datetime: iso, title: exact }, ago(date));
1477
+ return h('time', { datetime: iso }, style === 'clock' ? clock.format(date) : exact);
1478
+ }
1479
+ function took(start, end) {
1480
+ const seconds = Math.round((new Date(end) - new Date(start)) / 1000);
1481
+ if (!(seconds >= 0)) return null;
1482
+ if (seconds < 60) return seconds + 's';
1483
+ const minutes = Math.floor(seconds / 60);
1484
+ if (minutes < 60) return minutes + 'm ' + pad(seconds % 60) + 's';
1485
+ return Math.floor(minutes / 60) + 'h ' + pad(minutes % 60) + 'm';
1486
+ }
286
1487
 
287
- function outcomePill(run) {
288
- if (run.status === 'running') return h('span', { class: 'pill live' }, 'Running');
1488
+ // The run's one headline badge: its verdict once it has one, otherwise
1489
+ // where the run got to.
1490
+ function runState(run) {
1491
+ if (run.status === 'running') return { label: 'Running', tone: 'arc', live: true };
289
1492
  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');
1493
+ return { label: 'Interrupted', tone: 'warning', icon: 'alert-triangle' };
1494
+ if (run.status === 'failed')
1495
+ return { label: 'Run failed', tone: 'destructive', icon: 'octagon-x' };
292
1496
  const outcome = run.score && run.score.outcome;
293
1497
  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];
1498
+ return {
1499
+ label: run.status === 'stopped' ? 'Stopped' : run.status,
1500
+ tone: 'quiet',
1501
+ icon: run.status === 'stopped' ? 'stop' : null,
1502
+ };
1503
+ const [label, tone, mark] = {
1504
+ passed: ['Passed', 'success', 'check-circle'],
1505
+ failed: ['Failed', 'destructive', 'x-circle'],
1506
+ inconclusive: ['Inconclusive', 'warning', 'alert-triangle'],
1507
+ }[outcome];
1508
+ return {
1509
+ label: run.status === 'stopped' ? label + ', stopped' : label,
1510
+ tone,
1511
+ icon: mark,
1512
+ outcome,
1513
+ };
1514
+ }
1515
+ function stateBadge(run, size) {
1516
+ const state = runState(run);
297
1517
  return h(
298
1518
  'span',
299
- { class: 'pill ' + tone },
300
- run.status === 'stopped' ? label + ', stopped' : label
1519
+ { class: 'badge ' + state.tone + (size ? ' ' + size : '') },
1520
+ state.live && h('span', { class: 'bulb live', 'aria-hidden': 'true' }),
1521
+ state.icon && icon(state.icon),
1522
+ state.label
301
1523
  );
302
1524
  }
303
1525
 
304
1526
  const inUnit = (value, unit) => (unit == null ? value + '%' : value + ' ' + unit);
305
- const scoreText = (score) =>
306
- score && score.score != null ? inUnit(score.score, score.unit) : '';
1527
+ // A checklist with no weights has no score, so the run reads as "3 of 4
1528
+ // passed", the same count the finish_run reply gives. Informational and
1529
+ // skipped checks are left out, as the scorer leaves them out.
1530
+ const tally = (run) => {
1531
+ const checks = (run.checklist_snapshot && run.checklist_snapshot.checks) || [];
1532
+ const status = Object.fromEntries((run.results || []).map((r) => [r.check, r.status]));
1533
+ const scored = checks.filter(
1534
+ (c) => c.type !== 'informational' && status[c.key] !== 'skipped'
1535
+ );
1536
+ return {
1537
+ passed: scored.filter((c) => status[c.key] === 'passed').length,
1538
+ total: scored.length,
1539
+ };
1540
+ };
1541
+ const scoreText = (score, run) => {
1542
+ if (score && score.score != null) return inUnit(score.score, score.unit);
1543
+ if (!score || !run || !run.checklist_snapshot) return '';
1544
+ const t = tally(run);
1545
+ return t.total ? t.passed + ' of ' + t.total + ' passed' : '';
1546
+ };
307
1547
 
308
- const runnerPill = (run) =>
309
- h('span', { class: 'pill' }, run.runner.kind === 'cloud' ? 'Cloud' : 'Local, self-judged');
1548
+ const runner = (run) =>
1549
+ run.runner.kind === 'cloud'
1550
+ ? { icon: 'server', label: 'Cloud' }
1551
+ : { icon: 'monitor', label: 'Local, self-judged' };
1552
+ const checklistName = (run) =>
1553
+ (run.checklist_snapshot && run.checklist_snapshot.name) || run.checklist;
310
1554
 
311
1555
  const RESULT = {
312
- passed: ['Passed', 'positive'],
313
- failed: ['Failed', 'negative'],
314
- informational: ['Observed', ''],
315
- error: ['Not judged', 'caution'],
316
- skipped: ['Skipped', ''],
1556
+ passed: ['Passed', 'success', 'check-circle'],
1557
+ failed: ['Failed', 'destructive', 'x-circle'],
1558
+ informational: ['Observed', 'arc', 'info'],
1559
+ error: ['Not judged', 'warning', 'alert-triangle'],
1560
+ skipped: ['Skipped', 'quiet', 'minus-circle'],
317
1561
  };
318
1562
 
319
1563
  async function getJson(url) {
@@ -322,59 +1566,196 @@
322
1566
  return res.json();
323
1567
  }
324
1568
 
1569
+ // A live region outside #app, so a redraw never re-announces the page.
1570
+ const status = document.getElementById('status');
1571
+ const announce = (text) => {
1572
+ if (status.textContent !== text) status.textContent = text;
1573
+ };
1574
+
1575
+ // The label stays short so the header does not shift; the command is
1576
+ // the button's title.
325
1577
  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);
1578
+ const label = button.lastChild;
1579
+ label.dataset.label ??= label.textContent;
1580
+ let done = 'Copied';
1581
+ try {
1582
+ await navigator.clipboard.writeText(text);
1583
+ } catch {
1584
+ done = 'Copy failed';
1585
+ }
1586
+ label.textContent = done;
1587
+ announce(done === 'Copied' ? 'Copied ' + text : done);
1588
+ clearTimeout(button.resetLabel);
1589
+ button.resetLabel = setTimeout(() => (label.textContent = label.dataset.label), 1600);
1590
+ }
1591
+
1592
+ // Set when a Stop is refused, shown beside the button on the next draw.
1593
+ let stopError = null;
1594
+
1595
+ function pageHeader({ back, title, badge, description, chips, actions }) {
1596
+ return h(
1597
+ 'header',
1598
+ { class: 'page-header' },
1599
+ back &&
1600
+ h(
1601
+ 'div',
1602
+ { class: 'back' },
1603
+ h('a', { href: back.href, 'data-nav': true }, icon('arrow-left'), back.label),
1604
+ back.trail && [
1605
+ h('span', { class: 'slash', 'aria-hidden': 'true' }, '/'),
1606
+ h('span', { class: 'truncate mono' }, back.trail),
1607
+ ]
1608
+ ),
1609
+ h(
1610
+ 'div',
1611
+ { class: 'header-grid' },
1612
+ h(
1613
+ 'div',
1614
+ { class: 'header-main' },
1615
+ h('div', { class: 'title-row' }, h('h1', { class: 'page-title' }, title), badge),
1616
+ description && h('p', { class: 'description' }, description),
1617
+ chips &&
1618
+ chips.length > 0 &&
1619
+ h(
1620
+ 'ul',
1621
+ { class: 'chips' },
1622
+ chips.map((chip) =>
1623
+ h(
1624
+ 'li',
1625
+ {},
1626
+ h(
1627
+ chip.href ? 'a' : 'span',
1628
+ {
1629
+ class: 'chip',
1630
+ title: chip.title,
1631
+ href: chip.href,
1632
+ target: chip.href && '_blank',
1633
+ rel: chip.href && 'noopener noreferrer',
1634
+ },
1635
+ icon(chip.icon),
1636
+ h('span', { class: 'truncate' + (chip.mono ? ' mono' : '') }, chip.label)
1637
+ )
1638
+ )
1639
+ )
1640
+ )
1641
+ ),
1642
+ actions && h('div', { class: 'actions' }, actions)
1643
+ )
1644
+ );
1645
+ }
1646
+
1647
+ function emptyState(mark, title, ...description) {
1648
+ return h(
1649
+ 'div',
1650
+ { class: 'empty' },
1651
+ h(
1652
+ 'div',
1653
+ { class: 'frame-marks' },
1654
+ h('div', { class: 'medallion' }, icon(mark)),
1655
+ h('h2', {}, title),
1656
+ h('p', {}, description)
1657
+ )
1658
+ );
330
1659
  }
331
1660
 
332
1661
  async function renderRuns() {
333
1662
  const runs = await getJson('/api/runs');
334
1663
  if (runs.length === 0) {
335
1664
  return [
336
- h('h1', {}, 'Runs'),
337
- h(
338
- 'div',
339
- { class: 'card empty muted' },
340
- 'No runs yet. Type ',
1665
+ h('h1', { class: 'sr-only' }, 'Runs'),
1666
+ emptyState(
1667
+ 'checklist',
1668
+ 'No runs yet',
1669
+ 'Type ',
341
1670
  h('code', {}, '/stuntdouble run <checklist>'),
342
1671
  ' in Claude Code to start one.'
343
1672
  ),
344
1673
  ];
345
1674
  }
346
1675
  return [
347
- h('h1', {}, 'Runs'),
348
- h('p', { class: 'muted' }, 'Local and cloud runs of this project, newest first.'),
1676
+ pageHeader({
1677
+ title: 'Runs',
1678
+ description: 'Local and cloud runs of this project, newest first.',
1679
+ }),
349
1680
  h(
350
1681
  'div',
351
- { class: 'card rows' },
1682
+ { class: 'group card' },
1683
+ h(
1684
+ 'div',
1685
+ { class: 'runs-head', 'aria-hidden': 'true' },
1686
+ h('span', {}, 'Checklist'),
1687
+ h('span', {}, 'Result'),
1688
+ h('span', {}, 'Actor'),
1689
+ h('span', {}, 'Runner'),
1690
+ h('span', {}, 'Started')
1691
+ ),
352
1692
  runs.map((entry) =>
353
1693
  'error' in entry
354
1694
  ? h(
355
1695
  'div',
356
- {},
357
- h('span', {}, entry.id),
358
- h('span', { class: 'pill caution' }, 'Unreadable'),
359
- h('span', { class: 'meta mono' }, entry.error)
1696
+ { class: 'run-row' },
1697
+ h(
1698
+ 'span',
1699
+ { class: 'run-name' },
1700
+ icon('alert-triangle', 'tone-error'),
1701
+ h('span', { class: 'truncate mono' }, entry.id)
1702
+ ),
1703
+ h(
1704
+ 'span',
1705
+ { class: 'result' },
1706
+ h('span', { class: 'badge warning' }, 'Unreadable')
1707
+ ),
1708
+ h('span', { class: 'facts error-text' }, entry.error)
360
1709
  )
361
1710
  : h(
362
1711
  'a',
363
- { href: '/runs/' + encodeURIComponent(entry.id), 'data-nav': true },
364
- h('strong', {}, entry.checklist),
1712
+ {
1713
+ class: 'run-row',
1714
+ href: '/runs/' + encodeURIComponent(entry.id),
1715
+ 'data-nav': true,
1716
+ },
365
1717
  h(
366
1718
  'span',
367
- {},
368
- outcomePill(entry),
369
- ' ',
370
- h('span', { class: 'muted' }, scoreText(entry.score))
1719
+ { class: 'run-name' },
1720
+ icon('checklist'),
1721
+ h(
1722
+ 'div',
1723
+ {},
1724
+ h(
1725
+ 'strong',
1726
+ { class: 'truncate', style: 'display:block' },
1727
+ checklistName(entry)
1728
+ ),
1729
+ h(
1730
+ 'span',
1731
+ { class: 'run-id mono truncate', style: 'display:block' },
1732
+ entry.id
1733
+ )
1734
+ )
1735
+ ),
1736
+ h(
1737
+ 'span',
1738
+ { class: 'result' },
1739
+ stateBadge(entry),
1740
+ scoreText(entry.score, entry) &&
1741
+ h('span', { class: 'tally' }, scoreText(entry.score, entry))
371
1742
  ),
372
1743
  h(
373
1744
  'span',
374
- { class: 'meta' },
375
- entry.actor,
376
- runnerPill(entry),
377
- when(entry.started_at)
1745
+ { class: 'facts' },
1746
+ h(
1747
+ 'span',
1748
+ { class: 'fact' },
1749
+ icon('actor'),
1750
+ h('span', { class: 'truncate' }, entry.actor)
1751
+ ),
1752
+ h(
1753
+ 'span',
1754
+ { class: 'fact' },
1755
+ icon(runner(entry).icon),
1756
+ h('span', { class: 'truncate' }, runner(entry).label)
1757
+ ),
1758
+ h('span', { class: 'fact' }, time(entry.started_at, 'relative'))
378
1759
  )
379
1760
  )
380
1761
  )
@@ -382,166 +1763,522 @@
382
1763
  ];
383
1764
  }
384
1765
 
385
- function checkCard(run, check, result) {
386
- const [label, tone] = result ? RESULT[result.status] : ['Waiting', ''];
1766
+ // How a check is judged, beside its key. Silent for a plain pass/fail
1767
+ // check, an unconditional one and one that lets the run continue: those
1768
+ // are the defaults, and a badge on every row would bury the rest.
1769
+ function checkMeta(check) {
1770
+ const weight = check.weight ?? 1;
387
1771
  return h(
1772
+ 'span',
1773
+ { class: 'check-meta' },
1774
+ h('code', {}, check.key),
1775
+ check.type === 'informational' &&
1776
+ h('span', { class: 'badge outline sm' }, icon('info'), 'Observation'),
1777
+ check.type === 'weighted' &&
1778
+ h(
1779
+ 'span',
1780
+ { class: 'badge outline sm' },
1781
+ icon(weight < 0 ? 'minus-circle' : 'scale'),
1782
+ h('span', { class: 'mono' }, weight < 0 ? weight + ' penalty' : weight + 'x')
1783
+ ),
1784
+ check.run_condition &&
1785
+ check.run_condition !== 'always' &&
1786
+ h(
1787
+ 'span',
1788
+ { class: 'badge outline sm' },
1789
+ icon('git-branch'),
1790
+ check.run_condition.replaceAll('_', ' ')
1791
+ ),
1792
+ check.on_failure === 'stop' &&
1793
+ h('span', { class: 'badge outline sm' }, icon('octagon-x'), 'Stops the run if it fails')
1794
+ );
1795
+ }
1796
+
1797
+ // Per page view: which rows a reader opened or closed, and what this
1798
+ // tab had already seen of each run, so a redraw keeps the rows as they
1799
+ // were and only something that just landed moves.
1800
+ const toggled = new Map();
1801
+ const seen = new Map();
1802
+
1803
+ function checkRow(run, check, result, opts) {
1804
+ if (!result) {
1805
+ return h(
1806
+ 'li',
1807
+ { class: 'check waiting' },
1808
+ h(
1809
+ 'div',
1810
+ { class: 'check-head' },
1811
+ icon(opts.running ? 'pending' : 'circle-dashed', 'tone-waiting'),
1812
+ h(
1813
+ 'span',
1814
+ { class: 'check-body' },
1815
+ h('span', { class: 'check-desc' }, check.description),
1816
+ checkMeta(check)
1817
+ ),
1818
+ h('span', { class: 'badge quiet sm' }, opts.running ? 'Waiting' : 'Not reached')
1819
+ )
1820
+ );
1821
+ }
1822
+ const [label, tone, mark] = RESULT[result.status];
1823
+ const shot = result.screenshot && fileUrl('runs/' + run.id + '/' + result.screenshot);
1824
+ const key = run.id + ':' + check.key;
1825
+ const open = toggled.has(key) ? toggled.get(key) : opts.openByDefault;
1826
+ const detail = h(
388
1827
  'div',
389
- { class: 'check' },
1828
+ { class: 'check-detail', id: 'detail-' + check.key, hidden: !open },
390
1829
  h(
391
- 'header',
392
- {},
393
- h('span', {}, h('code', {}, check.key), ' ', check.description),
394
- h('span', { class: 'pill ' + tone }, label)
1830
+ 'p',
1831
+ { class: 'evidence' + (result.status === 'informational' ? ' observed' : '') },
1832
+ result.evidence
395
1833
  ),
396
- result && h('p', {}, result.evidence),
397
- result &&
398
- result.locator &&
1834
+ result.locator &&
399
1835
  h(
400
- 'div',
401
- { class: 'meta mono' },
1836
+ 'p',
1837
+ { class: 'cited mono' },
402
1838
  'Cited: ' + (result.locator.text || result.locator.selector)
403
1839
  ),
404
- result &&
405
- result.screenshot &&
1840
+ shot &&
406
1841
  h(
407
1842
  'a',
408
- { href: fileUrl('runs/' + run.id + '/' + result.screenshot), target: '_blank' },
1843
+ {
1844
+ class: 'shot',
1845
+ href: shot,
1846
+ target: '_blank',
1847
+ title: 'Open the full-size screenshot',
1848
+ },
409
1849
  h('img', {
410
- src: fileUrl('runs/' + run.id + '/' + result.screenshot),
1850
+ src: shot,
411
1851
  alt: 'The page when ' + check.key + ' was judged',
412
1852
  loading: 'lazy',
413
1853
  })
414
1854
  )
415
1855
  );
1856
+ const thumb =
1857
+ shot && h('img', { class: 'thumb', src: shot, alt: '', loading: 'lazy', hidden: open });
1858
+ const row = h(
1859
+ 'li',
1860
+ { class: 'check' + (open ? ' open' : '') + (opts.arrived ? ' animate-rise' : '') },
1861
+ h(
1862
+ 'button',
1863
+ {
1864
+ type: 'button',
1865
+ class: 'check-head',
1866
+ 'aria-expanded': String(open),
1867
+ 'aria-controls': 'detail-' + check.key,
1868
+ 'aria-labelledby': 'desc-' + check.key + ' label-' + check.key,
1869
+ 'data-focus-key': 'check:' + check.key,
1870
+ onclick: () => {
1871
+ const now = detail.hidden;
1872
+ toggled.set(key, now);
1873
+ detail.hidden = !now;
1874
+ if (thumb) thumb.hidden = now;
1875
+ row.classList.toggle('open', now);
1876
+ row.firstChild.setAttribute('aria-expanded', String(now));
1877
+ },
1878
+ },
1879
+ icon(mark, 'tone-' + result.status),
1880
+ h(
1881
+ 'span',
1882
+ { class: 'check-body' },
1883
+ h('span', { class: 'check-desc', id: 'desc-' + check.key }, check.description),
1884
+ checkMeta(check)
1885
+ ),
1886
+ h(
1887
+ 'span',
1888
+ { class: 'check-side' },
1889
+ thumb,
1890
+ h('span', { class: 'badge sm ' + tone, id: 'label-' + check.key }, label),
1891
+ icon('chevron-down', 'chevron')
1892
+ )
1893
+ ),
1894
+ detail
1895
+ );
1896
+ return row;
1897
+ }
1898
+
1899
+ function summaryCard(run, fresh) {
1900
+ const score = run.score;
1901
+ const state = runState(run);
1902
+ if (!score || !state.outcome) return null;
1903
+ const weighted = score.score != null;
1904
+ const t = tally(run);
1905
+ const counts = { passed: 0, failed: 0, error: 0, informational: 0, skipped: 0 };
1906
+ for (const r of run.results) counts[r.status]++;
1907
+ const judged = counts.passed + counts.failed + counts.error;
1908
+ const share = (n) => (judged ? (n / judged) * 100 : 0) + '%';
1909
+ return h(
1910
+ 'section',
1911
+ { class: 'card summary', 'aria-label': 'Score' },
1912
+ h(
1913
+ 'div',
1914
+ { class: 'summary-top' },
1915
+ h(
1916
+ 'div',
1917
+ {},
1918
+ h('p', { class: 'overline' }, weighted ? 'Weighted score' : 'Checks passed'),
1919
+ h(
1920
+ 'p',
1921
+ {
1922
+ class:
1923
+ 'figure tone-' + (state.outcome === 'inconclusive' ? 'error' : state.outcome),
1924
+ },
1925
+ weighted
1926
+ ? inUnit(score.score, score.unit)
1927
+ : [String(t.passed), h('span', { class: 'of' }, '/' + t.total)]
1928
+ ),
1929
+ // Unweighted, the figure is the tally: no line repeats it.
1930
+ weighted &&
1931
+ h(
1932
+ 'p',
1933
+ { class: 'summary-line' },
1934
+ score.pass_threshold == null
1935
+ ? 'No pass threshold set'
1936
+ : 'Passes at ' + inUnit(score.pass_threshold, score.unit)
1937
+ )
1938
+ ),
1939
+ h(
1940
+ 'div',
1941
+ { class: 'verdict' },
1942
+ score.grade && h('span', { class: 'badge outline lg' }, score.grade),
1943
+ h(
1944
+ 'span',
1945
+ {
1946
+ class:
1947
+ 'badge stamp tone-' +
1948
+ (state.outcome === 'inconclusive' ? 'error' : state.outcome) +
1949
+ (fresh ? ' animate-stamp' : ''),
1950
+ },
1951
+ state.label
1952
+ )
1953
+ )
1954
+ ),
1955
+ h(
1956
+ 'div',
1957
+ { class: 'meter', 'aria-hidden': 'true' },
1958
+ ['passed', 'failed', 'error'].map(
1959
+ (s) => counts[s] > 0 && h('span', { class: s, style: 'width:' + share(counts[s]) })
1960
+ )
1961
+ ),
1962
+ h(
1963
+ 'ul',
1964
+ { class: 'counts' },
1965
+ Object.entries(counts)
1966
+ .filter(([, n]) => n > 0)
1967
+ .map(([s, n]) =>
1968
+ h(
1969
+ 'li',
1970
+ {},
1971
+ h(
1972
+ 'span',
1973
+ { class: 'badge lg ' + RESULT[s][1] },
1974
+ icon(RESULT[s][2]),
1975
+ h('span', { class: 'mono' }, n),
1976
+ RESULT[s][0]
1977
+ )
1978
+ )
1979
+ )
1980
+ )
1981
+ );
416
1982
  }
417
1983
 
418
1984
  async function renderRun(id) {
419
1985
  const data = await getJson('/api/runs/' + encodeURIComponent(id));
420
- if (data.error) return [h('h1', {}, id), h('p', { class: 'mono' }, data.error)];
1986
+ if (data.error) {
1987
+ return [
1988
+ pageHeader({ back: { href: '/', label: 'Runs' }, title: id }),
1989
+ h('div', { class: 'notice warning' }, icon('alert-triangle'), h('p', {}, data.error)),
1990
+ ];
1991
+ }
421
1992
  const run = data.run;
422
1993
  const results = new Map(run.results.map((r) => [r.check, r]));
423
1994
  const checks = run.checklist_snapshot.checks;
424
1995
  const running = run.status === 'running';
425
1996
  const resumable = run.status === 'interrupted' || run.status === 'stopped';
426
1997
 
1998
+ const before = seen.get(run.id);
1999
+ seen.set(run.id, { status: run.status, results: new Set(results.keys()) });
2000
+ const fresh = before && before.status === 'running' && !running;
2001
+ const arrived = (key) => before && !before.results.has(key);
2002
+
2003
+ // Stop sits in the live card, beside the tally, as CancelRunButton does.
2004
+ const pending = data.stop_requested;
2005
+ const stopButton =
2006
+ running &&
2007
+ h(
2008
+ 'button',
2009
+ {
2010
+ type: 'button',
2011
+ class: 'btn outline',
2012
+ 'aria-disabled': pending && 'true',
2013
+ 'data-pending': pending,
2014
+ 'data-focus-key': 'stop',
2015
+ onclick: async (event) => {
2016
+ const button = event.currentTarget;
2017
+ if (button.hasAttribute('data-pending')) return;
2018
+ button.setAttribute('aria-disabled', 'true');
2019
+ button.setAttribute('data-pending', '');
2020
+ button.lastChild.textContent = 'Stopping at the next step';
2021
+ stopError = null;
2022
+ let res = null;
2023
+ try {
2024
+ res = await fetch('/api/stop', {
2025
+ method: 'POST',
2026
+ headers: { 'Content-Type': 'application/json' },
2027
+ body: JSON.stringify({ run: run.id }),
2028
+ });
2029
+ } catch {
2030
+ res = null;
2031
+ }
2032
+ if (res && (res.ok || res.status === 409)) return; // 409: it already finished
2033
+ stopError =
2034
+ res && res.status === 403
2035
+ ? 'Stop was refused. Open the viewer from the link Claude Code gave you.'
2036
+ : 'The run could not be stopped. Try again.';
2037
+ announce(stopError);
2038
+ render(false);
2039
+ },
2040
+ },
2041
+ icon('stop'),
2042
+ h('span', {}, pending ? 'Stopping at the next step' : 'Stop')
2043
+ );
2044
+
2045
+ const rerun = '/stuntdouble run ' + run.checklist + (resumable ? ' resume' : '');
2046
+ const cloud = '/stuntdouble cloud ' + run.checklist;
427
2047
  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
- });
2048
+ ? null
2049
+ : run.runner.kind === 'local'
2050
+ ? [
2051
+ h(
2052
+ 'button',
2053
+ {
2054
+ type: 'button',
2055
+ class: 'btn outline',
2056
+ title: rerun,
2057
+ 'data-focus-key': 'rerun',
2058
+ onclick: (event) => copy(rerun, event.currentTarget),
443
2059
  },
444
- },
445
- data.stop_requested ? 'Stopping at the next step' : 'Stop'
446
- )
447
- )
448
- : run.runner.kind === 'local' &&
2060
+ icon('copy'),
2061
+ h('span', {}, resumable ? 'Copy resume command' : 'Copy re-run command')
2062
+ ),
2063
+ h(
2064
+ 'button',
2065
+ {
2066
+ type: 'button',
2067
+ class: 'btn outline',
2068
+ title: cloud,
2069
+ 'data-focus-key': 'cloud',
2070
+ onclick: (event) => copy(cloud, event.currentTarget),
2071
+ },
2072
+ icon('copy'),
2073
+ h('span', {}, 'Run in the cloud')
2074
+ ),
2075
+ ]
2076
+ : webUrl(run.runner.url)
2077
+ ? [
2078
+ h(
2079
+ 'a',
2080
+ {
2081
+ class: 'btn outline',
2082
+ href: run.runner.url,
2083
+ target: '_blank',
2084
+ rel: 'noopener noreferrer',
2085
+ },
2086
+ icon('external-link'),
2087
+ h('span', {}, 'Open this run in Stunt Double')
2088
+ ),
2089
+ ]
2090
+ : null;
2091
+
2092
+ const duration = run.finished_at && took(run.started_at, run.finished_at);
2093
+ const header = pageHeader({
2094
+ back: { href: '/', label: 'Runs', trail: run.id },
2095
+ title: checklistName(run),
2096
+ badge: stateBadge(run, 'lg'),
2097
+ chips: [
2098
+ { icon: runner(run).icon, label: runner(run).label },
2099
+ { icon: 'actor', label: run.actor, title: 'Actor' },
2100
+ { icon: 'globe', label: run.url, href: webUrl(run.url), title: 'Target' },
2101
+ { icon: 'calendar', label: time(run.started_at), title: 'Started' },
2102
+ duration && {
2103
+ icon: 'timer',
2104
+ label: duration,
2105
+ mono: true,
2106
+ title: 'How long the run took',
2107
+ },
2108
+ ].filter(Boolean),
2109
+ actions,
2110
+ });
2111
+
2112
+ const judged = run.results.length;
2113
+ const checked = pad(judged) + ' of ' + pad(checks.length) + ' checked';
2114
+ if (running) announce(stopError || judged + ' of ' + checks.length + ' checked');
2115
+ else if (fresh) announce(runState(run).label);
2116
+ if (!running) stopError = null;
2117
+ const live =
2118
+ running &&
2119
+ h(
2120
+ 'section',
2121
+ { class: 'card live-card', 'aria-label': 'Live' },
449
2122
  h(
450
2123
  'div',
451
- { class: 'actions' },
2124
+ { class: 'live-head' },
452
2125
  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'
2126
+ 'p',
2127
+ { class: 'live-step' },
2128
+ h('span', { class: 'chase', role: 'img', 'aria-label': 'Run in progress' }, [
2129
+ h('span'),
2130
+ h('span'),
2131
+ h('span'),
2132
+ h('span'),
2133
+ h('span'),
2134
+ ]),
2135
+ h(
2136
+ 'span',
2137
+ { class: 'truncate' },
2138
+ data.activity.length > 0
2139
+ ? data.activity[data.activity.length - 1].text.split('\n')[0]
2140
+ : 'Running'
2141
+ )
463
2142
  ),
464
2143
  h(
465
- 'button',
466
- { onclick: (event) => copy('/stuntdouble cloud ' + run.checklist, event.target) },
467
- 'Run in the cloud'
2144
+ 'span',
2145
+ { class: 'live-side' },
2146
+ h(
2147
+ 'span',
2148
+ { class: 'badge arc' },
2149
+ h('span', { class: 'bulb live', 'aria-hidden': 'true' }),
2150
+ 'Live'
2151
+ ),
2152
+ h('span', { class: 'tally' }, checked),
2153
+ stopButton,
2154
+ stopError && h('span', { class: 'stop-error' }, stopError)
468
2155
  )
469
- );
2156
+ ),
2157
+ h(
2158
+ 'div',
2159
+ {
2160
+ class: 'progress',
2161
+ role: 'progressbar',
2162
+ 'aria-label': 'Run progress',
2163
+ 'aria-valuemin': 0,
2164
+ 'aria-valuemax': checks.length,
2165
+ 'aria-valuenow': judged,
2166
+ },
2167
+ h('span', {
2168
+ style: 'width:' + (checks.length ? (judged / checks.length) * 100 : 0) + '%',
2169
+ })
2170
+ ),
2171
+ h('img', {
2172
+ class: 'frame',
2173
+ id: 'frame',
2174
+ src: fileUrl('.live/latest.png') + '?v=' + Date.now(),
2175
+ alt: 'The browser now',
2176
+ })
2177
+ );
470
2178
 
471
- const verdicts = h(
472
- 'div',
473
- { class: 'card' },
474
- checks.map((check) => checkCard(run, check, results.get(check.key)))
475
- );
2179
+ // What went wrong first, since that is what somebody opens a report to
2180
+ // find, then what held, then what was only read off the page, then what
2181
+ // never ran.
2182
+ const groups = [
2183
+ ['Needs attention', ['failed', 'error'], true],
2184
+ ['Passed', ['passed'], false],
2185
+ ['Observations', ['informational'], true],
2186
+ ['Skipped', ['skipped'], false],
2187
+ ]
2188
+ .map(([title, statuses, openByDefault]) => ({
2189
+ title,
2190
+ openByDefault,
2191
+ checks: checks.filter(
2192
+ (c) => results.has(c.key) && statuses.includes(results.get(c.key).status)
2193
+ ),
2194
+ }))
2195
+ .concat({
2196
+ title: running ? 'Still to check' : 'Not reached',
2197
+ checks: checks.filter((c) => !results.has(c.key)),
2198
+ })
2199
+ .filter((g) => g.checks.length > 0);
2200
+
2201
+ const notice =
2202
+ run.summary &&
2203
+ (run.status === 'failed'
2204
+ ? h('div', { class: 'notice destructive' }, icon('octagon-x'), h('p', {}, run.summary))
2205
+ : h('p', { class: 'quote' }, run.summary));
476
2206
 
477
2207
  return [
478
- h('p', { class: 'meta' }, h('a', { href: '/', 'data-nav': true }, 'Runs'), '/', run.id),
479
- h('h1', {}, run.checklist),
2208
+ header,
480
2209
  h(
481
2210
  'div',
482
- { class: 'meta' },
483
- outcomePill(run),
484
- scoreText(run.score) &&
2211
+ { class: 'stack' },
2212
+ live,
2213
+ notice,
2214
+ summaryCard(run, fresh),
2215
+ groups.map((group) =>
485
2216
  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' },
2217
+ 'section',
2218
+ { class: 'section' },
506
2219
  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
- })
2220
+ 'h2',
2221
+ { class: 'section-title' },
2222
+ group.title,
2223
+ h('span', { class: 'count' }, pad(group.checks.length))
516
2224
  ),
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
2225
  h(
527
- 'li',
528
- {},
529
- h('span', { class: 'muted' }, new Date(line.at).toLocaleTimeString()),
530
- h('code', {}, line.tool),
531
- h('span', {}, line.text)
2226
+ 'ul',
2227
+ { class: 'group' },
2228
+ group.checks.map((check) =>
2229
+ checkRow(run, check, results.get(check.key), {
2230
+ running,
2231
+ openByDefault: group.openByDefault,
2232
+ arrived: results.has(check.key) && arrived(check.key),
2233
+ })
2234
+ )
532
2235
  )
533
2236
  )
534
2237
  ),
535
- ],
2238
+ data.activity.length > 0 &&
2239
+ h(
2240
+ 'section',
2241
+ { class: 'section' },
2242
+ h(
2243
+ 'h2',
2244
+ { class: 'section-title' },
2245
+ 'Activity',
2246
+ h('span', { class: 'count' }, pad(data.activity.length))
2247
+ ),
2248
+ h(
2249
+ 'ol',
2250
+ { class: 'group activity' },
2251
+ data.activity.map((line) =>
2252
+ h(
2253
+ 'li',
2254
+ {},
2255
+ time(line.at, 'clock'),
2256
+ h('code', {}, line.tool),
2257
+ h('span', { class: 'text' }, line.text)
2258
+ )
2259
+ )
2260
+ )
2261
+ )
2262
+ ),
536
2263
  ];
537
2264
  }
538
2265
 
539
2266
  function definitionError(entry) {
540
2267
  return h(
541
- 'div',
542
- {},
543
- h('code', {}, entry.slug),
544
- h('span', { class: 'meta mono' }, entry.error)
2268
+ 'li',
2269
+ { class: 'def' },
2270
+ h(
2271
+ 'div',
2272
+ { class: 'def-head' },
2273
+ h(
2274
+ 'span',
2275
+ { class: 'def-name' },
2276
+ icon('alert-triangle', 'tone-error'),
2277
+ h('code', {}, entry.slug)
2278
+ ),
2279
+ h('span', { class: 'badge warning' }, 'Unreadable')
2280
+ ),
2281
+ h('p', { class: 'def-body error-text' }, entry.error)
545
2282
  );
546
2283
  }
547
2284
 
@@ -549,107 +2286,107 @@
549
2286
  const { checklists, actors } = await getJson('/api/definitions');
550
2287
  const cloudIds = (cloud) =>
551
2288
  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'),
2289
+ ? h('span', { class: 'badge arc' }, 'Linked to the cloud')
2290
+ : h('span', { class: 'badge secondary' }, 'Local only');
2291
+ const section = (title, entries, mark, empty, row) =>
556
2292
  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
- )
2293
+ 'section',
2294
+ { class: 'section' },
2295
+ h(
2296
+ 'h2',
2297
+ { class: 'section-title large' },
2298
+ title,
2299
+ h('span', { class: 'count' }, pad(entries.length))
2300
+ ),
2301
+ entries.length === 0
2302
+ ? h('div', { class: 'card inline-empty' }, icon(mark), h('p', {}, empty))
2303
+ : h(
2304
+ 'ul',
2305
+ { class: 'group card' },
2306
+ entries.map((entry) => (entry.error ? definitionError(entry) : row(entry)))
609
2307
  )
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
- )
2308
+ );
2309
+ return [
2310
+ pageHeader({
2311
+ title: 'Checklists and actors',
2312
+ description:
2313
+ 'Read only. Edit them in Claude Code: they are the JSON files in .stuntdouble/.',
2314
+ }),
2315
+ h(
2316
+ 'div',
2317
+ { class: 'stack' },
2318
+ section('Checklists', checklists, 'checklist', 'No checklists yet.', (entry) =>
2319
+ h(
2320
+ 'li',
2321
+ { class: 'def' },
2322
+ h(
2323
+ 'div',
2324
+ { class: 'def-head' },
2325
+ h(
2326
+ 'span',
2327
+ { class: 'def-name' },
2328
+ icon('checklist'),
2329
+ h('span', { class: 'truncate' }, entry.data.name)
2330
+ ),
2331
+ cloudIds(entry.data.cloud)
2332
+ ),
2333
+ h(
2334
+ 'div',
2335
+ { class: 'def-facts' },
2336
+ h('code', {}, entry.slug),
2337
+ h('span', { class: 'fact' }, icon('actor'), 'Run by ' + entry.data.actor),
2338
+ h('span', { class: 'fact' }, icon('globe'), entry.data.url)
2339
+ ),
2340
+ h('p', { class: 'def-body' }, entry.data.instructions),
2341
+ h(
2342
+ 'ul',
2343
+ { class: 'def-checks' },
2344
+ entry.data.checks.map((c) =>
2345
+ h('li', {}, h('span', {}, c.description), checkMeta(c))
2346
+ )
643
2347
  )
644
- ),
2348
+ )
2349
+ ),
2350
+ section('Actors', actors, 'actor', 'No actors yet.', (entry) =>
2351
+ h(
2352
+ 'li',
2353
+ { class: 'def' },
2354
+ h(
2355
+ 'div',
2356
+ { class: 'def-head' },
2357
+ h(
2358
+ 'span',
2359
+ { class: 'def-name' },
2360
+ icon('actor'),
2361
+ h('span', { class: 'truncate' }, entry.data.name)
2362
+ ),
2363
+ cloudIds(entry.data.cloud)
2364
+ ),
2365
+ h(
2366
+ 'div',
2367
+ { class: 'def-facts' },
2368
+ h('code', {}, entry.slug),
2369
+ entry.data.persist_login && h('span', {}, 'Keeps its logins')
2370
+ ),
2371
+ entry.data.description && h('p', { class: 'def-body' }, entry.data.description),
2372
+ entry.data.knowledge.length > 0 &&
2373
+ h(
2374
+ 'p',
2375
+ { class: 'def-body fact' },
2376
+ icon('knowledge'),
2377
+ 'Knows: ' + entry.data.knowledge.map((k) => k.title).join(', ')
2378
+ )
2379
+ )
2380
+ )
2381
+ ),
645
2382
  ];
646
2383
  }
647
2384
 
648
2385
  let rendering = Promise.resolve();
649
- function render() {
2386
+ function render(enter) {
650
2387
  rendering = rendering.then(async () => {
651
2388
  const path = location.pathname;
652
- for (const link of document.querySelectorAll('header nav a')) {
2389
+ for (const link of document.querySelectorAll('.bar nav a')) {
653
2390
  const current = link.getAttribute('href') === (path.startsWith('/runs/') ? '/' : path);
654
2391
  if (current) link.setAttribute('aria-current', 'page');
655
2392
  else link.removeAttribute('aria-current');
@@ -662,9 +2399,27 @@
662
2399
  ? await renderDefinitions()
663
2400
  : await renderRuns();
664
2401
  } catch (err) {
665
- view = [h('p', { class: 'mono' }, err.message)];
2402
+ view = [
2403
+ h('div', { class: 'notice destructive' }, icon('octagon-x'), h('p', {}, err.message)),
2404
+ ];
666
2405
  }
2406
+ app.classList.toggle('narrow', path === '/definitions');
2407
+ // A live redraw replaces the view: keep keyboard focus where it was.
2408
+ const focused = document.activeElement?.closest?.('[data-focus-key]');
2409
+ const focusKey = focused && app.contains(focused) && focused.dataset.focusKey;
667
2410
  app.replaceChildren(...[view].flat(2).filter(Boolean));
2411
+ if (focusKey) {
2412
+ app
2413
+ .querySelector('[data-focus-key="' + CSS.escape(focusKey) + '"]')
2414
+ ?.focus({ preventScroll: true });
2415
+ }
2416
+ // A view the reader went to rises in once. Redraws for live updates,
2417
+ // and anything reached from the keyboard, cut.
2418
+ if (enter) {
2419
+ app.classList.remove('animate-page-in');
2420
+ void app.offsetWidth;
2421
+ app.classList.add('animate-page-in');
2422
+ }
668
2423
  });
669
2424
  }
670
2425
 
@@ -673,9 +2428,10 @@
673
2428
  if (!link || event.metaKey || event.ctrlKey) return;
674
2429
  event.preventDefault();
675
2430
  history.pushState(null, '', link.getAttribute('href'));
676
- render();
2431
+ window.scrollTo(0, 0);
2432
+ render(event.detail > 0);
677
2433
  });
678
- window.addEventListener('popstate', render);
2434
+ window.addEventListener('popstate', () => render(false));
679
2435
 
680
2436
  // Live updates: a new frame swaps the picture, anything else redraws the page.
681
2437
  let queued = false;
@@ -689,10 +2445,12 @@
689
2445
  queued = true;
690
2446
  setTimeout(() => {
691
2447
  queued = false;
692
- render();
2448
+ render(false);
693
2449
  }, 300);
694
2450
  };
695
- render();
2451
+ // Relative times on the runs list stay current.
2452
+ setInterval(() => location.pathname === '/' && render(false), 60_000);
2453
+ render(false);
696
2454
  </script>
697
2455
  </body>
698
2456
  </html>