staysfixed 0.2.0 → 0.2.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.
@@ -14,12 +14,15 @@
14
14
  *
15
15
  * The design brief, in one line: this thing is pinned against a person's work
16
16
  * all day, so it must be pleasant to have open and never once demand attention
17
- * it has not earned. That gives three rules the whole layout follows.
17
+ * it has not earned. That gives four rules the whole layout follows.
18
18
  *
19
- * 1. The picture is the hero. It is the only large thing on the page, and
20
- * everything else is sized to defer to it. A watch panel that shows you a
21
- * photograph of your own app the instant the shutter fires is alive; a
22
- * watch panel that shows you a list of names is a log file with a border.
19
+ * 1. The picture is the hero, and it is a REAL picture. The panel is itself a
20
+ * local page, so it loads the full-resolution PNG straight off the disk the
21
+ * run just wrote it to `shotFile`, `approvedFile`, `diffFile`. The small
22
+ * preview that travels inside the event is only the frame or two before
23
+ * that file exists. A watch panel that shows you a blurred stamp of your
24
+ * own app is worse than useless: looking at the picture IS the product, so
25
+ * it has to survive being looked at closely.
23
26
  *
24
27
  * 2. Nothing is said twice, and detail is earned. At rest every check is one
25
28
  * quiet line — a dot, a name, a time. The description, the outcome, the
@@ -33,6 +36,12 @@
33
36
  * one red (broke). The timing breakdown, which is information rather than
34
37
  * state, is drawn in shades of the text colour — so a screenful of colour
35
38
  * always means a screenful of things to look at.
39
+ *
40
+ * 4. Everything moves, and nothing waves. State never snaps: pictures cross
41
+ * fade, rows settle, numbers count, the verdict arrives. Every one of those
42
+ * is between 150 and 320 milliseconds on one easing curve, every one of
43
+ * them is tied to something that really happened, and every one of them is
44
+ * switched off for anyone who has asked their computer for less motion.
36
45
  */
37
46
 
38
47
  import { escapeHtml } from '../report/html.js';
@@ -124,6 +133,14 @@ const COPY = [
124
133
  '</svg>',
125
134
  ].join('');
126
135
 
136
+ /** A cross: put the big picture away. */
137
+ const CLOSE = [
138
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
139
+ ' stroke-width="1.7" stroke-linecap="round" aria-hidden="true">',
140
+ '<path d="M7 7l10 10M17 7L7 17"></path>',
141
+ '</svg>',
142
+ ].join('');
143
+
127
144
  /**
128
145
  * The whole panel document.
129
146
  * @param {PanelPlan} [plan]
@@ -173,6 +190,10 @@ export function panelHtml(plan = {}) {
173
190
  '<div class="track" id="track"><div class="fill" id="fill"></div></div>',
174
191
  '<span class="counts mono" id="counts"></span>',
175
192
  '</div>',
193
+ // Said once, plainly. Without it the list of names and times reads as a
194
+ // speed report — which is exactly how it read to the first person who
195
+ // looked at it. The times are how long a check took; the check is this.
196
+ '<p class="what" id="what"></p>',
176
197
  '</header>',
177
198
 
178
199
  // --- the two columns. One on a narrow panel, two on a wide one. ---------
@@ -183,7 +204,9 @@ export function panelHtml(plan = {}) {
183
204
  '<div class="shot" id="shot">',
184
205
  '<img class="layer" id="layerA" alt="">',
185
206
  '<img class="layer" id="layerB" alt="">',
207
+ '<span class="sweep" aria-hidden="true"></span>',
186
208
  '<p class="blank" id="blank">The first picture appears the moment it is taken.</p>',
209
+ '<span class="closer" aria-hidden="true">click to look closer</span>',
187
210
  '</div>',
188
211
  '<div class="caption" id="caption">',
189
212
  '<span class="shotname mono" id="shotname"></span>',
@@ -234,7 +257,39 @@ export function panelHtml(plan = {}) {
234
257
  '</footer>',
235
258
 
236
259
  '</div>',
237
- '<div class="lightbox" id="lightbox" hidden><img id="lightimg" alt=""></div>',
260
+
261
+ // --- the viewer: the real picture, as big as the window goes ------------
262
+ //
263
+ // This is where a person decides whether to approve something, so it is the
264
+ // most carefully built thing on the page: the full-resolution file, zoom to
265
+ // eight times with the actual pixels showing rather than a smoothed guess,
266
+ // drag to move around, and — for a screen that moved — the approved picture
267
+ // and the new one under one draggable line, plus the difference.
268
+ '<div class="viewer" id="viewer" hidden>',
269
+ '<header class="vtop">',
270
+ '<span class="vname mono" id="vname"></span>',
271
+ '<div class="vmodes" id="vmodes"></div>',
272
+ '<span class="vright">',
273
+ '<span class="vzoom mono" id="vzoom" title="double-click the picture to fit it again">1.0&#215;</span>',
274
+ `<button class="vclose" id="vclose" type="button" aria-label="close the picture">${CLOSE}</button>`,
275
+ '</span>',
276
+ '</header>',
277
+ '<div class="vstage" id="vstage">',
278
+ '<div class="vframe" id="vframe">',
279
+ '<img class="vlayer" id="vApproved" alt="the approved picture">',
280
+ '<img class="vlayer" id="vNow" alt="the picture just taken">',
281
+ '<img class="vlayer" id="vDiff" alt="the difference between them">',
282
+ '</div>',
283
+ '<div class="vwipe" id="vwipe" hidden><span class="vgrip" aria-hidden="true"></span></div>',
284
+ '</div>',
285
+ '<footer class="vfoot" id="vfoot" hidden>',
286
+ '<span class="vside">approved</span>',
287
+ '<input class="vblend" id="vblend" type="range" min="0" max="100" value="100" aria-label="fade between the approved picture and the new one">',
288
+ '<span class="vside">now</span>',
289
+ '</footer>',
290
+ '<p class="vhelp">scroll to zoom &#183; drag to move &#183; double-click to fit &#183; esc to close</p>',
291
+ '</div>',
292
+
238
293
  `<script type="application/json" id="staysfixed-plan">${embedded}</script>`,
239
294
  `<script>${SCRIPT}</script>`,
240
295
  '</body>',
@@ -252,6 +307,9 @@ const STYLE = `
252
307
  broke. Everything structural is the ground, the ink, or a hairline. That is
253
308
  what lets a single amber dot be seen from across a desk.
254
309
 
310
+ One type scale, five steps, used everywhere. One easing curve, used by every
311
+ moving thing on the page, so the whole panel moves like one object.
312
+
255
313
  Dark is the design — this window lives beside an editor all day. Light is a
256
314
  warm paper, never the white page with grey cards the brief rules out.
257
315
  -------------------------------------------------------------------------- */
@@ -260,7 +318,7 @@ const STYLE = `
260
318
 
261
319
  --ground: #0a0c12;
262
320
  --lift: #12161f;
263
- --card: rgba(255, 255, 255, 0.032);
321
+ --card: rgba(255, 255, 255, 0.03);
264
322
  --card-hover: rgba(255, 255, 255, 0.055);
265
323
  --well: #070910;
266
324
  --glass: rgba(10, 12, 18, 0.74);
@@ -269,7 +327,7 @@ const STYLE = `
269
327
  --soft: #939bad;
270
328
  --faint: #616a7d;
271
329
 
272
- --line: rgba(255, 255, 255, 0.07);
330
+ --line: rgba(255, 255, 255, 0.06);
273
331
  --line-firm: rgba(255, 255, 255, 0.13);
274
332
  --sheen: rgba(255, 255, 255, 0.06);
275
333
  --shadow: rgba(0, 0, 0, 0.66);
@@ -278,14 +336,27 @@ const STYLE = `
278
336
  --held: #55bd8c;
279
337
  --moved: #e2a84f;
280
338
  --broke: #ef6a61;
281
- --resting: rgba(255, 255, 255, 0.09);
339
+ --resting: rgba(255, 255, 255, 0.08);
282
340
 
283
341
  --radius: 20px;
284
342
  --radius-sm: 13px;
285
343
  --radius-xs: 9px;
286
344
 
287
- --pad: 16px;
345
+ --pad: 18px;
288
346
  --tint: var(--accent);
347
+
348
+ /* The type scale. Nothing on this page is set at a size that is not here. */
349
+ --t-label: 10px;
350
+ --t-meta: 10.5px;
351
+ --t-body: 11.5px;
352
+ --t-name: 12px;
353
+ --t-lead: 17px;
354
+
355
+ /* One curve. Everything that moves, moves on this. */
356
+ --ease: cubic-bezier(0.22, 0.72, 0.24, 1);
357
+ --quick: 170ms;
358
+ --calm: 260ms;
359
+ --slow: 320ms;
289
360
  }
290
361
  /*
291
362
  * Light is opt-in, and that is deliberate.
@@ -302,7 +373,7 @@ const STYLE = `
302
373
 
303
374
  --ground: #e3ded3;
304
375
  --lift: #fdfbf7;
305
- --card: rgba(255, 255, 255, 0.82);
376
+ --card: rgba(255, 255, 255, 0.78);
306
377
  --card-hover: rgba(255, 255, 255, 1);
307
378
  --well: #dad4c8;
308
379
  --glass: rgba(231, 226, 217, 0.82);
@@ -311,7 +382,7 @@ const STYLE = `
311
382
  --soft: #6b665c;
312
383
  --faint: #8b8578;
313
384
 
314
- --line: rgba(28, 24, 18, 0.1);
385
+ --line: rgba(28, 24, 18, 0.09);
315
386
  --line-firm: rgba(28, 24, 18, 0.19);
316
387
  --sheen: rgba(255, 255, 255, 0.85);
317
388
  --shadow: rgba(58, 47, 30, 0.34);
@@ -352,7 +423,7 @@ button { font: inherit; color: inherit; }
352
423
  background:
353
424
  radial-gradient(120% 46% at 50% -8%, color-mix(in srgb, var(--tint) 15%, transparent), transparent 70%),
354
425
  radial-gradient(100% 60% at 50% 112%, var(--shadow), transparent 66%);
355
- transition: background 500ms ease;
426
+ transition: background 620ms var(--ease);
356
427
  }
357
428
 
358
429
  .panel {
@@ -364,11 +435,10 @@ button { font: inherit; color: inherit; }
364
435
  /* --- header -------------------------------------------------------------- */
365
436
  .top {
366
437
  flex: 0 0 auto;
367
- padding: 15px var(--pad) 14px;
438
+ padding: 16px var(--pad) 15px;
368
439
  background: var(--glass);
369
440
  backdrop-filter: blur(22px) saturate(150%);
370
441
  -webkit-backdrop-filter: blur(22px) saturate(150%);
371
- border-bottom: 1px solid var(--line);
372
442
  }
373
443
  .brand { display: flex; align-items: center; gap: 9px; }
374
444
  .badge {
@@ -383,42 +453,63 @@ button { font: inherit; color: inherit; }
383
453
  .badge .glyph { width: 15px; height: 15px; }
384
454
  .wordmark {
385
455
  flex: 1 1 auto; min-width: 0;
386
- font-size: 11px; font-weight: 600;
456
+ font-size: var(--t-label); font-weight: 600;
387
457
  letter-spacing: 0.2em; text-transform: uppercase;
388
458
  color: var(--soft);
389
459
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
390
460
  }
391
- .elapsed { flex: 0 0 auto; font-size: 11.5px; color: var(--faint); }
461
+ .elapsed { flex: 0 0 auto; font-size: var(--t-body); color: var(--faint); }
392
462
 
393
463
  .target {
394
464
  display: flex; align-items: baseline; gap: 6px;
395
- margin-top: 12px;
396
- font-size: 12px;
465
+ margin-top: 13px;
466
+ font-size: var(--t-name);
397
467
  white-space: nowrap; overflow: hidden;
398
468
  }
399
469
  .target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
400
470
  .target .sep { color: var(--faint); flex: 0 0 auto; }
401
- .target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: 11.5px; }
471
+ .target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: var(--t-body); }
402
472
 
403
473
  /* The sentence. The largest text on the page after the picture, because it is
404
474
  the one thing a person reads from four feet away. */
405
475
  .state {
406
476
  margin-top: 9px;
407
- font-size: 17px; font-weight: 620; line-height: 1.32;
477
+ font-size: var(--t-lead); font-weight: 620; line-height: 1.32;
408
478
  letter-spacing: -0.011em;
409
479
  overflow-wrap: anywhere;
410
- transition: color 300ms ease;
480
+ transition: color var(--slow) var(--ease);
411
481
  }
412
482
  .state.held { color: var(--ink); }
413
483
  .state.moved { color: var(--moved); }
414
484
  .state.broke { color: var(--broke); }
415
485
  .state.wait { color: var(--accent); }
416
- .note { margin-top: 5px; font-size: 11.5px; color: var(--faint); overflow-wrap: anywhere; }
486
+ /* The verdict arrives rather than appearing: it is the one sentence the whole
487
+ run was for, and a run that ends by swapping a word looks like a page that
488
+ was not paying attention. */
489
+ .state.arrive { animation: arrive 340ms var(--ease) both; }
490
+ @keyframes arrive {
491
+ from { opacity: 0; transform: translateY(7px); filter: blur(4px); }
492
+ to { opacity: 1; transform: none; filter: blur(0); }
493
+ }
494
+ .what {
495
+ margin: 8px 0 0; font-size: var(--t-small); color: var(--faint);
496
+ line-height: 1.45; overflow-wrap: anywhere;
497
+ }
498
+ .rverdict {
499
+ flex: 0 0 auto; font-size: var(--t-small); color: var(--faint);
500
+ white-space: nowrap; padding-left: 10px;
501
+ }
502
+ .item.held .rverdict { color: var(--faint); }
503
+ .item.moved .rverdict { color: var(--moved); }
504
+ .item.broke .rverdict { color: var(--broke); }
505
+ .item.waiting .rverdict { color: var(--waiting); }
506
+ .item.pending .rverdict, .item.skip .rverdict { color: var(--faintest, var(--faint)); }
507
+ .note { margin-top: 5px; font-size: var(--t-body); color: var(--faint); overflow-wrap: anywhere; }
417
508
 
418
509
  /* One hairline, not a row of blocks. Each finished check adds its own slice of
419
510
  colour to a single continuous line, so progress and outcome are the same
420
511
  object and there is one fewer thing on the page. */
421
- .meter { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
512
+ .meter { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
422
513
  .track {
423
514
  flex: 1 1 auto; min-width: 0;
424
515
  height: 4px; border-radius: 999px;
@@ -429,7 +520,7 @@ button { font: inherit; color: inherit; }
429
520
  .slice {
430
521
  min-width: 0; height: 100%;
431
522
  background: var(--resting);
432
- transition: background 260ms ease, flex-basis 260ms ease;
523
+ transition: background var(--slow) var(--ease), flex-basis var(--slow) var(--ease);
433
524
  }
434
525
  .slice.held { background: var(--held); }
435
526
  .slice.moved { background: var(--moved); }
@@ -438,10 +529,17 @@ button { font: inherit; color: inherit; }
438
529
  .slice.skip { background: color-mix(in srgb, var(--ink) 16%, transparent); }
439
530
  .slice.running {
440
531
  background: var(--accent);
441
- animation: breathe 1.9s ease-in-out infinite;
532
+ animation: breathe 1.9s var(--ease) infinite;
533
+ }
534
+ /* A segment lights as it lands, once, and then it is just a colour. */
535
+ .slice.land { animation: land 520ms var(--ease) 1; }
536
+ @keyframes land { 0% { filter: brightness(2.1); } 100% { filter: none; } }
537
+ @keyframes breathe { 0%, 100% { opacity: 0.42; } 50% { opacity: 1; } }
538
+ .counts {
539
+ flex: 0 1 auto; min-width: 0; max-width: 66%;
540
+ font-size: var(--t-meta); color: var(--faint); letter-spacing: 0.02em;
541
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
442
542
  }
443
- @keyframes breathe { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
444
- .counts { flex: 0 0 auto; font-size: 10.5px; color: var(--faint); letter-spacing: 0.02em; }
445
543
 
446
544
  /* --- the body: one column, or two when there is room --------------------- */
447
545
  .body {
@@ -468,10 +566,9 @@ button { font: inherit; color: inherit; }
468
566
  could compete with it — only a soft floor shadow and the faintest rim to
469
567
  keep a white screenshot from bleeding into a light ground. */
470
568
  box-shadow:
471
- 0 1px 0 0 var(--sheen) inset,
472
569
  0 0 0 1px var(--line),
473
570
  0 26px 50px -30px var(--shadow);
474
- transition: box-shadow 300ms ease;
571
+ transition: box-shadow var(--slow) var(--ease);
475
572
  }
476
573
  .shot.empty { cursor: default; }
477
574
  .layer {
@@ -479,51 +576,94 @@ button { font: inherit; color: inherit; }
479
576
  width: 100%; height: 100%;
480
577
  object-fit: contain;
481
578
  opacity: 0;
482
- transition: opacity 260ms ease;
579
+ transform: scale(1.014);
580
+ transition: opacity var(--slow) var(--ease), transform 480ms var(--ease);
483
581
  }
484
- .layer.on { opacity: 1; }
582
+ .layer.on { opacity: 1; transform: none; }
583
+ .layer.out { opacity: 0; transform: scale(0.996); }
485
584
  .blank {
486
585
  position: relative;
487
- color: var(--faint); font-size: 11.5px; line-height: 1.6;
586
+ color: var(--faint); font-size: var(--t-body); line-height: 1.6;
488
587
  text-align: center; padding: 0 30px; max-width: 260px;
489
588
  }
490
589
 
590
+ /* The shutter, made visible. One pass of soft light across the glass for
591
+ exactly as long as the app is really being photographed — it starts when the
592
+ screen starts and it is gone the instant the picture lands, so it is a report
593
+ of what is happening rather than a decoration that never stops. */
594
+ .sweep {
595
+ position: absolute; inset: 0;
596
+ pointer-events: none; opacity: 0;
597
+ background:
598
+ linear-gradient(100deg,
599
+ transparent 4%,
600
+ color-mix(in srgb, var(--accent) 10%, transparent) 30%,
601
+ color-mix(in srgb, var(--accent) 34%, transparent) 47%,
602
+ color-mix(in srgb, var(--accent) 82%, transparent) 50%,
603
+ color-mix(in srgb, var(--accent) 34%, transparent) 53%,
604
+ color-mix(in srgb, var(--accent) 10%, transparent) 70%,
605
+ transparent 96%);
606
+ transform: translateX(-100%);
607
+ }
608
+ .shot.scanning .sweep { opacity: 1; animation: sweep 1.6s var(--ease) infinite; }
609
+ .shot.scanning {
610
+ box-shadow:
611
+ 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
612
+ 0 26px 50px -30px var(--shadow);
613
+ }
614
+ @keyframes sweep {
615
+ from { transform: translateX(-100%); }
616
+ to { transform: translateX(100%); }
617
+ }
618
+
619
+ /* Said once, on hover, and never in the way of the picture. */
620
+ .closer {
621
+ position: absolute; right: 10px; bottom: 10px;
622
+ font-size: var(--t-label); letter-spacing: 0.02em;
623
+ color: var(--ink);
624
+ background: color-mix(in srgb, var(--ground) 72%, transparent);
625
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
626
+ border-radius: 999px; padding: 4px 10px;
627
+ opacity: 0; transform: translateY(4px);
628
+ transition: opacity var(--quick) var(--ease), transform var(--quick) var(--ease);
629
+ pointer-events: none;
630
+ }
631
+ .shot:hover .closer { opacity: 1; transform: none; }
632
+ .shot.empty .closer { display: none; }
633
+
491
634
  .caption {
492
635
  display: flex; align-items: baseline; gap: 10px;
493
- margin-top: 11px; min-height: 18px;
636
+ margin-top: 12px; min-height: 18px;
494
637
  }
495
- .shotname { flex: 0 1 auto; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
496
- .shotout { flex: 1 1 auto; min-width: 0; font-size: 11.5px; color: var(--soft); text-align: right; overflow-wrap: anywhere; }
638
+ .shotname { flex: 0 1 auto; font-size: var(--t-name); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
639
+ .shotout { flex: 1 1 auto; min-width: 0; font-size: var(--t-body); color: var(--soft); text-align: right; overflow-wrap: anywhere; }
497
640
  .shotout.moved { color: var(--moved); }
498
641
  .shotout.broke { color: var(--broke); }
499
642
  .shotout.wait { color: var(--accent); }
500
643
 
501
- /* Approved / now / difference. One pill, three quiet words the only control
502
- on the page that is ever shown without being asked for, because a screen
503
- that moved is the one moment there is a real decision to make. */
504
- .switch {
505
- display: flex; gap: 2px;
506
- margin-top: 10px; padding: 2px;
507
- border-radius: 999px;
508
- background: var(--card);
509
- box-shadow: inset 0 0 0 1px var(--line);
510
- }
644
+ /* Approved / now / difference. Three quiet words with a line that slides
645
+ between them no pill, no box, because a screen that moved already has
646
+ enough asking for the eye. */
647
+ .switch { display: flex; gap: 2px; margin-top: 10px; }
511
648
  .switch button {
649
+ position: relative;
512
650
  flex: 1 1 0; min-width: 0;
513
- font-size: 11px; letter-spacing: 0.01em;
514
- color: var(--soft);
651
+ font-size: var(--t-body); letter-spacing: 0.01em;
652
+ color: var(--faint);
515
653
  background: transparent; border: 0;
516
- border-radius: 999px;
517
- padding: 5px 6px; cursor: pointer;
654
+ padding: 6px 4px 7px; cursor: pointer;
518
655
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
519
- transition: color 160ms ease, background 160ms ease;
656
+ transition: color var(--quick) var(--ease);
520
657
  }
521
- .switch button:hover { color: var(--ink); }
522
- .switch button.on {
523
- color: var(--ink);
524
- background: var(--card-hover);
525
- box-shadow: 0 1px 2px -1px var(--shadow), inset 0 0 0 1px var(--line);
658
+ .switch button::after {
659
+ content: ''; position: absolute; left: 50%; right: 50%; bottom: 0; height: 1.5px;
660
+ border-radius: 2px; background: var(--tone, var(--accent));
661
+ transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
662
+ opacity: 0;
526
663
  }
664
+ .switch button:hover { color: var(--soft); }
665
+ .switch button.on { color: var(--ink); }
666
+ .switch button.on::after { left: 12%; right: 12%; opacity: 1; }
527
667
 
528
668
  /* --- the list ------------------------------------------------------------ */
529
669
  .scroll {
@@ -532,7 +672,7 @@ button { font: inherit; color: inherit; }
532
672
  overscroll-behavior: contain;
533
673
  scrollbar-width: thin;
534
674
  scrollbar-color: color-mix(in srgb, var(--soft) 22%, transparent) transparent;
535
- padding: 16px 5px 14px 0;
675
+ padding: 18px 5px 14px 0;
536
676
  /* The list slides away under the header rather than being cut off by a box. */
537
677
  mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
538
678
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
@@ -544,24 +684,34 @@ button { font: inherit; color: inherit; }
544
684
  }
545
685
  .scroll::-webkit-scrollbar-track { background: transparent; }
546
686
 
547
- .group + .group { margin-top: 18px; }
687
+ .group + .group { margin-top: 20px; }
548
688
  .grouplabel {
549
689
  display: flex; align-items: baseline; gap: 9px;
550
- padding: 0 6px 8px 4px;
551
- font-size: 10px; font-weight: 600;
690
+ padding: 0 6px 9px 4px;
691
+ font-size: var(--t-label); font-weight: 600;
552
692
  letter-spacing: 0.16em; text-transform: uppercase;
553
693
  color: var(--faint);
554
694
  }
555
- .grouplabel .mono { font-size: 10px; letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
695
+ .grouplabel .mono { font-size: var(--t-label); letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
556
696
 
557
697
  .items {
558
698
  border-radius: var(--radius);
559
699
  background: var(--card);
560
- box-shadow: inset 0 0 0 1px var(--line), 0 18px 34px -30px var(--shadow);
561
700
  overflow: hidden;
562
701
  }
563
702
  .item + .item { box-shadow: inset 0 1px 0 var(--line); }
703
+ .item {
704
+ transition: background var(--calm) var(--ease);
705
+ }
564
706
  .item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 5%, transparent); }
707
+ .item.running { background: color-mix(in srgb, var(--accent) 5%, transparent); }
708
+ /* Something broke. It is said once, with weight, and then it sits still — a
709
+ panel that keeps flashing beside your work is a panel you turn off. */
710
+ .item.alarm { animation: alarm 900ms var(--ease) 1; }
711
+ @keyframes alarm {
712
+ 0% { background: color-mix(in srgb, var(--tone, var(--broke)) 26%, transparent); }
713
+ 100% { background: color-mix(in srgb, var(--tone, var(--broke)) 5%, transparent); }
714
+ }
565
715
 
566
716
  /* Big, quiet, touch-sized rows. At rest a check is a dot, a name and a time —
567
717
  that is the whole of it. */
@@ -572,7 +722,7 @@ button { font: inherit; color: inherit; }
572
722
  text-align: left;
573
723
  background: transparent; border: 0;
574
724
  cursor: pointer;
575
- transition: background 160ms ease;
725
+ transition: background var(--quick) var(--ease);
576
726
  }
577
727
  .row:hover { background: var(--card-hover); }
578
728
  .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
@@ -580,13 +730,18 @@ button { font: inherit; color: inherit; }
580
730
  .item.showing .row { box-shadow: inset 2px 0 0 var(--line-firm); }
581
731
  .item.showing .rname { color: var(--ink); }
582
732
  .item.plain .row:hover { background: transparent; }
583
- .item.fresh { animation: settle 200ms ease both; }
584
- @keyframes settle { from { opacity: 0; } to { opacity: 1; } }
733
+ /* Rows settle in rather than appearing, and they do it one after another, which
734
+ is what makes a list of eleven screens read as one thing arriving. */
735
+ .item.fresh { animation: settle 300ms var(--ease) both; }
736
+ @keyframes settle {
737
+ from { opacity: 0; transform: translateY(7px); }
738
+ to { opacity: 1; transform: none; }
739
+ }
585
740
 
586
741
  .dot {
587
742
  flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
588
743
  background: var(--resting);
589
- transition: background 240ms ease, box-shadow 240ms ease;
744
+ transition: background var(--calm) var(--ease), box-shadow var(--calm) var(--ease);
590
745
  }
591
746
  .item.held .dot { background: color-mix(in srgb, var(--held) 78%, transparent); }
592
747
  .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 15%, transparent); }
@@ -596,10 +751,10 @@ button { font: inherit; color: inherit; }
596
751
  .item.running .dot {
597
752
  background: transparent;
598
753
  box-shadow: inset 0 0 0 2px var(--accent);
599
- animation: ping 1.7s ease-out infinite;
754
+ animation: ping 1.8s var(--ease) infinite;
600
755
  }
601
756
  @keyframes ping {
602
- 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); }
757
+ 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 42%, transparent); }
603
758
  70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
604
759
  }
605
760
  .item.running .rname { color: var(--ink); }
@@ -608,20 +763,21 @@ button { font: inherit; color: inherit; }
608
763
  flex: 1 1 auto; min-width: 0;
609
764
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
610
765
  font-variant-numeric: tabular-nums;
611
- font-size: 12px; color: var(--ink);
766
+ font-size: var(--t-name); color: var(--ink);
612
767
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
613
768
  }
614
769
  .item.pending .rname, .item.skip .rname { color: var(--faint); }
770
+ .rtime { margin-left: 12px; }
615
771
  .rtime {
616
772
  flex: 0 0 auto;
617
773
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
618
774
  font-variant-numeric: tabular-nums;
619
- font-size: 10.5px; color: var(--faint);
775
+ font-size: var(--t-meta); color: var(--faint);
620
776
  }
621
777
  .row .chev {
622
778
  flex: 0 0 auto; width: 14px; height: 14px;
623
779
  color: var(--faint); opacity: 0;
624
- transition: transform 200ms ease, opacity 160ms ease;
780
+ transition: transform var(--calm) var(--ease), opacity var(--quick) var(--ease);
625
781
  }
626
782
  .row:hover .chev, .item.open .chev { opacity: 1; }
627
783
  .item.open .chev { transform: rotate(180deg); }
@@ -630,9 +786,11 @@ button { font: inherit; color: inherit; }
630
786
  /* Everything a person did not ask for lives here. */
631
787
  .detail {
632
788
  padding: 0 14px 13px 33px;
633
- font-size: 11.5px; line-height: 1.6;
789
+ font-size: var(--t-body); line-height: 1.6;
634
790
  overflow-wrap: anywhere;
635
791
  }
792
+ .item.open .detail { animation: unfold 240ms var(--ease) both; }
793
+ @keyframes unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
636
794
  .detail .why { color: var(--soft); }
637
795
  .detail .out { color: var(--soft); margin-top: 3px; }
638
796
  .detail .out.moved { color: var(--moved); }
@@ -642,20 +800,20 @@ button { font: inherit; color: inherit; }
642
800
  margin-top: 9px; padding: 9px 12px;
643
801
  border-radius: var(--radius-xs);
644
802
  background: var(--well);
645
- box-shadow: inset 2px 0 0 var(--broke), inset 0 0 0 1px var(--line);
803
+ box-shadow: inset 2px 0 0 var(--broke);
646
804
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
647
- font-size: 11px; color: var(--ink);
805
+ font-size: var(--t-body); color: var(--ink);
648
806
  }
649
807
  .detail .claim em { font-style: normal; color: var(--faint); }
650
808
  .detail .story { margin-top: 7px; color: var(--faint); }
651
809
 
652
- .nothing { padding: 26px 16px; color: var(--faint); font-size: 11.5px; line-height: 1.7; text-align: center; }
810
+ .nothing { padding: 26px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.7; text-align: center; }
653
811
  .nothing .mono { color: var(--soft); }
654
812
 
655
813
  /* --- where the time went ------------------------------------------------- */
656
814
  .timing {
657
815
  flex: 0 0 auto;
658
- padding: 11px var(--pad) 12px;
816
+ padding: 12px var(--pad) 13px;
659
817
  border-top: 1px solid var(--line);
660
818
  background: var(--glass);
661
819
  backdrop-filter: blur(22px) saturate(150%);
@@ -669,13 +827,13 @@ button { font: inherit; color: inherit; }
669
827
  }
670
828
  .tlabel {
671
829
  flex: 1 1 auto;
672
- font-size: 10px; font-weight: 600;
830
+ font-size: var(--t-label); font-weight: 600;
673
831
  letter-spacing: 0.16em; text-transform: uppercase;
674
832
  color: var(--faint);
675
833
  }
676
834
  .ttotal {
677
835
  flex: 0 1 auto; min-width: 0;
678
- font-size: 10.5px; color: var(--faint);
836
+ font-size: var(--t-meta); color: var(--faint);
679
837
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
680
838
  }
681
839
  .ttotal b {
@@ -683,20 +841,20 @@ button { font: inherit; color: inherit; }
683
841
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
684
842
  font-variant-numeric: tabular-nums;
685
843
  }
686
- .tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform 200ms ease; }
844
+ .tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform var(--calm) var(--ease); }
687
845
  .tophead[aria-expanded='true'] .chev { transform: rotate(180deg); }
688
846
  .tophead:hover .tlabel, .tophead:hover .ttotal, .tophead:hover .chev { color: var(--soft); }
689
847
 
690
848
  /* Deliberately not coloured. Where the time went is information, not state,
691
849
  and colour on this page only ever means something needs a person. */
692
850
  .tbar { display: flex; gap: 1px; height: 6px; border-radius: 999px; overflow: hidden; background: var(--resting); }
693
- .tpart { min-width: 2px; }
694
- .tkey { list-style: none; margin: 11px 0 1px; padding: 0; }
695
- .tkey li { display: flex; align-items: center; gap: 9px; padding: 3px 4px; font-size: 11px; color: var(--soft); }
851
+ .tpart { min-width: 2px; transition: flex-grow var(--slow) var(--ease); }
852
+ .tkey { list-style: none; margin: 12px 0 1px; padding: 0; }
853
+ .tkey li { display: flex; align-items: center; gap: 9px; padding: 3px 4px; font-size: var(--t-body); color: var(--soft); }
696
854
  .tkey .tswatch { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 2px; }
697
855
  .tkey .tlabelled { flex: 1 1 auto; min-width: 0; }
698
856
  .tkey b {
699
- flex: 0 0 auto; font-weight: 500; color: var(--ink); font-size: 11px;
857
+ flex: 0 0 auto; font-weight: 500; color: var(--ink); font-size: var(--t-body);
700
858
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
701
859
  font-variant-numeric: tabular-nums;
702
860
  }
@@ -705,27 +863,29 @@ button { font: inherit; color: inherit; }
705
863
  .follow {
706
864
  position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
707
865
  z-index: 4;
708
- font-size: 11px;
866
+ font-size: var(--t-body);
709
867
  color: var(--ink);
710
868
  background: var(--lift);
711
869
  border: 1px solid var(--line-firm); border-radius: 999px;
712
870
  padding: 6px 15px; cursor: pointer;
713
871
  box-shadow: 0 12px 24px -14px var(--shadow);
714
- transition: border-color 160ms ease, color 160ms ease;
872
+ animation: settle 240ms var(--ease) both;
873
+ transition: border-color var(--quick) var(--ease), color var(--quick) var(--ease);
715
874
  }
716
875
  .follow:hover { border-color: var(--accent); color: var(--accent); }
717
876
 
718
877
  /* --- the one thing left to do ------------------------------------------- */
719
878
  .foot {
720
879
  flex: 0 0 auto;
721
- padding: 13px var(--pad) 15px;
880
+ padding: 14px var(--pad) 16px;
722
881
  background: var(--glass);
723
882
  backdrop-filter: blur(22px) saturate(150%);
724
883
  -webkit-backdrop-filter: blur(22px) saturate(150%);
725
884
  border-top: 1px solid var(--line);
885
+ animation: arrive 340ms var(--ease) both;
726
886
  }
727
887
  .nextlabel {
728
- font-size: 10px; font-weight: 600;
888
+ font-size: var(--t-label); font-weight: 600;
729
889
  letter-spacing: 0.16em; text-transform: uppercase;
730
890
  color: var(--faint);
731
891
  margin-bottom: 8px;
@@ -736,8 +896,7 @@ button { font: inherit; color: inherit; }
736
896
  display: flex; align-items: center;
737
897
  background: var(--well);
738
898
  border-radius: var(--radius-xs);
739
- box-shadow: inset 0 0 0 1px var(--line-firm);
740
- padding: 8px 11px; font-size: 11.5px;
899
+ padding: 9px 11px; font-size: var(--t-body);
741
900
  color: var(--ink);
742
901
  overflow-wrap: anywhere;
743
902
  user-select: all;
@@ -746,56 +905,181 @@ button { font: inherit; color: inherit; }
746
905
  .copy {
747
906
  flex: 0 0 auto; width: 36px;
748
907
  display: flex; align-items: center; justify-content: center;
749
- color: var(--soft); background: transparent;
750
- border: 1px solid var(--line-firm); border-radius: var(--radius-xs);
908
+ color: var(--soft); background: var(--well);
909
+ border: 0; border-radius: var(--radius-xs);
751
910
  cursor: pointer;
752
- transition: color 160ms ease, border-color 160ms ease;
911
+ transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
753
912
  }
754
- .copy:hover { color: var(--ink); border-color: var(--soft); }
755
- .copy.done { color: var(--held); border-color: color-mix(in srgb, var(--held) 45%, transparent); }
913
+ .copy:hover { color: var(--ink); background: var(--card-hover); }
914
+ .copy.done { color: var(--held); }
756
915
 
757
- /* --- one picture, made big ---------------------------------------------- */
758
- .lightbox {
759
- position: fixed; inset: 0; z-index: 10;
760
- background: color-mix(in srgb, var(--ground) 78%, #000);
761
- backdrop-filter: blur(10px);
762
- -webkit-backdrop-filter: blur(10px);
916
+ /* --------------------------------------------------------------------------
917
+ The viewer.
918
+
919
+ The one screen a person uses to decide whether to approve something, so it
920
+ gets the whole window, the real file at its real resolution, and — when a
921
+ screen moved — the approved picture and the new one under a line you drag.
922
+ Past about two times, the smoothing comes off: somebody zooming in is asking
923
+ to see the actual pixels, not a guess at what is between them.
924
+ -------------------------------------------------------------------------- */
925
+ .viewer {
926
+ position: fixed; inset: 0; z-index: 20;
927
+ display: flex; flex-direction: column;
928
+ background: color-mix(in srgb, var(--ground) 92%, #000);
929
+ animation: viewerin 220ms var(--ease) both;
930
+ }
931
+ @keyframes viewerin { from { opacity: 0; } to { opacity: 1; } }
932
+
933
+ .vtop {
934
+ flex: 0 0 auto;
935
+ display: flex; align-items: center; gap: 10px;
936
+ padding: 11px 12px 11px var(--pad);
937
+ }
938
+ /* Three parts, and the outer two share what is left over, so the words stay in
939
+ the middle and the way out stays hard right whether there are four pictures
940
+ to choose between or one. */
941
+ .vname {
942
+ flex: 1 1 0; min-width: 0;
943
+ font-size: var(--t-name); color: var(--ink);
944
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
945
+ }
946
+ .vmodes {
947
+ flex: 0 1 auto; min-width: 0;
948
+ display: flex; justify-content: center; gap: 3px;
949
+ overflow-x: auto; scrollbar-width: none;
950
+ }
951
+ .vmodes::-webkit-scrollbar { display: none; }
952
+ .vright { flex: 1 1 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
953
+ .vmodes button {
954
+ position: relative;
955
+ font-size: var(--t-body); color: var(--faint);
956
+ background: transparent; border: 0; cursor: pointer;
957
+ padding: 5px 9px 6px; white-space: nowrap;
958
+ transition: color var(--quick) var(--ease);
959
+ }
960
+ .vmodes button::after {
961
+ content: ''; position: absolute; left: 50%; right: 50%; bottom: 1px; height: 1.5px;
962
+ border-radius: 2px; background: var(--accent); opacity: 0;
963
+ transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
964
+ }
965
+ .vmodes button:hover { color: var(--soft); }
966
+ .vmodes button.on { color: var(--ink); }
967
+ .vmodes button.on::after { left: 14%; right: 14%; opacity: 1; }
968
+ .vzoom { flex: 0 0 auto; font-size: var(--t-meta); color: var(--faint); }
969
+ .vclose {
970
+ flex: 0 0 auto;
971
+ display: flex; align-items: center; justify-content: center;
972
+ width: 28px; height: 28px; border-radius: 8px;
973
+ color: var(--soft); background: transparent; border: 0; cursor: pointer;
974
+ transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
975
+ }
976
+ .vclose:hover { color: var(--ink); background: var(--card-hover); }
977
+
978
+ .vstage {
979
+ position: relative;
980
+ flex: 1 1 auto; min-height: 0;
981
+ display: flex; align-items: center; justify-content: center;
982
+ overflow: hidden;
983
+ cursor: grab;
984
+ touch-action: none;
985
+ }
986
+ .vstage.dragging { cursor: grabbing; }
987
+ .vframe {
988
+ position: relative;
989
+ will-change: transform;
990
+ box-shadow: 0 0 0 1px var(--line-firm), 0 40px 80px -50px #000;
991
+ /* Opacity only. An animation that also names the transform wins against the
992
+ inline transform the zoom writes, and the picture would never move again. */
993
+ animation: viewerpic 260ms var(--ease) both;
994
+ }
995
+ @keyframes viewerpic { from { opacity: 0; } to { opacity: 1; } }
996
+ .vlayer {
997
+ position: absolute; inset: 0;
998
+ width: 100%; height: 100%;
999
+ object-fit: contain;
1000
+ display: none;
1001
+ }
1002
+ .vlayer.show { display: block; }
1003
+ .vframe.sharp .vlayer { image-rendering: pixelated; }
1004
+
1005
+ /* The line you drag. It lives in the window rather than in the picture, so it
1006
+ stays one hair wide however far in you have zoomed. */
1007
+ .vwipe {
1008
+ position: absolute; top: 0; width: 22px;
1009
+ margin-left: -11px;
763
1010
  display: flex; align-items: center; justify-content: center;
764
- padding: 18px; cursor: zoom-out;
1011
+ cursor: ew-resize;
1012
+ touch-action: none;
1013
+ }
1014
+ .vwipe::before {
1015
+ content: ''; position: absolute; top: 0; height: 100%; width: 1.5px;
1016
+ background: color-mix(in srgb, #fff 78%, transparent);
1017
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
1018
+ }
1019
+ .vgrip {
1020
+ position: relative;
1021
+ width: 22px; height: 22px; border-radius: 50%;
1022
+ background: color-mix(in srgb, #fff 88%, transparent);
1023
+ box-shadow: 0 2px 10px -2px rgba(0, 0, 0, 0.7);
1024
+ transition: transform var(--quick) var(--ease);
1025
+ }
1026
+ .vwipe:hover .vgrip, .vwipe.holding .vgrip { transform: scale(1.14); }
1027
+
1028
+ .vfoot {
1029
+ flex: 0 0 auto;
1030
+ display: flex; align-items: center; gap: 12px;
1031
+ padding: 12px var(--pad) 6px;
1032
+ }
1033
+ .vhelp {
1034
+ flex: 0 0 auto;
1035
+ padding: 10px var(--pad) 15px;
1036
+ text-align: center;
1037
+ font-size: var(--t-label); letter-spacing: 0.03em;
1038
+ color: var(--faint);
1039
+ }
1040
+ .vside { flex: 0 0 auto; font-size: var(--t-label); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); }
1041
+ .vblend {
1042
+ flex: 1 1 auto; min-width: 0;
1043
+ -webkit-appearance: none; appearance: none;
1044
+ height: 3px; border-radius: 999px;
1045
+ background: linear-gradient(to right, var(--moved), var(--accent));
1046
+ outline: none; cursor: pointer;
765
1047
  }
766
- .lightbox img {
767
- max-width: 100%; max-height: 100%;
768
- border-radius: var(--radius-sm);
769
- box-shadow: 0 0 0 1px var(--line-firm), 0 36px 70px -34px #000;
1048
+ .vblend::-webkit-slider-thumb {
1049
+ -webkit-appearance: none; appearance: none;
1050
+ width: 15px; height: 15px; border-radius: 50%;
1051
+ background: var(--ink);
1052
+ box-shadow: 0 2px 8px -2px var(--shadow);
1053
+ transition: transform var(--quick) var(--ease);
770
1054
  }
1055
+ .vblend:active::-webkit-slider-thumb { transform: scale(1.15); }
771
1056
 
772
1057
  /* --- the range it has to survive ---------------------------------------- */
773
1058
 
774
1059
  /* Dragged narrow. Everything stays, nothing wraps into a mess. */
775
1060
  @media (max-width: 330px) {
776
- :root { --pad: 11px; --radius: 16px; }
777
- .state { font-size: 15px; }
1061
+ :root { --pad: 12px; --radius: 16px; --t-lead: 15px; }
778
1062
  .target .app, .target .sep { display: none; }
779
1063
  .row { gap: 9px; padding: 9px 11px; }
780
1064
  .detail { padding: 0 11px 12px 28px; }
781
1065
  .shot { max-height: 30vh; }
782
- .switch button { font-size: 10px; padding: 5px 3px; }
1066
+ .switch button { font-size: var(--t-meta); padding: 6px 2px 7px; }
783
1067
  .ttotal { display: none; }
784
1068
  .caption { display: block; }
785
1069
  .shotname { display: block; white-space: normal; overflow-wrap: anywhere; }
786
1070
  .shotout { display: block; text-align: left; margin-top: 2px; }
787
1071
  .timing { padding: 10px var(--pad) 11px; }
1072
+ .vname { display: none; }
788
1073
  }
789
1074
 
790
1075
  /* Pulled wide. Two columns rather than one very long thin one, so the picture
791
1076
  gets the room and the list stays beside it instead of below the fold. */
792
1077
  @media (min-width: 720px) {
793
- :root { --pad: 22px; }
794
- .body { flex-direction: row; gap: 26px; padding-bottom: 4px; }
1078
+ :root { --pad: 24px; --t-lead: 19px; }
1079
+ .body { flex-direction: row; gap: 28px; padding-bottom: 4px; }
795
1080
  .stage { flex: 1 1 54%; min-width: 0; align-self: center; padding-top: 0; }
796
- .scroll { flex: 1 1 46%; min-width: 0; padding-top: 20px; }
1081
+ .scroll { flex: 1 1 46%; min-width: 0; padding-top: 22px; }
797
1082
  .shot { max-height: 58vh; }
798
- .state { font-size: 19px; }
799
1083
  .timing { margin-top: 22px; }
800
1084
  }
801
1085
 
@@ -820,12 +1104,23 @@ const SCRIPT = `
820
1104
  // still appear one by one as the run reaches them.
821
1105
  }
822
1106
 
1107
+ /**
1108
+ * Point an image at a file, or at nothing at all.
1109
+ * @param {HTMLImageElement} img
1110
+ * @param {string|undefined|null} url
1111
+ */
1112
+ function setSource(img, url) {
1113
+ if (!img) return;
1114
+ if (url) img.setAttribute('src', url);
1115
+ else img.removeAttribute('src');
1116
+ }
1117
+
823
1118
  function el(id) { return document.getElementById(id); }
824
1119
 
825
1120
  var ui = {
826
1121
  clock: el('clock'), project: el('project'), app: el('app'), targetsep: el('targetsep'),
827
1122
  state: el('state'), note: el('note'),
828
- track: el('track'), fill: el('fill'), counts: el('counts'),
1123
+ track: el('track'), fill: el('fill'), counts: el('counts'), what: el('what'),
829
1124
  stage: el('stage'), shot: el('shot'), layerA: el('layerA'), layerB: el('layerB'), blank: el('blank'),
830
1125
  caption: el('caption'), shotname: el('shotname'), shotout: el('shotout'), tabs: el('tabs'),
831
1126
  scroll: el('scroll'), follow: el('follow'), nothing: el('nothing'),
@@ -834,11 +1129,20 @@ const SCRIPT = `
834
1129
  timing: el('timing'), thead: el('thead'), ttotal: el('ttotal'), tbar: el('tbar'), tkey: el('tkey'),
835
1130
  footer: el('footer'), cmd: el('cmd'), copy: el('copy'),
836
1131
  aura: document.querySelector('.aura'),
837
- lightbox: el('lightbox'), lightimg: el('lightimg')
1132
+ viewer: el('viewer'), vname: el('vname'), vmodes: el('vmodes'), vzoom: el('vzoom'),
1133
+ vclose: el('vclose'), vstage: el('vstage'), vframe: el('vframe'),
1134
+ vApproved: el('vApproved'), vNow: el('vNow'), vDiff: el('vDiff'),
1135
+ vwipe: el('vwipe'), vfoot: el('vfoot'), vblend: el('vblend')
838
1136
  };
839
1137
 
840
1138
  var CHEVRON = '<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path></svg>';
841
1139
 
1140
+ // Somebody who has asked their computer for less movement gets none of it:
1141
+ // the CSS switches every animation off, and everything this file animates in
1142
+ // JavaScript jumps straight to its final value instead.
1143
+ var CALM = false;
1144
+ try { CALM = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { CALM = false; }
1145
+
842
1146
  // -------------------------------------------------------------------------
843
1147
  // Words. These match the terminal on purpose — the same run should not be
844
1148
  // described two different ways depending on where you happen to be reading.
@@ -917,6 +1221,127 @@ const SCRIPT = `
917
1221
  }
918
1222
  }
919
1223
 
1224
+ /**
1225
+ * Two or three words for the row itself.
1226
+ *
1227
+ * The long sentence stays in the detail; this is what a person reads while
1228
+ * scanning the column. A check that held has to say so — silence next to a
1229
+ * duration is what made the panel look like it was timing page loads.
1230
+ */
1231
+ function shortOutcome(kind, ev) {
1232
+ var status = ev.status;
1233
+ if (kind === 'guard') {
1234
+ if (status === 'passed') return 'still holds';
1235
+ if (status === 'skipped') return 'left out';
1236
+ return 'broken again';
1237
+ }
1238
+ switch (status) {
1239
+ case 'passed': return 'matches';
1240
+ case 'changed':
1241
+ var n = ev.diffPixels || 0;
1242
+ return commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' moved';
1243
+ case 'new': return 'needs your eyes';
1244
+ case 'missing': return 'no approved picture';
1245
+ case 'failed': return 'no picture taken';
1246
+ case 'flaky': return 'would not settle';
1247
+ case 'skipped': return 'left out';
1248
+ default: return '';
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * The one line that says what a run is, in the words a person would use.
1254
+ */
1255
+ function describeWork(screens, guards) {
1256
+ var bits = [];
1257
+ if (screens) {
1258
+ bits.push(
1259
+ screens + ' ' + plural(screens, 'screen', 'screens') +
1260
+ ' photographed and compared, pixel for pixel, with the picture you approved',
1261
+ );
1262
+ }
1263
+ if (guards) {
1264
+ bits.push(
1265
+ guards + ' ' + plural(guards, 'guard', 'guards') +
1266
+ ' — one per bug that was already fixed once',
1267
+ );
1268
+ }
1269
+ return bits.join(' · ');
1270
+ }
1271
+
1272
+ // -------------------------------------------------------------------------
1273
+ // Numbers count rather than jump.
1274
+ //
1275
+ // A tally that flicks from 3 to 7 has to be re-read; one that counts up to 7
1276
+ // has already been read by the time it stops. Four hundred milliseconds, the
1277
+ // same curve as everything else, and nothing at all for anyone who asked for
1278
+ // less motion.
1279
+ // -------------------------------------------------------------------------
1280
+
1281
+ function tweenTo(node, to, format) {
1282
+ var target = Number(to);
1283
+ if (!isFinite(target)) return;
1284
+ var from = typeof node.__from === 'number' ? node.__from : 0;
1285
+ if (node.__raf) { cancelAnimationFrame(node.__raf); node.__raf = 0; }
1286
+ if (CALM || from === target) {
1287
+ node.__from = target;
1288
+ node.textContent = format(target);
1289
+ return;
1290
+ }
1291
+ var began = performance.now();
1292
+ var span = 420;
1293
+ function step(now) {
1294
+ var k = (now - began) / span;
1295
+ if (k > 1) k = 1;
1296
+ var eased = 1 - Math.pow(1 - k, 3);
1297
+ var value = k === 1 ? target : from + (target - from) * eased;
1298
+ node.__from = value;
1299
+ node.textContent = format(value);
1300
+ if (k < 1) node.__raf = requestAnimationFrame(step);
1301
+ else { node.__raf = 0; node.__from = target; }
1302
+ }
1303
+ node.__raf = requestAnimationFrame(step);
1304
+ }
1305
+
1306
+ // -------------------------------------------------------------------------
1307
+ // Pictures.
1308
+ //
1309
+ // The panel is a local page, so it can load the real PNG the run just wrote
1310
+ // to disk. That is the whole difference between a picture you can look at and
1311
+ // a smudge: the small preview inside the event is 320 pixels wide, and this
1312
+ // window is wider than that before anybody zooms in. So the preview is only
1313
+ // ever what fills the frame for the moment before the file is on disk, and it
1314
+ // is replaced by the real thing the instant that file will load.
1315
+ // -------------------------------------------------------------------------
1316
+
1317
+ var known = Object.create(null); // src -> 1 loaded, 2 refused
1318
+ var held = []; // keep the decoded pictures alive
1319
+
1320
+ function preload(src, then) {
1321
+ if (!src) { if (then) then(false); return; }
1322
+ if (known[src] === 1) { if (then) then(true); return; }
1323
+ if (known[src] === 2) { if (then) then(false); return; }
1324
+ var img = new Image();
1325
+ img.onload = function () { known[src] = 1; if (then) then(true); };
1326
+ img.onerror = function () { known[src] = 2; if (then) then(false); };
1327
+ img.src = src;
1328
+ held.push(img);
1329
+ if (held.length > 24) held.shift();
1330
+ }
1331
+
1332
+ function preloadAll(p) {
1333
+ if (!p) return;
1334
+ preload(p.shotFile);
1335
+ preload(p.approvedFile);
1336
+ preload(p.diffFile);
1337
+ }
1338
+
1339
+ // What to show for each of the three pictures: the real file when there is
1340
+ // one, the preview until then.
1341
+ function nowOf(p) { return (p && (p.shotFile || p.thumbnail)) || ''; }
1342
+ function approvedOf(p) { return (p && (p.approvedFile || p.approvedThumb)) || ''; }
1343
+ function diffOf(p) { return (p && (p.diffFile || p.diffThumb)) || ''; }
1344
+
920
1345
  // -------------------------------------------------------------------------
921
1346
  // The list. One line per check at rest; everything else on request.
922
1347
  // -------------------------------------------------------------------------
@@ -925,12 +1350,23 @@ const SCRIPT = `
925
1350
  var order = [];
926
1351
  var outcomes = Object.create(null);
927
1352
  var expected = { picture: 0, guard: 0 };
1353
+ var seeding = false;
1354
+ var TONES = ['held', 'moved', 'broke', 'wait', 'skip', 'running', 'pending'];
928
1355
 
929
1356
  function keyFor(kind, name) { return kind + ':' + name; }
930
1357
 
931
1358
  function addItem(kind, name, describe) {
932
1359
  var root = document.createElement('div');
933
1360
  root.className = 'item fresh pending';
1361
+ // Rows arrive one after another rather than all at once. Only while the
1362
+ // opening list is being drawn — a row that appears mid-run is on its own
1363
+ // and has nothing to be staggered against.
1364
+ var wait = seeding ? Math.min(order.length, 16) * 24 : 0;
1365
+ if (wait && !CALM) root.style.animationDelay = wait + 'ms';
1366
+ setTimeout(function () {
1367
+ root.classList.remove('fresh');
1368
+ root.style.animationDelay = '';
1369
+ }, 420 + wait);
934
1370
 
935
1371
  var row = document.createElement('button');
936
1372
  row.type = 'button';
@@ -942,10 +1378,13 @@ const SCRIPT = `
942
1378
  var rname = document.createElement('span');
943
1379
  rname.className = 'rname';
944
1380
  rname.textContent = name;
1381
+ var rverdict = document.createElement('span');
1382
+ rverdict.className = 'rverdict';
945
1383
  var rtime = document.createElement('span');
946
1384
  rtime.className = 'rtime';
947
1385
  row.appendChild(dot);
948
1386
  row.appendChild(rname);
1387
+ row.appendChild(rverdict);
949
1388
  row.appendChild(rtime);
950
1389
  row.insertAdjacentHTML('beforeend', CHEVRON);
951
1390
 
@@ -957,7 +1396,7 @@ const SCRIPT = `
957
1396
  root.appendChild(detail);
958
1397
 
959
1398
  var entry = {
960
- kind: kind, name: name, root: root, row: row, time: rtime,
1399
+ kind: kind, name: name, root: root, row: row, time: rtime, verdict: rverdict,
961
1400
  detail: detail, describe: describe || '', open: false, hasDetail: false
962
1401
  };
963
1402
 
@@ -1038,20 +1477,18 @@ const SCRIPT = `
1038
1477
 
1039
1478
  /** Put a screen's pictures back in the hero, with the words that went with them. */
1040
1479
  function recall(entry) {
1041
- var pics = entry.pics;
1042
- if (!pics) return;
1480
+ var p = entry.pics;
1481
+ if (!p) return;
1043
1482
  var showed = false;
1044
- if (pics.status === 'changed') showed = comparePictures(pics);
1045
- if (!showed && pics.thumbnail) {
1046
- singlePicture();
1047
- showShot(pics.thumbnail, 'the picture taken of ' + entry.name);
1483
+ if (p.status === 'changed') showed = comparePictures(p);
1484
+ if (!showed && nowOf(p)) {
1485
+ singlePicture(p, entry.name);
1048
1486
  showed = true;
1049
1487
  }
1050
1488
  if (!showed) return;
1051
1489
  nameTheShot(entry.name);
1052
- sayOutcome(entry.tone, outcomeShort(pics));
1053
- for (var i = 0; i < order.length; i++) order[i].root.classList.remove('showing');
1054
- entry.root.classList.add('showing');
1490
+ sayOutcome(entry.tone, outcomeShort(p));
1491
+ markShowing(entry);
1055
1492
  }
1056
1493
 
1057
1494
  function setOpen(entry, open) {
@@ -1068,22 +1505,21 @@ const SCRIPT = `
1068
1505
 
1069
1506
  function setTone(entry, tone) {
1070
1507
  entry.tone = tone;
1071
- var classes = ['item'];
1072
- if (tone) classes.push(tone);
1073
- if (!tone) classes.push('pending');
1074
- if (needsPerson(tone)) classes.push('attention');
1075
- if (entry.open) classes.push('open');
1076
- if (!entry.hasDetail) classes.push('plain');
1077
- if (entry.root.classList.contains('showing')) classes.push('showing');
1078
- entry.root.className = classes.join(' ');
1508
+ var list = entry.root.classList;
1509
+ for (var i = 0; i < TONES.length; i++) list.remove(TONES[i]);
1510
+ list.add(tone || 'pending');
1511
+ list.toggle('attention', needsPerson(tone));
1079
1512
  entry.root.style.setProperty('--tone', tone ? 'var(--' + tone + ')' : 'var(--accent)');
1080
1513
  paintMeter();
1081
1514
  }
1082
1515
 
1083
1516
  // -------------------------------------------------------------------------
1084
- // The meter: one hairline, sliced by outcome.
1517
+ // The meter: one hairline, sliced by outcome. Each slice lights once as it
1518
+ // lands, in its own colour, and then it is just a colour.
1085
1519
  // -------------------------------------------------------------------------
1086
1520
 
1521
+ var painted = [];
1522
+
1087
1523
  function meterTotal() {
1088
1524
  var planned = (expected.picture || 0) + (expected.guard || 0);
1089
1525
  return Math.max(order.length, planned, 1);
@@ -1103,38 +1539,64 @@ const SCRIPT = `
1103
1539
  }
1104
1540
  for (var i = 0; i < order.length; i++) {
1105
1541
  var tone = order[i].tone || '';
1106
- slices[i].className = 'slice' + (tone ? ' ' + tone : '');
1542
+ var settled = tone && tone !== 'running';
1543
+ var lands = settled && painted[i] !== tone && !CALM;
1544
+ slices[i].className = 'slice' + (tone ? ' ' + tone : '') + (lands ? ' land' : '');
1107
1545
  slices[i].style.width = width;
1546
+ painted[i] = tone;
1108
1547
  }
1109
1548
  }
1110
1549
 
1111
- function updateCounts() {
1112
- var done = 0;
1550
+ var doneTicker = { __from: 0, textContent: '' };
1551
+
1552
+ function tally() {
1553
+ var counted = { done: 0, changed: 0, failed: 0, waiting: 0, skipped: 0 };
1113
1554
  for (var i = 0; i < order.length; i++) {
1114
- if (outcomes[keyFor(order[i].kind, order[i].name)]) done++;
1555
+ var status = outcomes[keyFor(order[i].kind, order[i].name)];
1556
+ if (!status) continue;
1557
+ counted.done++;
1558
+ if (status === 'moved') counted.changed++;
1559
+ else if (status === 'broke') counted.failed++;
1560
+ else if (status === 'wait') counted.waiting++;
1561
+ else if (status === 'skip') counted.skipped++;
1115
1562
  }
1563
+ return counted;
1564
+ }
1565
+
1566
+ // Say what happened, not just how far along it is. "11 of 11" tells a person the
1567
+ // run finished and nothing else; they still have to read the whole list to find
1568
+ // out whether they can walk away. The tally answers that in four words.
1569
+ function countsLine(done, counted, total) {
1570
+ var parts = [];
1571
+ if (counted.failed) parts.push(commas(counted.failed) + ' broke');
1572
+ if (counted.changed) parts.push(commas(counted.changed) + ' moved');
1573
+ if (counted.waiting) parts.push(commas(counted.waiting) + ' needs you');
1574
+ if (counted.skipped) parts.push(commas(counted.skipped) + ' skipped');
1575
+ var line = commas(done) + ' of ' + commas(total);
1576
+ if (counted.done === total && total > 0 && parts.length === 0) line += ' — all held';
1577
+ return line + (parts.length ? ' · ' + parts.join(' · ') : '');
1578
+ }
1579
+
1580
+ function updateCounts() {
1581
+ var counted = tally();
1116
1582
  var total = meterTotal();
1117
- // Say what happened, not just how far along it is. "11/11" tells a person the
1118
- // run finished and nothing else; they still have to read the whole list to find
1119
- // out whether they can walk away. The tally answers that in four words.
1120
- var tally = { changed: 0, failed: 0, waiting: 0, skipped: 0 };
1121
- for (var t = 0; t < order.length; t++) {
1122
- var st = outcomes[keyFor(order[t].kind, order[t].name)];
1123
- if (st === 'changed') tally.changed++;
1124
- else if (st === 'failed' || st === 'missing') tally.failed++;
1125
- else if (st === 'new') tally.waiting++;
1126
- else if (st === 'skipped') tally.skipped++;
1583
+ if (!order.length) {
1584
+ ui.counts.textContent = '';
1585
+ doneTicker.__from = 0;
1586
+ } else {
1587
+ tweenTo(doneTicker, counted.done, function (value) {
1588
+ ui.counts.textContent = countsLine(Math.round(value), counted, total);
1589
+ return '';
1590
+ });
1127
1591
  }
1128
- var parts = [];
1129
- if (tally.failed) parts.push(commas(tally.failed) + ' broke');
1130
- if (tally.changed) parts.push(commas(tally.changed) + ' moved');
1131
- if (tally.waiting) parts.push(commas(tally.waiting) + ' needs you');
1132
- if (tally.skipped) parts.push(commas(tally.skipped) + ' skipped');
1133
- var counted = commas(done) + ' of ' + commas(total);
1134
- if (done === total && total > 0 && parts.length === 0) counted += ' — all held';
1135
- ui.counts.textContent = order.length ? counted + (parts.length ? ' · ' + parts.join(' · ') : '') : '';
1136
1592
  ui.countScreens.textContent = countIn('picture');
1137
1593
  ui.countGuards.textContent = countIn('guard');
1594
+ var nScreens = 0, nGuards = 0;
1595
+ for (var w = 0; w < order.length; w++) {
1596
+ if (order[w].kind === 'guard') nGuards++; else nScreens++;
1597
+ }
1598
+ ui.what.textContent = describeWork(nScreens, nGuards);
1599
+ ui.what.hidden = order.length === 0;
1138
1600
  ui.nothing.hidden = order.length > 0;
1139
1601
  paintMeter();
1140
1602
  }
@@ -1160,7 +1622,7 @@ const SCRIPT = `
1160
1622
  ui.follow.addEventListener('click', function () {
1161
1623
  following = true;
1162
1624
  ui.follow.hidden = true;
1163
- if (runningItem) runningItem.root.scrollIntoView({ block: 'nearest' });
1625
+ if (runningItem) runningItem.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
1164
1626
  });
1165
1627
 
1166
1628
  /** Which row the picture on the glass belongs to. */
@@ -1173,35 +1635,65 @@ const SCRIPT = `
1173
1635
  function markRunning(entry) {
1174
1636
  runningItem = entry;
1175
1637
  setTone(entry, 'running');
1176
- if (following) entry.root.scrollIntoView({ block: 'nearest' });
1638
+ if (following) entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
1177
1639
  }
1178
1640
 
1179
1641
  // -------------------------------------------------------------------------
1180
- // The picture
1642
+ // The hero
1181
1643
  // -------------------------------------------------------------------------
1182
1644
 
1183
1645
  var frontIsA = false;
1184
- var lastShot = '';
1185
1646
  var shownSrc = '';
1186
1647
  var shownAlt = '';
1648
+ var showSeq = 0;
1649
+
1650
+ // What is on the glass right now, so the viewer opens on the same thing.
1651
+ // Whether the person picked that picture themselves: it is the
1652
+ // difference between continuing what they were looking at and second-guessing
1653
+ // a default the panel chose for them.
1654
+ var onGlass = { pics: null, name: '', mode: 'now', touched: false };
1187
1655
 
1188
- function showShot(src, alt) {
1656
+ function paint(src, alt) {
1189
1657
  if (!src) return;
1190
1658
  // A run that turned out to have pictures after all gets its glass back.
1191
1659
  ui.stage.hidden = false;
1192
- shownSrc = src;
1193
- shownAlt = alt || '';
1194
1660
  ui.blank.hidden = true;
1195
1661
  ui.shot.classList.remove('empty');
1662
+ shownAlt = alt || '';
1663
+ if (src === shownSrc) return;
1664
+ shownSrc = src;
1196
1665
  var next = frontIsA ? ui.layerB : ui.layerA;
1197
1666
  var current = frontIsA ? ui.layerA : ui.layerB;
1667
+ next.classList.remove('on');
1668
+ next.classList.remove('out');
1198
1669
  next.src = src;
1199
1670
  next.alt = shownAlt;
1671
+ // Read a layout value so the browser starts the fade from where the layer
1672
+ // rests rather than skipping straight to the end of it.
1673
+ void next.offsetWidth;
1200
1674
  next.classList.add('on');
1201
1675
  current.classList.remove('on');
1676
+ current.classList.add('out');
1202
1677
  frontIsA = !frontIsA;
1203
1678
  }
1204
1679
 
1680
+ /**
1681
+ * Show a picture: the real file if it will load, the small preview until it
1682
+ * does. The swap between the two is the same cross-fade as any other change
1683
+ * of picture, so a screen going from blurred to sharp reads as the panel
1684
+ * finishing a thought rather than as a glitch.
1685
+ */
1686
+ function showPicture(sharp, preview, alt) {
1687
+ var mine = ++showSeq;
1688
+ if (sharp && known[sharp] === 1) { paint(sharp, alt); return; }
1689
+ if (preview) paint(preview, alt);
1690
+ if (!sharp || sharp === preview) return;
1691
+ preload(sharp, function (ok) {
1692
+ if (!ok || mine !== showSeq) return;
1693
+ paint(sharp, alt);
1694
+ });
1695
+ }
1696
+
1205
1697
  function nameTheShot(name) {
1206
1698
  ui.shotname.textContent = name || '';
1207
1699
  }
@@ -1214,14 +1706,26 @@ const SCRIPT = `
1214
1706
  document.documentElement.style.setProperty('--tint', value);
1215
1707
  }
1216
1708
 
1709
+ // The shutter. On while the app is really being photographed, off the instant
1710
+ // the picture lands — never a loop that runs when nothing is happening.
1711
+ function scanning(on) {
1712
+ ui.shot.classList.toggle('scanning', !!on);
1713
+ }
1714
+
1217
1715
  function clearTabs() {
1218
1716
  ui.tabs.hidden = true;
1219
1717
  ui.tabs.textContent = '';
1220
1718
  }
1221
1719
 
1222
- function singlePicture() {
1720
+ /** One picture, no comparison to make: the one that was just taken. */
1721
+ function singlePicture(p, name) {
1223
1722
  clearTabs();
1224
1723
  ui.shotout.hidden = true;
1724
+ onGlass.pics = p || null;
1725
+ onGlass.name = name || '';
1726
+ onGlass.mode = 'now';
1727
+ onGlass.touched = false;
1728
+ showPicture(p && p.shotFile, p && p.thumbnail, 'the picture taken of ' + (name || 'this screen'));
1225
1729
  }
1226
1730
 
1227
1731
  /**
@@ -1242,30 +1746,37 @@ const SCRIPT = `
1242
1746
  // A screen that moved is the one moment a person has a real decision to make,
1243
1747
  // so all three pictures are one click away and the difference is the one
1244
1748
  // already on screen — that is the picture that answers the question.
1245
- function comparePictures(ev) {
1749
+ function comparePictures(p) {
1246
1750
  var choices = [];
1247
- if (ev.approvedThumb) choices.push({ label: 'Approved', src: ev.approvedThumb });
1248
- var now = ev.thumbnail || lastShot;
1249
- if (now) choices.push({ label: 'Now', src: now });
1250
- if (ev.diffThumb) choices.push({ label: 'Difference', src: ev.diffThumb });
1751
+ if (approvedOf(p)) choices.push({ mode: 'approved', label: 'Approved', sharp: p.approvedFile, preview: p.approvedThumb });
1752
+ if (nowOf(p)) choices.push({ mode: 'now', label: 'Now', sharp: p.shotFile, preview: p.thumbnail });
1753
+ if (diffOf(p)) choices.push({ mode: 'diff', label: 'Difference', sharp: p.diffFile, preview: p.diffThumb });
1251
1754
  // Two pictures is the whole point; one on its own says nothing a person
1252
1755
  // could act on, so leave the live picture up instead.
1253
1756
  if (choices.length < 2) return false;
1254
1757
 
1758
+ onGlass.pics = p;
1759
+ onGlass.name = p.name || '';
1760
+ onGlass.touched = false;
1255
1761
  ui.tabs.textContent = '';
1762
+ ui.tabs.style.setProperty('--tone', 'var(--moved)');
1763
+
1256
1764
  var buttons = [];
1257
1765
  function select(index) {
1258
1766
  for (var i = 0; i < buttons.length; i++) {
1259
1767
  buttons[i].className = i === index ? 'on' : '';
1260
1768
  buttons[i].setAttribute('aria-pressed', i === index ? 'true' : 'false');
1261
1769
  }
1262
- showShot(choices[index].src, choices[index].label.toLowerCase() + ' picture of ' + (ev.name || 'this screen'));
1770
+ var choice = choices[index];
1771
+ onGlass.mode = choice.mode;
1772
+ showPicture(choice.sharp, choice.preview,
1773
+ choice.label.toLowerCase() + ' picture of ' + (p.name || 'this screen'));
1263
1774
  }
1264
1775
  choices.forEach(function (choice, index) {
1265
1776
  var button = document.createElement('button');
1266
1777
  button.type = 'button';
1267
1778
  button.textContent = choice.label;
1268
- button.addEventListener('click', function () { select(index); });
1779
+ button.addEventListener('click', function () { onGlass.touched = true; select(index); });
1269
1780
  buttons.push(button);
1270
1781
  ui.tabs.appendChild(button);
1271
1782
  });
@@ -1273,22 +1784,314 @@ const SCRIPT = `
1273
1784
 
1274
1785
  var preferred = choices.length - 1;
1275
1786
  for (var i = 0; i < choices.length; i++) {
1276
- if (choices[i].label === 'Difference') preferred = i;
1787
+ if (choices[i].mode === 'diff') preferred = i;
1277
1788
  }
1278
1789
  select(preferred);
1279
1790
  return true;
1280
1791
  }
1281
1792
 
1282
- function enlarge(src, alt) {
1283
- if (!src) return;
1284
- ui.lightimg.src = src;
1285
- ui.lightimg.alt = alt || '';
1286
- ui.lightbox.hidden = false;
1793
+ // -------------------------------------------------------------------------
1794
+ // The viewer.
1795
+ //
1796
+ // The panel is a local page, so this is the real file at its real size. Wheel
1797
+ // or pinch to zoom, drag to move around, double-click to fit, Escape to put
1798
+ // it away. Past about twice the size it fits at, the smoothing comes off —
1799
+ // somebody who has zoomed in is asking to see the pixels themselves.
1800
+ //
1801
+ // For a screen that moved, this is also where the decision gets made, so the
1802
+ // approved picture and the new one sit under one line you can drag across
1803
+ // them, with a fade for the changes a hard edge hides, and the difference is
1804
+ // one word away.
1805
+ // -------------------------------------------------------------------------
1806
+
1807
+ var view = {
1808
+ open: false, mode: '', z: 1, px: 0, py: 0,
1809
+ natW: 0, natH: 0, frameW: 0, frameH: 0, fit: 1,
1810
+ wipe: 50, blend: 100, pics: null, name: ''
1811
+ };
1812
+
1813
+ function viewerSources(p) {
1814
+ return {
1815
+ now: p ? (p.shotFile || p.thumbnail || '') : '',
1816
+ approved: p ? (p.approvedFile || p.approvedThumb || '') : '',
1817
+ diff: p ? (p.diffFile || p.diffThumb || '') : ''
1818
+ };
1287
1819
  }
1288
- ui.shot.addEventListener('click', function () { enlarge(shownSrc, shownAlt); });
1289
- ui.lightbox.addEventListener('click', function () { ui.lightbox.hidden = true; });
1820
+
1821
+ var MODE_WORDS = { now: 'Now', approved: 'Approved', compare: 'Before & after', diff: 'Difference' };
1822
+
1823
+ function openViewer(p, name, wanted) {
1824
+ var src = viewerSources(p);
1825
+ if (!src.now && !src.approved && !src.diff) return;
1826
+
1827
+ view.pics = p;
1828
+ view.name = name || '';
1829
+ view.wipe = 50;
1830
+ view.blend = 100;
1831
+ ui.vblend.value = '100';
1832
+ ui.vname.textContent = view.name;
1833
+
1834
+ // An empty src is not "no picture" — the browser resolves it against the page's
1835
+ // own address and fetches the panel document as though it were an image, which
1836
+ // fails and leaves a broken element sitting in the viewer. Take the attribute off.
1837
+ setSource(ui.vApproved, src.approved);
1838
+ setSource(ui.vNow, src.now);
1839
+ setSource(ui.vDiff, src.diff);
1840
+
1841
+ var modes = [];
1842
+ if (src.now) modes.push('now');
1843
+ if (src.approved) modes.push('approved');
1844
+ if (src.approved && src.now) modes.push('compare');
1845
+ if (src.diff) modes.push('diff');
1846
+
1847
+ ui.vmodes.textContent = '';
1848
+ modes.forEach(function (mode) {
1849
+ var button = document.createElement('button');
1850
+ button.type = 'button';
1851
+ button.dataset.mode = mode;
1852
+ button.textContent = MODE_WORDS[mode];
1853
+ button.addEventListener('click', function () { setMode(mode); });
1854
+ ui.vmodes.appendChild(button);
1855
+ });
1856
+ ui.vmodes.hidden = modes.length < 2;
1857
+
1858
+ // The size everything is laid out at: whichever real picture we have.
1859
+ var measuring = src.now || src.approved || src.diff;
1860
+ view.natW = 0; view.natH = 0;
1861
+ var probe = new Image();
1862
+ probe.onload = function () {
1863
+ view.natW = probe.naturalWidth || 0;
1864
+ view.natH = probe.naturalHeight || 0;
1865
+ fitPicture();
1866
+ };
1867
+ probe.onerror = function () { view.natW = 0; view.natH = 0; fitPicture(); };
1868
+ probe.src = measuring;
1869
+
1870
+ view.open = true;
1871
+ ui.viewer.hidden = false;
1872
+
1873
+ var start = wanted && modes.indexOf(wanted) >= 0 ? wanted
1874
+ : (modes.indexOf('compare') >= 0 ? 'compare' : modes[0]);
1875
+ setMode(start);
1876
+ resetZoom(true);
1877
+ }
1878
+
1879
+ function closeViewer() {
1880
+ if (!view.open) return;
1881
+ view.open = false;
1882
+ ui.viewer.hidden = true;
1883
+ ui.vNow.style.clipPath = '';
1884
+ ui.vwipe.hidden = true;
1885
+ }
1886
+
1887
+ function setMode(mode) {
1888
+ view.mode = mode;
1889
+ var buttons = ui.vmodes.children;
1890
+ for (var i = 0; i < buttons.length; i++) {
1891
+ buttons[i].className = buttons[i].dataset.mode === mode ? 'on' : '';
1892
+ }
1893
+ var comparing = mode === 'compare';
1894
+ ui.vApproved.classList.toggle('show', mode === 'approved' || comparing);
1895
+ ui.vNow.classList.toggle('show', mode === 'now' || comparing);
1896
+ ui.vDiff.classList.toggle('show', mode === 'diff');
1897
+ ui.vwipe.hidden = !comparing;
1898
+ ui.vfoot.hidden = !comparing;
1899
+ ui.vNow.style.opacity = comparing ? String(view.blend / 100) : '1';
1900
+ ui.vNow.style.clipPath = comparing ? 'inset(0 0 0 ' + view.wipe + '%)' : '';
1901
+ placeWipe();
1902
+ }
1903
+
1904
+ /** How big the frame is when the whole picture fits in the window. */
1905
+ function fitPicture() {
1906
+ var wide = ui.vstage.clientWidth;
1907
+ var tall = ui.vstage.clientHeight;
1908
+ var natW = view.natW || 16;
1909
+ var natH = view.natH || 10;
1910
+ var room = 0.94;
1911
+ view.fit = Math.min((wide * room) / natW, (tall * room) / natH);
1912
+ view.frameW = Math.max(1, Math.round(natW * view.fit));
1913
+ view.frameH = Math.max(1, Math.round(natH * view.fit));
1914
+ ui.vframe.style.width = view.frameW + 'px';
1915
+ ui.vframe.style.height = view.frameH + 'px';
1916
+ applyView();
1917
+ }
1918
+
1919
+ function clampPan() {
1920
+ var wide = ui.vstage.clientWidth;
1921
+ var tall = ui.vstage.clientHeight;
1922
+ var w = view.frameW * view.z;
1923
+ var h = view.frameH * view.z;
1924
+ var mx = Math.max(0, (w - wide) / 2) + 20;
1925
+ var my = Math.max(0, (h - tall) / 2) + 20;
1926
+ if (view.px > mx) view.px = mx;
1927
+ if (view.px < -mx) view.px = -mx;
1928
+ if (view.py > my) view.py = my;
1929
+ if (view.py < -my) view.py = -my;
1930
+ }
1931
+
1932
+ function applyView() {
1933
+ clampPan();
1934
+ ui.vframe.style.transform =
1935
+ 'translate3d(' + Math.round(view.px) + 'px,' + Math.round(view.py) + 'px,0) scale(' + view.z + ')';
1936
+ ui.vzoom.textContent = view.z.toFixed(1) + '×';
1937
+ // Once one pixel of the picture is covering about two pixels of the screen,
1938
+ // the smoothing comes off and the real pixels show. Not a moment before: a
1939
+ // picture being made SMALLER, which is what a retina screenshot in a narrow
1940
+ // panel is doing even at four times, is wrecked by turning smoothing off.
1941
+ // The screen's own density counts, which is why it is in the sum.
1942
+ var dense = view.fit * view.z * (window.devicePixelRatio || 1);
1943
+ ui.vframe.classList.toggle('sharp', dense >= 1.9);
1944
+ placeWipe();
1945
+ }
1946
+
1947
+ // The line lives in the window rather than in the picture, so it stays one
1948
+ // hair wide however far in somebody has zoomed — which means its position has
1949
+ // to be worked out again every time the picture moves.
1950
+ function placeWipe() {
1951
+ if (ui.vwipe.hidden) return;
1952
+ var wide = ui.vstage.clientWidth;
1953
+ var tall = ui.vstage.clientHeight;
1954
+ var x = wide / 2 + view.px + view.z * (view.frameW * (view.wipe / 100) - view.frameW / 2);
1955
+ ui.vwipe.style.left = Math.round(x) + 'px';
1956
+ // It is only ever as long as the picture is, so it never draws a line
1957
+ // through the empty space above and below one.
1958
+ var height = view.frameH * view.z;
1959
+ var top = tall / 2 + view.py - height / 2;
1960
+ var bottom = top + height;
1961
+ if (top < 0) top = 0;
1962
+ if (bottom > tall) bottom = tall;
1963
+ ui.vwipe.style.top = Math.round(top) + 'px';
1964
+ ui.vwipe.style.height = Math.round(Math.max(0, bottom - top)) + 'px';
1965
+ ui.vNow.style.clipPath = 'inset(0 0 0 ' + view.wipe + '%)';
1966
+ }
1967
+
1968
+ function resetZoom(quiet) {
1969
+ view.z = 1; view.px = 0; view.py = 0;
1970
+ if (!quiet && !CALM) {
1971
+ ui.vframe.style.transition = 'transform 260ms cubic-bezier(0.22,0.72,0.24,1)';
1972
+ setTimeout(function () { ui.vframe.style.transition = ''; }, 300);
1973
+ }
1974
+ applyView();
1975
+ }
1976
+
1977
+ function zoomAt(factor, clientX, clientY) {
1978
+ var rect = ui.vstage.getBoundingClientRect();
1979
+ var cx = clientX - rect.left - rect.width / 2;
1980
+ var cy = clientY - rect.top - rect.height / 2;
1981
+ var next = view.z * factor;
1982
+ if (next < 1) next = 1;
1983
+ if (next > 8) next = 8;
1984
+ if (next === view.z) return;
1985
+ // Keep whatever is under the pointer under the pointer.
1986
+ var fx = (cx - view.px) / view.z;
1987
+ var fy = (cy - view.py) / view.z;
1988
+ view.px = cx - next * fx;
1989
+ view.py = cy - next * fy;
1990
+ view.z = next;
1991
+ applyView();
1992
+ }
1993
+
1994
+ ui.vstage.addEventListener('wheel', function (e) {
1995
+ if (!view.open) return;
1996
+ e.preventDefault();
1997
+ // A trackpad pinch arrives here as a wheel with the control key held, and
1998
+ // it wants a finer touch than a mouse wheel does.
1999
+ var rate = e.ctrlKey ? 0.011 : 0.0026;
2000
+ zoomAt(Math.exp(-e.deltaY * rate), e.clientX, e.clientY);
2001
+ }, { passive: false });
2002
+
2003
+ ui.vstage.addEventListener('dblclick', function () { resetZoom(false); });
2004
+
2005
+ var panning = null;
2006
+ ui.vstage.addEventListener('pointerdown', function (e) {
2007
+ if (!view.open || e.button !== 0) return;
2008
+ if (ui.vwipe.contains(e.target)) return;
2009
+ panning = { x: e.clientX, y: e.clientY, px: view.px, py: view.py, id: e.pointerId };
2010
+ ui.vstage.classList.add('dragging');
2011
+ try { ui.vstage.setPointerCapture(e.pointerId); } catch (err) { /* a pointer we cannot hold is still a pointer we can follow */ }
2012
+ });
2013
+ ui.vstage.addEventListener('pointermove', function (e) {
2014
+ if (!panning || e.pointerId !== panning.id) return;
2015
+ view.px = panning.px + (e.clientX - panning.x);
2016
+ view.py = panning.py + (e.clientY - panning.y);
2017
+ applyView();
2018
+ });
2019
+ function endPan(e) {
2020
+ if (!panning) return;
2021
+ ui.vstage.classList.remove('dragging');
2022
+ try { ui.vstage.releasePointerCapture(panning.id); } catch (err) { /* already gone */ }
2023
+ panning = null;
2024
+ }
2025
+ ui.vstage.addEventListener('pointerup', endPan);
2026
+ ui.vstage.addEventListener('pointercancel', endPan);
2027
+
2028
+ var wiping = null;
2029
+ ui.vwipe.addEventListener('pointerdown', function (e) {
2030
+ e.stopPropagation();
2031
+ wiping = e.pointerId;
2032
+ ui.vwipe.classList.add('holding');
2033
+ try { ui.vwipe.setPointerCapture(e.pointerId); } catch (err) { /* as above */ }
2034
+ });
2035
+ ui.vwipe.addEventListener('pointermove', function (e) {
2036
+ if (wiping === null || e.pointerId !== wiping) return;
2037
+ var rect = ui.vstage.getBoundingClientRect();
2038
+ var cx = e.clientX - rect.left - rect.width / 2;
2039
+ var inFrame = (cx - view.px) / view.z + view.frameW / 2;
2040
+ var pct = (inFrame / view.frameW) * 100;
2041
+ if (pct < 0) pct = 0;
2042
+ if (pct > 100) pct = 100;
2043
+ view.wipe = pct;
2044
+ placeWipe();
2045
+ });
2046
+ function endWipe(e) {
2047
+ if (wiping === null) return;
2048
+ ui.vwipe.classList.remove('holding');
2049
+ try { ui.vwipe.releasePointerCapture(wiping); } catch (err) { /* already gone */ }
2050
+ wiping = null;
2051
+ }
2052
+ ui.vwipe.addEventListener('pointerup', endWipe);
2053
+ ui.vwipe.addEventListener('pointercancel', endWipe);
2054
+
2055
+ ui.vblend.addEventListener('input', function () {
2056
+ view.blend = Number(ui.vblend.value) || 0;
2057
+ if (view.mode === 'compare') ui.vNow.style.opacity = String(view.blend / 100);
2058
+ });
2059
+
2060
+ ui.vclose.addEventListener('click', closeViewer);
2061
+
2062
+ window.addEventListener('resize', function () {
2063
+ if (view.open) fitPicture();
2064
+ });
2065
+
2066
+ // The picture on the glass opens at the same picture, so clicking it is a
2067
+ // continuation rather than a jump.
2068
+ ui.shot.addEventListener('click', function () {
2069
+ if (ui.shot.classList.contains('empty')) return;
2070
+ if (onGlass.pics) {
2071
+ // A screen that moved opens on the comparison, because that is the
2072
+ // question being asked — unless the person has already chosen which of
2073
+ // the three pictures they wanted to look at, in which case they get that.
2074
+ var wanted = onGlass.mode;
2075
+ if (!onGlass.touched && onGlass.pics.status === 'changed') wanted = 'compare';
2076
+ openViewer(onGlass.pics, onGlass.name, wanted);
2077
+ return;
2078
+ }
2079
+ if (shownSrc) openViewer({ shotFile: shownSrc }, onGlass.name || ui.shotname.textContent, 'now');
2080
+ });
2081
+
1290
2082
  document.addEventListener('keydown', function (e) {
1291
- if (e.key === 'Escape') ui.lightbox.hidden = true;
2083
+ if (!view.open) return;
2084
+ if (e.key === 'Escape') { closeViewer(); return; }
2085
+ if (e.key === '0') { resetZoom(false); return; }
2086
+ if (e.key === '+' || e.key === '=') {
2087
+ var rect = ui.vstage.getBoundingClientRect();
2088
+ zoomAt(1.35, rect.left + rect.width / 2, rect.top + rect.height / 2);
2089
+ return;
2090
+ }
2091
+ if (e.key === '-' || e.key === '_') {
2092
+ var box = ui.vstage.getBoundingClientRect();
2093
+ zoomAt(1 / 1.35, box.left + box.width / 2, box.top + box.height / 2);
2094
+ }
1292
2095
  });
1293
2096
 
1294
2097
  // -------------------------------------------------------------------------
@@ -1296,19 +2099,29 @@ const SCRIPT = `
1296
2099
  // -------------------------------------------------------------------------
1297
2100
 
1298
2101
  var origin = null;
1299
- var ticking = null;
2102
+ var ticking = 0;
2103
+ var finished = false;
2104
+ var lastPrinted = '';
1300
2105
 
1301
2106
  function startClock(at) {
1302
- if (origin !== null) return;
2107
+ if (origin !== null || finished) return;
1303
2108
  origin = performance.now() - (Number(at) || 0);
1304
- ticking = setInterval(function () {
1305
- ui.clock.textContent = fmt(performance.now() - origin);
1306
- }, 100);
2109
+ var tick = function () {
2110
+ var text = fmt(performance.now() - origin);
2111
+ if (text !== lastPrinted) { lastPrinted = text; ui.clock.textContent = text; }
2112
+ ticking = requestAnimationFrame(tick);
2113
+ };
2114
+ ticking = requestAnimationFrame(tick);
1307
2115
  }
1308
2116
 
1309
2117
  function stopClock(finalMs) {
1310
- if (ticking) { clearInterval(ticking); ticking = null; }
1311
- if (typeof finalMs === 'number' && isFinite(finalMs)) ui.clock.textContent = fmt(finalMs);
2118
+ if (ticking) { cancelAnimationFrame(ticking); ticking = 0; }
2119
+ finished = true;
2120
+ if (typeof finalMs !== 'number' || !isFinite(finalMs)) return;
2121
+ // It counts the last stretch out rather than jumping to the total, which is
2122
+ // what makes the end of a run read as an arrival instead of a cut.
2123
+ ui.clock.__from = origin === null ? finalMs : performance.now() - origin;
2124
+ tweenTo(ui.clock, finalMs, fmt);
1312
2125
  }
1313
2126
 
1314
2127
  // -------------------------------------------------------------------------
@@ -1433,9 +2246,14 @@ const SCRIPT = `
1433
2246
  // The sentence at the top
1434
2247
  // -------------------------------------------------------------------------
1435
2248
 
1436
- function setState(text, tone) {
2249
+ function setState(text, tone, arriving) {
1437
2250
  ui.state.textContent = text;
1438
2251
  ui.state.className = 'state' + (tone ? ' ' + tone : '');
2252
+ if (!arriving || CALM) return;
2253
+ // Restart the animation rather than letting a second class do nothing.
2254
+ ui.state.classList.remove('arrive');
2255
+ void ui.state.offsetWidth;
2256
+ ui.state.classList.add('arrive');
1439
2257
  }
1440
2258
 
1441
2259
  function countOf(list) { return Array.isArray(list) ? list.length : 0; }
@@ -1489,16 +2307,17 @@ const SCRIPT = `
1489
2307
  // name under the last screen's picture — a caption that lies for a
1490
2308
  // second is worse than one that is a second behind.
1491
2309
  markRunning(ensureItem('picture', String(ev.name || ''), ev.describe));
2310
+ scanning(true);
1492
2311
  return;
1493
2312
  }
1494
2313
 
1495
2314
  if (type === 'screen:shot') {
2315
+ scanning(false);
1496
2316
  var shotRow = ev.name ? ensureItem('picture', String(ev.name), ev.describe) : null;
1497
- if (ev.thumbnail) {
1498
- lastShot = ev.thumbnail;
1499
- if (shotRow) shotRow.pics = { name: ev.name, status: '', thumbnail: ev.thumbnail };
1500
- singlePicture();
1501
- showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
2317
+ if (ev.thumbnail || ev.shotFile) {
2318
+ var taken = { name: ev.name, status: '', thumbnail: ev.thumbnail || '', shotFile: ev.shotFile || '' };
2319
+ if (shotRow) shotRow.pics = taken;
2320
+ singlePicture(taken, String(ev.name || ''));
1502
2321
  }
1503
2322
  if (ev.name) nameTheShot(ev.name);
1504
2323
  markShowing(shotRow);
@@ -1506,25 +2325,21 @@ const SCRIPT = `
1506
2325
  }
1507
2326
 
1508
2327
  if (type === 'screen:done') {
2328
+ scanning(false);
1509
2329
  var done = ensureItem('picture', String(ev.name || ''), ev.describe);
1510
2330
  var tone = finish(done, 'picture', ev);
1511
- if (ev.thumbnail) lastShot = ev.thumbnail;
1512
2331
  // The name, the caption and the light behind the glass all move together
1513
2332
  // with the picture, or none of them move. A screen that could not be
1514
2333
  // photographed leaves the last real one up, correctly labelled, rather
1515
2334
  // than putting its own name under someone else's picture.
1516
2335
  var showed = false;
1517
- if (ev.status === 'changed') {
1518
- showed = comparePictures(ev);
1519
- if (!showed && ev.thumbnail) {
1520
- singlePicture();
1521
- showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
2336
+ var p = done.pics;
2337
+ if (p) {
2338
+ if (ev.status === 'changed') showed = comparePictures(p);
2339
+ if (!showed && nowOf(p)) {
2340
+ singlePicture(p, String(ev.name || ''));
1522
2341
  showed = true;
1523
2342
  }
1524
- } else if (ev.thumbnail) {
1525
- singlePicture();
1526
- showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
1527
- showed = true;
1528
2343
  }
1529
2344
  if (showed) {
1530
2345
  markShowing(done);
@@ -1558,11 +2373,12 @@ const SCRIPT = `
1558
2373
  if (type === 'run:done') {
1559
2374
  runningItem = null;
1560
2375
  ui.follow.hidden = true;
2376
+ scanning(false);
1561
2377
  var summary = ev.summary || null;
1562
2378
  stopClock(typeof ev.at === 'number' ? ev.at : (summary && summary.durationMs));
1563
2379
  var verdict = ev.verdict || fallbackVerdict();
1564
2380
  var worst = worstTone();
1565
- setState(verdict, worst);
2381
+ setState(verdict, worst, true);
1566
2382
  tint(worst);
1567
2383
  showTiming(ev.timings || (summary && summary.timings));
1568
2384
  showNextStep(summary);
@@ -1573,7 +2389,7 @@ const SCRIPT = `
1573
2389
  for (var i = 0; i < order.length && !first; i++) {
1574
2390
  if (needsPerson(order[i].tone)) first = order[i];
1575
2391
  }
1576
- if (first) first.root.scrollIntoView({ block: 'center' });
2392
+ if (first) first.root.scrollIntoView({ block: 'center', behavior: CALM ? 'auto' : 'smooth' });
1577
2393
  else ui.scroll.scrollTop = 0;
1578
2394
  });
1579
2395
  }
@@ -1586,25 +2402,49 @@ const SCRIPT = `
1586
2402
  function finish(entry, kind, ev) {
1587
2403
  var tone = toneOf(ev.status);
1588
2404
  if (kind === 'picture') {
2405
+ var before = entry.pics || {};
1589
2406
  entry.pics = {
1590
2407
  name: ev.name, status: ev.status,
1591
- thumbnail: ev.thumbnail || (entry.pics && entry.pics.thumbnail) || '',
2408
+ thumbnail: ev.thumbnail || before.thumbnail || '',
2409
+ shotFile: ev.shotFile || before.shotFile || '',
1592
2410
  approvedThumb: ev.approvedThumb || '',
2411
+ approvedFile: ev.approvedFile || '',
1593
2412
  diffThumb: ev.diffThumb || '',
2413
+ diffFile: ev.diffFile || '',
1594
2414
  diffPixels: ev.diffPixels, message: ev.message
1595
2415
  };
1596
- if (!entry.pics.thumbnail && !entry.pics.approvedThumb && !entry.pics.diffThumb) entry.pics = null;
2416
+ if (!nowOf(entry.pics) && !approvedOf(entry.pics) && !diffOf(entry.pics)) entry.pics = null;
2417
+ // Everything this screen owns is fetched now, so the viewer opens on a
2418
+ // picture that is already decoded and the swap never flashes.
2419
+ preloadAll(entry.pics);
1597
2420
  }
1598
2421
  entry.outText = outcomeText(kind, ev);
2422
+ if (entry.verdict) entry.verdict.textContent = shortOutcome(kind, ev);
1599
2423
  entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
1600
2424
  entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because) ? ev.because : '';
1601
- if (typeof ev.durationMs === 'number') entry.time.textContent = fmt(ev.durationMs);
2425
+ if (typeof ev.durationMs === 'number') tweenTo(entry.time, ev.durationMs, fmt);
1602
2426
  entry.tone = tone;
1603
2427
  redraw(entry);
1604
2428
  setTone(entry, tone);
1605
2429
  // Only the checks that want a person open themselves. Everything else stays
1606
- // one line, which is what keeps a clean run quiet.
1607
- if (needsPerson(tone)) setOpen(entry, true);
2430
+ // one line, which is what keeps a clean run quiet. A row that has just
2431
+ // opened is worth seeing all of, so the list makes room for it — unless the
2432
+ // person has taken hold of the list, in which case nothing moves.
2433
+ if (needsPerson(tone)) {
2434
+ setOpen(entry, true);
2435
+ if (following) {
2436
+ requestAnimationFrame(function () {
2437
+ entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
2438
+ });
2439
+ }
2440
+ }
2441
+ // Something that broke says so once, with weight, and then sits still.
2442
+ if (tone === 'broke' && !CALM) {
2443
+ entry.root.classList.remove('alarm');
2444
+ void entry.root.offsetWidth;
2445
+ entry.root.classList.add('alarm');
2446
+ setTimeout(function () { entry.root.classList.remove('alarm'); }, 1000);
2447
+ }
1608
2448
  outcomes[keyFor(entry.kind, entry.name)] = tone || 'held';
1609
2449
  updateCounts();
1610
2450
  return tone;
@@ -1655,6 +2495,7 @@ const SCRIPT = `
1655
2495
  // forever next to a result that is already final.
1656
2496
  window.__staysfixed_detach = function () {
1657
2497
  stopClock();
2498
+ scanning(false);
1658
2499
  runningItem = null;
1659
2500
  };
1660
2501
 
@@ -1662,12 +2503,14 @@ const SCRIPT = `
1662
2503
  // at from the first frame instead of appearing empty.
1663
2504
  (function seed() {
1664
2505
  var i;
2506
+ seeding = true;
1665
2507
  for (i = 0; i < (plan.screens || []).length; i++) {
1666
2508
  addItem('picture', plan.screens[i].name, plan.screens[i].describe);
1667
2509
  }
1668
2510
  for (i = 0; i < (plan.guards || []).length; i++) {
1669
2511
  addItem('guard', plan.guards[i].name, plan.guards[i].describe);
1670
2512
  }
2513
+ seeding = false;
1671
2514
  updateCounts();
1672
2515
  if (!order.length) ui.shot.classList.add('empty');
1673
2516
  // Guards only: there will never be a picture, so the panel does not hold a