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