staysfixed 0.1.1 → 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.
@@ -0,0 +1,2445 @@
1
+ /**
2
+ * The page inside the watch window — the calm companion.
3
+ *
4
+ * This builds one self-contained HTML document as a string. It is written to a
5
+ * temp file and opened over a local file address, so it can fetch nothing: no
6
+ * fonts, no CDN, no framework, no build step. Everything it will ever need is
7
+ * in the string this file returns, including every icon, which are drawn here
8
+ * in SVG rather than downloaded from anywhere.
9
+ *
10
+ * It is fed one `RunEvent` at a time by `window.__staysfixed_push`, which the
11
+ * run calls over the debugging connection as things happen. The page keeps no
12
+ * state of its own beyond what those events tell it, so a panel that opens late
13
+ * and is handed the whole history catches up by replaying it.
14
+ *
15
+ * The design brief, in one line: this thing is pinned against a person's work
16
+ * all day, so it must be pleasant to have open and never once demand attention
17
+ * it has not earned. That gives four rules the whole layout follows.
18
+ *
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.
26
+ *
27
+ * 2. Nothing is said twice, and detail is earned. At rest every check is one
28
+ * quiet line — a dot, a name, a time. The description, the outcome, the
29
+ * claim that failed and the story of why a guard exists all live one click
30
+ * away, and open by themselves only for the checks that need a person. A
31
+ * run where everything held is therefore almost silent, which is exactly
32
+ * how often it deserves to be read.
33
+ *
34
+ * 3. Colour is state and nothing else. One accent (blue: something is
35
+ * happening, or someone is needed), one green (held), one amber (moved),
36
+ * one red (broke). The timing breakdown, which is information rather than
37
+ * state, is drawn in shades of the text colour — so a screenful of colour
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.
45
+ */
46
+
47
+ import { escapeHtml } from '../report/html.js';
48
+
49
+ /**
50
+ * One line in the list: a screen or a guard.
51
+ * @typedef {object} PanelRow
52
+ * @property {string} name
53
+ * @property {string} [describe]
54
+ */
55
+
56
+ /**
57
+ * What the panel is told before the run starts.
58
+ * @typedef {object} PanelPlan
59
+ * @property {string} [project] The project folder's name.
60
+ * @property {string} [app] What is being checked, in plain words.
61
+ * @property {PanelRow[]} [screens] The screens, in the order they will be photographed.
62
+ * @property {PanelRow[]} [guards] The guards, in the order they will run.
63
+ * @property {'dark'|'light'|'system'} [theme] Default 'dark'. Stated, not sniffed — a fresh
64
+ * browser profile always claims the computer is in light mode.
65
+ */
66
+
67
+ /**
68
+ * JSON safe to sit inside a script tag. A closing tag inside a string would end
69
+ * the tag early and leave half the plan on the page as text, so the one
70
+ * character that can do that never survives.
71
+ * @param {unknown} value
72
+ * @returns {string}
73
+ */
74
+ function embedJson(value) {
75
+ return JSON.stringify(value ?? null)
76
+ .replace(/</g, '\\u003c');
77
+ }
78
+
79
+ /**
80
+ * Keep only what the page uses. A screen config carries steps, masks and
81
+ * tolerances; none of that belongs in a window that is only drawing a list.
82
+ * A caller who has counted rather than listed hands us a number, which is not
83
+ * a list and is not an error either — it becomes an empty list here, and the
84
+ * counts arrive separately on `run:start`.
85
+ * @param {PanelRow[]|number|undefined} list
86
+ * @returns {{name: string, describe: string}[]}
87
+ */
88
+ function tidyRows(list) {
89
+ if (!Array.isArray(list)) return [];
90
+ /** @type {{name: string, describe: string}[]} */
91
+ const out = [];
92
+ for (const item of list) {
93
+ if (!item || typeof item !== 'object') continue;
94
+ const name = String(/** @type {any} */ (item).name ?? '').trim();
95
+ if (!name) continue;
96
+ const describe = String(/** @type {any} */ (item).describe ?? '').trim();
97
+ out.push({ name, describe });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * The mark. A padlock with a tick inside it: the whole product in one shape —
104
+ * the thing that was already fixed is still shut. Monoline, drawn on the 24
105
+ * grid, inheriting its colour so it can never fight the theme.
106
+ *
107
+ * It carries no namespace attribute: inline SVG in an HTML document does not
108
+ * need one, and the panel is not allowed to name an address of any kind.
109
+ */
110
+ const MARK = [
111
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
112
+ ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
113
+ '<rect x="4.2" y="10" width="15.6" height="10.4" rx="3.6"></rect>',
114
+ '<path d="M8.1 10V7.9a3.9 3.9 0 0 1 7.8 0V10"></path>',
115
+ '<path d="M9.7 15.2l1.8 1.9 2.9-3.5"></path>',
116
+ '</svg>',
117
+ ].join('');
118
+
119
+ /** A chevron, used for every "there is more underneath this" control. */
120
+ const CHEVRON = [
121
+ '<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
122
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
123
+ '<path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path>',
124
+ '</svg>',
125
+ ].join('');
126
+
127
+ /** Two stacked sheets: copy. */
128
+ const COPY = [
129
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
130
+ ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
131
+ '<rect x="9" y="9" width="11" height="11" rx="3"></rect>',
132
+ '<path d="M15 6.2A2.2 2.2 0 0 0 12.8 4H7a3 3 0 0 0-3 3v5.8A2.2 2.2 0 0 0 6.2 15"></path>',
133
+ '</svg>',
134
+ ].join('');
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
+
144
+ /**
145
+ * The whole panel document.
146
+ * @param {PanelPlan} [plan]
147
+ * @returns {string}
148
+ */
149
+ export function panelHtml(plan = {}) {
150
+ const project = String(plan.project ?? '').trim() || 'this project';
151
+ const app = String(plan.app ?? '').trim();
152
+ const screens = tidyRows(/** @type {any} */ (plan.screens));
153
+ const guards = tidyRows(/** @type {any} */ (plan.guards));
154
+
155
+ // Dark unless somebody asks otherwise — see the note above the light palette.
156
+ const wanted = String(plan.theme ?? 'dark');
157
+ const themeAttr = wanted === 'light' || wanted === 'system' ? wanted : 'dark';
158
+
159
+ const embedded = embedJson({ project, app, screens, guards });
160
+
161
+ return [
162
+ '<!doctype html>',
163
+ `<html lang="en" data-theme="${themeAttr}">`,
164
+ '<head>',
165
+ '<meta charset="utf-8">',
166
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
167
+ // Just the name of the thing, because this string IS the window's title bar.
168
+ // "tdproof — Stays Fixed" reads as a browser tab; "Stays Fixed" reads as an
169
+ // application. What is being watched is said inside the panel, under the mark.
170
+ '<title>Stays Fixed</title>',
171
+ `<style>${STYLE}</style>`,
172
+ '</head>',
173
+ '<body>',
174
+ '<div class="aura" aria-hidden="true"></div>',
175
+ '<div class="panel">',
176
+
177
+ // --- the header: whose window this is, and where the run has got to -----
178
+ '<header class="top">',
179
+ '<div class="brand">',
180
+ `<span class="badge">${MARK}</span>`,
181
+ '<span class="wordmark">Stays Fixed</span>',
182
+ '<span class="elapsed mono" id="clock">0.0s</span>',
183
+ '</div>',
184
+ `<p class="target" id="target"><span class="mono" id="project">${escapeHtml(project)}</span>`,
185
+ `<span class="sep"${app ? '' : ' hidden'} id="targetsep">&#183;</span>`,
186
+ `<span class="app" id="app"${app ? '' : ' hidden'}>${escapeHtml(app)}</span></p>`,
187
+ '<p class="state" id="state">getting ready</p>',
188
+ '<p class="note" id="note" hidden></p>',
189
+ '<div class="meter">',
190
+ '<div class="track" id="track"><div class="fill" id="fill"></div></div>',
191
+ '<span class="counts mono" id="counts"></span>',
192
+ '</div>',
193
+ '</header>',
194
+
195
+ // --- the two columns. One on a narrow panel, two on a wide one. ---------
196
+ '<div class="body">',
197
+
198
+ // --- the hero: the picture that was just taken -------------------------
199
+ '<section class="stage" id="stage">',
200
+ '<div class="shot" id="shot">',
201
+ '<img class="layer" id="layerA" alt="">',
202
+ '<img class="layer" id="layerB" alt="">',
203
+ '<span class="sweep" aria-hidden="true"></span>',
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>',
206
+ '</div>',
207
+ '<div class="caption" id="caption">',
208
+ '<span class="shotname mono" id="shotname"></span>',
209
+ '<span class="shotout" id="shotout" hidden></span>',
210
+ '</div>',
211
+ '<div class="switch" id="tabs" hidden></div>',
212
+ '</section>',
213
+
214
+ // --- every check, one quiet line each ----------------------------------
215
+ '<div class="scroll" id="scroll">',
216
+ '<section class="group" id="groupScreens" hidden>',
217
+ '<p class="grouplabel">Screens<span class="mono" id="countScreens"></span></p>',
218
+ '<div class="items" id="listScreens"></div>',
219
+ '</section>',
220
+ '<section class="group" id="groupGuards" hidden>',
221
+ '<p class="grouplabel">Guards<span class="mono" id="countGuards"></span></p>',
222
+ '<div class="items" id="listGuards"></div>',
223
+ '</section>',
224
+ '<p class="nothing" id="nothing" hidden>Nothing is set up to be checked yet.<br>Run <span class="mono">staysfixed init</span> to pick the screens worth watching.</p>',
225
+ '</div>',
226
+
227
+ '</div>',
228
+
229
+ '<button class="follow" id="follow" type="button" hidden>Follow the run</button>',
230
+
231
+ // --- where the time went. One folded line, so it is always in sight and
232
+ // never in the way. ------------------------------------------------
233
+ '<section class="timing" id="timing" hidden>',
234
+ '<button class="tophead" id="thead" type="button" aria-expanded="false">',
235
+ '<span class="tlabel">Where the time went</span>',
236
+ '<span class="ttotal" id="ttotal"></span>',
237
+ `${CHEVRON}`,
238
+ '</button>',
239
+ '<div class="tbar" id="tbar"></div>',
240
+ '<ul class="tkey" id="tkey" hidden></ul>',
241
+ '</section>',
242
+
243
+ // --- the one thing left to do ------------------------------------------
244
+ '<footer class="foot" id="footer" hidden>',
245
+ '<p class="nextlabel" id="nextlabel">Your turn</p>',
246
+ '<div class="cmd">',
247
+ // The prompt mark is not decoration: it is what tells a person at a glance that
248
+ // this line is something to type, not something to read. It is never copied.
249
+ '<span class="prompt mono" aria-hidden="true">$</span>',
250
+ '<code class="mono" id="cmd"></code>',
251
+ `<button class="copy" id="copy" type="button" title="copy" aria-label="copy the command">${COPY}</button>`,
252
+ '</div>',
253
+ '</footer>',
254
+
255
+ '</div>',
256
+
257
+ // --- the viewer: the real picture, as big as the window goes ------------
258
+ //
259
+ // This is where a person decides whether to approve something, so it is the
260
+ // most carefully built thing on the page: the full-resolution file, zoom to
261
+ // eight times with the actual pixels showing rather than a smoothed guess,
262
+ // drag to move around, and — for a screen that moved — the approved picture
263
+ // and the new one under one draggable line, plus the difference.
264
+ '<div class="viewer" id="viewer" hidden>',
265
+ '<header class="vtop">',
266
+ '<span class="vname mono" id="vname"></span>',
267
+ '<div class="vmodes" id="vmodes"></div>',
268
+ '<span class="vright">',
269
+ '<span class="vzoom mono" id="vzoom" title="double-click the picture to fit it again">1.0&#215;</span>',
270
+ `<button class="vclose" id="vclose" type="button" aria-label="close the picture">${CLOSE}</button>`,
271
+ '</span>',
272
+ '</header>',
273
+ '<div class="vstage" id="vstage">',
274
+ '<div class="vframe" id="vframe">',
275
+ '<img class="vlayer" id="vApproved" alt="the approved picture">',
276
+ '<img class="vlayer" id="vNow" alt="the picture just taken">',
277
+ '<img class="vlayer" id="vDiff" alt="the difference between them">',
278
+ '</div>',
279
+ '<div class="vwipe" id="vwipe" hidden><span class="vgrip" aria-hidden="true"></span></div>',
280
+ '</div>',
281
+ '<footer class="vfoot" id="vfoot" hidden>',
282
+ '<span class="vside">approved</span>',
283
+ '<input class="vblend" id="vblend" type="range" min="0" max="100" value="100" aria-label="fade between the approved picture and the new one">',
284
+ '<span class="vside">now</span>',
285
+ '</footer>',
286
+ '<p class="vhelp">scroll to zoom &#183; drag to move &#183; double-click to fit &#183; esc to close</p>',
287
+ '</div>',
288
+
289
+ `<script type="application/json" id="staysfixed-plan">${embedded}</script>`,
290
+ `<script>${SCRIPT}</script>`,
291
+ '</body>',
292
+ '</html>',
293
+ '',
294
+ ].join('\n');
295
+ }
296
+
297
+ const STYLE = `
298
+ /* --------------------------------------------------------------------------
299
+ Tokens.
300
+
301
+ Four colours carry meaning and nothing else does: accent blue for "this is
302
+ happening" and "you are needed", green for held, amber for moved, red for
303
+ broke. Everything structural is the ground, the ink, or a hairline. That is
304
+ what lets a single amber dot be seen from across a desk.
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
+
309
+ Dark is the design — this window lives beside an editor all day. Light is a
310
+ warm paper, never the white page with grey cards the brief rules out.
311
+ -------------------------------------------------------------------------- */
312
+ :root {
313
+ color-scheme: dark;
314
+
315
+ --ground: #0a0c12;
316
+ --lift: #12161f;
317
+ --card: rgba(255, 255, 255, 0.03);
318
+ --card-hover: rgba(255, 255, 255, 0.055);
319
+ --well: #070910;
320
+ --glass: rgba(10, 12, 18, 0.74);
321
+
322
+ --ink: #e7eaf2;
323
+ --soft: #939bad;
324
+ --faint: #616a7d;
325
+
326
+ --line: rgba(255, 255, 255, 0.06);
327
+ --line-firm: rgba(255, 255, 255, 0.13);
328
+ --sheen: rgba(255, 255, 255, 0.06);
329
+ --shadow: rgba(0, 0, 0, 0.66);
330
+
331
+ --accent: #6f9dff;
332
+ --held: #55bd8c;
333
+ --moved: #e2a84f;
334
+ --broke: #ef6a61;
335
+ --resting: rgba(255, 255, 255, 0.08);
336
+
337
+ --radius: 20px;
338
+ --radius-sm: 13px;
339
+ --radius-xs: 9px;
340
+
341
+ --pad: 18px;
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;
356
+ }
357
+ /*
358
+ * Light is opt-in, and that is deliberate.
359
+ *
360
+ * This window runs on a brand new browser profile, and a fresh profile answers
361
+ * "prefers-color-scheme" with "light" whatever the computer around it is set to. So the
362
+ * panel came up pale on a dark desktop, which is exactly the thing it was asked not to
363
+ * do. The look is therefore stated on the html element rather than sniffed: dark unless
364
+ * somebody asks for light, and 'system' for anyone who does want the browser's opinion.
365
+ */
366
+ :root[data-theme='light'],
367
+ :root[data-theme='system'] {
368
+ color-scheme: light;
369
+
370
+ --ground: #e3ded3;
371
+ --lift: #fdfbf7;
372
+ --card: rgba(255, 255, 255, 0.78);
373
+ --card-hover: rgba(255, 255, 255, 1);
374
+ --well: #dad4c8;
375
+ --glass: rgba(231, 226, 217, 0.82);
376
+
377
+ --ink: #1c1a16;
378
+ --soft: #6b665c;
379
+ --faint: #8b8578;
380
+
381
+ --line: rgba(28, 24, 18, 0.09);
382
+ --line-firm: rgba(28, 24, 18, 0.19);
383
+ --sheen: rgba(255, 255, 255, 0.85);
384
+ --shadow: rgba(58, 47, 30, 0.34);
385
+
386
+ --accent: #2f57c9;
387
+ --held: #1f7a4d;
388
+ --moved: #94620a;
389
+ --broke: #b52f23;
390
+ --resting: rgba(28, 24, 18, 0.1);
391
+ }
392
+
393
+ * { box-sizing: border-box; }
394
+ [hidden] { display: none !important; }
395
+ html, body { margin: 0; padding: 0; height: 100%; }
396
+ body {
397
+ background: var(--ground);
398
+ color: var(--ink);
399
+ font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
400
+ -webkit-font-smoothing: antialiased;
401
+ overflow: hidden;
402
+ }
403
+ /* The terminal character of the thing: every name, number and command is set
404
+ in the monospace, and nothing else is. */
405
+ .mono, code {
406
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
407
+ font-variant-numeric: tabular-nums;
408
+ }
409
+ p, h2 { margin: 0; }
410
+ img { display: block; }
411
+ button { font: inherit; color: inherit; }
412
+ .glyph { width: 16px; height: 16px; flex: 0 0 auto; }
413
+
414
+ /* A single soft light from above, tinted by how the run is going. It is the
415
+ only decoration on the page, it has no edge you could point at, and it is
416
+ the thing that stops the dark reading as a hole in the screen. */
417
+ .aura {
418
+ position: fixed; inset: 0; pointer-events: none; z-index: 0;
419
+ background:
420
+ radial-gradient(120% 46% at 50% -8%, color-mix(in srgb, var(--tint) 15%, transparent), transparent 70%),
421
+ radial-gradient(100% 60% at 50% 112%, var(--shadow), transparent 66%);
422
+ transition: background 620ms var(--ease);
423
+ }
424
+
425
+ .panel {
426
+ position: relative; z-index: 1;
427
+ display: flex; flex-direction: column;
428
+ height: 100%;
429
+ }
430
+
431
+ /* --- header -------------------------------------------------------------- */
432
+ .top {
433
+ flex: 0 0 auto;
434
+ padding: 16px var(--pad) 15px;
435
+ background: var(--glass);
436
+ backdrop-filter: blur(22px) saturate(150%);
437
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
438
+ }
439
+ .brand { display: flex; align-items: center; gap: 9px; }
440
+ .badge {
441
+ flex: 0 0 auto;
442
+ display: flex; align-items: center; justify-content: center;
443
+ width: 24px; height: 24px;
444
+ border-radius: 8px;
445
+ color: var(--accent);
446
+ background: color-mix(in srgb, var(--accent) 13%, transparent);
447
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
448
+ }
449
+ .badge .glyph { width: 15px; height: 15px; }
450
+ .wordmark {
451
+ flex: 1 1 auto; min-width: 0;
452
+ font-size: var(--t-label); font-weight: 600;
453
+ letter-spacing: 0.2em; text-transform: uppercase;
454
+ color: var(--soft);
455
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
456
+ }
457
+ .elapsed { flex: 0 0 auto; font-size: var(--t-body); color: var(--faint); }
458
+
459
+ .target {
460
+ display: flex; align-items: baseline; gap: 6px;
461
+ margin-top: 13px;
462
+ font-size: var(--t-name);
463
+ white-space: nowrap; overflow: hidden;
464
+ }
465
+ .target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
466
+ .target .sep { color: var(--faint); flex: 0 0 auto; }
467
+ .target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: var(--t-body); }
468
+
469
+ /* The sentence. The largest text on the page after the picture, because it is
470
+ the one thing a person reads from four feet away. */
471
+ .state {
472
+ margin-top: 9px;
473
+ font-size: var(--t-lead); font-weight: 620; line-height: 1.32;
474
+ letter-spacing: -0.011em;
475
+ overflow-wrap: anywhere;
476
+ transition: color var(--slow) var(--ease);
477
+ }
478
+ .state.held { color: var(--ink); }
479
+ .state.moved { color: var(--moved); }
480
+ .state.broke { color: var(--broke); }
481
+ .state.wait { color: var(--accent); }
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; }
491
+
492
+ /* One hairline, not a row of blocks. Each finished check adds its own slice of
493
+ colour to a single continuous line, so progress and outcome are the same
494
+ object and there is one fewer thing on the page. */
495
+ .meter { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
496
+ .track {
497
+ flex: 1 1 auto; min-width: 0;
498
+ height: 4px; border-radius: 999px;
499
+ background: var(--resting);
500
+ overflow: hidden;
501
+ }
502
+ .fill { display: flex; height: 100%; width: 100%; }
503
+ .slice {
504
+ min-width: 0; height: 100%;
505
+ background: var(--resting);
506
+ transition: background var(--slow) var(--ease), flex-basis var(--slow) var(--ease);
507
+ }
508
+ .slice.held { background: var(--held); }
509
+ .slice.moved { background: var(--moved); }
510
+ .slice.broke { background: var(--broke); }
511
+ .slice.wait { background: var(--accent); }
512
+ .slice.skip { background: color-mix(in srgb, var(--ink) 16%, transparent); }
513
+ .slice.running {
514
+ background: var(--accent);
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;
525
+ }
526
+
527
+ /* --- the body: one column, or two when there is room --------------------- */
528
+ .body {
529
+ flex: 1 1 auto; min-height: 0;
530
+ display: flex; flex-direction: column;
531
+ gap: 4px;
532
+ padding: 0 var(--pad);
533
+ overflow: hidden;
534
+ }
535
+
536
+ /* --- the hero ------------------------------------------------------------ */
537
+ .stage { flex: 0 0 auto; padding-top: 16px; }
538
+ .shot {
539
+ position: relative;
540
+ aspect-ratio: 16 / 10;
541
+ max-height: 40vh;
542
+ margin: 0 auto;
543
+ border-radius: var(--radius);
544
+ background: var(--well);
545
+ overflow: hidden;
546
+ display: flex; align-items: center; justify-content: center;
547
+ cursor: zoom-in;
548
+ /* No border. The picture is the hero, so nothing is drawn around it that
549
+ could compete with it — only a soft floor shadow and the faintest rim to
550
+ keep a white screenshot from bleeding into a light ground. */
551
+ box-shadow:
552
+ 0 0 0 1px var(--line),
553
+ 0 26px 50px -30px var(--shadow);
554
+ transition: box-shadow var(--slow) var(--ease);
555
+ }
556
+ .shot.empty { cursor: default; }
557
+ .layer {
558
+ position: absolute; inset: 0;
559
+ width: 100%; height: 100%;
560
+ object-fit: contain;
561
+ opacity: 0;
562
+ transform: scale(1.014);
563
+ transition: opacity var(--slow) var(--ease), transform 480ms var(--ease);
564
+ }
565
+ .layer.on { opacity: 1; transform: none; }
566
+ .layer.out { opacity: 0; transform: scale(0.996); }
567
+ .blank {
568
+ position: relative;
569
+ color: var(--faint); font-size: var(--t-body); line-height: 1.6;
570
+ text-align: center; padding: 0 30px; max-width: 260px;
571
+ }
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
+
617
+ .caption {
618
+ display: flex; align-items: baseline; gap: 10px;
619
+ margin-top: 12px; min-height: 18px;
620
+ }
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; }
623
+ .shotout.moved { color: var(--moved); }
624
+ .shotout.broke { color: var(--broke); }
625
+ .shotout.wait { color: var(--accent); }
626
+
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; }
631
+ .switch button {
632
+ position: relative;
633
+ flex: 1 1 0; min-width: 0;
634
+ font-size: var(--t-body); letter-spacing: 0.01em;
635
+ color: var(--faint);
636
+ background: transparent; border: 0;
637
+ padding: 6px 4px 7px; cursor: pointer;
638
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
639
+ transition: color var(--quick) var(--ease);
640
+ }
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;
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; }
650
+
651
+ /* --- the list ------------------------------------------------------------ */
652
+ .scroll {
653
+ flex: 1 1 auto; min-height: 70px;
654
+ overflow-y: auto;
655
+ overscroll-behavior: contain;
656
+ scrollbar-width: thin;
657
+ scrollbar-color: color-mix(in srgb, var(--soft) 22%, transparent) transparent;
658
+ padding: 18px 5px 14px 0;
659
+ /* The list slides away under the header rather than being cut off by a box. */
660
+ mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
661
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
662
+ }
663
+ .scroll::-webkit-scrollbar { width: 10px; }
664
+ .scroll::-webkit-scrollbar-thumb {
665
+ background: color-mix(in srgb, var(--soft) 22%, transparent);
666
+ border-radius: 8px; border: 3px solid transparent; background-clip: padding-box;
667
+ }
668
+ .scroll::-webkit-scrollbar-track { background: transparent; }
669
+
670
+ .group + .group { margin-top: 20px; }
671
+ .grouplabel {
672
+ display: flex; align-items: baseline; gap: 9px;
673
+ padding: 0 6px 9px 4px;
674
+ font-size: var(--t-label); font-weight: 600;
675
+ letter-spacing: 0.16em; text-transform: uppercase;
676
+ color: var(--faint);
677
+ }
678
+ .grouplabel .mono { font-size: var(--t-label); letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
679
+
680
+ .items {
681
+ border-radius: var(--radius);
682
+ background: var(--card);
683
+ overflow: hidden;
684
+ }
685
+ .item + .item { box-shadow: inset 0 1px 0 var(--line); }
686
+ .item {
687
+ transition: background var(--calm) var(--ease);
688
+ }
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
+ }
698
+
699
+ /* Big, quiet, touch-sized rows. At rest a check is a dot, a name and a time —
700
+ that is the whole of it. */
701
+ .row {
702
+ display: flex; align-items: center; gap: 11px;
703
+ width: 100%; min-height: 44px;
704
+ padding: 10px 14px;
705
+ text-align: left;
706
+ background: transparent; border: 0;
707
+ cursor: pointer;
708
+ transition: background var(--quick) var(--ease);
709
+ }
710
+ .row:hover { background: var(--card-hover); }
711
+ .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
712
+ .item.plain .row { cursor: default; }
713
+ .item.showing .row { box-shadow: inset 2px 0 0 var(--line-firm); }
714
+ .item.showing .rname { color: var(--ink); }
715
+ .item.plain .row:hover { background: transparent; }
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
+ }
723
+
724
+ .dot {
725
+ flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
726
+ background: var(--resting);
727
+ transition: background var(--calm) var(--ease), box-shadow var(--calm) var(--ease);
728
+ }
729
+ .item.held .dot { background: color-mix(in srgb, var(--held) 78%, transparent); }
730
+ .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 15%, transparent); }
731
+ .item.broke .dot { background: var(--broke); box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 15%, transparent); }
732
+ .item.wait .dot { background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); }
733
+ .item.skip .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting); }
734
+ .item.running .dot {
735
+ background: transparent;
736
+ box-shadow: inset 0 0 0 2px var(--accent);
737
+ animation: ping 1.8s var(--ease) infinite;
738
+ }
739
+ @keyframes ping {
740
+ 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 42%, transparent); }
741
+ 70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
742
+ }
743
+ .item.running .rname { color: var(--ink); }
744
+
745
+ .rname {
746
+ flex: 1 1 auto; min-width: 0;
747
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
748
+ font-variant-numeric: tabular-nums;
749
+ font-size: var(--t-name); color: var(--ink);
750
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
751
+ }
752
+ .item.pending .rname, .item.skip .rname { color: var(--faint); }
753
+ .rtime {
754
+ flex: 0 0 auto;
755
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
756
+ font-variant-numeric: tabular-nums;
757
+ font-size: var(--t-meta); color: var(--faint);
758
+ }
759
+ .row .chev {
760
+ flex: 0 0 auto; width: 14px; height: 14px;
761
+ color: var(--faint); opacity: 0;
762
+ transition: transform var(--calm) var(--ease), opacity var(--quick) var(--ease);
763
+ }
764
+ .row:hover .chev, .item.open .chev { opacity: 1; }
765
+ .item.open .chev { transform: rotate(180deg); }
766
+ .item.plain .chev { display: none; }
767
+
768
+ /* Everything a person did not ask for lives here. */
769
+ .detail {
770
+ padding: 0 14px 13px 33px;
771
+ font-size: var(--t-body); line-height: 1.6;
772
+ overflow-wrap: anywhere;
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; } }
776
+ .detail .why { color: var(--soft); }
777
+ .detail .out { color: var(--soft); margin-top: 3px; }
778
+ .detail .out.moved { color: var(--moved); }
779
+ .detail .out.broke { color: var(--broke); }
780
+ .detail .out.wait { color: var(--accent); }
781
+ .detail .claim {
782
+ margin-top: 9px; padding: 9px 12px;
783
+ border-radius: var(--radius-xs);
784
+ background: var(--well);
785
+ box-shadow: inset 2px 0 0 var(--broke);
786
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
787
+ font-size: var(--t-body); color: var(--ink);
788
+ }
789
+ .detail .claim em { font-style: normal; color: var(--faint); }
790
+ .detail .story { margin-top: 7px; color: var(--faint); }
791
+
792
+ .nothing { padding: 26px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.7; text-align: center; }
793
+ .nothing .mono { color: var(--soft); }
794
+
795
+ /* --- where the time went ------------------------------------------------- */
796
+ .timing {
797
+ flex: 0 0 auto;
798
+ padding: 12px var(--pad) 13px;
799
+ border-top: 1px solid var(--line);
800
+ background: var(--glass);
801
+ backdrop-filter: blur(22px) saturate(150%);
802
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
803
+ }
804
+ .tophead {
805
+ display: flex; align-items: center; gap: 8px;
806
+ width: 100%; padding: 0 0 8px;
807
+ background: transparent; border: 0; cursor: pointer;
808
+ text-align: left;
809
+ }
810
+ .tlabel {
811
+ flex: 1 1 auto;
812
+ font-size: var(--t-label); font-weight: 600;
813
+ letter-spacing: 0.16em; text-transform: uppercase;
814
+ color: var(--faint);
815
+ }
816
+ .ttotal {
817
+ flex: 0 1 auto; min-width: 0;
818
+ font-size: var(--t-meta); color: var(--faint);
819
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
820
+ }
821
+ .ttotal b {
822
+ font-weight: 500; color: var(--soft);
823
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
824
+ font-variant-numeric: tabular-nums;
825
+ }
826
+ .tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform var(--calm) var(--ease); }
827
+ .tophead[aria-expanded='true'] .chev { transform: rotate(180deg); }
828
+ .tophead:hover .tlabel, .tophead:hover .ttotal, .tophead:hover .chev { color: var(--soft); }
829
+
830
+ /* Deliberately not coloured. Where the time went is information, not state,
831
+ and colour on this page only ever means something needs a person. */
832
+ .tbar { display: flex; gap: 1px; height: 6px; border-radius: 999px; overflow: hidden; background: var(--resting); }
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); }
836
+ .tkey .tswatch { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 2px; }
837
+ .tkey .tlabelled { flex: 1 1 auto; min-width: 0; }
838
+ .tkey b {
839
+ flex: 0 0 auto; font-weight: 500; color: var(--ink); font-size: var(--t-body);
840
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
841
+ font-variant-numeric: tabular-nums;
842
+ }
843
+
844
+ /* --- follow ------------------------------------------------------------- */
845
+ .follow {
846
+ position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
847
+ z-index: 4;
848
+ font-size: var(--t-body);
849
+ color: var(--ink);
850
+ background: var(--lift);
851
+ border: 1px solid var(--line-firm); border-radius: 999px;
852
+ padding: 6px 15px; cursor: pointer;
853
+ box-shadow: 0 12px 24px -14px var(--shadow);
854
+ animation: settle 240ms var(--ease) both;
855
+ transition: border-color var(--quick) var(--ease), color var(--quick) var(--ease);
856
+ }
857
+ .follow:hover { border-color: var(--accent); color: var(--accent); }
858
+
859
+ /* --- the one thing left to do ------------------------------------------- */
860
+ .foot {
861
+ flex: 0 0 auto;
862
+ padding: 14px var(--pad) 16px;
863
+ background: var(--glass);
864
+ backdrop-filter: blur(22px) saturate(150%);
865
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
866
+ border-top: 1px solid var(--line);
867
+ animation: arrive 340ms var(--ease) both;
868
+ }
869
+ .nextlabel {
870
+ font-size: var(--t-label); font-weight: 600;
871
+ letter-spacing: 0.16em; text-transform: uppercase;
872
+ color: var(--faint);
873
+ margin-bottom: 8px;
874
+ }
875
+ .cmd { display: flex; align-items: stretch; gap: 7px; }
876
+ .cmd code {
877
+ flex: 1 1 auto; min-width: 0;
878
+ display: flex; align-items: center;
879
+ background: var(--well);
880
+ border-radius: var(--radius-xs);
881
+ padding: 9px 11px; font-size: var(--t-body);
882
+ color: var(--ink);
883
+ overflow-wrap: anywhere;
884
+ user-select: all;
885
+ }
886
+ .prompt { flex: 0 0 auto; color: var(--held); opacity: 0.75; user-select: none; }
887
+ .copy {
888
+ flex: 0 0 auto; width: 36px;
889
+ display: flex; align-items: center; justify-content: center;
890
+ color: var(--soft); background: var(--well);
891
+ border: 0; border-radius: var(--radius-xs);
892
+ cursor: pointer;
893
+ transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
894
+ }
895
+ .copy:hover { color: var(--ink); background: var(--card-hover); }
896
+ .copy.done { color: var(--held); }
897
+
898
+ /* --------------------------------------------------------------------------
899
+ The viewer.
900
+
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;
992
+ display: flex; align-items: center; justify-content: center;
993
+ cursor: ew-resize;
994
+ touch-action: none;
995
+ }
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);
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); }
1038
+
1039
+ /* --- the range it has to survive ---------------------------------------- */
1040
+
1041
+ /* Dragged narrow. Everything stays, nothing wraps into a mess. */
1042
+ @media (max-width: 330px) {
1043
+ :root { --pad: 12px; --radius: 16px; --t-lead: 15px; }
1044
+ .target .app, .target .sep { display: none; }
1045
+ .row { gap: 9px; padding: 9px 11px; }
1046
+ .detail { padding: 0 11px 12px 28px; }
1047
+ .shot { max-height: 30vh; }
1048
+ .switch button { font-size: var(--t-meta); padding: 6px 2px 7px; }
1049
+ .ttotal { display: none; }
1050
+ .caption { display: block; }
1051
+ .shotname { display: block; white-space: normal; overflow-wrap: anywhere; }
1052
+ .shotout { display: block; text-align: left; margin-top: 2px; }
1053
+ .timing { padding: 10px var(--pad) 11px; }
1054
+ .vname { display: none; }
1055
+ }
1056
+
1057
+ /* Pulled wide. Two columns rather than one very long thin one, so the picture
1058
+ gets the room and the list stays beside it instead of below the fold. */
1059
+ @media (min-width: 720px) {
1060
+ :root { --pad: 24px; --t-lead: 19px; }
1061
+ .body { flex-direction: row; gap: 28px; padding-bottom: 4px; }
1062
+ .stage { flex: 1 1 54%; min-width: 0; align-self: center; padding-top: 0; }
1063
+ .scroll { flex: 1 1 46%; min-width: 0; padding-top: 22px; }
1064
+ .shot { max-height: 58vh; }
1065
+ .timing { margin-top: 22px; }
1066
+ }
1067
+
1068
+ @media (prefers-reduced-motion: reduce) {
1069
+ * { animation: none !important; transition: none !important; }
1070
+ }
1071
+ `;
1072
+
1073
+ const SCRIPT = `
1074
+ (function () {
1075
+ 'use strict';
1076
+
1077
+ // This page only ever runs in the browser window Stays Fixed just opened, so
1078
+ // there is no compatibility question to answer and nothing to load.
1079
+
1080
+ var plan = { project: '', app: '', screens: [], guards: [] };
1081
+ try {
1082
+ var blob = document.getElementById('staysfixed-plan');
1083
+ if (blob && blob.textContent) plan = JSON.parse(blob.textContent) || plan;
1084
+ } catch (err) {
1085
+ // A plan we cannot read costs the opening list, not the panel. Rows will
1086
+ // still appear one by one as the run reaches them.
1087
+ }
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
+
1100
+ function el(id) { return document.getElementById(id); }
1101
+
1102
+ var ui = {
1103
+ clock: el('clock'), project: el('project'), app: el('app'), targetsep: el('targetsep'),
1104
+ state: el('state'), note: el('note'),
1105
+ track: el('track'), fill: el('fill'), counts: el('counts'),
1106
+ stage: el('stage'), shot: el('shot'), layerA: el('layerA'), layerB: el('layerB'), blank: el('blank'),
1107
+ caption: el('caption'), shotname: el('shotname'), shotout: el('shotout'), tabs: el('tabs'),
1108
+ scroll: el('scroll'), follow: el('follow'), nothing: el('nothing'),
1109
+ groupScreens: el('groupScreens'), listScreens: el('listScreens'), countScreens: el('countScreens'),
1110
+ groupGuards: el('groupGuards'), listGuards: el('listGuards'), countGuards: el('countGuards'),
1111
+ timing: el('timing'), thead: el('thead'), ttotal: el('ttotal'), tbar: el('tbar'), tkey: el('tkey'),
1112
+ footer: el('footer'), cmd: el('cmd'), copy: el('copy'),
1113
+ aura: document.querySelector('.aura'),
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')
1118
+ };
1119
+
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>';
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
+
1128
+ // -------------------------------------------------------------------------
1129
+ // Words. These match the terminal on purpose — the same run should not be
1130
+ // described two different ways depending on where you happen to be reading.
1131
+ // The originals live in src/report/console.js.
1132
+ // -------------------------------------------------------------------------
1133
+
1134
+ function commas(n) {
1135
+ var v = Math.round(Number(n) || 0);
1136
+ return v.toLocaleString('en-US');
1137
+ }
1138
+
1139
+ function fmt(ms) {
1140
+ var v = Number(ms);
1141
+ if (!isFinite(v) || v < 0) v = 0;
1142
+ if (v < 1000) return Math.round(v) + 'ms';
1143
+ if (v < 60000) return (v / 1000).toFixed(1) + 's';
1144
+ var m = Math.floor(v / 60000);
1145
+ var s = Math.round((v % 60000) / 1000);
1146
+ return m + 'm ' + s + 's';
1147
+ }
1148
+
1149
+ function plural(n, one, many) { return n === 1 ? one : many; }
1150
+
1151
+ // The whole colour vocabulary, in one place: green held, amber moved, red
1152
+ // broke, blue is waiting for a person to look, and a check nobody ran has no
1153
+ // colour at all.
1154
+ function toneOf(status) {
1155
+ switch (status) {
1156
+ case 'running': return 'running';
1157
+ case 'passed': return 'held';
1158
+ case 'changed': case 'flaky': return 'moved';
1159
+ case 'failed': case 'missing': return 'broke';
1160
+ case 'new': return 'wait';
1161
+ case 'skipped': return 'skip';
1162
+ default: return '';
1163
+ }
1164
+ }
1165
+
1166
+ // A tone that means "somebody has to look at this". These are the rows that
1167
+ // open themselves; everything else waits to be asked.
1168
+ function needsPerson(tone) { return tone === 'moved' || tone === 'broke' || tone === 'wait'; }
1169
+
1170
+ // The terse form, for the line beside the picture. The colour and the compare
1171
+ // control have already said most of it there.
1172
+ function outcomeShort(ev) {
1173
+ switch (ev.status) {
1174
+ case 'changed':
1175
+ var n = ev.diffPixels || 0;
1176
+ return commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' moved';
1177
+ case 'new': return 'not approved yet';
1178
+ case 'missing': return 'no approved picture';
1179
+ case 'failed': return ev.message || 'could not be photographed';
1180
+ case 'flaky': return 'changed between tries';
1181
+ default: return '';
1182
+ }
1183
+ }
1184
+
1185
+ function outcomeText(kind, ev) {
1186
+ var status = ev.status;
1187
+ if (kind === 'guard') {
1188
+ if (status === 'passed') return 'still holds';
1189
+ if (status === 'skipped') return 'left out on purpose';
1190
+ return ev.message || 'this one is broken again';
1191
+ }
1192
+ switch (status) {
1193
+ case 'passed': return 'still the same';
1194
+ case 'changed':
1195
+ var n = ev.diffPixels || 0;
1196
+ return 'looks different — ' + commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' changed';
1197
+ case 'new': return 'nobody has approved this picture yet';
1198
+ case 'missing': return 'the approved picture is gone';
1199
+ case 'failed': return ev.message || 'could not be photographed';
1200
+ case 'flaky': return 'changed its mind between tries';
1201
+ case 'skipped': return 'left out on purpose';
1202
+ default: return ev.message || '';
1203
+ }
1204
+ }
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
+
1279
+ // -------------------------------------------------------------------------
1280
+ // The list. One line per check at rest; everything else on request.
1281
+ // -------------------------------------------------------------------------
1282
+
1283
+ var items = Object.create(null);
1284
+ var order = [];
1285
+ var outcomes = Object.create(null);
1286
+ var expected = { picture: 0, guard: 0 };
1287
+ var seeding = false;
1288
+ var TONES = ['held', 'moved', 'broke', 'wait', 'skip', 'running', 'pending'];
1289
+
1290
+ function keyFor(kind, name) { return kind + ':' + name; }
1291
+
1292
+ function addItem(kind, name, describe) {
1293
+ var root = document.createElement('div');
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);
1304
+
1305
+ var row = document.createElement('button');
1306
+ row.type = 'button';
1307
+ row.className = 'row';
1308
+ row.setAttribute('aria-expanded', 'false');
1309
+
1310
+ var dot = document.createElement('span');
1311
+ dot.className = 'dot';
1312
+ var rname = document.createElement('span');
1313
+ rname.className = 'rname';
1314
+ rname.textContent = name;
1315
+ var rtime = document.createElement('span');
1316
+ rtime.className = 'rtime';
1317
+ row.appendChild(dot);
1318
+ row.appendChild(rname);
1319
+ row.appendChild(rtime);
1320
+ row.insertAdjacentHTML('beforeend', CHEVRON);
1321
+
1322
+ var detail = document.createElement('div');
1323
+ detail.className = 'detail';
1324
+ detail.hidden = true;
1325
+
1326
+ root.appendChild(row);
1327
+ root.appendChild(detail);
1328
+
1329
+ var entry = {
1330
+ kind: kind, name: name, root: root, row: row, time: rtime,
1331
+ detail: detail, describe: describe || '', open: false, hasDetail: false
1332
+ };
1333
+
1334
+ // One rule, so a click is never a surprise: the first click brings this
1335
+ // check forward — its picture on the glass, its detail open. The second
1336
+ // click, on the row that is already forward, folds it away again. That is
1337
+ // what makes the whole run browsable afterwards instead of only the last
1338
+ // screen photographed.
1339
+ row.addEventListener('click', function () {
1340
+ var forward = entry.pics && !entry.root.classList.contains('showing');
1341
+ if (forward) {
1342
+ recall(entry);
1343
+ setOpen(entry, true);
1344
+ return;
1345
+ }
1346
+ toggle(entry);
1347
+ });
1348
+ redraw(entry);
1349
+
1350
+ (kind === 'guard' ? ui.listGuards : ui.listScreens).appendChild(root);
1351
+ (kind === 'guard' ? ui.groupGuards : ui.groupScreens).hidden = false;
1352
+
1353
+ items[keyFor(kind, name)] = entry;
1354
+ order.push(entry);
1355
+ return entry;
1356
+ }
1357
+
1358
+ function ensureItem(kind, name, describe) {
1359
+ var entry = items[keyFor(kind, name)];
1360
+ if (!entry) return addItem(kind, name, describe);
1361
+ if (describe && !entry.describe) { entry.describe = describe; redraw(entry); }
1362
+ return entry;
1363
+ }
1364
+
1365
+ /**
1366
+ * Rebuild what is hidden under a row. Written out every time rather than
1367
+ * patched, because the same row is drawn twice at most and a rebuilt block
1368
+ * can never disagree with the event that caused it.
1369
+ */
1370
+ function redraw(entry) {
1371
+ var d = entry.detail;
1372
+ d.textContent = '';
1373
+
1374
+ if (entry.describe) {
1375
+ var why = document.createElement('div');
1376
+ why.className = 'why';
1377
+ why.textContent = entry.describe;
1378
+ d.appendChild(why);
1379
+ }
1380
+ // "Still the same" is not worth a line of anybody's attention, so a check
1381
+ // that held says only what it is for.
1382
+ if (entry.outText && entry.tone !== 'held') {
1383
+ var out = document.createElement('div');
1384
+ out.className = 'out' + (entry.tone ? ' ' + entry.tone : '');
1385
+ out.textContent = entry.outText;
1386
+ d.appendChild(out);
1387
+ }
1388
+ if (entry.failedAt) {
1389
+ var claim = document.createElement('div');
1390
+ claim.className = 'claim';
1391
+ var label = document.createElement('em');
1392
+ label.textContent = 'expected ';
1393
+ claim.appendChild(label);
1394
+ claim.appendChild(document.createTextNode(entry.failedAt));
1395
+ d.appendChild(claim);
1396
+ }
1397
+ if (entry.story && entry.story !== entry.describe) {
1398
+ var story = document.createElement('div');
1399
+ story.className = 'story';
1400
+ story.textContent = 'Why this guard exists: ' + entry.story;
1401
+ d.appendChild(story);
1402
+ }
1403
+
1404
+ entry.hasDetail = !!d.firstChild;
1405
+ entry.root.classList.toggle('plain', !entry.hasDetail);
1406
+ if (!entry.hasDetail) setOpen(entry, false);
1407
+ }
1408
+
1409
+ /** Put a screen's pictures back in the hero, with the words that went with them. */
1410
+ function recall(entry) {
1411
+ var p = entry.pics;
1412
+ if (!p) return;
1413
+ var showed = false;
1414
+ if (p.status === 'changed') showed = comparePictures(p);
1415
+ if (!showed && nowOf(p)) {
1416
+ singlePicture(p, entry.name);
1417
+ showed = true;
1418
+ }
1419
+ if (!showed) return;
1420
+ nameTheShot(entry.name);
1421
+ sayOutcome(entry.tone, outcomeShort(p));
1422
+ markShowing(entry);
1423
+ }
1424
+
1425
+ function setOpen(entry, open) {
1426
+ entry.open = !!open && entry.hasDetail;
1427
+ entry.detail.hidden = !entry.open;
1428
+ entry.root.classList.toggle('open', entry.open);
1429
+ entry.row.setAttribute('aria-expanded', entry.open ? 'true' : 'false');
1430
+ }
1431
+
1432
+ function toggle(entry) {
1433
+ if (!entry.hasDetail) return;
1434
+ setOpen(entry, !entry.open);
1435
+ }
1436
+
1437
+ function setTone(entry, tone) {
1438
+ entry.tone = tone;
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));
1443
+ entry.root.style.setProperty('--tone', tone ? 'var(--' + tone + ')' : 'var(--accent)');
1444
+ paintMeter();
1445
+ }
1446
+
1447
+ // -------------------------------------------------------------------------
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.
1450
+ // -------------------------------------------------------------------------
1451
+
1452
+ var painted = [];
1453
+
1454
+ function meterTotal() {
1455
+ var planned = (expected.picture || 0) + (expected.guard || 0);
1456
+ return Math.max(order.length, planned, 1);
1457
+ }
1458
+
1459
+ function paintMeter() {
1460
+ var total = meterTotal();
1461
+ var width = (100 / total) + '%';
1462
+ var slices = ui.fill.children;
1463
+ // One slice per known check, in order. The rest of the track stays empty,
1464
+ // which is what tells you how much is still to come.
1465
+ while (slices.length > order.length) ui.fill.removeChild(ui.fill.lastChild);
1466
+ while (slices.length < order.length) {
1467
+ var span = document.createElement('span');
1468
+ span.className = 'slice';
1469
+ ui.fill.appendChild(span);
1470
+ }
1471
+ for (var i = 0; i < order.length; i++) {
1472
+ var tone = order[i].tone || '';
1473
+ var settled = tone && tone !== 'running';
1474
+ var lands = settled && painted[i] !== tone && !CALM;
1475
+ slices[i].className = 'slice' + (tone ? ' ' + tone : '') + (lands ? ' land' : '');
1476
+ slices[i].style.width = width;
1477
+ painted[i] = tone;
1478
+ }
1479
+ }
1480
+
1481
+ var doneTicker = { __from: 0, textContent: '' };
1482
+
1483
+ function tally() {
1484
+ var counted = { done: 0, changed: 0, failed: 0, waiting: 0, skipped: 0 };
1485
+ for (var i = 0; i < order.length; i++) {
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++;
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();
1513
+ var total = meterTotal();
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
+ });
1522
+ }
1523
+ ui.countScreens.textContent = countIn('picture');
1524
+ ui.countGuards.textContent = countIn('guard');
1525
+ ui.nothing.hidden = order.length > 0;
1526
+ paintMeter();
1527
+ }
1528
+
1529
+ function countIn(kind) {
1530
+ var n = 0;
1531
+ for (var i = 0; i < order.length; i++) if (order[i].kind === kind) n++;
1532
+ return n ? String(n) : '';
1533
+ }
1534
+
1535
+ // Auto-scroll follows the running row, and stops the moment the person
1536
+ // scrolls for themselves. Nothing is more annoying than a list that yanks
1537
+ // itself back while you are reading it.
1538
+ var following = true;
1539
+ function stopFollowing() {
1540
+ if (!following) return;
1541
+ following = false;
1542
+ ui.follow.hidden = false;
1543
+ }
1544
+ ['wheel', 'touchmove', 'keydown'].forEach(function (name) {
1545
+ ui.scroll.addEventListener(name, stopFollowing, { passive: true });
1546
+ });
1547
+ ui.follow.addEventListener('click', function () {
1548
+ following = true;
1549
+ ui.follow.hidden = true;
1550
+ if (runningItem) runningItem.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
1551
+ });
1552
+
1553
+ /** Which row the picture on the glass belongs to. */
1554
+ function markShowing(entry) {
1555
+ for (var i = 0; i < order.length; i++) order[i].root.classList.remove('showing');
1556
+ if (entry) entry.root.classList.add('showing');
1557
+ }
1558
+
1559
+ var runningItem = null;
1560
+ function markRunning(entry) {
1561
+ runningItem = entry;
1562
+ setTone(entry, 'running');
1563
+ if (following) entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
1564
+ }
1565
+
1566
+ // -------------------------------------------------------------------------
1567
+ // The hero
1568
+ // -------------------------------------------------------------------------
1569
+
1570
+ var frontIsA = false;
1571
+ var shownSrc = '';
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 };
1580
+
1581
+ function paint(src, alt) {
1582
+ if (!src) return;
1583
+ // A run that turned out to have pictures after all gets its glass back.
1584
+ ui.stage.hidden = false;
1585
+ ui.blank.hidden = true;
1586
+ ui.shot.classList.remove('empty');
1587
+ shownAlt = alt || '';
1588
+ if (src === shownSrc) return;
1589
+ shownSrc = src;
1590
+ var next = frontIsA ? ui.layerB : ui.layerA;
1591
+ var current = frontIsA ? ui.layerA : ui.layerB;
1592
+ next.classList.remove('on');
1593
+ next.classList.remove('out');
1594
+ next.src = src;
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;
1599
+ next.classList.add('on');
1600
+ current.classList.remove('on');
1601
+ current.classList.add('out');
1602
+ frontIsA = !frontIsA;
1603
+ }
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
+
1622
+ function nameTheShot(name) {
1623
+ ui.shotname.textContent = name || '';
1624
+ }
1625
+
1626
+ // The light behind everything takes the colour of the worst thing that has
1627
+ // happened so far. It is the one piece of the panel a person reads without
1628
+ // looking at it.
1629
+ function tint(tone) {
1630
+ var value = tone && tone !== 'held' && tone !== 'skip' ? 'var(--' + tone + ')' : 'var(--accent)';
1631
+ document.documentElement.style.setProperty('--tint', value);
1632
+ }
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
+
1640
+ function clearTabs() {
1641
+ ui.tabs.hidden = true;
1642
+ ui.tabs.textContent = '';
1643
+ }
1644
+
1645
+ /** One picture, no comparison to make: the one that was just taken. */
1646
+ function singlePicture(p, name) {
1647
+ clearTabs();
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'));
1654
+ }
1655
+
1656
+ /**
1657
+ * The line beside the picture's name. Silent when a screen simply held,
1658
+ * because a panel that congratulates you on every screen is a panel you stop
1659
+ * reading.
1660
+ */
1661
+ function sayOutcome(tone, text) {
1662
+ if (!text || !tone || tone === 'held' || tone === 'skip') {
1663
+ ui.shotout.hidden = true;
1664
+ return;
1665
+ }
1666
+ ui.shotout.textContent = text;
1667
+ ui.shotout.className = 'shotout ' + tone;
1668
+ ui.shotout.hidden = false;
1669
+ }
1670
+
1671
+ // A screen that moved is the one moment a person has a real decision to make,
1672
+ // so all three pictures are one click away and the difference is the one
1673
+ // already on screen — that is the picture that answers the question.
1674
+ function comparePictures(p) {
1675
+ var choices = [];
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 });
1679
+ // Two pictures is the whole point; one on its own says nothing a person
1680
+ // could act on, so leave the live picture up instead.
1681
+ if (choices.length < 2) return false;
1682
+
1683
+ onGlass.pics = p;
1684
+ onGlass.name = p.name || '';
1685
+ onGlass.touched = false;
1686
+ ui.tabs.textContent = '';
1687
+ ui.tabs.style.setProperty('--tone', 'var(--moved)');
1688
+
1689
+ var buttons = [];
1690
+ function select(index) {
1691
+ for (var i = 0; i < buttons.length; i++) {
1692
+ buttons[i].className = i === index ? 'on' : '';
1693
+ buttons[i].setAttribute('aria-pressed', i === index ? 'true' : 'false');
1694
+ }
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'));
1699
+ }
1700
+ choices.forEach(function (choice, index) {
1701
+ var button = document.createElement('button');
1702
+ button.type = 'button';
1703
+ button.textContent = choice.label;
1704
+ button.addEventListener('click', function () { onGlass.touched = true; select(index); });
1705
+ buttons.push(button);
1706
+ ui.tabs.appendChild(button);
1707
+ });
1708
+ ui.tabs.hidden = false;
1709
+
1710
+ var preferred = choices.length - 1;
1711
+ for (var i = 0; i < choices.length; i++) {
1712
+ if (choices[i].mode === 'diff') preferred = i;
1713
+ }
1714
+ select(preferred);
1715
+ return true;
1716
+ }
1717
+
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
+ };
1744
+ }
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
+
2007
+ document.addEventListener('keydown', function (e) {
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
+ }
2020
+ });
2021
+
2022
+ // -------------------------------------------------------------------------
2023
+ // The clock
2024
+ // -------------------------------------------------------------------------
2025
+
2026
+ var origin = null;
2027
+ var ticking = 0;
2028
+ var finished = false;
2029
+ var lastPrinted = '';
2030
+
2031
+ function startClock(at) {
2032
+ if (origin !== null || finished) return;
2033
+ origin = performance.now() - (Number(at) || 0);
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);
2040
+ }
2041
+
2042
+ function stopClock(finalMs) {
2043
+ if (ticking) { cancelAnimationFrame(ticking); ticking = 0; }
2044
+ finished = true;
2045
+ if (typeof finalMs !== 'number' || !isFinite(finalMs)) return;
2046
+ // It counts the last stretch out rather than jumping to the total, which is
2047
+ // what makes the end of a run read as an arrival instead of a cut.
2048
+ ui.clock.__from = origin === null ? finalMs : performance.now() - origin;
2049
+ tweenTo(ui.clock, finalMs, fmt);
2050
+ }
2051
+
2052
+ // -------------------------------------------------------------------------
2053
+ // Where the time went. Folded shut, because it is a thing you go and look at
2054
+ // once in a while rather than something you need on screen all day.
2055
+ // -------------------------------------------------------------------------
2056
+
2057
+ var TIMING_PARTS = [
2058
+ ['launch', 'opening the app'],
2059
+ ['steps', 'clicking through the app'],
2060
+ ['prepare', 'waiting for fonts and pictures'],
2061
+ ['settle', 'waiting for the screen to hold still'],
2062
+ ['compare', 'comparing the pictures'],
2063
+ ['guards', 'running the guards'],
2064
+ ['other', 'everything else']
2065
+ ];
2066
+
2067
+ // Shades of the text colour, darkest first. Not a palette — a ramp, so the
2068
+ // breakdown reads as one object and never competes with the four colours
2069
+ // that actually mean something.
2070
+ function shade(index, count) {
2071
+ var top = 46;
2072
+ var bottom = 11;
2073
+ var step = count > 1 ? (top - bottom) / (count - 1) : 0;
2074
+ return 'color-mix(in srgb, var(--ink) ' + (top - step * index).toFixed(1) + '%, transparent)';
2075
+ }
2076
+
2077
+ var timingOpen = false;
2078
+ ui.thead.addEventListener('click', function () {
2079
+ timingOpen = !timingOpen;
2080
+ ui.tkey.hidden = !timingOpen;
2081
+ ui.thead.setAttribute('aria-expanded', timingOpen ? 'true' : 'false');
2082
+ });
2083
+
2084
+ function showTiming(timings) {
2085
+ if (!timings || typeof timings !== 'object') return;
2086
+ var parts = [];
2087
+ var sum = 0;
2088
+ TIMING_PARTS.forEach(function (part) {
2089
+ var ms = Number(timings[part[0]]);
2090
+ if (!isFinite(ms) || ms <= 0) return;
2091
+ sum += ms;
2092
+ parts.push({ label: part[1], ms: ms });
2093
+ });
2094
+ if (!parts.length || sum <= 0) return;
2095
+
2096
+ // Biggest first: the answer to "where did the time go" is the first row.
2097
+ parts.sort(function (a, b) { return b.ms - a.ms; });
2098
+
2099
+ ui.tbar.textContent = '';
2100
+ ui.tkey.textContent = '';
2101
+ parts.forEach(function (part, index) {
2102
+ var colour = shade(index, parts.length);
2103
+
2104
+ var bar = document.createElement('span');
2105
+ bar.className = 'tpart';
2106
+ bar.style.flex = String(part.ms / sum) + ' 1 0';
2107
+ bar.style.background = colour;
2108
+ bar.title = part.label + ' — ' + fmt(part.ms);
2109
+ ui.tbar.appendChild(bar);
2110
+
2111
+ var item = document.createElement('li');
2112
+ var swatch = document.createElement('span');
2113
+ swatch.className = 'tswatch';
2114
+ swatch.style.background = colour;
2115
+ var text = document.createElement('span');
2116
+ text.className = 'tlabelled';
2117
+ text.textContent = part.label;
2118
+ var value = document.createElement('b');
2119
+ value.textContent = fmt(part.ms);
2120
+ item.appendChild(swatch);
2121
+ item.appendChild(text);
2122
+ item.appendChild(value);
2123
+ ui.tkey.appendChild(item);
2124
+ });
2125
+ ui.ttotal.textContent = '';
2126
+ var most = document.createElement('b');
2127
+ most.textContent = fmt(parts[0].ms);
2128
+ ui.ttotal.appendChild(most);
2129
+ ui.ttotal.appendChild(document.createTextNode(' ' + parts[0].label));
2130
+ ui.timing.hidden = false;
2131
+ }
2132
+
2133
+ // -------------------------------------------------------------------------
2134
+ // What to do next
2135
+ // -------------------------------------------------------------------------
2136
+
2137
+ function showNextStep(summary) {
2138
+ var pictures = (summary && summary.pictures) || [];
2139
+ var waiting = pictures.filter(function (p) {
2140
+ return p && (p.status === 'changed' || p.status === 'new' || p.status === 'missing');
2141
+ });
2142
+ // The verdict is already the biggest sentence on the page. The footer only
2143
+ // ever exists when there is something for a person to actually type.
2144
+ if (!waiting.length) { ui.footer.hidden = true; return; }
2145
+ ui.cmd.textContent = waiting.length === 1
2146
+ ? 'staysfixed approve ' + waiting[0].name
2147
+ : 'staysfixed approve --all';
2148
+ ui.footer.hidden = false;
2149
+ }
2150
+
2151
+ ui.copy.addEventListener('click', function () {
2152
+ var text = ui.cmd.textContent || '';
2153
+ // A page opened from a file is not a secure context, so the modern
2154
+ // clipboard is usually refused here. The old way still works.
2155
+ var box = document.createElement('textarea');
2156
+ box.value = text;
2157
+ box.setAttribute('readonly', '');
2158
+ box.style.position = 'fixed';
2159
+ box.style.opacity = '0';
2160
+ document.body.appendChild(box);
2161
+ box.select();
2162
+ try {
2163
+ document.execCommand('copy');
2164
+ ui.copy.classList.add('done');
2165
+ setTimeout(function () { ui.copy.classList.remove('done'); }, 1300);
2166
+ } catch (err) { /* nothing to do */ }
2167
+ document.body.removeChild(box);
2168
+ });
2169
+
2170
+ // -------------------------------------------------------------------------
2171
+ // The sentence at the top
2172
+ // -------------------------------------------------------------------------
2173
+
2174
+ function setState(text, tone, arriving) {
2175
+ ui.state.textContent = text;
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');
2182
+ }
2183
+
2184
+ function countOf(list) { return Array.isArray(list) ? list.length : 0; }
2185
+
2186
+ var announcedScreens = false;
2187
+ var announcedGuards = false;
2188
+
2189
+ // -------------------------------------------------------------------------
2190
+ // Events
2191
+ // -------------------------------------------------------------------------
2192
+
2193
+ function handle(ev) {
2194
+ var type = ev.type;
2195
+ if (typeof ev.at === 'number') startClock(ev.at);
2196
+
2197
+ if (type === 'run:start') {
2198
+ var planned = ev.plan || {};
2199
+ if (planned.project) ui.project.textContent = planned.project;
2200
+ if (planned.app) {
2201
+ ui.app.textContent = planned.app;
2202
+ ui.app.hidden = false;
2203
+ ui.targetsep.hidden = false;
2204
+ }
2205
+ // A caller who counted instead of listing still gets a truthful meter.
2206
+ if (typeof planned.screens === 'number') expected.picture = planned.screens;
2207
+ if (typeof planned.guards === 'number') expected.guard = planned.guards;
2208
+ setState('opening the app');
2209
+ updateCounts();
2210
+ return;
2211
+ }
2212
+
2213
+ if (type === 'phase') {
2214
+ if (ev.message) setState(ev.message);
2215
+ return;
2216
+ }
2217
+
2218
+ if (type === 'note') {
2219
+ if (ev.message) { ui.note.textContent = ev.message; ui.note.hidden = false; }
2220
+ return;
2221
+ }
2222
+
2223
+ if (type === 'screen:start') {
2224
+ if (typeof ev.total === 'number' && ev.total > expected.picture) expected.picture = ev.total;
2225
+ if (!announcedScreens) {
2226
+ announcedScreens = true;
2227
+ var screens = ev.total || expected.picture || countOf(plan.screens);
2228
+ setState(screens ? 'photographing ' + commas(screens) + ' ' + plural(screens, 'screen', 'screens') : 'photographing the screens');
2229
+ }
2230
+ // The picture is deliberately left alone here. Nothing has been
2231
+ // photographed yet, so renaming the frame now would put this screen's
2232
+ // name under the last screen's picture — a caption that lies for a
2233
+ // second is worse than one that is a second behind.
2234
+ markRunning(ensureItem('picture', String(ev.name || ''), ev.describe));
2235
+ scanning(true);
2236
+ return;
2237
+ }
2238
+
2239
+ if (type === 'screen:shot') {
2240
+ scanning(false);
2241
+ var shotRow = ev.name ? ensureItem('picture', String(ev.name), ev.describe) : null;
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 || ''));
2246
+ }
2247
+ if (ev.name) nameTheShot(ev.name);
2248
+ markShowing(shotRow);
2249
+ return;
2250
+ }
2251
+
2252
+ if (type === 'screen:done') {
2253
+ scanning(false);
2254
+ var done = ensureItem('picture', String(ev.name || ''), ev.describe);
2255
+ var tone = finish(done, 'picture', ev);
2256
+ // The name, the caption and the light behind the glass all move together
2257
+ // with the picture, or none of them move. A screen that could not be
2258
+ // photographed leaves the last real one up, correctly labelled, rather
2259
+ // than putting its own name under someone else's picture.
2260
+ var showed = false;
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 || ''));
2266
+ showed = true;
2267
+ }
2268
+ }
2269
+ if (showed) {
2270
+ markShowing(done);
2271
+ nameTheShot(ev.name);
2272
+ // Anything but "still the same" is said beside the picture as well as
2273
+ // in the list, because the picture is where the person is looking and
2274
+ // that line is what tells them there is something to decide.
2275
+ sayOutcome(tone, outcomeShort(ev));
2276
+ }
2277
+ tint(worstTone());
2278
+ return;
2279
+ }
2280
+
2281
+ if (type === 'guard:start') {
2282
+ if (typeof ev.total === 'number' && ev.total > expected.guard) expected.guard = ev.total;
2283
+ if (!announcedGuards) {
2284
+ announcedGuards = true;
2285
+ var guards = ev.total || expected.guard || countOf(plan.guards);
2286
+ setState(guards ? 'running ' + commas(guards) + ' ' + plural(guards, 'guard', 'guards') : 'running the guards');
2287
+ }
2288
+ markRunning(ensureItem('guard', String(ev.name || ''), ev.describe));
2289
+ return;
2290
+ }
2291
+
2292
+ if (type === 'guard:done') {
2293
+ finish(ensureItem('guard', String(ev.name || ''), ev.describe), 'guard', ev);
2294
+ tint(worstTone());
2295
+ return;
2296
+ }
2297
+
2298
+ if (type === 'run:done') {
2299
+ runningItem = null;
2300
+ ui.follow.hidden = true;
2301
+ scanning(false);
2302
+ var summary = ev.summary || null;
2303
+ stopClock(typeof ev.at === 'number' ? ev.at : (summary && summary.durationMs));
2304
+ var verdict = ev.verdict || fallbackVerdict();
2305
+ var worst = worstTone();
2306
+ setState(verdict, worst, true);
2307
+ tint(worst);
2308
+ showTiming(ev.timings || (summary && summary.timings));
2309
+ showNextStep(summary);
2310
+ updateCounts();
2311
+ if (following) {
2312
+ requestAnimationFrame(function () {
2313
+ var first = null;
2314
+ for (var i = 0; i < order.length && !first; i++) {
2315
+ if (needsPerson(order[i].tone)) first = order[i];
2316
+ }
2317
+ if (first) first.root.scrollIntoView({ block: 'center', behavior: CALM ? 'auto' : 'smooth' });
2318
+ else ui.scroll.scrollTop = 0;
2319
+ });
2320
+ }
2321
+ return;
2322
+ }
2323
+ // Anything else is something a newer version of Stays Fixed knows about
2324
+ // and this page does not. Ignoring it is the only safe thing to do.
2325
+ }
2326
+
2327
+ function finish(entry, kind, ev) {
2328
+ var tone = toneOf(ev.status);
2329
+ if (kind === 'picture') {
2330
+ var before = entry.pics || {};
2331
+ entry.pics = {
2332
+ name: ev.name, status: ev.status,
2333
+ thumbnail: ev.thumbnail || before.thumbnail || '',
2334
+ shotFile: ev.shotFile || before.shotFile || '',
2335
+ approvedThumb: ev.approvedThumb || '',
2336
+ approvedFile: ev.approvedFile || '',
2337
+ diffThumb: ev.diffThumb || '',
2338
+ diffFile: ev.diffFile || '',
2339
+ diffPixels: ev.diffPixels, message: ev.message
2340
+ };
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);
2345
+ }
2346
+ entry.outText = outcomeText(kind, ev);
2347
+ entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
2348
+ entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because) ? ev.because : '';
2349
+ if (typeof ev.durationMs === 'number') tweenTo(entry.time, ev.durationMs, fmt);
2350
+ entry.tone = tone;
2351
+ redraw(entry);
2352
+ setTone(entry, tone);
2353
+ // Only the checks that want a person open themselves. Everything else stays
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
+ }
2372
+ outcomes[keyFor(entry.kind, entry.name)] = tone || 'held';
2373
+ updateCounts();
2374
+ return tone;
2375
+ }
2376
+
2377
+ // The worst thing that happened, as a colour. Red beats amber beats blue
2378
+ // beats green, because a person should see the most serious state first.
2379
+ function worstTone() {
2380
+ var rank = { broke: 4, moved: 3, wait: 2, held: 1, skip: 0 };
2381
+ var worst = 'held';
2382
+ for (var key in outcomes) {
2383
+ var tone = outcomes[key];
2384
+ if ((rank[tone] || 0) > (rank[worst] || 0)) worst = tone;
2385
+ }
2386
+ return worst === 'skip' ? '' : worst;
2387
+ }
2388
+
2389
+ // Only used when a run finishes without the verdict the terminal printed —
2390
+ // an older run, or one that stopped early. It deliberately does not try to
2391
+ // reproduce those sentences; src/report/console.js owns them.
2392
+ function fallbackVerdict() {
2393
+ var total = order.length;
2394
+ var bad = 0;
2395
+ for (var key in outcomes) {
2396
+ if (outcomes[key] === 'broke' || outcomes[key] === 'moved' || outcomes[key] === 'wait') bad++;
2397
+ }
2398
+ if (!total) return 'finished';
2399
+ if (!bad) return 'Everything that worked still works.';
2400
+ return commas(bad) + ' of ' + commas(total) + ' ' + plural(total, 'check', 'checks') + ' needs a look.';
2401
+ }
2402
+
2403
+ // -------------------------------------------------------------------------
2404
+ // The one thing the run calls
2405
+ // -------------------------------------------------------------------------
2406
+
2407
+ window.__staysfixed_push = function (input) {
2408
+ try {
2409
+ var ev = typeof input === 'string' ? JSON.parse(input) : input;
2410
+ if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return;
2411
+ handle(ev);
2412
+ } catch (err) {
2413
+ // A watch window must never be the reason a run looks broken. Whatever
2414
+ // this event was, the next one still has to land.
2415
+ }
2416
+ };
2417
+
2418
+ // Called when the run lets go of the window, so the clock does not tick on
2419
+ // forever next to a result that is already final.
2420
+ window.__staysfixed_detach = function () {
2421
+ stopClock();
2422
+ scanning(false);
2423
+ runningItem = null;
2424
+ };
2425
+
2426
+ // Draw the plan before anything has happened, so the window is worth looking
2427
+ // at from the first frame instead of appearing empty.
2428
+ (function seed() {
2429
+ var i;
2430
+ seeding = true;
2431
+ for (i = 0; i < (plan.screens || []).length; i++) {
2432
+ addItem('picture', plan.screens[i].name, plan.screens[i].describe);
2433
+ }
2434
+ for (i = 0; i < (plan.guards || []).length; i++) {
2435
+ addItem('guard', plan.guards[i].name, plan.guards[i].describe);
2436
+ }
2437
+ seeding = false;
2438
+ updateCounts();
2439
+ if (!order.length) ui.shot.classList.add('empty');
2440
+ // Guards only: there will never be a picture, so the panel does not hold a
2441
+ // screen's worth of empty glass open waiting for one.
2442
+ if (!(plan.screens || []).length && (plan.guards || []).length) ui.stage.hidden = true;
2443
+ })();
2444
+ })();
2445
+ `;