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