staysfixed 0.2.3 → 0.3.0

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.
@@ -304,57 +304,97 @@ export function panelHtml(plan = {}) {
304
304
 
305
305
  const STYLE = `
306
306
  /* --------------------------------------------------------------------------
307
- Tokens.
308
-
309
- Four colours carry meaning and nothing else does: accent blue for "this is
310
- happening" and "you are needed", green for held, amber for moved, red for
311
- broke. Everything structural is the ground, the ink, or a hairline. That is
312
- what lets a single amber dot be seen from across a desk.
313
-
314
- One type scale, five steps, used everywhere. One easing curve, used by every
315
- moving thing on the page, so the whole panel moves like one object.
316
-
317
- Dark is the design this window lives beside an editor all day. Light is a
318
- warm paper, never the white page with grey cards the brief rules out.
307
+ Tokens — graphite and signal.
308
+
309
+ The page is graphite. Ground, cards, glass, the picture's well and every
310
+ piece of furniture on it are one family of greys, separated only by how much
311
+ light they carry and by a hairline where two of them meet. Type does the
312
+ rest: one monospace for names and numbers, one text face for sentences, one
313
+ scale of five sizes.
314
+
315
+ Colour is not decoration here and it is not a mood. It is reserved, entirely,
316
+ for the three things a person has to be told: a screen MOVED, a guard BROKE,
317
+ or something is WAITING for them to look at it. A check that held is not
318
+ coloured it is simply present, in grey. A check that is running is not
319
+ coloured either; it is only lit. That is why the colours can be this pure:
320
+ they are the only ones on the page, so one amber dot in a column of graphite
321
+ is unmissable from across a desk.
322
+
323
+ The five states are also a ladder of lightness, in even steps of about
324
+ 1.46:1, so they can be told apart with no colour vision at all — and each
325
+ one carries its own silhouette as well: a hollow ring for a check nobody ran,
326
+ a plain disc for one that held, a disc inside a crisp ring for one waiting,
327
+ a disc in a soft halo for one that moved, a diamond for one that broke.
328
+
329
+ Dark is the design — this window sits beside an editor all day. Light is
330
+ quarried from the same rock: a stone ground with cards floating a shade
331
+ above it, never a white page.
319
332
  -------------------------------------------------------------------------- */
320
333
  :root {
321
334
  color-scheme: dark;
322
335
 
323
- --ground: #0a0c12;
324
- --lift: #12161f;
325
- --card: rgba(255, 255, 255, 0.03);
326
- --card-hover: rgba(255, 255, 255, 0.055);
327
- --well: #070910;
328
- --glass: rgba(10, 12, 18, 0.74);
329
-
330
- --ink: #e7eaf2;
331
- --soft: #939bad;
332
- --faint: #616a7d;
333
-
334
- --line: rgba(255, 255, 255, 0.06);
335
- --line-firm: rgba(255, 255, 255, 0.13);
336
- --sheen: rgba(255, 255, 255, 0.06);
337
- --shadow: rgba(0, 0, 0, 0.66);
338
-
339
- --accent: #6f9dff;
340
- --held: #55bd8c;
341
- --moved: #e2a84f;
342
- --broke: #ef6a61;
343
- --resting: rgba(255, 255, 255, 0.08);
344
-
345
- --radius: 20px;
346
- --radius-sm: 13px;
347
- --radius-xs: 9px;
348
-
349
- --pad: 18px;
336
+ /* Borrowed simplicity, not a borrowed skin.
337
+ The app this sits beside is a flat neutral grey with hairline borders and
338
+ small radii, and copying that wholesale made this look like a piece of it
339
+ rather than a thing of its own. So: the same restraint, a different surface.
340
+ A degree or two cooler and a shade deeper, so the panel reads as the quiet
341
+ instrument next to the window rather than as more of the window. */
342
+ --ground: #14161a;
343
+ --lift: #1b1e23;
344
+ --card: rgba(200, 220, 255, 0.035);
345
+ --card-hover: rgba(200, 220, 255, 0.06);
346
+ --well: #0f1114;
347
+ --glass: rgba(20, 22, 26, 0.9);
348
+
349
+ --ink: #e8eaee;
350
+ --soft: #aeb3bb;
351
+ --faint: #8b9098;
352
+ --faintest: #6b7079;
353
+
354
+ --line: rgba(255, 255, 255, 0.05);
355
+ --line-firm: rgba(255, 255, 255, 0.17);
356
+ --sheen: rgba(255, 255, 255, 0.03);
357
+ --shadow: rgba(0, 0, 0, 0.5);
358
+
359
+ /* Activity, not state. Something is happening — the shutter, a running dot,
360
+ a live segment of the meter. Deliberately colourless: "happening" is not
361
+ news, and the moment it has news it has a colour instead. */
362
+ --accent: #9a9a9a;
363
+
364
+ /* Held is a grey. It is the whole idea: nothing to say, so nothing is said. */
365
+ --held: #6b7079;
366
+
367
+ /* The three that have something to say. Pure, because they are alone. */
368
+ --broke: #ff4438;
369
+ /* Not the blue next door — a teal, so "this one wants you" is unmistakably
370
+ this panel talking and not the app it is watching. */
371
+ --wait: #3fb9a6;
372
+ --moved: #ffc24d;
373
+
374
+ /* Nobody ran it. Barely there on purpose. */
375
+ --resting: rgba(255, 255, 255, 0.09);
376
+ --skip: var(--faintest);
377
+ --pending: var(--faintest);
378
+ --running: var(--accent);
379
+ --waiting: var(--wait);
380
+
381
+ /* Small radii, flat surfaces. The app this sits beside uses 8px and almost no
382
+ shadow; a panel with 20px corners and floating cards read as a different
383
+ product bolted on to the side of it. */
384
+ --radius: 10px;
385
+ --radius-sm: 8px;
386
+ --radius-xs: 6px;
387
+
388
+ --pad: 16px;
350
389
  --tint: var(--accent);
351
390
 
352
391
  /* The type scale. Nothing on this page is set at a size that is not here. */
353
392
  --t-label: 10px;
354
393
  --t-meta: 10.5px;
394
+ --t-small: 11px;
355
395
  --t-body: 11.5px;
356
396
  --t-name: 12px;
357
- --t-lead: 17px;
397
+ --t-lead: 18px;
358
398
 
359
399
  /* One curve. Everything that moves, moves on this. */
360
400
  --ease: cubic-bezier(0.22, 0.72, 0.24, 1);
@@ -370,32 +410,41 @@ const STYLE = `
370
410
  * panel came up pale on a dark desktop, which is exactly the thing it was asked not to
371
411
  * do. The look is therefore stated on the html element rather than sniffed: dark unless
372
412
  * somebody asks for light, and 'system' for anyone who does want the browser's opinion.
413
+ *
414
+ * The same rock, lit from the other side: a stone ground, cards a shade brighter
415
+ * than it rather than the other way round, and the three signal colours taken
416
+ * down until they hold on paper. The ladder inverts with the ground — the
417
+ * quietest state is now the lightest — and the steps stay wide enough to read
418
+ * with no colour vision.
373
419
  */
374
420
  :root[data-theme='light'],
375
421
  :root[data-theme='system'] {
376
422
  color-scheme: light;
377
423
 
378
- --ground: #e3ded3;
379
- --lift: #fdfbf7;
380
- --card: rgba(255, 255, 255, 0.78);
381
- --card-hover: rgba(255, 255, 255, 1);
382
- --well: #dad4c8;
383
- --glass: rgba(231, 226, 217, 0.82);
424
+ --ground: #d9dade;
425
+ --lift: #eef0f3;
426
+ --card: rgba(255, 255, 255, 0.6);
427
+ --card-hover: #ffffff;
428
+ --well: #c7c9cf;
429
+ --glass: rgba(232, 234, 238, 0.9);
384
430
 
385
- --ink: #1c1a16;
386
- --soft: #6b665c;
387
- --faint: #8b8578;
431
+ --ink: #14161a;
432
+ --soft: #545a62;
433
+ --faint: #5e646c;
434
+ --faintest: #6f757d;
388
435
 
389
- --line: rgba(28, 24, 18, 0.09);
390
- --line-firm: rgba(28, 24, 18, 0.19);
391
- --sheen: rgba(255, 255, 255, 0.85);
392
- --shadow: rgba(58, 47, 30, 0.34);
436
+ --line: rgba(20, 22, 26, 0.11);
437
+ --line-firm: rgba(20, 22, 26, 0.24);
438
+ --sheen: rgba(255, 255, 255, 0.9);
439
+ --shadow: rgba(26, 30, 38, 0.34);
393
440
 
394
- --accent: #2f57c9;
395
- --held: #1f7a4d;
396
- --moved: #94620a;
397
- --broke: #b52f23;
398
- --resting: rgba(28, 24, 18, 0.1);
441
+ --accent: #474d56;
442
+ --held: #757b83;
443
+ --broke: #7e1105;
444
+ --wait: #0e5588;
445
+ --moved: #8a5f06;
446
+
447
+ --resting: rgba(20, 22, 26, 0.13);
399
448
  }
400
449
 
401
450
  * { box-sizing: border-box; }
@@ -419,14 +468,17 @@ img { display: block; }
419
468
  button { font: inherit; color: inherit; }
420
469
  .glyph { width: 16px; height: 16px; flex: 0 0 auto; }
421
470
 
422
- /* A single soft light from above, tinted by how the run is going. It is the
423
- only decoration on the page, it has no edge you could point at, and it is
424
- the thing that stops the dark reading as a hole in the screen. */
471
+ /* One light from above, one shadow below, and a thin sheen along the very top
472
+ edge of the window. Three flat washes, no pattern and no edge you could point
473
+ at it is what stops a page of greys reading as paint. The light takes the
474
+ colour of the worst thing that has happened, which on a run where everything
475
+ held is no colour at all: only a little more light. */
425
476
  .aura {
426
477
  position: fixed; inset: 0; pointer-events: none; z-index: 0;
427
478
  background:
428
- radial-gradient(120% 46% at 50% -8%, color-mix(in srgb, var(--tint) 15%, transparent), transparent 70%),
429
- radial-gradient(100% 60% at 50% 112%, var(--shadow), transparent 66%);
479
+ radial-gradient(132% 44% at 50% -12%, color-mix(in srgb, var(--tint) 14%, transparent), transparent 68%),
480
+ radial-gradient(74% 26% at 50% -4%, var(--sheen), transparent 76%),
481
+ radial-gradient(120% 62% at 50% 114%, var(--shadow), transparent 64%);
430
482
  transition: background 620ms var(--ease);
431
483
  }
432
484
 
@@ -441,24 +493,29 @@ button { font: inherit; color: inherit; }
441
493
  flex: 0 0 auto;
442
494
  padding: 16px var(--pad) 15px;
443
495
  background: var(--glass);
444
- backdrop-filter: blur(22px) saturate(150%);
445
- -webkit-backdrop-filter: blur(22px) saturate(150%);
496
+ backdrop-filter: blur(22px) saturate(120%);
497
+ -webkit-backdrop-filter: blur(22px) saturate(120%);
498
+ /* Separated from the list by a hairline and nothing else. */
499
+ box-shadow: 0 1px 0 var(--line);
446
500
  }
447
501
  .brand { display: flex; align-items: center; gap: 9px; }
502
+ /* The mark sits on a milled square of the same graphite, lit along its top
503
+ edge. It is the product's own face, so it is never one of the three colours
504
+ that mean something is wrong. */
448
505
  .badge {
449
506
  flex: 0 0 auto;
450
507
  display: flex; align-items: center; justify-content: center;
451
508
  width: 24px; height: 24px;
452
509
  border-radius: 8px;
453
- color: var(--accent);
454
- background: color-mix(in srgb, var(--accent) 13%, transparent);
455
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
510
+ color: var(--ink);
511
+ background: var(--lift);
512
+ box-shadow: inset 0 0 0 1px var(--line-firm);
456
513
  }
457
514
  .badge .glyph { width: 15px; height: 15px; }
458
515
  .wordmark {
459
516
  flex: 1 1 auto; min-width: 0;
460
517
  font-size: var(--t-label); font-weight: 600;
461
- letter-spacing: 0.2em; text-transform: uppercase;
518
+ letter-spacing: 0.22em; text-transform: uppercase;
462
519
  color: var(--soft);
463
520
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
464
521
  }
@@ -471,22 +528,45 @@ button { font: inherit; color: inherit; }
471
528
  white-space: nowrap; overflow: hidden;
472
529
  }
473
530
  .target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
474
- .target .sep { color: var(--faint); flex: 0 0 auto; }
531
+ .target .sep { color: var(--faintest); flex: 0 0 auto; }
475
532
  .target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: var(--t-body); }
476
533
 
477
534
  /* The sentence. The largest text on the page after the picture, because it is
478
- the one thing a person reads from four feet away. */
535
+ the one thing a person reads from four feet away. It is white while the news
536
+ is good and takes a colour only when there is news. */
479
537
  .state {
480
- margin-top: 9px;
481
- font-size: var(--t-lead); font-weight: 620; line-height: 1.32;
482
- letter-spacing: -0.011em;
538
+ margin-top: 10px;
539
+ font-size: var(--t-lead); font-weight: 600; line-height: 1.3;
540
+ letter-spacing: -0.014em;
483
541
  overflow-wrap: anywhere;
484
542
  transition: color var(--slow) var(--ease);
485
543
  }
486
- .state.held { color: var(--ink); }
487
- .state.moved { color: var(--moved); }
488
- .state.broke { color: var(--broke); }
489
- .state.wait { color: var(--accent); }
544
+ .state.held, .state.moved, .state.broke, .state.wait { color: var(--ink); }
545
+ /* The sentence stays white; a signal sits in front of it. It is the same mark
546
+ the row it is about carries — a haloed disc for a screen that moved, a disc
547
+ in a ring for one waiting on a person, a diamond for a guard that broke — so
548
+ the top of the window and the list underneath speak one language. The words
549
+ are what you read from four feet away; the mark is what you see before you
550
+ have read anything at all. */
551
+ .state.moved::before, .state.wait::before, .state.broke::before {
552
+ content: ''; display: inline-block;
553
+ width: 10px; height: 10px; border-radius: 50%;
554
+ margin-right: 13px; vertical-align: 0.1em;
555
+ }
556
+ .state.moved::before {
557
+ background: var(--moved);
558
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--moved) 17%, transparent);
559
+ }
560
+ .state.wait::before {
561
+ background: var(--wait);
562
+ box-shadow:
563
+ 0 0 0 2px color-mix(in srgb, var(--wait) 42%, transparent),
564
+ 0 0 0 5px color-mix(in srgb, var(--wait) 12%, transparent);
565
+ }
566
+ .state.broke::before {
567
+ background: var(--broke); border-radius: 2px; transform: rotate(45deg);
568
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--broke) 17%, transparent);
569
+ }
490
570
  /* The verdict arrives rather than appearing: it is the one sentence the whole
491
571
  run was for, and a run that ends by swapping a word looks like a page that
492
572
  was not paying attention. */
@@ -496,8 +576,8 @@ button { font: inherit; color: inherit; }
496
576
  to { opacity: 1; transform: none; filter: blur(0); }
497
577
  }
498
578
  .what {
499
- margin: 8px 0 0; font-size: var(--t-small); color: var(--faint);
500
- line-height: 1.45; overflow-wrap: anywhere;
579
+ margin: 7px 0 0; font-size: var(--t-small); color: var(--faint);
580
+ line-height: 1.5; overflow-wrap: anywhere;
501
581
  }
502
582
  .rverdict {
503
583
  flex: 0 0 auto; font-size: var(--t-small); color: var(--faint);
@@ -506,13 +586,15 @@ button { font: inherit; color: inherit; }
506
586
  .item.held .rverdict { color: var(--faint); }
507
587
  .item.moved .rverdict { color: var(--moved); }
508
588
  .item.broke .rverdict { color: var(--broke); }
589
+ .item.wait .rverdict { color: var(--wait); }
509
590
  .item.waiting .rverdict { color: var(--waiting); }
510
- .item.pending .rverdict, .item.skip .rverdict { color: var(--faintest, var(--faint)); }
511
- .note { margin-top: 5px; font-size: var(--t-body); color: var(--faint); overflow-wrap: anywhere; }
591
+ .item.pending .rverdict, .item.skip .rverdict { color: var(--faintest); }
592
+ .note { margin-top: 6px; font-size: var(--t-body); color: var(--faint); overflow-wrap: anywhere; }
512
593
 
513
594
  /* One hairline, not a row of blocks. Each finished check adds its own slice of
514
595
  colour to a single continuous line, so progress and outcome are the same
515
- object and there is one fewer thing on the page. */
596
+ object and there is one fewer thing on the page. A run where everything held
597
+ finishes as one unbroken grey rule — which is the point. */
516
598
  .meter { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
517
599
  .track {
518
600
  flex: 1 1 auto; min-width: 0;
@@ -529,8 +611,8 @@ button { font: inherit; color: inherit; }
529
611
  .slice.held { background: var(--held); }
530
612
  .slice.moved { background: var(--moved); }
531
613
  .slice.broke { background: var(--broke); }
532
- .slice.wait { background: var(--accent); }
533
- .slice.skip { background: color-mix(in srgb, var(--ink) 16%, transparent); }
614
+ .slice.wait { background: var(--wait); }
615
+ .slice.skip { background: color-mix(in srgb, var(--ink) 13%, transparent); }
534
616
  .slice.running {
535
617
  background: var(--accent);
536
618
  animation: breathe 1.9s var(--ease) infinite;
@@ -538,7 +620,7 @@ button { font: inherit; color: inherit; }
538
620
  /* A segment lights as it lands, once, and then it is just a colour. */
539
621
  .slice.land { animation: land 520ms var(--ease) 1; }
540
622
  @keyframes land { 0% { filter: brightness(2.1); } 100% { filter: none; } }
541
- @keyframes breathe { 0%, 100% { opacity: 0.42; } 50% { opacity: 1; } }
623
+ @keyframes breathe { 0%, 100% { opacity: 0.34; } 50% { opacity: 1; } }
542
624
  .counts {
543
625
  flex: 0 1 auto; min-width: 0; max-width: 66%;
544
626
  font-size: var(--t-meta); color: var(--faint); letter-spacing: 0.02em;
@@ -569,9 +651,7 @@ button { font: inherit; color: inherit; }
569
651
  /* No border. The picture is the hero, so nothing is drawn around it that
570
652
  could compete with it — only a soft floor shadow and the faintest rim to
571
653
  keep a white screenshot from bleeding into a light ground. */
572
- box-shadow:
573
- 0 0 0 1px var(--line),
574
- 0 26px 50px -30px var(--shadow);
654
+ box-shadow: 0 0 0 1px var(--line);
575
655
  transition: box-shadow var(--slow) var(--ease);
576
656
  }
577
657
  .shot.empty { cursor: default; }
@@ -591,29 +671,27 @@ button { font: inherit; color: inherit; }
591
671
  text-align: center; padding: 0 30px; max-width: 260px;
592
672
  }
593
673
 
594
- /* The shutter, made visible. One pass of soft light across the glass for
674
+ /* The shutter, made visible. One pass of plain light across the glass for
595
675
  exactly as long as the app is really being photographed — it starts when the
596
- screen starts and it is gone the instant the picture lands, so it is a report
597
- of what is happening rather than a decoration that never stops. */
676
+ screen starts and it is gone the instant the picture lands. It is light, not
677
+ colour: nothing has been found out yet, so there is nothing to say. */
598
678
  .sweep {
599
679
  position: absolute; inset: 0;
600
680
  pointer-events: none; opacity: 0;
601
681
  background:
602
682
  linear-gradient(100deg,
603
683
  transparent 4%,
604
- color-mix(in srgb, var(--accent) 10%, transparent) 30%,
605
- color-mix(in srgb, var(--accent) 34%, transparent) 47%,
606
- color-mix(in srgb, var(--accent) 82%, transparent) 50%,
607
- color-mix(in srgb, var(--accent) 34%, transparent) 53%,
608
- color-mix(in srgb, var(--accent) 10%, transparent) 70%,
684
+ color-mix(in srgb, var(--accent) 8%, transparent) 30%,
685
+ color-mix(in srgb, var(--accent) 28%, transparent) 47%,
686
+ color-mix(in srgb, var(--accent) 72%, transparent) 50%,
687
+ color-mix(in srgb, var(--accent) 28%, transparent) 53%,
688
+ color-mix(in srgb, var(--accent) 8%, transparent) 70%,
609
689
  transparent 96%);
610
690
  transform: translateX(-100%);
611
691
  }
612
692
  .shot.scanning .sweep { opacity: 1; animation: sweep 1.6s var(--ease) infinite; }
613
693
  .shot.scanning {
614
- box-shadow:
615
- 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
616
- 0 26px 50px -30px var(--shadow);
694
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent);
617
695
  }
618
696
  @keyframes sweep {
619
697
  from { transform: translateX(-100%); }
@@ -625,8 +703,9 @@ button { font: inherit; color: inherit; }
625
703
  position: absolute; right: 10px; bottom: 10px;
626
704
  font-size: var(--t-label); letter-spacing: 0.02em;
627
705
  color: var(--ink);
628
- background: color-mix(in srgb, var(--ground) 72%, transparent);
706
+ background: color-mix(in srgb, var(--ground) 76%, transparent);
629
707
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
708
+ box-shadow: inset 0 0 0 1px var(--line-firm);
630
709
  border-radius: 999px; padding: 4px 10px;
631
710
  opacity: 0; transform: translateY(4px);
632
711
  transition: opacity var(--quick) var(--ease), transform var(--quick) var(--ease);
@@ -643,11 +722,12 @@ button { font: inherit; color: inherit; }
643
722
  .shotout { flex: 1 1 auto; min-width: 0; font-size: var(--t-body); color: var(--soft); text-align: right; overflow-wrap: anywhere; }
644
723
  .shotout.moved { color: var(--moved); }
645
724
  .shotout.broke { color: var(--broke); }
646
- .shotout.wait { color: var(--accent); }
725
+ .shotout.wait { color: var(--wait); }
647
726
 
648
727
  /* Approved / now / difference. Three quiet words with a line that slides
649
728
  between them — no pill, no box, because a screen that moved already has
650
- enough asking for the eye. */
729
+ enough asking for the eye. The line under the chosen word is the one place
730
+ the screen's own colour appears in the furniture. */
651
731
  .switch { display: flex; gap: 2px; margin-top: 10px; }
652
732
  .switch button {
653
733
  position: relative;
@@ -661,7 +741,7 @@ button { font: inherit; color: inherit; }
661
741
  }
662
742
  .switch button::after {
663
743
  content: ''; position: absolute; left: 50%; right: 50%; bottom: 0; height: 1.5px;
664
- border-radius: 2px; background: var(--tone, var(--accent));
744
+ border-radius: 2px; background: var(--ink);
665
745
  transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
666
746
  opacity: 0;
667
747
  }
@@ -675,7 +755,7 @@ button { font: inherit; color: inherit; }
675
755
  overflow-y: auto;
676
756
  overscroll-behavior: contain;
677
757
  scrollbar-width: thin;
678
- scrollbar-color: color-mix(in srgb, var(--soft) 22%, transparent) transparent;
758
+ scrollbar-color: color-mix(in srgb, var(--soft) 20%, transparent) transparent;
679
759
  padding: 18px 5px 14px 0;
680
760
  /* The list slides away under the header rather than being cut off by a box. */
681
761
  mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
@@ -683,38 +763,42 @@ button { font: inherit; color: inherit; }
683
763
  }
684
764
  .scroll::-webkit-scrollbar { width: 10px; }
685
765
  .scroll::-webkit-scrollbar-thumb {
686
- background: color-mix(in srgb, var(--soft) 22%, transparent);
766
+ background: color-mix(in srgb, var(--soft) 20%, transparent);
687
767
  border-radius: 8px; border: 3px solid transparent; background-clip: padding-box;
688
768
  }
689
769
  .scroll::-webkit-scrollbar-track { background: transparent; }
690
770
 
691
- .group + .group { margin-top: 20px; }
771
+ .group + .group { margin-top: 22px; }
692
772
  .grouplabel {
693
773
  display: flex; align-items: baseline; gap: 9px;
694
774
  padding: 0 6px 9px 4px;
695
775
  font-size: var(--t-label); font-weight: 600;
696
- letter-spacing: 0.16em; text-transform: uppercase;
776
+ letter-spacing: 0.2em; text-transform: uppercase;
697
777
  color: var(--faint);
698
778
  }
699
- .grouplabel .mono { font-size: var(--t-label); letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
779
+ .grouplabel .mono { font-size: var(--t-label); letter-spacing: 0.06em; color: var(--faintest); }
700
780
 
781
+ /* A card, floating: a shade of graphite above the ground, lit along its top
782
+ edge, one hairline round it and a soft floor under it. Nothing but light
783
+ separates it from the page. */
701
784
  .items {
702
785
  border-radius: var(--radius);
703
786
  background: var(--card);
704
787
  overflow: hidden;
788
+ box-shadow: inset 0 0 0 1px var(--line);
705
789
  }
706
790
  .item + .item { box-shadow: inset 0 1px 0 var(--line); }
707
791
  .item {
708
792
  transition: background var(--calm) var(--ease);
709
793
  }
710
- .item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 5%, transparent); }
711
- .item.running { background: color-mix(in srgb, var(--accent) 5%, transparent); }
794
+ .item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 3.5%, transparent); }
795
+ .item.running { background: color-mix(in srgb, var(--accent) 4%, transparent); }
712
796
  /* Something broke. It is said once, with weight, and then it sits still — a
713
797
  panel that keeps flashing beside your work is a panel you turn off. */
714
798
  .item.alarm { animation: alarm 900ms var(--ease) 1; }
715
799
  @keyframes alarm {
716
- 0% { background: color-mix(in srgb, var(--tone, var(--broke)) 26%, transparent); }
717
- 100% { background: color-mix(in srgb, var(--tone, var(--broke)) 5%, transparent); }
800
+ 0% { background: color-mix(in srgb, var(--tone, var(--broke)) 22%, transparent); }
801
+ 100% { background: color-mix(in srgb, var(--tone, var(--broke)) 3.5%, transparent); }
718
802
  }
719
803
 
720
804
  /* Big, quiet, touch-sized rows. At rest a check is a dot, a name and a time —
@@ -731,8 +815,12 @@ button { font: inherit; color: inherit; }
731
815
  .row:hover { background: var(--card-hover); }
732
816
  .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
733
817
  .item.plain .row { cursor: default; }
734
- .item.showing .row { box-shadow: inset 2px 0 0 var(--line-firm); }
735
- .item.showing .rname { color: var(--ink); }
818
+ .item.showing .row, .item.working .row { box-shadow: inset 2px 0 0 var(--line-firm); }
819
+ .item.showing .rname, .item.working .rname { color: var(--ink); }
820
+ /* A check that wants a person is marked with a line down its edge in its own
821
+ colour, not a wash across it. A wash over graphite is a stain; a line is a
822
+ flag, and it reads at the same glance as the dot beside the name. */
823
+ .item.attention .row { box-shadow: inset 2px 0 0 var(--tone, var(--accent)); }
736
824
  .item.plain .row:hover { background: transparent; }
737
825
  /* Rows settle in rather than appearing, and they do it one after another, which
738
826
  is what makes a list of eleven screens read as one thing arriving. */
@@ -742,23 +830,36 @@ button { font: inherit; color: inherit; }
742
830
  to { opacity: 1; transform: none; }
743
831
  }
744
832
 
833
+ /* The mark beside a name. Five states, five silhouettes, five rungs of a
834
+ lightness ladder — so it can be read with no colour vision, in a photocopy,
835
+ or out of the corner of an eye:
836
+ nobody ran it a hollow ring, barely there
837
+ it held a plain disc, grey, nothing around it
838
+ it is waiting a disc inside a crisp ring
839
+ it moved a disc in a soft wide halo
840
+ it broke a diamond in a soft wide halo
841
+ */
745
842
  .dot {
746
843
  flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
747
844
  background: var(--resting);
748
845
  transition: background var(--calm) var(--ease), box-shadow var(--calm) var(--ease);
749
846
  }
750
- .item.held .dot { background: color-mix(in srgb, var(--held) 78%, transparent); }
751
- .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 15%, transparent); }
752
- .item.broke .dot { background: var(--broke); box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 15%, transparent); }
753
- .item.wait .dot { background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); }
754
- .item.skip .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting); }
847
+ .item.held .dot { width: 7px; height: 7px; background: var(--held); box-shadow: none; }
848
+ .item.wait .dot { background: var(--wait); box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--wait) 42%, transparent), 0 0 0 4px color-mix(in srgb, var(--wait) 12%, transparent); }
849
+ .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 17%, transparent); }
850
+ .item.broke .dot {
851
+ background: var(--broke);
852
+ border-radius: 2px; transform: rotate(45deg);
853
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 17%, transparent);
854
+ }
855
+ .item.skip .dot, .item.pending .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting); }
755
856
  .item.running .dot {
756
857
  background: transparent;
757
858
  box-shadow: inset 0 0 0 2px var(--accent);
758
859
  animation: ping 1.8s var(--ease) infinite;
759
860
  }
760
861
  @keyframes ping {
761
- 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 42%, transparent); }
862
+ 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 34%, transparent); }
762
863
  70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
763
864
  }
764
865
  .item.running .rname { color: var(--ink); }
@@ -776,11 +877,11 @@ button { font: inherit; color: inherit; }
776
877
  flex: 0 0 auto;
777
878
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
778
879
  font-variant-numeric: tabular-nums;
779
- font-size: var(--t-meta); color: var(--faint);
880
+ font-size: var(--t-meta); color: var(--faintest);
780
881
  }
781
882
  .row .chev {
782
883
  flex: 0 0 auto; width: 14px; height: 14px;
783
- color: var(--faint); opacity: 0;
884
+ color: var(--faintest); opacity: 0;
784
885
  transition: transform var(--calm) var(--ease), opacity var(--quick) var(--ease);
785
886
  }
786
887
  .row:hover .chev, .item.open .chev { opacity: 1; }
@@ -795,16 +896,16 @@ button { font: inherit; color: inherit; }
795
896
  }
796
897
  .item.open .detail { animation: unfold 240ms var(--ease) both; }
797
898
  @keyframes unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
798
- .detail .why { color: var(--soft); }
899
+ .detail .why { color: var(--faint); }
900
+ /* Grey, whatever happened. The row above it has already said so in colour and
901
+ the working below colours the step that did it — a page that says the same
902
+ thing three times in the same colour is a page nobody scans. */
799
903
  .detail .out { color: var(--soft); margin-top: 3px; }
800
- .detail .out.moved { color: var(--moved); }
801
- .detail .out.broke { color: var(--broke); }
802
- .detail .out.wait { color: var(--accent); }
803
904
  .detail .claim {
804
905
  margin-top: 9px; padding: 9px 12px;
805
906
  border-radius: var(--radius-xs);
806
907
  background: var(--well);
807
- box-shadow: inset 2px 0 0 var(--broke);
908
+ box-shadow: inset 2px 0 0 var(--broke), inset 0 0 0 1px var(--line);
808
909
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
809
910
  font-size: var(--t-body); color: var(--ink);
810
911
  }
@@ -822,12 +923,35 @@ button { font: inherit; color: inherit; }
822
923
  the top of the window. */
823
924
  .work-here { padding: 2px var(--pad) 10px; }
824
925
  .work-here .worklabel { margin: 0 0 6px; }
825
- .detail .worklabel {
826
- margin: 11px 0 6px;
926
+ .detail .worklabel { margin: 11px 0 6px; }
927
+ /* The heading of a working list: what it is, and — quietly, on the right —
928
+ which check it belongs to. The name matters now that the list is live: a
929
+ guard's claims can be ticking off while the last screen's picture is still
930
+ on the glass, and a heading with no name would leave a person guessing
931
+ whose working they are reading. */
932
+ .worklabel {
933
+ display: flex; align-items: baseline; gap: 8px;
827
934
  font-size: var(--t-label); font-weight: 600;
828
- letter-spacing: 0.16em; text-transform: uppercase;
935
+ letter-spacing: 0.2em; text-transform: uppercase;
829
936
  color: var(--faint);
830
937
  }
938
+ /* The heading never wraps: three words that never change. Anything that has to
939
+ give is the name beside it, which ellipses. */
940
+ .worklabel .worktitle { flex: 0 0 auto; white-space: nowrap; }
941
+ .worklabel .workwho {
942
+ flex: 1 1 auto; min-width: 0;
943
+ font-weight: 500; letter-spacing: 0.02em; text-transform: none;
944
+ text-align: right;
945
+ color: var(--faintest);
946
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
947
+ }
948
+ /* Said only while a list is empty, so a check that has just started does not
949
+ look like a check that found nothing to do. */
950
+ .workwait {
951
+ padding: 4px 0 3px 11px;
952
+ box-shadow: inset 1px 0 0 var(--line);
953
+ font-size: var(--t-body); color: var(--faint);
954
+ }
831
955
  .work { list-style: none; margin: 0; padding: 0; }
832
956
  .step {
833
957
  display: flex; align-items: baseline; gap: 9px;
@@ -836,18 +960,57 @@ button { font: inherit; color: inherit; }
836
960
  }
837
961
  .step.warn { box-shadow: inset 1.5px 0 0 var(--moved); }
838
962
  .step.bad { box-shadow: inset 1.5px 0 0 var(--broke); }
839
- .step.skipped { box-shadow: inset 1px 0 0 var(--resting); }
963
+ .step.skipped, .step.ended { box-shadow: inset 1px 0 0 var(--resting); }
964
+ .step.running { box-shadow: inset 1.5px 0 0 color-mix(in srgb, var(--accent) 66%, transparent); }
965
+ /* A line arrives once, quietly, and then never moves again. */
966
+ .step.arrive { animation: stepin 240ms var(--ease) both; }
967
+ @keyframes stepin { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
968
+
969
+ /* The tick. This is the thing a person actually watches: each line takes its
970
+ mark the moment it settles, so a screen being checked reads as a list
971
+ ticking off rather than a table that appears when it is all over. A step
972
+ still running carries a soft dot that breathes — an honest "this one is
973
+ happening", with nothing that spins on after the step it belongs to.
974
+ A tick is grey. Twenty grey ticks and one red cross is a page you can read
975
+ in a glance; twenty green ticks and one red cross is a Christmas tree. */
976
+ .stepmark {
977
+ /* Level with the first line of the label, never floating between the label
978
+ and the number under it. */
979
+ flex: 0 0 13px; align-self: flex-start;
980
+ display: flex; align-items: center; justify-content: center;
981
+ height: 18px; color: var(--faint);
982
+ transition: color var(--calm) var(--ease);
983
+ }
984
+ .stepmark .glyph { width: 13px; height: 13px; }
985
+ .step.ok .stepmark { color: var(--held); }
986
+ .step.skipped .stepmark, .step.ended .stepmark { color: var(--faintest); }
987
+ .step.running .stepmark { color: var(--accent); }
988
+ .step.running .stepmark .glyph { animation: markpulse 1.6s var(--ease) infinite; }
989
+ @keyframes markpulse { 0%, 100% { opacity: 0.34; } 50% { opacity: 1; } }
990
+ .step.running .stepwhat { color: var(--ink); }
991
+
992
+ /* Guards: the claims are what a guard is for, so they carry the weight, and
993
+ the actions that get the app into position sit back a little.
994
+ Named 'stepclaim' rather than 'claim' on purpose — a bare 'claim' inside a
995
+ detail is already the boxed expectation that failed, and these lines would
996
+ have been swept up by it. */
997
+ .step.stepclaim .stepwhat { color: var(--ink); font-weight: 560; }
998
+ .step.stepact .stepwhat { color: var(--soft); }
999
+ .step.stepact .stepno, .step.stepact .stepnum { color: var(--faintest); }
1000
+ .step.stepact .stepmark { opacity: 0.8; }
840
1001
  /* The numeral keeps its own column whatever happens to the right of it — a
841
1002
  number that has been left behind on a line of its own is not a sequence any
842
1003
  more, which is what a long label does to it on a panel dragged narrow. */
843
- .stepno { flex: 0 0 15px; text-align: right; font-size: var(--t-label); color: var(--faint); }
1004
+ .stepno { flex: 0 0 15px; text-align: right; font-size: var(--t-label); color: var(--faintest); }
844
1005
  /* Label on one line, its number under it.
845
1006
  They used to share a line and wrap to a second, right-aligned one whenever the
846
1007
  number was long — which at 460px was most of them, and the column came out
847
1008
  ragged. Stacked, it reads down the page like a receipt: what was done, then
848
1009
  what it came to. */
849
1010
  .stepbody { flex: 1 1 auto; min-width: 0; }
850
- .stepwhat { color: var(--soft); }
1011
+ /* One size, whether the working is beside the picture or under a row: the same
1012
+ size the check names are set at, so the two lists read as one thing. */
1013
+ .stepwhat { color: var(--soft); font-size: var(--t-name); line-height: 1.5; }
851
1014
  .stepnum {
852
1015
  display: block; margin-top: 1px;
853
1016
  font-size: var(--t-meta); color: var(--faint);
@@ -856,10 +1019,11 @@ button { font: inherit; color: inherit; }
856
1019
  /* A step that wants a person is the one thing in the list that is allowed to
857
1020
  be read from across the row: its words go to full strength, its number takes
858
1021
  the colour of what happened. */
859
- .step.warn .stepwhat, .step.bad .stepwhat { color: var(--ink); }
860
- .step.warn .stepno, .step.warn .stepnum { color: var(--moved); }
861
- .step.bad .stepno, .step.bad .stepnum { color: var(--broke); }
862
- .step.skipped .stepwhat, .step.skipped .stepno, .step.skipped .stepnum { color: var(--faint); }
1022
+ .step.warn .stepwhat, .step.bad .stepwhat { color: var(--ink); font-weight: 560; }
1023
+ .step.warn .stepno, .step.warn .stepnum, .step.warn .stepmark { color: var(--moved); }
1024
+ .step.bad .stepno, .step.bad .stepnum, .step.bad .stepmark { color: var(--broke); }
1025
+ .step.skipped .stepwhat, .step.skipped .stepno, .step.skipped .stepnum,
1026
+ .step.ended .stepwhat, .step.ended .stepno, .step.ended .stepnum { color: var(--faintest); }
863
1027
 
864
1028
  .nothing { padding: 26px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.7; text-align: center; }
865
1029
  .nothing .mono { color: var(--soft); }
@@ -870,8 +1034,8 @@ button { font: inherit; color: inherit; }
870
1034
  padding: 12px var(--pad) 13px;
871
1035
  border-top: 1px solid var(--line);
872
1036
  background: var(--glass);
873
- backdrop-filter: blur(22px) saturate(150%);
874
- -webkit-backdrop-filter: blur(22px) saturate(150%);
1037
+ backdrop-filter: blur(22px) saturate(120%);
1038
+ -webkit-backdrop-filter: blur(22px) saturate(120%);
875
1039
  }
876
1040
  .tophead {
877
1041
  display: flex; align-items: center; gap: 8px;
@@ -882,7 +1046,7 @@ button { font: inherit; color: inherit; }
882
1046
  .tlabel {
883
1047
  flex: 1 1 auto;
884
1048
  font-size: var(--t-label); font-weight: 600;
885
- letter-spacing: 0.16em; text-transform: uppercase;
1049
+ letter-spacing: 0.2em; text-transform: uppercase;
886
1050
  color: var(--faint);
887
1051
  }
888
1052
  .ttotal {
@@ -895,7 +1059,7 @@ button { font: inherit; color: inherit; }
895
1059
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
896
1060
  font-variant-numeric: tabular-nums;
897
1061
  }
898
- .tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform var(--calm) var(--ease); }
1062
+ .tophead .chev { color: var(--faintest); width: 13px; height: 13px; transition: transform var(--calm) var(--ease); }
899
1063
  .tophead[aria-expanded='true'] .chev { transform: rotate(180deg); }
900
1064
  .tophead:hover .tlabel, .tophead:hover .ttotal, .tophead:hover .chev { color: var(--soft); }
901
1065
 
@@ -915,32 +1079,34 @@ button { font: inherit; color: inherit; }
915
1079
 
916
1080
  /* --- follow ------------------------------------------------------------- */
917
1081
  .follow {
918
- position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
1082
+ /* Clear of the pinned strip at the bottom, not on top of it. At 16px it sat
1083
+ squarely over the timing line and covered the words it floated above. */
1084
+ position: fixed; left: 50%; bottom: 62px; transform: translateX(-50%);
919
1085
  z-index: 4;
920
1086
  font-size: var(--t-body);
921
- color: var(--ink);
1087
+ color: var(--soft);
922
1088
  background: var(--lift);
923
- border: 1px solid var(--line-firm); border-radius: 999px;
924
- padding: 6px 15px; cursor: pointer;
925
- box-shadow: 0 12px 24px -14px var(--shadow);
1089
+ border: 0; border-radius: 999px;
1090
+ padding: 7px 15px; cursor: pointer;
1091
+ box-shadow: inset 0 0 0 1px var(--line-firm);
926
1092
  animation: settle 240ms var(--ease) both;
927
- transition: border-color var(--quick) var(--ease), color var(--quick) var(--ease);
1093
+ transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
928
1094
  }
929
- .follow:hover { border-color: var(--accent); color: var(--accent); }
1095
+ .follow:hover { color: var(--ink); background: var(--card-hover); }
930
1096
 
931
1097
  /* --- the one thing left to do ------------------------------------------- */
932
1098
  .foot {
933
1099
  flex: 0 0 auto;
934
1100
  padding: 14px var(--pad) 16px;
935
1101
  background: var(--glass);
936
- backdrop-filter: blur(22px) saturate(150%);
937
- -webkit-backdrop-filter: blur(22px) saturate(150%);
1102
+ backdrop-filter: blur(22px) saturate(120%);
1103
+ -webkit-backdrop-filter: blur(22px) saturate(120%);
938
1104
  border-top: 1px solid var(--line);
939
1105
  animation: arrive 340ms var(--ease) both;
940
1106
  }
941
1107
  .nextlabel {
942
1108
  font-size: var(--t-label); font-weight: 600;
943
- letter-spacing: 0.16em; text-transform: uppercase;
1109
+ letter-spacing: 0.2em; text-transform: uppercase;
944
1110
  color: var(--faint);
945
1111
  margin-bottom: 8px;
946
1112
  }
@@ -950,22 +1116,26 @@ button { font: inherit; color: inherit; }
950
1116
  display: flex; align-items: center;
951
1117
  background: var(--well);
952
1118
  border-radius: var(--radius-xs);
1119
+ box-shadow: inset 0 0 0 1px var(--line);
953
1120
  padding: 9px 11px; font-size: var(--t-body);
954
1121
  color: var(--ink);
955
1122
  overflow-wrap: anywhere;
956
1123
  user-select: all;
957
1124
  }
958
- .prompt { flex: 0 0 auto; color: var(--held); opacity: 0.75; user-select: none; }
1125
+ .prompt { flex: 0 0 auto; color: var(--faint); user-select: none; }
959
1126
  .copy {
960
1127
  flex: 0 0 auto; width: 36px;
961
1128
  display: flex; align-items: center; justify-content: center;
962
1129
  color: var(--soft); background: var(--well);
963
1130
  border: 0; border-radius: var(--radius-xs);
1131
+ box-shadow: inset 0 0 0 1px var(--line);
964
1132
  cursor: pointer;
965
1133
  transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
966
1134
  }
967
1135
  .copy:hover { color: var(--ink); background: var(--card-hover); }
968
- .copy.done { color: var(--held); }
1136
+ /* Copied. Not a colour — colour means something is wrong — but a hard invert,
1137
+ which is louder than any tint and gone again in a second. */
1138
+ .copy.done { color: var(--ground); background: var(--ink); }
969
1139
 
970
1140
  /* --------------------------------------------------------------------------
971
1141
  The viewer.
@@ -979,7 +1149,7 @@ button { font: inherit; color: inherit; }
979
1149
  .viewer {
980
1150
  position: fixed; inset: 0; z-index: 20;
981
1151
  display: flex; flex-direction: column;
982
- background: color-mix(in srgb, var(--ground) 92%, #000);
1152
+ background: color-mix(in srgb, var(--ground) 88%, #000);
983
1153
  animation: viewerin 220ms var(--ease) both;
984
1154
  }
985
1155
  @keyframes viewerin { from { opacity: 0; } to { opacity: 1; } }
@@ -1013,7 +1183,7 @@ button { font: inherit; color: inherit; }
1013
1183
  }
1014
1184
  .vmodes button::after {
1015
1185
  content: ''; position: absolute; left: 50%; right: 50%; bottom: 1px; height: 1.5px;
1016
- border-radius: 2px; background: var(--accent); opacity: 0;
1186
+ border-radius: 2px; background: var(--ink); opacity: 0;
1017
1187
  transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
1018
1188
  }
1019
1189
  .vmodes button:hover { color: var(--soft); }
@@ -1089,14 +1259,16 @@ button { font: inherit; color: inherit; }
1089
1259
  padding: 10px var(--pad) 15px;
1090
1260
  text-align: center;
1091
1261
  font-size: var(--t-label); letter-spacing: 0.03em;
1092
- color: var(--faint);
1262
+ color: var(--faintest);
1093
1263
  }
1094
- .vside { flex: 0 0 auto; font-size: var(--t-label); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); }
1264
+ .vside { flex: 0 0 auto; font-size: var(--t-label); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); }
1265
+ /* Grey at the approved end, the screen's own amber at the new one: the slider
1266
+ itself says which way is "what changed". */
1095
1267
  .vblend {
1096
1268
  flex: 1 1 auto; min-width: 0;
1097
1269
  -webkit-appearance: none; appearance: none;
1098
1270
  height: 3px; border-radius: 999px;
1099
- background: linear-gradient(to right, var(--moved), var(--accent));
1271
+ background: linear-gradient(to right, var(--line-firm), var(--moved));
1100
1272
  outline: none; cursor: pointer;
1101
1273
  }
1102
1274
  .vblend::-webkit-slider-thumb {
@@ -1112,7 +1284,7 @@ button { font: inherit; color: inherit; }
1112
1284
 
1113
1285
  /* Dragged narrow. Everything stays, nothing wraps into a mess. */
1114
1286
  @media (max-width: 330px) {
1115
- :root { --pad: 12px; --radius: 16px; --t-lead: 15px; }
1287
+ :root { --pad: 12px; --radius: 16px; --t-lead: 16px; }
1116
1288
  .target .app, .target .sep { display: none; }
1117
1289
  .row { gap: 9px; padding: 9px 11px; }
1118
1290
  .detail { padding: 0 11px 12px 28px; }
@@ -1129,7 +1301,7 @@ button { font: inherit; color: inherit; }
1129
1301
  /* Pulled wide. Two columns rather than one very long thin one, so the picture
1130
1302
  gets the room and the list stays beside it instead of below the fold. */
1131
1303
  @media (min-width: 720px) {
1132
- :root { --pad: 24px; --t-lead: 19px; }
1304
+ :root { --pad: 24px; --t-lead: 20px; }
1133
1305
  .body { flex-direction: row; gap: 28px; padding-bottom: 4px; }
1134
1306
  .stage { flex: 1 1 54%; min-width: 0; align-self: center; padding-top: 0; }
1135
1307
  .scroll { flex: 1 1 46%; min-width: 0; padding-top: 22px; }
@@ -1460,7 +1632,12 @@ const SCRIPT = `
1460
1632
  // what makes the whole run browsable afterwards instead of only the last
1461
1633
  // screen photographed.
1462
1634
  row.addEventListener('click', function () {
1463
- var forward = entry.pics && !entry.root.classList.contains('showing');
1635
+ // Its picture, or its working, or both — whichever this check has. A
1636
+ // guard has no picture and its claims are the whole point of it, so a
1637
+ // guard row brings its own working back exactly like a screen does.
1638
+ var forward =
1639
+ (entry.pics && !entry.root.classList.contains('showing')) ||
1640
+ ((hasWork(entry) || entry.running) && workOwner !== entry);
1464
1641
  if (forward) {
1465
1642
  recall(entry);
1466
1643
  setOpen(entry, true);
@@ -1508,18 +1685,21 @@ const SCRIPT = `
1508
1685
  out.textContent = entry.outText;
1509
1686
  d.appendChild(out);
1510
1687
  }
1511
- // The working. Everything that was really done to this screen, in the
1512
- // order it happened, numbered so it reads as a sequence rather than a bag
1513
- // of facts. Absent on older runs and on guards, and then simply not drawn.
1514
- var work = workList(entry.checks);
1515
- if (work) {
1516
- var worklabel = document.createElement('p');
1517
- worklabel.className = 'worklabel';
1518
- worklabel.textContent = 'What was done';
1519
- d.appendChild(worklabel);
1520
- d.appendChild(work);
1688
+ // The working. Everything that was really done to this check, in the order
1689
+ // it happened, numbered so it reads as a sequence rather than a bag of
1690
+ // facts. The heading and the list are the SAME two elements every time —
1691
+ // built once for this row and put back — so a redraw in the middle of a
1692
+ // run never tears down lines that are still ticking.
1693
+ if (hasWork(entry)) {
1694
+ var ol = detailWork(entry);
1695
+ d.appendChild(entry.workLabel);
1696
+ d.appendChild(ol);
1697
+ renderWork(ol, entry.checks, entry.kind);
1521
1698
  }
1522
- if (entry.failedAt) {
1699
+ // The expectation that failed, boxed — unless the working above already
1700
+ // ends on it, which it does now that a guard shows its claims. Nothing on
1701
+ // this page is said twice.
1702
+ if (entry.failedAt && !inWork(entry, entry.failedAt)) {
1523
1703
  var claim = document.createElement('div');
1524
1704
  claim.className = 'claim';
1525
1705
  var label = document.createElement('em');
@@ -1540,95 +1720,387 @@ const SCRIPT = `
1540
1720
  if (!entry.hasDetail) setOpen(entry, false);
1541
1721
  }
1542
1722
 
1723
+ // -------------------------------------------------------------------------
1724
+ // The working, ticked off live.
1725
+ //
1726
+ // A table that appears once a screen has already finished tells nobody what
1727
+ // is happening — which is exactly what the first person to watch this said.
1728
+ // So the run now announces each thing as it reaches it, and this is the part
1729
+ // of the panel that draws them: the names of what is being checked on the
1730
+ // check that is running right now, each one taking its mark as it settles.
1731
+ //
1732
+ // Two rules hold it up. Every line is found again by its own id rather than
1733
+ // redrawn, so a list that is already right is never torn down and rebuilt —
1734
+ // that is what stops it flickering when the verdict arrives carrying the
1735
+ // whole list again. And a step whose outcome needs a person shows the number
1736
+ // behind it the moment it lands, because that is the moment somebody is
1737
+ // looking.
1738
+ // -------------------------------------------------------------------------
1739
+
1740
+ // The marks, drawn here like every other icon on this page. A tick for a
1741
+ // thing that held, a cross for one that broke, a stroke for a thing that was
1742
+ // deliberately not done, and a soft dot for the one happening now.
1743
+ var MARKS = {
1744
+ ok: '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5.4 12.6l4.3 4.3 8.9-9.8"></path></svg>',
1745
+ warn: '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5.4v8.4"></path><path d="M12 18.3h.01"></path></svg>',
1746
+ bad: '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6.6 6.6l10.8 10.8M17.4 6.6L6.6 17.4"></path></svg>',
1747
+ skipped: '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6.4 12h11.2"></path></svg>',
1748
+ ended: '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6.4 12h11.2"></path></svg>',
1749
+ running: '<svg class="glyph" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="4.1" fill="currentColor"></circle></svg>'
1750
+ };
1751
+
1752
+ var STEP_STATES = { running: 1, ok: 1, warn: 1, bad: 1, skipped: 1, ended: 1 };
1753
+
1754
+ function labelOf(step) { return String(step && step.label == null ? '' : step.label).trim(); }
1755
+ function detailOf(step) { return String(step && step.detail == null ? '' : step.detail).trim(); }
1756
+ function stateOf(step) {
1757
+ var s = step && step.state;
1758
+ return STEP_STATES[s] ? s : 'ok';
1759
+ }
1760
+
1761
+ // How a step is found again. The run gives a stable id to anything it will
1762
+ // announce twice — once as it starts, once as it settles — and without one
1763
+ // the words themselves have to do it.
1764
+ function idOf(step) {
1765
+ var key = step && step.key != null ? String(step.key).trim() : '';
1766
+ return key ? 'k:' + key : 'l:' + labelOf(step);
1767
+ }
1768
+
1769
+ // Guards only. A guard is a promise about behaviour, and the promises are
1770
+ // the claims it makes — 'the sidebar column is gone' — with actions in
1771
+ // between to get the app into position. The claims are what the guard is
1772
+ // for, so they are the ones that carry the weight.
1773
+ var CLAIM_KEY = /^(expect|claim|assert|expected)([^a-z0-9]|$)/i;
1774
+ var ACT_KEY = /^(do|did|act|action|step|fresh|run|ran|open|click|type|press|hover|scroll|wait|read|note)([^a-z0-9]|$)/i;
1775
+ // A step that had to be tried twice is the same step, and its id says so.
1776
+ var RETRY = /^try[0-9]+[^a-z0-9]/i;
1777
+ var ACT_WORD = /^(opened|clicked|typed|pressed|hovered|scrolled|waited|went|ran|read|reached|took|started|closed|filled|chose|picked|dragged|reloaded|moved|set|gave)([^a-z0-9]|$)/i;
1778
+
1779
+ function weightOf(kind, step) {
1780
+ if (kind !== 'guard') return '';
1781
+ var key = step && step.key != null ? String(step.key).trim() : '';
1782
+ if (RETRY.test(key)) key = key.replace(RETRY, '');
1783
+ if (CLAIM_KEY.test(key)) return 'stepclaim';
1784
+ if (ACT_KEY.test(key)) return 'stepact';
1785
+ return ACT_WORD.test(labelOf(step)) ? 'stepact' : 'stepclaim';
1786
+ }
1787
+
1788
+ /** One line, built once and then only ever updated in place. */
1789
+ function makeLine() {
1790
+ var li = document.createElement('li');
1791
+ li.className = 'step';
1792
+ var no = document.createElement('span');
1793
+ no.className = 'stepno mono';
1794
+ var mark = document.createElement('span');
1795
+ mark.className = 'stepmark';
1796
+ var body = document.createElement('span');
1797
+ body.className = 'stepbody';
1798
+ var what = document.createElement('span');
1799
+ what.className = 'stepwhat';
1800
+ var num = document.createElement('span');
1801
+ num.className = 'stepnum mono';
1802
+ num.hidden = true;
1803
+ body.appendChild(what);
1804
+ body.appendChild(num);
1805
+ li.appendChild(no);
1806
+ li.appendChild(mark);
1807
+ li.appendChild(body);
1808
+ li.__no = no; li.__mark = mark; li.__what = what; li.__num = num;
1809
+ li.__state = ''; li.__weight = '';
1810
+ return li;
1811
+ }
1812
+
1813
+ function fillLine(li, step, n, kind, fresh) {
1814
+ var state = stateOf(step);
1815
+ var weight = weightOf(kind, step);
1816
+ var said = labelOf(step);
1817
+ var behind = detailOf(step);
1818
+
1819
+ if (li.__no.textContent !== String(n)) li.__no.textContent = String(n);
1820
+ if (li.__state !== state) {
1821
+ li.__state = state;
1822
+ li.__mark.innerHTML = MARKS[state] || MARKS.ok;
1823
+ }
1824
+ li.__weight = weight;
1825
+ li.className = 'step ' + state + (weight ? ' ' + weight : '') + (fresh ? ' arrive' : '');
1826
+ if (li.__what.textContent !== said) li.__what.textContent = said;
1827
+ // The number behind a step is shown the instant the step has one — a line
1828
+ // that has just gone amber or red is the one moment somebody needs it.
1829
+ if (li.__num.textContent !== behind) li.__num.textContent = behind;
1830
+ li.__num.hidden = !behind;
1831
+ }
1832
+
1543
1833
  /**
1544
- * The working, as a numbered list or null when there is nothing to show.
1834
+ * Draw a list of steps into a list element, reconciling by id.
1545
1835
  *
1546
- * Everything that was really done to a screen, in the order it happened.
1547
- * Numbered because that is the question people ask: not "did it pass" but
1548
- * "what did you actually check". Absent on older runs and on guards.
1836
+ * Lines already on the page are found and updated; new ones settle in at
1837
+ * their place; anything the authoritative list no longer mentions goes. So
1838
+ * the same function serves a step arriving on its own and the whole array
1839
+ * that comes with the verdict, and the second one changes nothing a watcher
1840
+ * already had right.
1549
1841
  *
1842
+ * @param {any} ol
1550
1843
  * @param {any} checks
1551
- * @returns {HTMLOListElement|null}
1844
+ * @param {string} kind
1845
+ * @returns {number} How many lines are shown.
1552
1846
  */
1553
- function workList(checks) {
1554
- var steps = Array.isArray(checks) ? checks : null;
1555
- if (!steps || !steps.length) return null;
1556
- var work = document.createElement('ol');
1557
- work.className = 'work';
1558
- var counted = 0;
1559
- for (var k = 0; k < steps.length; k++) {
1560
- var step = steps[k];
1847
+ function renderWork(ol, checks, kind) {
1848
+ var steps = Array.isArray(checks) ? checks : [];
1849
+ var lines = ol.__lines || (ol.__lines = Object.create(null));
1850
+ var seen = Object.create(null);
1851
+ var n = 0;
1852
+ for (var i = 0; i < steps.length; i++) {
1853
+ var step = steps[i];
1561
1854
  if (!step || typeof step !== 'object') continue;
1562
- var said = String(step.label == null ? '' : step.label).trim();
1563
- if (!said) continue;
1564
- counted++;
1565
-
1566
- var line = document.createElement('li');
1567
- var state = step.state;
1568
- var carries = state === 'warn' || state === 'bad' || state === 'skipped';
1569
- line.className = 'step ' + (carries ? state : 'ok');
1570
-
1571
- var no = document.createElement('span');
1572
- no.className = 'stepno mono';
1573
- no.textContent = String(counted);
1574
- var body = document.createElement('span');
1575
- body.className = 'stepbody';
1576
- var what = document.createElement('span');
1577
- what.className = 'stepwhat';
1578
- what.textContent = said;
1579
- body.appendChild(what);
1580
-
1581
- var behind = String(step.detail == null ? '' : step.detail).trim();
1582
- if (behind) {
1583
- var num = document.createElement('span');
1584
- num.className = 'stepnum mono';
1585
- num.textContent = behind;
1586
- body.appendChild(num);
1587
- }
1588
- line.appendChild(no);
1589
- line.appendChild(body);
1590
- work.appendChild(line);
1855
+ if (!labelOf(step)) continue;
1856
+ n++;
1857
+ var id = idOf(step);
1858
+ // Two steps that answer to the same name in one list still get a line
1859
+ // each, rather than one of them quietly replacing the other.
1860
+ if (seen[id]) id = id + '#' + n;
1861
+ seen[id] = true;
1862
+
1863
+ var li = lines[id];
1864
+ var fresh = false;
1865
+ if (!li) { li = makeLine(); lines[id] = li; fresh = !CALM; }
1866
+ fillLine(li, step, n, kind, fresh);
1867
+ if (fresh) forget(li);
1868
+ var at = ol.children[n - 1];
1869
+ if (at !== li) ol.insertBefore(li, at || null);
1591
1870
  }
1592
- return counted ? work : null;
1871
+ for (var gone in lines) {
1872
+ if (seen[gone]) continue;
1873
+ if (lines[gone].parentNode === ol) ol.removeChild(lines[gone]);
1874
+ delete lines[gone];
1875
+ }
1876
+ return n;
1593
1877
  }
1594
1878
 
1595
- /**
1596
- * The same list, always visible, under the picture on the glass.
1597
- *
1598
- * Behind a click it may as well not exist — the first person to use this asked
1599
- * for the checks and never found them, because a passing row is closed by
1600
- * default. Whichever screen is being shown says what was done to it, right
1601
- * there, without anybody having to go looking.
1602
- *
1603
- * @param {any} checks
1604
- */
1605
- function showWork(checks) {
1606
- var list = workList(checks);
1607
- ui.work.textContent = '';
1608
- if (!list) { ui.work.hidden = true; return; }
1879
+ // The arriving animation is worn once and then taken off, so a line that is
1880
+ // later moved or updated never plays it again.
1881
+ function forget(li) {
1882
+ setTimeout(function () { li.classList.remove('arrive'); }, 420);
1883
+ }
1884
+
1885
+ function hasWork(entry) {
1886
+ return !!(entry && Array.isArray(entry.checks) && entry.checks.length);
1887
+ }
1888
+
1889
+ /** Is this sentence already one of the lines of the working? */
1890
+ function inWork(entry, said) {
1891
+ var want = String(said == null ? '' : said).trim();
1892
+ if (!want || !hasWork(entry)) return false;
1893
+ for (var i = 0; i < entry.checks.length; i++) {
1894
+ if (labelOf(entry.checks[i]) === want) return true;
1895
+ }
1896
+ return false;
1897
+ }
1898
+
1899
+ /** This row's own copy of the list, for under the row. Built once. */
1900
+ function detailWork(entry) {
1901
+ if (!entry.workOl) {
1902
+ var label = document.createElement('p');
1903
+ label.className = 'worklabel';
1904
+ var title = document.createElement('span');
1905
+ title.className = 'worktitle';
1906
+ label.appendChild(title);
1907
+ entry.workLabel = label;
1908
+ entry.workTitle = title;
1909
+ entry.workOl = document.createElement('ol');
1910
+ entry.workOl.className = 'work';
1911
+ }
1912
+ entry.workTitle.textContent = entry.running ? 'What is being checked' : 'What was done';
1913
+ return entry.workOl;
1914
+ }
1915
+
1916
+ // -------------------------------------------------------------------------
1917
+ // The working on the glass: whichever check the panel is showing.
1918
+ // -------------------------------------------------------------------------
1919
+
1920
+ var workOwner = null;
1921
+ // Whether this run says anything as it goes. An older Stays Fixed only ever
1922
+ // hands over the whole list at the end, and a heading promising that things
1923
+ // will appear as they are checked would be a promise nobody kept.
1924
+ var sawSteps = false;
1925
+
1926
+ // Built once, into the section that already sits at the top of the scrolling
1927
+ // column. It has to stay there: put inside the picture's own box it steals
1928
+ // the picture's height and squashes the screenshot to a sliver.
1929
+ var workShell = (function () {
1609
1930
  var label = document.createElement('p');
1610
1931
  label.className = 'worklabel';
1611
- label.textContent = 'What was done';
1932
+ var title = document.createElement('span');
1933
+ title.className = 'worktitle';
1934
+ var who = document.createElement('span');
1935
+ who.className = 'workwho mono';
1936
+ label.appendChild(title);
1937
+ label.appendChild(who);
1938
+ var waiting = document.createElement('p');
1939
+ waiting.className = 'workwait';
1940
+ waiting.hidden = true;
1941
+ var list = document.createElement('ol');
1942
+ list.className = 'work';
1612
1943
  ui.work.appendChild(label);
1944
+ ui.work.appendChild(waiting);
1613
1945
  ui.work.appendChild(list);
1614
- ui.work.hidden = false;
1946
+ return { title: title, who: who, waiting: waiting, list: list };
1947
+ })();
1948
+
1949
+ /**
1950
+ * Bring one check's working onto the glass.
1951
+ *
1952
+ * A bring is for a check that has just STARTED: the list is about to tick off
1953
+ * and it is no use doing that below the fold, so the column goes back to the
1954
+ * top where the working lives — unless the person has taken hold of the list,
1955
+ * in which case nothing moves under them, like everywhere else on this page.
1956
+ * A click never brings: somebody who reached for a row is already looking at
1957
+ * it, and the row opening under their finger is the answer.
1958
+ */
1959
+ function openWork(entry, bring) {
1960
+ if (!entry) return;
1961
+ if (workOwner && workOwner !== entry) workOwner.root.classList.remove('working');
1962
+ workOwner = entry;
1963
+ entry.root.classList.add('working');
1964
+ syncWork(entry);
1965
+ if (bring && following && ui.scroll.scrollTop > 0) {
1966
+ ui.scroll.scrollTo({ top: 0, behavior: CALM ? 'auto' : 'smooth' });
1967
+ }
1968
+ }
1969
+
1970
+ /** Redraw wherever this check's working is on show. */
1971
+ function syncWork(entry) {
1972
+ if (!entry) return;
1973
+ if (workOwner === entry) {
1974
+ var n = renderWork(workShell.list, entry.checks, entry.kind);
1975
+ workShell.title.textContent = entry.running ? 'What is being checked' : 'What was done';
1976
+ workShell.who.textContent = entry.name || '';
1977
+ workShell.waiting.textContent = entry.running
1978
+ ? 'Each thing appears here as it is checked.'
1979
+ : 'Nothing was written down for this one.';
1980
+ workShell.waiting.hidden = n > 0 || !sawSteps;
1981
+ workShell.list.hidden = n === 0;
1982
+ // Nothing to show and nothing promised: the section stays out of the way.
1983
+ ui.work.hidden = n === 0 && !sawSteps;
1984
+ }
1985
+ // Under the row. Cheap once the list is on the page; the first step of a
1986
+ // check has to go the long way round, because that is what turns a row
1987
+ // with nothing under it into one worth opening.
1988
+ if (entry.workOl && entry.workOl.parentNode) {
1989
+ detailWork(entry);
1990
+ renderWork(entry.workOl, entry.checks, entry.kind);
1991
+ } else if (hasWork(entry)) {
1992
+ redraw(entry);
1993
+ }
1994
+ }
1995
+
1996
+ /**
1997
+ * One step, as it happens. Announced as running, found again by its id and
1998
+ * settled when it is done.
1999
+ */
2000
+ function applyStep(entry, step) {
2001
+ if (!entry || !step || typeof step !== 'object') return;
2002
+ if (!labelOf(step)) return;
2003
+ if (!Array.isArray(entry.checks)) entry.checks = [];
2004
+ var id = idOf(step);
2005
+ var kept = {
2006
+ label: labelOf(step),
2007
+ detail: detailOf(step),
2008
+ state: stateOf(step),
2009
+ key: step.key == null ? '' : String(step.key)
2010
+ };
2011
+ var found = -1;
2012
+ for (var i = 0; i < entry.checks.length; i++) {
2013
+ if (idOf(entry.checks[i]) === id) { found = i; break; }
2014
+ }
2015
+ if (found >= 0) entry.checks[found] = kept;
2016
+ else entry.checks.push(kept);
2017
+ syncWork(entry);
2018
+ }
2019
+
2020
+ /**
2021
+ * Take the authoritative list that arrives with a verdict.
2022
+ *
2023
+ * Steps keep the id they were announced with wherever the final list does not
2024
+ * carry one, so a line a watcher has already been ticking is found again and
2025
+ * updated rather than thrown away and drawn a second time. That is what makes
2026
+ * the moment a check finishes look like nothing happened at all.
2027
+ */
2028
+ function adopt(entry, checks) {
2029
+ var live = Array.isArray(entry.checks) ? entry.checks : [];
2030
+ var known = Object.create(null);
2031
+ var i;
2032
+ for (i = 0; i < live.length; i++) {
2033
+ if (live[i] && live[i].key) known[labelOf(live[i])] = String(live[i].key);
2034
+ }
2035
+ var out = [];
2036
+ for (i = 0; i < checks.length; i++) {
2037
+ var step = checks[i];
2038
+ if (!step || typeof step !== 'object') continue;
2039
+ var said = labelOf(step);
2040
+ if (!said) continue;
2041
+ var key = step.key == null ? '' : String(step.key);
2042
+ out.push({
2043
+ label: said,
2044
+ detail: detailOf(step),
2045
+ state: stateOf(step),
2046
+ key: key || known[said] || ''
2047
+ });
2048
+ }
2049
+ entry.checks = out;
2050
+ }
2051
+
2052
+ /**
2053
+ * A check has stopped. Anything still marked as happening is not happening
2054
+ * any more, and a mark that keeps breathing next to a finished result is a
2055
+ * lie — so those lines settle to a plain, colourless end.
2056
+ */
2057
+ function hush(entry) {
2058
+ if (!entry || !Array.isArray(entry.checks)) return;
2059
+ for (var i = 0; i < entry.checks.length; i++) {
2060
+ var step = entry.checks[i];
2061
+ if (stateOf(step) !== 'running') continue;
2062
+ entry.checks[i] = { label: labelOf(step), detail: detailOf(step), state: 'ended', key: step.key || '' };
2063
+ }
1615
2064
  }
1616
2065
 
1617
- /** Put a screen's pictures back in the hero, with the words that went with them. */
2066
+ /**
2067
+ * The claim a guard died on, as the last line of its working.
2068
+ *
2069
+ * A guard that failed has to show every claim that held before the one that
2070
+ * did not, and this makes sure the one that did not is there even when the
2071
+ * run only reported it in the verdict.
2072
+ */
2073
+ function markFailedClaim(entry, claim) {
2074
+ var said = String(claim == null ? '' : claim).trim();
2075
+ if (!said) return;
2076
+ if (!Array.isArray(entry.checks)) entry.checks = [];
2077
+ for (var i = 0; i < entry.checks.length; i++) {
2078
+ if (labelOf(entry.checks[i]) !== said) continue;
2079
+ entry.checks[i] = {
2080
+ label: said, detail: detailOf(entry.checks[i]), state: 'bad', key: entry.checks[i].key || ''
2081
+ };
2082
+ return;
2083
+ }
2084
+ entry.checks.push({ label: said, detail: '', state: 'bad', key: 'claim:failed' });
2085
+ }
2086
+
2087
+ /** Put a check back on the glass: its pictures if it has any, its working always. */
1618
2088
  function recall(entry) {
1619
2089
  var p = entry.pics;
1620
- if (!p) return;
1621
- var showed = false;
1622
- if (p.status === 'changed') showed = comparePictures(p);
1623
- if (!showed && nowOf(p)) {
1624
- singlePicture(p, entry.name);
1625
- showed = true;
2090
+ if (p) {
2091
+ var showed = false;
2092
+ if (p.status === 'changed') showed = comparePictures(p);
2093
+ if (!showed && nowOf(p)) {
2094
+ singlePicture(p, entry.name);
2095
+ showed = true;
2096
+ }
2097
+ if (showed) {
2098
+ nameTheShot(entry.name);
2099
+ sayOutcome(entry.tone, outcomeShort(p));
2100
+ markShowing(entry);
2101
+ }
1626
2102
  }
1627
- if (!showed) return;
1628
- nameTheShot(entry.name);
1629
- sayOutcome(entry.tone, outcomeShort(p));
1630
- showWork(entry.checks);
1631
- markShowing(entry);
2103
+ openWork(entry);
1632
2104
  }
1633
2105
 
1634
2106
  function setOpen(entry, open) {
@@ -1773,7 +2245,16 @@ const SCRIPT = `
1773
2245
 
1774
2246
  var runningItem = null;
1775
2247
  function markRunning(entry) {
2248
+ if (runningItem && runningItem !== entry) {
2249
+ runningItem.running = false;
2250
+ hush(runningItem);
2251
+ syncWork(runningItem);
2252
+ }
1776
2253
  runningItem = entry;
2254
+ entry.running = true;
2255
+ // A check that is starting again starts with an empty list, so nothing is
2256
+ // left over from the last time it ran.
2257
+ entry.checks = [];
1777
2258
  setTone(entry, 'running');
1778
2259
  if (following) entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
1779
2260
  }
@@ -2446,11 +2927,26 @@ const SCRIPT = `
2446
2927
  // photographed yet, so renaming the frame now would put this screen's
2447
2928
  // name under the last screen's picture — a caption that lies for a
2448
2929
  // second is worse than one that is a second behind.
2449
- markRunning(ensureItem('picture', String(ev.name || ''), ev.describe));
2930
+ var starting = ensureItem('picture', String(ev.name || ''), ev.describe);
2931
+ markRunning(starting);
2932
+ // The working area clears and takes this screen's name, so the list that
2933
+ // ticks off from here is unmistakably about the screen being checked now.
2934
+ openWork(starting, true);
2450
2935
  scanning(true);
2451
2936
  return;
2452
2937
  }
2453
2938
 
2939
+ // One thing, the moment it happens. This is the whole point of the panel:
2940
+ // the names of what is being checked right now, each taking its mark as it
2941
+ // settles, instead of a table that lands when it is already over.
2942
+ if (type === 'screen:step' || type === 'guard:step') {
2943
+ sawSteps = true;
2944
+ var stepKind = type === 'guard:step' ? 'guard' : 'picture';
2945
+ var stepping = ensureItem(stepKind, String(ev.name || ''), ev.describe);
2946
+ applyStep(stepping, ev.step);
2947
+ return;
2948
+ }
2949
+
2454
2950
  if (type === 'screen:shot') {
2455
2951
  scanning(false);
2456
2952
  var shotRow = ev.name ? ensureItem('picture', String(ev.name), ev.describe) : null;
@@ -2488,9 +2984,9 @@ const SCRIPT = `
2488
2984
  // in the list, because the picture is where the person is looking and
2489
2985
  // that line is what tells them there is something to decide.
2490
2986
  sayOutcome(tone, outcomeShort(ev));
2491
- // And what was actually done to it, right under the picture, while the
2492
- // person is looking at it.
2493
- showWork(ev.checks);
2987
+ // And what was actually done to it, in the list beside the picture,
2988
+ // while the person is looking at it.
2989
+ openWork(done);
2494
2990
  }
2495
2991
  tint(worstTone());
2496
2992
  return;
@@ -2503,7 +2999,11 @@ const SCRIPT = `
2503
2999
  var guards = ev.total || expected.guard || countOf(plan.guards);
2504
3000
  setState(guards ? 'running ' + commas(guards) + ' ' + plural(guards, 'guard', 'guards') : 'running the guards');
2505
3001
  }
2506
- markRunning(ensureItem('guard', String(ev.name || ''), ev.describe));
3002
+ var guarding = ensureItem('guard', String(ev.name || ''), ev.describe);
3003
+ markRunning(guarding);
3004
+ // Guards have working too: the claims this one makes, ticking off as
3005
+ // each is checked. Shown the same way a screen's steps are.
3006
+ openWork(guarding, true);
2507
3007
  return;
2508
3008
  }
2509
3009
 
@@ -2514,6 +3014,12 @@ const SCRIPT = `
2514
3014
  }
2515
3015
 
2516
3016
  if (type === 'run:done') {
3017
+ // Nothing is happening any more, so nothing may still say it is.
3018
+ for (var q = 0; q < order.length; q++) {
3019
+ order[q].running = false;
3020
+ hush(order[q]);
3021
+ syncWork(order[q]);
3022
+ }
2517
3023
  runningItem = null;
2518
3024
  ui.follow.hidden = true;
2519
3025
  scanning(false);
@@ -2561,9 +3067,16 @@ const SCRIPT = `
2561
3067
  // picture that is already decoded and the swap never flashes.
2562
3068
  preloadAll(entry.pics);
2563
3069
  }
2564
- // What was actually done to this screen. Kept as it arrived the run is
2565
- // the one thing that knows, and the panel never invents a step.
2566
- if (Array.isArray(ev.checks)) entry.checks = ev.checks;
3070
+ // What was actually done to this check. The list that comes with the
3071
+ // verdict is the one that is right a watcher that opened halfway through,
3072
+ // or missed an event, ends up correct here whatever it saw on the way.
3073
+ entry.running = false;
3074
+ if (Array.isArray(ev.checks)) adopt(entry, ev.checks);
3075
+ // Anything the run left mid-flight stops looking like it is still going.
3076
+ hush(entry);
3077
+ // And a guard that broke shows the claim it broke on, under every claim
3078
+ // that held before it.
3079
+ if (kind === 'guard' && ev.status === 'failed') markFailedClaim(entry, ev.failedAt);
2567
3080
  entry.outText = outcomeText(kind, ev);
2568
3081
  if (entry.verdict) entry.verdict.textContent = shortOutcome(kind, ev);
2569
3082
  entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
@@ -2571,6 +3084,7 @@ const SCRIPT = `
2571
3084
  if (typeof ev.durationMs === 'number') tweenTo(entry.time, ev.durationMs, fmt);
2572
3085
  entry.tone = tone;
2573
3086
  redraw(entry);
3087
+ syncWork(entry);
2574
3088
  setTone(entry, tone);
2575
3089
  // Only the checks that want a person open themselves. Everything else stays
2576
3090
  // one line, which is what keeps a clean run quiet. A row that has just
@@ -2633,7 +3147,10 @@ const SCRIPT = `
2633
3147
  handle(ev);
2634
3148
  } catch (err) {
2635
3149
  // A watch window must never be the reason a run looks broken. Whatever
2636
- // this event was, the next one still has to land.
3150
+ // this event was, the next one still has to land. It is still said out
3151
+ // loud where anyone would look for it — a panel that swallows its own
3152
+ // mistakes in silence is a panel nobody can mend.
3153
+ if (window.console && console.error) console.error('stays fixed: ' + (err && err.message ? err.message : err));
2637
3154
  }
2638
3155
  };
2639
3156
 
@@ -2642,6 +3159,11 @@ const SCRIPT = `
2642
3159
  window.__staysfixed_detach = function () {
2643
3160
  stopClock();
2644
3161
  scanning(false);
3162
+ if (runningItem) {
3163
+ runningItem.running = false;
3164
+ hush(runningItem);
3165
+ syncWork(runningItem);
3166
+ }
2645
3167
  runningItem = null;
2646
3168
  };
2647
3169