staysfixed 0.2.0 → 0.2.1

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