staysfixed 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1678 @@
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 three rules the whole layout follows.
18
+ *
19
+ * 1. The picture is the hero. It is the only large thing on the page, and
20
+ * everything else is sized to defer to it. A watch panel that shows you a
21
+ * photograph of your own app the instant the shutter fires is alive; a
22
+ * watch panel that shows you a list of names is a log file with a border.
23
+ *
24
+ * 2. Nothing is said twice, and detail is earned. At rest every check is one
25
+ * quiet line — a dot, a name, a time. The description, the outcome, the
26
+ * claim that failed and the story of why a guard exists all live one click
27
+ * away, and open by themselves only for the checks that need a person. A
28
+ * run where everything held is therefore almost silent, which is exactly
29
+ * how often it deserves to be read.
30
+ *
31
+ * 3. Colour is state and nothing else. One accent (blue: something is
32
+ * happening, or someone is needed), one green (held), one amber (moved),
33
+ * one red (broke). The timing breakdown, which is information rather than
34
+ * state, is drawn in shades of the text colour — so a screenful of colour
35
+ * always means a screenful of things to look at.
36
+ */
37
+
38
+ import { escapeHtml } from '../report/html.js';
39
+
40
+ /**
41
+ * One line in the list: a screen or a guard.
42
+ * @typedef {object} PanelRow
43
+ * @property {string} name
44
+ * @property {string} [describe]
45
+ */
46
+
47
+ /**
48
+ * What the panel is told before the run starts.
49
+ * @typedef {object} PanelPlan
50
+ * @property {string} [project] The project folder's name.
51
+ * @property {string} [app] What is being checked, in plain words.
52
+ * @property {PanelRow[]} [screens] The screens, in the order they will be photographed.
53
+ * @property {PanelRow[]} [guards] The guards, in the order they will run.
54
+ * @property {'dark'|'light'|'system'} [theme] Default 'dark'. Stated, not sniffed — a fresh
55
+ * browser profile always claims the computer is in light mode.
56
+ */
57
+
58
+ /**
59
+ * JSON safe to sit inside a script tag. A closing tag inside a string would end
60
+ * the tag early and leave half the plan on the page as text, so the one
61
+ * character that can do that never survives.
62
+ * @param {unknown} value
63
+ * @returns {string}
64
+ */
65
+ function embedJson(value) {
66
+ return JSON.stringify(value ?? null)
67
+ .replace(/</g, '\\u003c');
68
+ }
69
+
70
+ /**
71
+ * Keep only what the page uses. A screen config carries steps, masks and
72
+ * tolerances; none of that belongs in a window that is only drawing a list.
73
+ * A caller who has counted rather than listed hands us a number, which is not
74
+ * a list and is not an error either — it becomes an empty list here, and the
75
+ * counts arrive separately on `run:start`.
76
+ * @param {PanelRow[]|number|undefined} list
77
+ * @returns {{name: string, describe: string}[]}
78
+ */
79
+ function tidyRows(list) {
80
+ if (!Array.isArray(list)) return [];
81
+ /** @type {{name: string, describe: string}[]} */
82
+ const out = [];
83
+ for (const item of list) {
84
+ if (!item || typeof item !== 'object') continue;
85
+ const name = String(/** @type {any} */ (item).name ?? '').trim();
86
+ if (!name) continue;
87
+ const describe = String(/** @type {any} */ (item).describe ?? '').trim();
88
+ out.push({ name, describe });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * The mark. A padlock with a tick inside it: the whole product in one shape —
95
+ * the thing that was already fixed is still shut. Monoline, drawn on the 24
96
+ * grid, inheriting its colour so it can never fight the theme.
97
+ *
98
+ * It carries no namespace attribute: inline SVG in an HTML document does not
99
+ * need one, and the panel is not allowed to name an address of any kind.
100
+ */
101
+ const MARK = [
102
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
103
+ ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
104
+ '<rect x="4.2" y="10" width="15.6" height="10.4" rx="3.6"></rect>',
105
+ '<path d="M8.1 10V7.9a3.9 3.9 0 0 1 7.8 0V10"></path>',
106
+ '<path d="M9.7 15.2l1.8 1.9 2.9-3.5"></path>',
107
+ '</svg>',
108
+ ].join('');
109
+
110
+ /** A chevron, used for every "there is more underneath this" control. */
111
+ const CHEVRON = [
112
+ '<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
113
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
114
+ '<path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path>',
115
+ '</svg>',
116
+ ].join('');
117
+
118
+ /** Two stacked sheets: copy. */
119
+ const COPY = [
120
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
121
+ ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
122
+ '<rect x="9" y="9" width="11" height="11" rx="3"></rect>',
123
+ '<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>',
124
+ '</svg>',
125
+ ].join('');
126
+
127
+ /**
128
+ * The whole panel document.
129
+ * @param {PanelPlan} [plan]
130
+ * @returns {string}
131
+ */
132
+ export function panelHtml(plan = {}) {
133
+ const project = String(plan.project ?? '').trim() || 'this project';
134
+ const app = String(plan.app ?? '').trim();
135
+ const screens = tidyRows(/** @type {any} */ (plan.screens));
136
+ const guards = tidyRows(/** @type {any} */ (plan.guards));
137
+
138
+ // Dark unless somebody asks otherwise — see the note above the light palette.
139
+ const wanted = String(plan.theme ?? 'dark');
140
+ const themeAttr = wanted === 'light' || wanted === 'system' ? wanted : 'dark';
141
+
142
+ const embedded = embedJson({ project, app, screens, guards });
143
+
144
+ return [
145
+ '<!doctype html>',
146
+ `<html lang="en" data-theme="${themeAttr}">`,
147
+ '<head>',
148
+ '<meta charset="utf-8">',
149
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
150
+ // Just the name of the thing, because this string IS the window's title bar.
151
+ // "tdproof — Stays Fixed" reads as a browser tab; "Stays Fixed" reads as an
152
+ // application. What is being watched is said inside the panel, under the mark.
153
+ '<title>Stays Fixed</title>',
154
+ `<style>${STYLE}</style>`,
155
+ '</head>',
156
+ '<body>',
157
+ '<div class="aura" aria-hidden="true"></div>',
158
+ '<div class="panel">',
159
+
160
+ // --- the header: whose window this is, and where the run has got to -----
161
+ '<header class="top">',
162
+ '<div class="brand">',
163
+ `<span class="badge">${MARK}</span>`,
164
+ '<span class="wordmark">Stays Fixed</span>',
165
+ '<span class="elapsed mono" id="clock">0.0s</span>',
166
+ '</div>',
167
+ `<p class="target" id="target"><span class="mono" id="project">${escapeHtml(project)}</span>`,
168
+ `<span class="sep"${app ? '' : ' hidden'} id="targetsep">&#183;</span>`,
169
+ `<span class="app" id="app"${app ? '' : ' hidden'}>${escapeHtml(app)}</span></p>`,
170
+ '<p class="state" id="state">getting ready</p>',
171
+ '<p class="note" id="note" hidden></p>',
172
+ '<div class="meter">',
173
+ '<div class="track" id="track"><div class="fill" id="fill"></div></div>',
174
+ '<span class="counts mono" id="counts"></span>',
175
+ '</div>',
176
+ '</header>',
177
+
178
+ // --- the two columns. One on a narrow panel, two on a wide one. ---------
179
+ '<div class="body">',
180
+
181
+ // --- the hero: the picture that was just taken -------------------------
182
+ '<section class="stage" id="stage">',
183
+ '<div class="shot" id="shot">',
184
+ '<img class="layer" id="layerA" alt="">',
185
+ '<img class="layer" id="layerB" alt="">',
186
+ '<p class="blank" id="blank">The first picture appears the moment it is taken.</p>',
187
+ '</div>',
188
+ '<div class="caption" id="caption">',
189
+ '<span class="shotname mono" id="shotname"></span>',
190
+ '<span class="shotout" id="shotout" hidden></span>',
191
+ '</div>',
192
+ '<div class="switch" id="tabs" hidden></div>',
193
+ '</section>',
194
+
195
+ // --- every check, one quiet line each ----------------------------------
196
+ '<div class="scroll" id="scroll">',
197
+ '<section class="group" id="groupScreens" hidden>',
198
+ '<p class="grouplabel">Screens<span class="mono" id="countScreens"></span></p>',
199
+ '<div class="items" id="listScreens"></div>',
200
+ '</section>',
201
+ '<section class="group" id="groupGuards" hidden>',
202
+ '<p class="grouplabel">Guards<span class="mono" id="countGuards"></span></p>',
203
+ '<div class="items" id="listGuards"></div>',
204
+ '</section>',
205
+ '<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>',
206
+ '</div>',
207
+
208
+ '</div>',
209
+
210
+ '<button class="follow" id="follow" type="button" hidden>Follow the run</button>',
211
+
212
+ // --- where the time went. One folded line, so it is always in sight and
213
+ // never in the way. ------------------------------------------------
214
+ '<section class="timing" id="timing" hidden>',
215
+ '<button class="tophead" id="thead" type="button" aria-expanded="false">',
216
+ '<span class="tlabel">Where the time went</span>',
217
+ '<span class="ttotal" id="ttotal"></span>',
218
+ `${CHEVRON}`,
219
+ '</button>',
220
+ '<div class="tbar" id="tbar"></div>',
221
+ '<ul class="tkey" id="tkey" hidden></ul>',
222
+ '</section>',
223
+
224
+ // --- the one thing left to do ------------------------------------------
225
+ '<footer class="foot" id="footer" hidden>',
226
+ '<p class="nextlabel" id="nextlabel">Your turn</p>',
227
+ '<div class="cmd">',
228
+ // The prompt mark is not decoration: it is what tells a person at a glance that
229
+ // this line is something to type, not something to read. It is never copied.
230
+ '<span class="prompt mono" aria-hidden="true">$</span>',
231
+ '<code class="mono" id="cmd"></code>',
232
+ `<button class="copy" id="copy" type="button" title="copy" aria-label="copy the command">${COPY}</button>`,
233
+ '</div>',
234
+ '</footer>',
235
+
236
+ '</div>',
237
+ '<div class="lightbox" id="lightbox" hidden><img id="lightimg" alt=""></div>',
238
+ `<script type="application/json" id="staysfixed-plan">${embedded}</script>`,
239
+ `<script>${SCRIPT}</script>`,
240
+ '</body>',
241
+ '</html>',
242
+ '',
243
+ ].join('\n');
244
+ }
245
+
246
+ const STYLE = `
247
+ /* --------------------------------------------------------------------------
248
+ Tokens.
249
+
250
+ Four colours carry meaning and nothing else does: accent blue for "this is
251
+ happening" and "you are needed", green for held, amber for moved, red for
252
+ broke. Everything structural is the ground, the ink, or a hairline. That is
253
+ what lets a single amber dot be seen from across a desk.
254
+
255
+ Dark is the design — this window lives beside an editor all day. Light is a
256
+ warm paper, never the white page with grey cards the brief rules out.
257
+ -------------------------------------------------------------------------- */
258
+ :root {
259
+ color-scheme: dark;
260
+
261
+ --ground: #0a0c12;
262
+ --lift: #12161f;
263
+ --card: rgba(255, 255, 255, 0.032);
264
+ --card-hover: rgba(255, 255, 255, 0.055);
265
+ --well: #070910;
266
+ --glass: rgba(10, 12, 18, 0.74);
267
+
268
+ --ink: #e7eaf2;
269
+ --soft: #939bad;
270
+ --faint: #616a7d;
271
+
272
+ --line: rgba(255, 255, 255, 0.07);
273
+ --line-firm: rgba(255, 255, 255, 0.13);
274
+ --sheen: rgba(255, 255, 255, 0.06);
275
+ --shadow: rgba(0, 0, 0, 0.66);
276
+
277
+ --accent: #6f9dff;
278
+ --held: #55bd8c;
279
+ --moved: #e2a84f;
280
+ --broke: #ef6a61;
281
+ --resting: rgba(255, 255, 255, 0.09);
282
+
283
+ --radius: 20px;
284
+ --radius-sm: 13px;
285
+ --radius-xs: 9px;
286
+
287
+ --pad: 16px;
288
+ --tint: var(--accent);
289
+ }
290
+ /*
291
+ * Light is opt-in, and that is deliberate.
292
+ *
293
+ * This window runs on a brand new browser profile, and a fresh profile answers
294
+ * "prefers-color-scheme" with "light" whatever the computer around it is set to. So the
295
+ * panel came up pale on a dark desktop, which is exactly the thing it was asked not to
296
+ * do. The look is therefore stated on the html element rather than sniffed: dark unless
297
+ * somebody asks for light, and 'system' for anyone who does want the browser's opinion.
298
+ */
299
+ :root[data-theme='light'],
300
+ :root[data-theme='system'] {
301
+ color-scheme: light;
302
+
303
+ --ground: #e3ded3;
304
+ --lift: #fdfbf7;
305
+ --card: rgba(255, 255, 255, 0.82);
306
+ --card-hover: rgba(255, 255, 255, 1);
307
+ --well: #dad4c8;
308
+ --glass: rgba(231, 226, 217, 0.82);
309
+
310
+ --ink: #1c1a16;
311
+ --soft: #6b665c;
312
+ --faint: #8b8578;
313
+
314
+ --line: rgba(28, 24, 18, 0.1);
315
+ --line-firm: rgba(28, 24, 18, 0.19);
316
+ --sheen: rgba(255, 255, 255, 0.85);
317
+ --shadow: rgba(58, 47, 30, 0.34);
318
+
319
+ --accent: #2f57c9;
320
+ --held: #1f7a4d;
321
+ --moved: #94620a;
322
+ --broke: #b52f23;
323
+ --resting: rgba(28, 24, 18, 0.1);
324
+ }
325
+
326
+ * { box-sizing: border-box; }
327
+ [hidden] { display: none !important; }
328
+ html, body { margin: 0; padding: 0; height: 100%; }
329
+ body {
330
+ background: var(--ground);
331
+ color: var(--ink);
332
+ font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
333
+ -webkit-font-smoothing: antialiased;
334
+ overflow: hidden;
335
+ }
336
+ /* The terminal character of the thing: every name, number and command is set
337
+ in the monospace, and nothing else is. */
338
+ .mono, code {
339
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
340
+ font-variant-numeric: tabular-nums;
341
+ }
342
+ p, h2 { margin: 0; }
343
+ img { display: block; }
344
+ button { font: inherit; color: inherit; }
345
+ .glyph { width: 16px; height: 16px; flex: 0 0 auto; }
346
+
347
+ /* A single soft light from above, tinted by how the run is going. It is the
348
+ only decoration on the page, it has no edge you could point at, and it is
349
+ the thing that stops the dark reading as a hole in the screen. */
350
+ .aura {
351
+ position: fixed; inset: 0; pointer-events: none; z-index: 0;
352
+ background:
353
+ radial-gradient(120% 46% at 50% -8%, color-mix(in srgb, var(--tint) 15%, transparent), transparent 70%),
354
+ radial-gradient(100% 60% at 50% 112%, var(--shadow), transparent 66%);
355
+ transition: background 500ms ease;
356
+ }
357
+
358
+ .panel {
359
+ position: relative; z-index: 1;
360
+ display: flex; flex-direction: column;
361
+ height: 100%;
362
+ }
363
+
364
+ /* --- header -------------------------------------------------------------- */
365
+ .top {
366
+ flex: 0 0 auto;
367
+ padding: 15px var(--pad) 14px;
368
+ background: var(--glass);
369
+ backdrop-filter: blur(22px) saturate(150%);
370
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
371
+ border-bottom: 1px solid var(--line);
372
+ }
373
+ .brand { display: flex; align-items: center; gap: 9px; }
374
+ .badge {
375
+ flex: 0 0 auto;
376
+ display: flex; align-items: center; justify-content: center;
377
+ width: 24px; height: 24px;
378
+ border-radius: 8px;
379
+ color: var(--accent);
380
+ background: color-mix(in srgb, var(--accent) 13%, transparent);
381
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
382
+ }
383
+ .badge .glyph { width: 15px; height: 15px; }
384
+ .wordmark {
385
+ flex: 1 1 auto; min-width: 0;
386
+ font-size: 11px; font-weight: 600;
387
+ letter-spacing: 0.2em; text-transform: uppercase;
388
+ color: var(--soft);
389
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
390
+ }
391
+ .elapsed { flex: 0 0 auto; font-size: 11.5px; color: var(--faint); }
392
+
393
+ .target {
394
+ display: flex; align-items: baseline; gap: 6px;
395
+ margin-top: 12px;
396
+ font-size: 12px;
397
+ white-space: nowrap; overflow: hidden;
398
+ }
399
+ .target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
400
+ .target .sep { color: var(--faint); flex: 0 0 auto; }
401
+ .target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: 11.5px; }
402
+
403
+ /* The sentence. The largest text on the page after the picture, because it is
404
+ the one thing a person reads from four feet away. */
405
+ .state {
406
+ margin-top: 9px;
407
+ font-size: 17px; font-weight: 620; line-height: 1.32;
408
+ letter-spacing: -0.011em;
409
+ overflow-wrap: anywhere;
410
+ transition: color 300ms ease;
411
+ }
412
+ .state.held { color: var(--ink); }
413
+ .state.moved { color: var(--moved); }
414
+ .state.broke { color: var(--broke); }
415
+ .state.wait { color: var(--accent); }
416
+ .note { margin-top: 5px; font-size: 11.5px; color: var(--faint); overflow-wrap: anywhere; }
417
+
418
+ /* One hairline, not a row of blocks. Each finished check adds its own slice of
419
+ colour to a single continuous line, so progress and outcome are the same
420
+ object and there is one fewer thing on the page. */
421
+ .meter { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
422
+ .track {
423
+ flex: 1 1 auto; min-width: 0;
424
+ height: 4px; border-radius: 999px;
425
+ background: var(--resting);
426
+ overflow: hidden;
427
+ }
428
+ .fill { display: flex; height: 100%; width: 100%; }
429
+ .slice {
430
+ min-width: 0; height: 100%;
431
+ background: var(--resting);
432
+ transition: background 260ms ease, flex-basis 260ms ease;
433
+ }
434
+ .slice.held { background: var(--held); }
435
+ .slice.moved { background: var(--moved); }
436
+ .slice.broke { background: var(--broke); }
437
+ .slice.wait { background: var(--accent); }
438
+ .slice.skip { background: color-mix(in srgb, var(--ink) 16%, transparent); }
439
+ .slice.running {
440
+ background: var(--accent);
441
+ animation: breathe 1.9s ease-in-out infinite;
442
+ }
443
+ @keyframes breathe { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }
444
+ .counts { flex: 0 0 auto; font-size: 10.5px; color: var(--faint); letter-spacing: 0.02em; }
445
+
446
+ /* --- the body: one column, or two when there is room --------------------- */
447
+ .body {
448
+ flex: 1 1 auto; min-height: 0;
449
+ display: flex; flex-direction: column;
450
+ gap: 4px;
451
+ padding: 0 var(--pad);
452
+ overflow: hidden;
453
+ }
454
+
455
+ /* --- the hero ------------------------------------------------------------ */
456
+ .stage { flex: 0 0 auto; padding-top: 16px; }
457
+ .shot {
458
+ position: relative;
459
+ aspect-ratio: 16 / 10;
460
+ max-height: 40vh;
461
+ margin: 0 auto;
462
+ border-radius: var(--radius);
463
+ background: var(--well);
464
+ overflow: hidden;
465
+ display: flex; align-items: center; justify-content: center;
466
+ cursor: zoom-in;
467
+ /* No border. The picture is the hero, so nothing is drawn around it that
468
+ could compete with it — only a soft floor shadow and the faintest rim to
469
+ keep a white screenshot from bleeding into a light ground. */
470
+ box-shadow:
471
+ 0 1px 0 0 var(--sheen) inset,
472
+ 0 0 0 1px var(--line),
473
+ 0 26px 50px -30px var(--shadow);
474
+ transition: box-shadow 300ms ease;
475
+ }
476
+ .shot.empty { cursor: default; }
477
+ .layer {
478
+ position: absolute; inset: 0;
479
+ width: 100%; height: 100%;
480
+ object-fit: contain;
481
+ opacity: 0;
482
+ transition: opacity 260ms ease;
483
+ }
484
+ .layer.on { opacity: 1; }
485
+ .blank {
486
+ position: relative;
487
+ color: var(--faint); font-size: 11.5px; line-height: 1.6;
488
+ text-align: center; padding: 0 30px; max-width: 260px;
489
+ }
490
+
491
+ .caption {
492
+ display: flex; align-items: baseline; gap: 10px;
493
+ margin-top: 11px; min-height: 18px;
494
+ }
495
+ .shotname { flex: 0 1 auto; font-size: 12px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
496
+ .shotout { flex: 1 1 auto; min-width: 0; font-size: 11.5px; color: var(--soft); text-align: right; overflow-wrap: anywhere; }
497
+ .shotout.moved { color: var(--moved); }
498
+ .shotout.broke { color: var(--broke); }
499
+ .shotout.wait { color: var(--accent); }
500
+
501
+ /* Approved / now / difference. One pill, three quiet words — the only control
502
+ on the page that is ever shown without being asked for, because a screen
503
+ that moved is the one moment there is a real decision to make. */
504
+ .switch {
505
+ display: flex; gap: 2px;
506
+ margin-top: 10px; padding: 2px;
507
+ border-radius: 999px;
508
+ background: var(--card);
509
+ box-shadow: inset 0 0 0 1px var(--line);
510
+ }
511
+ .switch button {
512
+ flex: 1 1 0; min-width: 0;
513
+ font-size: 11px; letter-spacing: 0.01em;
514
+ color: var(--soft);
515
+ background: transparent; border: 0;
516
+ border-radius: 999px;
517
+ padding: 5px 6px; cursor: pointer;
518
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
519
+ transition: color 160ms ease, background 160ms ease;
520
+ }
521
+ .switch button:hover { color: var(--ink); }
522
+ .switch button.on {
523
+ color: var(--ink);
524
+ background: var(--card-hover);
525
+ box-shadow: 0 1px 2px -1px var(--shadow), inset 0 0 0 1px var(--line);
526
+ }
527
+
528
+ /* --- the list ------------------------------------------------------------ */
529
+ .scroll {
530
+ flex: 1 1 auto; min-height: 70px;
531
+ overflow-y: auto;
532
+ overscroll-behavior: contain;
533
+ scrollbar-width: thin;
534
+ scrollbar-color: color-mix(in srgb, var(--soft) 22%, transparent) transparent;
535
+ padding: 16px 5px 14px 0;
536
+ /* The list slides away under the header rather than being cut off by a box. */
537
+ mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
538
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
539
+ }
540
+ .scroll::-webkit-scrollbar { width: 10px; }
541
+ .scroll::-webkit-scrollbar-thumb {
542
+ background: color-mix(in srgb, var(--soft) 22%, transparent);
543
+ border-radius: 8px; border: 3px solid transparent; background-clip: padding-box;
544
+ }
545
+ .scroll::-webkit-scrollbar-track { background: transparent; }
546
+
547
+ .group + .group { margin-top: 18px; }
548
+ .grouplabel {
549
+ display: flex; align-items: baseline; gap: 9px;
550
+ padding: 0 6px 8px 4px;
551
+ font-size: 10px; font-weight: 600;
552
+ letter-spacing: 0.16em; text-transform: uppercase;
553
+ color: var(--faint);
554
+ }
555
+ .grouplabel .mono { font-size: 10px; letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
556
+
557
+ .items {
558
+ border-radius: var(--radius);
559
+ background: var(--card);
560
+ box-shadow: inset 0 0 0 1px var(--line), 0 18px 34px -30px var(--shadow);
561
+ overflow: hidden;
562
+ }
563
+ .item + .item { box-shadow: inset 0 1px 0 var(--line); }
564
+ .item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 5%, transparent); }
565
+
566
+ /* Big, quiet, touch-sized rows. At rest a check is a dot, a name and a time —
567
+ that is the whole of it. */
568
+ .row {
569
+ display: flex; align-items: center; gap: 11px;
570
+ width: 100%; min-height: 44px;
571
+ padding: 10px 14px;
572
+ text-align: left;
573
+ background: transparent; border: 0;
574
+ cursor: pointer;
575
+ transition: background 160ms ease;
576
+ }
577
+ .row:hover { background: var(--card-hover); }
578
+ .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
579
+ .item.plain .row { cursor: default; }
580
+ .item.showing .row { box-shadow: inset 2px 0 0 var(--line-firm); }
581
+ .item.showing .rname { color: var(--ink); }
582
+ .item.plain .row:hover { background: transparent; }
583
+ .item.fresh { animation: settle 200ms ease both; }
584
+ @keyframes settle { from { opacity: 0; } to { opacity: 1; } }
585
+
586
+ .dot {
587
+ flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
588
+ background: var(--resting);
589
+ transition: background 240ms ease, box-shadow 240ms ease;
590
+ }
591
+ .item.held .dot { background: color-mix(in srgb, var(--held) 78%, transparent); }
592
+ .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 15%, transparent); }
593
+ .item.broke .dot { background: var(--broke); box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 15%, transparent); }
594
+ .item.wait .dot { background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); }
595
+ .item.skip .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting); }
596
+ .item.running .dot {
597
+ background: transparent;
598
+ box-shadow: inset 0 0 0 2px var(--accent);
599
+ animation: ping 1.7s ease-out infinite;
600
+ }
601
+ @keyframes ping {
602
+ 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent); }
603
+ 70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
604
+ }
605
+ .item.running .rname { color: var(--ink); }
606
+
607
+ .rname {
608
+ flex: 1 1 auto; min-width: 0;
609
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
610
+ font-variant-numeric: tabular-nums;
611
+ font-size: 12px; color: var(--ink);
612
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
613
+ }
614
+ .item.pending .rname, .item.skip .rname { color: var(--faint); }
615
+ .rtime {
616
+ flex: 0 0 auto;
617
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
618
+ font-variant-numeric: tabular-nums;
619
+ font-size: 10.5px; color: var(--faint);
620
+ }
621
+ .row .chev {
622
+ flex: 0 0 auto; width: 14px; height: 14px;
623
+ color: var(--faint); opacity: 0;
624
+ transition: transform 200ms ease, opacity 160ms ease;
625
+ }
626
+ .row:hover .chev, .item.open .chev { opacity: 1; }
627
+ .item.open .chev { transform: rotate(180deg); }
628
+ .item.plain .chev { display: none; }
629
+
630
+ /* Everything a person did not ask for lives here. */
631
+ .detail {
632
+ padding: 0 14px 13px 33px;
633
+ font-size: 11.5px; line-height: 1.6;
634
+ overflow-wrap: anywhere;
635
+ }
636
+ .detail .why { color: var(--soft); }
637
+ .detail .out { color: var(--soft); margin-top: 3px; }
638
+ .detail .out.moved { color: var(--moved); }
639
+ .detail .out.broke { color: var(--broke); }
640
+ .detail .out.wait { color: var(--accent); }
641
+ .detail .claim {
642
+ margin-top: 9px; padding: 9px 12px;
643
+ border-radius: var(--radius-xs);
644
+ background: var(--well);
645
+ box-shadow: inset 2px 0 0 var(--broke), inset 0 0 0 1px var(--line);
646
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
647
+ font-size: 11px; color: var(--ink);
648
+ }
649
+ .detail .claim em { font-style: normal; color: var(--faint); }
650
+ .detail .story { margin-top: 7px; color: var(--faint); }
651
+
652
+ .nothing { padding: 26px 16px; color: var(--faint); font-size: 11.5px; line-height: 1.7; text-align: center; }
653
+ .nothing .mono { color: var(--soft); }
654
+
655
+ /* --- where the time went ------------------------------------------------- */
656
+ .timing {
657
+ flex: 0 0 auto;
658
+ padding: 11px var(--pad) 12px;
659
+ border-top: 1px solid var(--line);
660
+ background: var(--glass);
661
+ backdrop-filter: blur(22px) saturate(150%);
662
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
663
+ }
664
+ .tophead {
665
+ display: flex; align-items: center; gap: 8px;
666
+ width: 100%; padding: 0 0 8px;
667
+ background: transparent; border: 0; cursor: pointer;
668
+ text-align: left;
669
+ }
670
+ .tlabel {
671
+ flex: 1 1 auto;
672
+ font-size: 10px; font-weight: 600;
673
+ letter-spacing: 0.16em; text-transform: uppercase;
674
+ color: var(--faint);
675
+ }
676
+ .ttotal {
677
+ flex: 0 1 auto; min-width: 0;
678
+ font-size: 10.5px; color: var(--faint);
679
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
680
+ }
681
+ .ttotal b {
682
+ font-weight: 500; color: var(--soft);
683
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
684
+ font-variant-numeric: tabular-nums;
685
+ }
686
+ .tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform 200ms ease; }
687
+ .tophead[aria-expanded='true'] .chev { transform: rotate(180deg); }
688
+ .tophead:hover .tlabel, .tophead:hover .ttotal, .tophead:hover .chev { color: var(--soft); }
689
+
690
+ /* Deliberately not coloured. Where the time went is information, not state,
691
+ and colour on this page only ever means something needs a person. */
692
+ .tbar { display: flex; gap: 1px; height: 6px; border-radius: 999px; overflow: hidden; background: var(--resting); }
693
+ .tpart { min-width: 2px; }
694
+ .tkey { list-style: none; margin: 11px 0 1px; padding: 0; }
695
+ .tkey li { display: flex; align-items: center; gap: 9px; padding: 3px 4px; font-size: 11px; color: var(--soft); }
696
+ .tkey .tswatch { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 2px; }
697
+ .tkey .tlabelled { flex: 1 1 auto; min-width: 0; }
698
+ .tkey b {
699
+ flex: 0 0 auto; font-weight: 500; color: var(--ink); font-size: 11px;
700
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
701
+ font-variant-numeric: tabular-nums;
702
+ }
703
+
704
+ /* --- follow ------------------------------------------------------------- */
705
+ .follow {
706
+ position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
707
+ z-index: 4;
708
+ font-size: 11px;
709
+ color: var(--ink);
710
+ background: var(--lift);
711
+ border: 1px solid var(--line-firm); border-radius: 999px;
712
+ padding: 6px 15px; cursor: pointer;
713
+ box-shadow: 0 12px 24px -14px var(--shadow);
714
+ transition: border-color 160ms ease, color 160ms ease;
715
+ }
716
+ .follow:hover { border-color: var(--accent); color: var(--accent); }
717
+
718
+ /* --- the one thing left to do ------------------------------------------- */
719
+ .foot {
720
+ flex: 0 0 auto;
721
+ padding: 13px var(--pad) 15px;
722
+ background: var(--glass);
723
+ backdrop-filter: blur(22px) saturate(150%);
724
+ -webkit-backdrop-filter: blur(22px) saturate(150%);
725
+ border-top: 1px solid var(--line);
726
+ }
727
+ .nextlabel {
728
+ font-size: 10px; font-weight: 600;
729
+ letter-spacing: 0.16em; text-transform: uppercase;
730
+ color: var(--faint);
731
+ margin-bottom: 8px;
732
+ }
733
+ .cmd { display: flex; align-items: stretch; gap: 7px; }
734
+ .cmd code {
735
+ flex: 1 1 auto; min-width: 0;
736
+ display: flex; align-items: center;
737
+ background: var(--well);
738
+ border-radius: var(--radius-xs);
739
+ box-shadow: inset 0 0 0 1px var(--line-firm);
740
+ padding: 8px 11px; font-size: 11.5px;
741
+ color: var(--ink);
742
+ overflow-wrap: anywhere;
743
+ user-select: all;
744
+ }
745
+ .prompt { flex: 0 0 auto; color: var(--held); opacity: 0.75; user-select: none; }
746
+ .copy {
747
+ flex: 0 0 auto; width: 36px;
748
+ display: flex; align-items: center; justify-content: center;
749
+ color: var(--soft); background: transparent;
750
+ border: 1px solid var(--line-firm); border-radius: var(--radius-xs);
751
+ cursor: pointer;
752
+ transition: color 160ms ease, border-color 160ms ease;
753
+ }
754
+ .copy:hover { color: var(--ink); border-color: var(--soft); }
755
+ .copy.done { color: var(--held); border-color: color-mix(in srgb, var(--held) 45%, transparent); }
756
+
757
+ /* --- one picture, made big ---------------------------------------------- */
758
+ .lightbox {
759
+ position: fixed; inset: 0; z-index: 10;
760
+ background: color-mix(in srgb, var(--ground) 78%, #000);
761
+ backdrop-filter: blur(10px);
762
+ -webkit-backdrop-filter: blur(10px);
763
+ display: flex; align-items: center; justify-content: center;
764
+ padding: 18px; cursor: zoom-out;
765
+ }
766
+ .lightbox img {
767
+ max-width: 100%; max-height: 100%;
768
+ border-radius: var(--radius-sm);
769
+ box-shadow: 0 0 0 1px var(--line-firm), 0 36px 70px -34px #000;
770
+ }
771
+
772
+ /* --- the range it has to survive ---------------------------------------- */
773
+
774
+ /* Dragged narrow. Everything stays, nothing wraps into a mess. */
775
+ @media (max-width: 330px) {
776
+ :root { --pad: 11px; --radius: 16px; }
777
+ .state { font-size: 15px; }
778
+ .target .app, .target .sep { display: none; }
779
+ .row { gap: 9px; padding: 9px 11px; }
780
+ .detail { padding: 0 11px 12px 28px; }
781
+ .shot { max-height: 30vh; }
782
+ .switch button { font-size: 10px; padding: 5px 3px; }
783
+ .ttotal { display: none; }
784
+ .caption { display: block; }
785
+ .shotname { display: block; white-space: normal; overflow-wrap: anywhere; }
786
+ .shotout { display: block; text-align: left; margin-top: 2px; }
787
+ .timing { padding: 10px var(--pad) 11px; }
788
+ }
789
+
790
+ /* Pulled wide. Two columns rather than one very long thin one, so the picture
791
+ gets the room and the list stays beside it instead of below the fold. */
792
+ @media (min-width: 720px) {
793
+ :root { --pad: 22px; }
794
+ .body { flex-direction: row; gap: 26px; padding-bottom: 4px; }
795
+ .stage { flex: 1 1 54%; min-width: 0; align-self: center; padding-top: 0; }
796
+ .scroll { flex: 1 1 46%; min-width: 0; padding-top: 20px; }
797
+ .shot { max-height: 58vh; }
798
+ .state { font-size: 19px; }
799
+ .timing { margin-top: 22px; }
800
+ }
801
+
802
+ @media (prefers-reduced-motion: reduce) {
803
+ * { animation: none !important; transition: none !important; }
804
+ }
805
+ `;
806
+
807
+ const SCRIPT = `
808
+ (function () {
809
+ 'use strict';
810
+
811
+ // This page only ever runs in the browser window Stays Fixed just opened, so
812
+ // there is no compatibility question to answer and nothing to load.
813
+
814
+ var plan = { project: '', app: '', screens: [], guards: [] };
815
+ try {
816
+ var blob = document.getElementById('staysfixed-plan');
817
+ if (blob && blob.textContent) plan = JSON.parse(blob.textContent) || plan;
818
+ } catch (err) {
819
+ // A plan we cannot read costs the opening list, not the panel. Rows will
820
+ // still appear one by one as the run reaches them.
821
+ }
822
+
823
+ function el(id) { return document.getElementById(id); }
824
+
825
+ var ui = {
826
+ clock: el('clock'), project: el('project'), app: el('app'), targetsep: el('targetsep'),
827
+ state: el('state'), note: el('note'),
828
+ track: el('track'), fill: el('fill'), counts: el('counts'),
829
+ stage: el('stage'), shot: el('shot'), layerA: el('layerA'), layerB: el('layerB'), blank: el('blank'),
830
+ caption: el('caption'), shotname: el('shotname'), shotout: el('shotout'), tabs: el('tabs'),
831
+ scroll: el('scroll'), follow: el('follow'), nothing: el('nothing'),
832
+ groupScreens: el('groupScreens'), listScreens: el('listScreens'), countScreens: el('countScreens'),
833
+ groupGuards: el('groupGuards'), listGuards: el('listGuards'), countGuards: el('countGuards'),
834
+ timing: el('timing'), thead: el('thead'), ttotal: el('ttotal'), tbar: el('tbar'), tkey: el('tkey'),
835
+ footer: el('footer'), cmd: el('cmd'), copy: el('copy'),
836
+ aura: document.querySelector('.aura'),
837
+ lightbox: el('lightbox'), lightimg: el('lightimg')
838
+ };
839
+
840
+ var CHEVRON = '<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path></svg>';
841
+
842
+ // -------------------------------------------------------------------------
843
+ // Words. These match the terminal on purpose — the same run should not be
844
+ // described two different ways depending on where you happen to be reading.
845
+ // The originals live in src/report/console.js.
846
+ // -------------------------------------------------------------------------
847
+
848
+ function commas(n) {
849
+ var v = Math.round(Number(n) || 0);
850
+ return v.toLocaleString('en-US');
851
+ }
852
+
853
+ function fmt(ms) {
854
+ var v = Number(ms);
855
+ if (!isFinite(v) || v < 0) v = 0;
856
+ if (v < 1000) return Math.round(v) + 'ms';
857
+ if (v < 60000) return (v / 1000).toFixed(1) + 's';
858
+ var m = Math.floor(v / 60000);
859
+ var s = Math.round((v % 60000) / 1000);
860
+ return m + 'm ' + s + 's';
861
+ }
862
+
863
+ function plural(n, one, many) { return n === 1 ? one : many; }
864
+
865
+ // The whole colour vocabulary, in one place: green held, amber moved, red
866
+ // broke, blue is waiting for a person to look, and a check nobody ran has no
867
+ // colour at all.
868
+ function toneOf(status) {
869
+ switch (status) {
870
+ case 'running': return 'running';
871
+ case 'passed': return 'held';
872
+ case 'changed': case 'flaky': return 'moved';
873
+ case 'failed': case 'missing': return 'broke';
874
+ case 'new': return 'wait';
875
+ case 'skipped': return 'skip';
876
+ default: return '';
877
+ }
878
+ }
879
+
880
+ // A tone that means "somebody has to look at this". These are the rows that
881
+ // open themselves; everything else waits to be asked.
882
+ function needsPerson(tone) { return tone === 'moved' || tone === 'broke' || tone === 'wait'; }
883
+
884
+ // The terse form, for the line beside the picture. The colour and the compare
885
+ // control have already said most of it there.
886
+ function outcomeShort(ev) {
887
+ switch (ev.status) {
888
+ case 'changed':
889
+ var n = ev.diffPixels || 0;
890
+ return commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' moved';
891
+ case 'new': return 'not approved yet';
892
+ case 'missing': return 'no approved picture';
893
+ case 'failed': return ev.message || 'could not be photographed';
894
+ case 'flaky': return 'changed between tries';
895
+ default: return '';
896
+ }
897
+ }
898
+
899
+ function outcomeText(kind, ev) {
900
+ var status = ev.status;
901
+ if (kind === 'guard') {
902
+ if (status === 'passed') return 'still holds';
903
+ if (status === 'skipped') return 'left out on purpose';
904
+ return ev.message || 'this one is broken again';
905
+ }
906
+ switch (status) {
907
+ case 'passed': return 'still the same';
908
+ case 'changed':
909
+ var n = ev.diffPixels || 0;
910
+ return 'looks different — ' + commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' changed';
911
+ case 'new': return 'nobody has approved this picture yet';
912
+ case 'missing': return 'the approved picture is gone';
913
+ case 'failed': return ev.message || 'could not be photographed';
914
+ case 'flaky': return 'changed its mind between tries';
915
+ case 'skipped': return 'left out on purpose';
916
+ default: return ev.message || '';
917
+ }
918
+ }
919
+
920
+ // -------------------------------------------------------------------------
921
+ // The list. One line per check at rest; everything else on request.
922
+ // -------------------------------------------------------------------------
923
+
924
+ var items = Object.create(null);
925
+ var order = [];
926
+ var outcomes = Object.create(null);
927
+ var expected = { picture: 0, guard: 0 };
928
+
929
+ function keyFor(kind, name) { return kind + ':' + name; }
930
+
931
+ function addItem(kind, name, describe) {
932
+ var root = document.createElement('div');
933
+ root.className = 'item fresh pending';
934
+
935
+ var row = document.createElement('button');
936
+ row.type = 'button';
937
+ row.className = 'row';
938
+ row.setAttribute('aria-expanded', 'false');
939
+
940
+ var dot = document.createElement('span');
941
+ dot.className = 'dot';
942
+ var rname = document.createElement('span');
943
+ rname.className = 'rname';
944
+ rname.textContent = name;
945
+ var rtime = document.createElement('span');
946
+ rtime.className = 'rtime';
947
+ row.appendChild(dot);
948
+ row.appendChild(rname);
949
+ row.appendChild(rtime);
950
+ row.insertAdjacentHTML('beforeend', CHEVRON);
951
+
952
+ var detail = document.createElement('div');
953
+ detail.className = 'detail';
954
+ detail.hidden = true;
955
+
956
+ root.appendChild(row);
957
+ root.appendChild(detail);
958
+
959
+ var entry = {
960
+ kind: kind, name: name, root: root, row: row, time: rtime,
961
+ detail: detail, describe: describe || '', open: false, hasDetail: false
962
+ };
963
+
964
+ // One rule, so a click is never a surprise: the first click brings this
965
+ // check forward — its picture on the glass, its detail open. The second
966
+ // click, on the row that is already forward, folds it away again. That is
967
+ // what makes the whole run browsable afterwards instead of only the last
968
+ // screen photographed.
969
+ row.addEventListener('click', function () {
970
+ var forward = entry.pics && !entry.root.classList.contains('showing');
971
+ if (forward) {
972
+ recall(entry);
973
+ setOpen(entry, true);
974
+ return;
975
+ }
976
+ toggle(entry);
977
+ });
978
+ redraw(entry);
979
+
980
+ (kind === 'guard' ? ui.listGuards : ui.listScreens).appendChild(root);
981
+ (kind === 'guard' ? ui.groupGuards : ui.groupScreens).hidden = false;
982
+
983
+ items[keyFor(kind, name)] = entry;
984
+ order.push(entry);
985
+ return entry;
986
+ }
987
+
988
+ function ensureItem(kind, name, describe) {
989
+ var entry = items[keyFor(kind, name)];
990
+ if (!entry) return addItem(kind, name, describe);
991
+ if (describe && !entry.describe) { entry.describe = describe; redraw(entry); }
992
+ return entry;
993
+ }
994
+
995
+ /**
996
+ * Rebuild what is hidden under a row. Written out every time rather than
997
+ * patched, because the same row is drawn twice at most and a rebuilt block
998
+ * can never disagree with the event that caused it.
999
+ */
1000
+ function redraw(entry) {
1001
+ var d = entry.detail;
1002
+ d.textContent = '';
1003
+
1004
+ if (entry.describe) {
1005
+ var why = document.createElement('div');
1006
+ why.className = 'why';
1007
+ why.textContent = entry.describe;
1008
+ d.appendChild(why);
1009
+ }
1010
+ // "Still the same" is not worth a line of anybody's attention, so a check
1011
+ // that held says only what it is for.
1012
+ if (entry.outText && entry.tone !== 'held') {
1013
+ var out = document.createElement('div');
1014
+ out.className = 'out' + (entry.tone ? ' ' + entry.tone : '');
1015
+ out.textContent = entry.outText;
1016
+ d.appendChild(out);
1017
+ }
1018
+ if (entry.failedAt) {
1019
+ var claim = document.createElement('div');
1020
+ claim.className = 'claim';
1021
+ var label = document.createElement('em');
1022
+ label.textContent = 'expected ';
1023
+ claim.appendChild(label);
1024
+ claim.appendChild(document.createTextNode(entry.failedAt));
1025
+ d.appendChild(claim);
1026
+ }
1027
+ if (entry.story && entry.story !== entry.describe) {
1028
+ var story = document.createElement('div');
1029
+ story.className = 'story';
1030
+ story.textContent = 'Why this guard exists: ' + entry.story;
1031
+ d.appendChild(story);
1032
+ }
1033
+
1034
+ entry.hasDetail = !!d.firstChild;
1035
+ entry.root.classList.toggle('plain', !entry.hasDetail);
1036
+ if (!entry.hasDetail) setOpen(entry, false);
1037
+ }
1038
+
1039
+ /** Put a screen's pictures back in the hero, with the words that went with them. */
1040
+ function recall(entry) {
1041
+ var pics = entry.pics;
1042
+ if (!pics) return;
1043
+ var showed = false;
1044
+ if (pics.status === 'changed') showed = comparePictures(pics);
1045
+ if (!showed && pics.thumbnail) {
1046
+ singlePicture();
1047
+ showShot(pics.thumbnail, 'the picture taken of ' + entry.name);
1048
+ showed = true;
1049
+ }
1050
+ if (!showed) return;
1051
+ nameTheShot(entry.name);
1052
+ sayOutcome(entry.tone, outcomeShort(pics));
1053
+ for (var i = 0; i < order.length; i++) order[i].root.classList.remove('showing');
1054
+ entry.root.classList.add('showing');
1055
+ }
1056
+
1057
+ function setOpen(entry, open) {
1058
+ entry.open = !!open && entry.hasDetail;
1059
+ entry.detail.hidden = !entry.open;
1060
+ entry.root.classList.toggle('open', entry.open);
1061
+ entry.row.setAttribute('aria-expanded', entry.open ? 'true' : 'false');
1062
+ }
1063
+
1064
+ function toggle(entry) {
1065
+ if (!entry.hasDetail) return;
1066
+ setOpen(entry, !entry.open);
1067
+ }
1068
+
1069
+ function setTone(entry, tone) {
1070
+ entry.tone = tone;
1071
+ var classes = ['item'];
1072
+ if (tone) classes.push(tone);
1073
+ if (!tone) classes.push('pending');
1074
+ if (needsPerson(tone)) classes.push('attention');
1075
+ if (entry.open) classes.push('open');
1076
+ if (!entry.hasDetail) classes.push('plain');
1077
+ if (entry.root.classList.contains('showing')) classes.push('showing');
1078
+ entry.root.className = classes.join(' ');
1079
+ entry.root.style.setProperty('--tone', tone ? 'var(--' + tone + ')' : 'var(--accent)');
1080
+ paintMeter();
1081
+ }
1082
+
1083
+ // -------------------------------------------------------------------------
1084
+ // The meter: one hairline, sliced by outcome.
1085
+ // -------------------------------------------------------------------------
1086
+
1087
+ function meterTotal() {
1088
+ var planned = (expected.picture || 0) + (expected.guard || 0);
1089
+ return Math.max(order.length, planned, 1);
1090
+ }
1091
+
1092
+ function paintMeter() {
1093
+ var total = meterTotal();
1094
+ var width = (100 / total) + '%';
1095
+ var slices = ui.fill.children;
1096
+ // One slice per known check, in order. The rest of the track stays empty,
1097
+ // which is what tells you how much is still to come.
1098
+ while (slices.length > order.length) ui.fill.removeChild(ui.fill.lastChild);
1099
+ while (slices.length < order.length) {
1100
+ var span = document.createElement('span');
1101
+ span.className = 'slice';
1102
+ ui.fill.appendChild(span);
1103
+ }
1104
+ for (var i = 0; i < order.length; i++) {
1105
+ var tone = order[i].tone || '';
1106
+ slices[i].className = 'slice' + (tone ? ' ' + tone : '');
1107
+ slices[i].style.width = width;
1108
+ }
1109
+ }
1110
+
1111
+ function updateCounts() {
1112
+ var done = 0;
1113
+ for (var i = 0; i < order.length; i++) {
1114
+ if (outcomes[keyFor(order[i].kind, order[i].name)]) done++;
1115
+ }
1116
+ var total = meterTotal();
1117
+ // Say what happened, not just how far along it is. "11/11" tells a person the
1118
+ // run finished and nothing else; they still have to read the whole list to find
1119
+ // out whether they can walk away. The tally answers that in four words.
1120
+ var tally = { changed: 0, failed: 0, waiting: 0, skipped: 0 };
1121
+ for (var t = 0; t < order.length; t++) {
1122
+ var st = outcomes[keyFor(order[t].kind, order[t].name)];
1123
+ if (st === 'changed') tally.changed++;
1124
+ else if (st === 'failed' || st === 'missing') tally.failed++;
1125
+ else if (st === 'new') tally.waiting++;
1126
+ else if (st === 'skipped') tally.skipped++;
1127
+ }
1128
+ var parts = [];
1129
+ if (tally.failed) parts.push(commas(tally.failed) + ' broke');
1130
+ if (tally.changed) parts.push(commas(tally.changed) + ' moved');
1131
+ if (tally.waiting) parts.push(commas(tally.waiting) + ' needs you');
1132
+ if (tally.skipped) parts.push(commas(tally.skipped) + ' skipped');
1133
+ var counted = commas(done) + ' of ' + commas(total);
1134
+ if (done === total && total > 0 && parts.length === 0) counted += ' — all held';
1135
+ ui.counts.textContent = order.length ? counted + (parts.length ? ' · ' + parts.join(' · ') : '') : '';
1136
+ ui.countScreens.textContent = countIn('picture');
1137
+ ui.countGuards.textContent = countIn('guard');
1138
+ ui.nothing.hidden = order.length > 0;
1139
+ paintMeter();
1140
+ }
1141
+
1142
+ function countIn(kind) {
1143
+ var n = 0;
1144
+ for (var i = 0; i < order.length; i++) if (order[i].kind === kind) n++;
1145
+ return n ? String(n) : '';
1146
+ }
1147
+
1148
+ // Auto-scroll follows the running row, and stops the moment the person
1149
+ // scrolls for themselves. Nothing is more annoying than a list that yanks
1150
+ // itself back while you are reading it.
1151
+ var following = true;
1152
+ function stopFollowing() {
1153
+ if (!following) return;
1154
+ following = false;
1155
+ ui.follow.hidden = false;
1156
+ }
1157
+ ['wheel', 'touchmove', 'keydown'].forEach(function (name) {
1158
+ ui.scroll.addEventListener(name, stopFollowing, { passive: true });
1159
+ });
1160
+ ui.follow.addEventListener('click', function () {
1161
+ following = true;
1162
+ ui.follow.hidden = true;
1163
+ if (runningItem) runningItem.root.scrollIntoView({ block: 'nearest' });
1164
+ });
1165
+
1166
+ /** Which row the picture on the glass belongs to. */
1167
+ function markShowing(entry) {
1168
+ for (var i = 0; i < order.length; i++) order[i].root.classList.remove('showing');
1169
+ if (entry) entry.root.classList.add('showing');
1170
+ }
1171
+
1172
+ var runningItem = null;
1173
+ function markRunning(entry) {
1174
+ runningItem = entry;
1175
+ setTone(entry, 'running');
1176
+ if (following) entry.root.scrollIntoView({ block: 'nearest' });
1177
+ }
1178
+
1179
+ // -------------------------------------------------------------------------
1180
+ // The picture
1181
+ // -------------------------------------------------------------------------
1182
+
1183
+ var frontIsA = false;
1184
+ var lastShot = '';
1185
+ var shownSrc = '';
1186
+ var shownAlt = '';
1187
+
1188
+ function showShot(src, alt) {
1189
+ if (!src) return;
1190
+ // A run that turned out to have pictures after all gets its glass back.
1191
+ ui.stage.hidden = false;
1192
+ shownSrc = src;
1193
+ shownAlt = alt || '';
1194
+ ui.blank.hidden = true;
1195
+ ui.shot.classList.remove('empty');
1196
+ var next = frontIsA ? ui.layerB : ui.layerA;
1197
+ var current = frontIsA ? ui.layerA : ui.layerB;
1198
+ next.src = src;
1199
+ next.alt = shownAlt;
1200
+ next.classList.add('on');
1201
+ current.classList.remove('on');
1202
+ frontIsA = !frontIsA;
1203
+ }
1204
+
1205
+ function nameTheShot(name) {
1206
+ ui.shotname.textContent = name || '';
1207
+ }
1208
+
1209
+ // The light behind everything takes the colour of the worst thing that has
1210
+ // happened so far. It is the one piece of the panel a person reads without
1211
+ // looking at it.
1212
+ function tint(tone) {
1213
+ var value = tone && tone !== 'held' && tone !== 'skip' ? 'var(--' + tone + ')' : 'var(--accent)';
1214
+ document.documentElement.style.setProperty('--tint', value);
1215
+ }
1216
+
1217
+ function clearTabs() {
1218
+ ui.tabs.hidden = true;
1219
+ ui.tabs.textContent = '';
1220
+ }
1221
+
1222
+ function singlePicture() {
1223
+ clearTabs();
1224
+ ui.shotout.hidden = true;
1225
+ }
1226
+
1227
+ /**
1228
+ * The line beside the picture's name. Silent when a screen simply held,
1229
+ * because a panel that congratulates you on every screen is a panel you stop
1230
+ * reading.
1231
+ */
1232
+ function sayOutcome(tone, text) {
1233
+ if (!text || !tone || tone === 'held' || tone === 'skip') {
1234
+ ui.shotout.hidden = true;
1235
+ return;
1236
+ }
1237
+ ui.shotout.textContent = text;
1238
+ ui.shotout.className = 'shotout ' + tone;
1239
+ ui.shotout.hidden = false;
1240
+ }
1241
+
1242
+ // A screen that moved is the one moment a person has a real decision to make,
1243
+ // so all three pictures are one click away and the difference is the one
1244
+ // already on screen — that is the picture that answers the question.
1245
+ function comparePictures(ev) {
1246
+ var choices = [];
1247
+ if (ev.approvedThumb) choices.push({ label: 'Approved', src: ev.approvedThumb });
1248
+ var now = ev.thumbnail || lastShot;
1249
+ if (now) choices.push({ label: 'Now', src: now });
1250
+ if (ev.diffThumb) choices.push({ label: 'Difference', src: ev.diffThumb });
1251
+ // Two pictures is the whole point; one on its own says nothing a person
1252
+ // could act on, so leave the live picture up instead.
1253
+ if (choices.length < 2) return false;
1254
+
1255
+ ui.tabs.textContent = '';
1256
+ var buttons = [];
1257
+ function select(index) {
1258
+ for (var i = 0; i < buttons.length; i++) {
1259
+ buttons[i].className = i === index ? 'on' : '';
1260
+ buttons[i].setAttribute('aria-pressed', i === index ? 'true' : 'false');
1261
+ }
1262
+ showShot(choices[index].src, choices[index].label.toLowerCase() + ' picture of ' + (ev.name || 'this screen'));
1263
+ }
1264
+ choices.forEach(function (choice, index) {
1265
+ var button = document.createElement('button');
1266
+ button.type = 'button';
1267
+ button.textContent = choice.label;
1268
+ button.addEventListener('click', function () { select(index); });
1269
+ buttons.push(button);
1270
+ ui.tabs.appendChild(button);
1271
+ });
1272
+ ui.tabs.hidden = false;
1273
+
1274
+ var preferred = choices.length - 1;
1275
+ for (var i = 0; i < choices.length; i++) {
1276
+ if (choices[i].label === 'Difference') preferred = i;
1277
+ }
1278
+ select(preferred);
1279
+ return true;
1280
+ }
1281
+
1282
+ function enlarge(src, alt) {
1283
+ if (!src) return;
1284
+ ui.lightimg.src = src;
1285
+ ui.lightimg.alt = alt || '';
1286
+ ui.lightbox.hidden = false;
1287
+ }
1288
+ ui.shot.addEventListener('click', function () { enlarge(shownSrc, shownAlt); });
1289
+ ui.lightbox.addEventListener('click', function () { ui.lightbox.hidden = true; });
1290
+ document.addEventListener('keydown', function (e) {
1291
+ if (e.key === 'Escape') ui.lightbox.hidden = true;
1292
+ });
1293
+
1294
+ // -------------------------------------------------------------------------
1295
+ // The clock
1296
+ // -------------------------------------------------------------------------
1297
+
1298
+ var origin = null;
1299
+ var ticking = null;
1300
+
1301
+ function startClock(at) {
1302
+ if (origin !== null) return;
1303
+ origin = performance.now() - (Number(at) || 0);
1304
+ ticking = setInterval(function () {
1305
+ ui.clock.textContent = fmt(performance.now() - origin);
1306
+ }, 100);
1307
+ }
1308
+
1309
+ function stopClock(finalMs) {
1310
+ if (ticking) { clearInterval(ticking); ticking = null; }
1311
+ if (typeof finalMs === 'number' && isFinite(finalMs)) ui.clock.textContent = fmt(finalMs);
1312
+ }
1313
+
1314
+ // -------------------------------------------------------------------------
1315
+ // Where the time went. Folded shut, because it is a thing you go and look at
1316
+ // once in a while rather than something you need on screen all day.
1317
+ // -------------------------------------------------------------------------
1318
+
1319
+ var TIMING_PARTS = [
1320
+ ['launch', 'opening the app'],
1321
+ ['steps', 'clicking through the app'],
1322
+ ['prepare', 'waiting for fonts and pictures'],
1323
+ ['settle', 'waiting for the screen to hold still'],
1324
+ ['compare', 'comparing the pictures'],
1325
+ ['guards', 'running the guards'],
1326
+ ['other', 'everything else']
1327
+ ];
1328
+
1329
+ // Shades of the text colour, darkest first. Not a palette — a ramp, so the
1330
+ // breakdown reads as one object and never competes with the four colours
1331
+ // that actually mean something.
1332
+ function shade(index, count) {
1333
+ var top = 46;
1334
+ var bottom = 11;
1335
+ var step = count > 1 ? (top - bottom) / (count - 1) : 0;
1336
+ return 'color-mix(in srgb, var(--ink) ' + (top - step * index).toFixed(1) + '%, transparent)';
1337
+ }
1338
+
1339
+ var timingOpen = false;
1340
+ ui.thead.addEventListener('click', function () {
1341
+ timingOpen = !timingOpen;
1342
+ ui.tkey.hidden = !timingOpen;
1343
+ ui.thead.setAttribute('aria-expanded', timingOpen ? 'true' : 'false');
1344
+ });
1345
+
1346
+ function showTiming(timings) {
1347
+ if (!timings || typeof timings !== 'object') return;
1348
+ var parts = [];
1349
+ var sum = 0;
1350
+ TIMING_PARTS.forEach(function (part) {
1351
+ var ms = Number(timings[part[0]]);
1352
+ if (!isFinite(ms) || ms <= 0) return;
1353
+ sum += ms;
1354
+ parts.push({ label: part[1], ms: ms });
1355
+ });
1356
+ if (!parts.length || sum <= 0) return;
1357
+
1358
+ // Biggest first: the answer to "where did the time go" is the first row.
1359
+ parts.sort(function (a, b) { return b.ms - a.ms; });
1360
+
1361
+ ui.tbar.textContent = '';
1362
+ ui.tkey.textContent = '';
1363
+ parts.forEach(function (part, index) {
1364
+ var colour = shade(index, parts.length);
1365
+
1366
+ var bar = document.createElement('span');
1367
+ bar.className = 'tpart';
1368
+ bar.style.flex = String(part.ms / sum) + ' 1 0';
1369
+ bar.style.background = colour;
1370
+ bar.title = part.label + ' — ' + fmt(part.ms);
1371
+ ui.tbar.appendChild(bar);
1372
+
1373
+ var item = document.createElement('li');
1374
+ var swatch = document.createElement('span');
1375
+ swatch.className = 'tswatch';
1376
+ swatch.style.background = colour;
1377
+ var text = document.createElement('span');
1378
+ text.className = 'tlabelled';
1379
+ text.textContent = part.label;
1380
+ var value = document.createElement('b');
1381
+ value.textContent = fmt(part.ms);
1382
+ item.appendChild(swatch);
1383
+ item.appendChild(text);
1384
+ item.appendChild(value);
1385
+ ui.tkey.appendChild(item);
1386
+ });
1387
+ ui.ttotal.textContent = '';
1388
+ var most = document.createElement('b');
1389
+ most.textContent = fmt(parts[0].ms);
1390
+ ui.ttotal.appendChild(most);
1391
+ ui.ttotal.appendChild(document.createTextNode(' ' + parts[0].label));
1392
+ ui.timing.hidden = false;
1393
+ }
1394
+
1395
+ // -------------------------------------------------------------------------
1396
+ // What to do next
1397
+ // -------------------------------------------------------------------------
1398
+
1399
+ function showNextStep(summary) {
1400
+ var pictures = (summary && summary.pictures) || [];
1401
+ var waiting = pictures.filter(function (p) {
1402
+ return p && (p.status === 'changed' || p.status === 'new' || p.status === 'missing');
1403
+ });
1404
+ // The verdict is already the biggest sentence on the page. The footer only
1405
+ // ever exists when there is something for a person to actually type.
1406
+ if (!waiting.length) { ui.footer.hidden = true; return; }
1407
+ ui.cmd.textContent = waiting.length === 1
1408
+ ? 'staysfixed approve ' + waiting[0].name
1409
+ : 'staysfixed approve --all';
1410
+ ui.footer.hidden = false;
1411
+ }
1412
+
1413
+ ui.copy.addEventListener('click', function () {
1414
+ var text = ui.cmd.textContent || '';
1415
+ // A page opened from a file is not a secure context, so the modern
1416
+ // clipboard is usually refused here. The old way still works.
1417
+ var box = document.createElement('textarea');
1418
+ box.value = text;
1419
+ box.setAttribute('readonly', '');
1420
+ box.style.position = 'fixed';
1421
+ box.style.opacity = '0';
1422
+ document.body.appendChild(box);
1423
+ box.select();
1424
+ try {
1425
+ document.execCommand('copy');
1426
+ ui.copy.classList.add('done');
1427
+ setTimeout(function () { ui.copy.classList.remove('done'); }, 1300);
1428
+ } catch (err) { /* nothing to do */ }
1429
+ document.body.removeChild(box);
1430
+ });
1431
+
1432
+ // -------------------------------------------------------------------------
1433
+ // The sentence at the top
1434
+ // -------------------------------------------------------------------------
1435
+
1436
+ function setState(text, tone) {
1437
+ ui.state.textContent = text;
1438
+ ui.state.className = 'state' + (tone ? ' ' + tone : '');
1439
+ }
1440
+
1441
+ function countOf(list) { return Array.isArray(list) ? list.length : 0; }
1442
+
1443
+ var announcedScreens = false;
1444
+ var announcedGuards = false;
1445
+
1446
+ // -------------------------------------------------------------------------
1447
+ // Events
1448
+ // -------------------------------------------------------------------------
1449
+
1450
+ function handle(ev) {
1451
+ var type = ev.type;
1452
+ if (typeof ev.at === 'number') startClock(ev.at);
1453
+
1454
+ if (type === 'run:start') {
1455
+ var planned = ev.plan || {};
1456
+ if (planned.project) ui.project.textContent = planned.project;
1457
+ if (planned.app) {
1458
+ ui.app.textContent = planned.app;
1459
+ ui.app.hidden = false;
1460
+ ui.targetsep.hidden = false;
1461
+ }
1462
+ // A caller who counted instead of listing still gets a truthful meter.
1463
+ if (typeof planned.screens === 'number') expected.picture = planned.screens;
1464
+ if (typeof planned.guards === 'number') expected.guard = planned.guards;
1465
+ setState('opening the app');
1466
+ updateCounts();
1467
+ return;
1468
+ }
1469
+
1470
+ if (type === 'phase') {
1471
+ if (ev.message) setState(ev.message);
1472
+ return;
1473
+ }
1474
+
1475
+ if (type === 'note') {
1476
+ if (ev.message) { ui.note.textContent = ev.message; ui.note.hidden = false; }
1477
+ return;
1478
+ }
1479
+
1480
+ if (type === 'screen:start') {
1481
+ if (typeof ev.total === 'number' && ev.total > expected.picture) expected.picture = ev.total;
1482
+ if (!announcedScreens) {
1483
+ announcedScreens = true;
1484
+ var screens = ev.total || expected.picture || countOf(plan.screens);
1485
+ setState(screens ? 'photographing ' + commas(screens) + ' ' + plural(screens, 'screen', 'screens') : 'photographing the screens');
1486
+ }
1487
+ // The picture is deliberately left alone here. Nothing has been
1488
+ // photographed yet, so renaming the frame now would put this screen's
1489
+ // name under the last screen's picture — a caption that lies for a
1490
+ // second is worse than one that is a second behind.
1491
+ markRunning(ensureItem('picture', String(ev.name || ''), ev.describe));
1492
+ return;
1493
+ }
1494
+
1495
+ if (type === 'screen:shot') {
1496
+ var shotRow = ev.name ? ensureItem('picture', String(ev.name), ev.describe) : null;
1497
+ if (ev.thumbnail) {
1498
+ lastShot = ev.thumbnail;
1499
+ if (shotRow) shotRow.pics = { name: ev.name, status: '', thumbnail: ev.thumbnail };
1500
+ singlePicture();
1501
+ showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
1502
+ }
1503
+ if (ev.name) nameTheShot(ev.name);
1504
+ markShowing(shotRow);
1505
+ return;
1506
+ }
1507
+
1508
+ if (type === 'screen:done') {
1509
+ var done = ensureItem('picture', String(ev.name || ''), ev.describe);
1510
+ var tone = finish(done, 'picture', ev);
1511
+ if (ev.thumbnail) lastShot = ev.thumbnail;
1512
+ // The name, the caption and the light behind the glass all move together
1513
+ // with the picture, or none of them move. A screen that could not be
1514
+ // photographed leaves the last real one up, correctly labelled, rather
1515
+ // than putting its own name under someone else's picture.
1516
+ var showed = false;
1517
+ if (ev.status === 'changed') {
1518
+ showed = comparePictures(ev);
1519
+ if (!showed && ev.thumbnail) {
1520
+ singlePicture();
1521
+ showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
1522
+ showed = true;
1523
+ }
1524
+ } else if (ev.thumbnail) {
1525
+ singlePicture();
1526
+ showShot(ev.thumbnail, 'the picture just taken of ' + (ev.name || 'this screen'));
1527
+ showed = true;
1528
+ }
1529
+ if (showed) {
1530
+ markShowing(done);
1531
+ nameTheShot(ev.name);
1532
+ // Anything but "still the same" is said beside the picture as well as
1533
+ // in the list, because the picture is where the person is looking and
1534
+ // that line is what tells them there is something to decide.
1535
+ sayOutcome(tone, outcomeShort(ev));
1536
+ }
1537
+ tint(worstTone());
1538
+ return;
1539
+ }
1540
+
1541
+ if (type === 'guard:start') {
1542
+ if (typeof ev.total === 'number' && ev.total > expected.guard) expected.guard = ev.total;
1543
+ if (!announcedGuards) {
1544
+ announcedGuards = true;
1545
+ var guards = ev.total || expected.guard || countOf(plan.guards);
1546
+ setState(guards ? 'running ' + commas(guards) + ' ' + plural(guards, 'guard', 'guards') : 'running the guards');
1547
+ }
1548
+ markRunning(ensureItem('guard', String(ev.name || ''), ev.describe));
1549
+ return;
1550
+ }
1551
+
1552
+ if (type === 'guard:done') {
1553
+ finish(ensureItem('guard', String(ev.name || ''), ev.describe), 'guard', ev);
1554
+ tint(worstTone());
1555
+ return;
1556
+ }
1557
+
1558
+ if (type === 'run:done') {
1559
+ runningItem = null;
1560
+ ui.follow.hidden = true;
1561
+ var summary = ev.summary || null;
1562
+ stopClock(typeof ev.at === 'number' ? ev.at : (summary && summary.durationMs));
1563
+ var verdict = ev.verdict || fallbackVerdict();
1564
+ var worst = worstTone();
1565
+ setState(verdict, worst);
1566
+ tint(worst);
1567
+ showTiming(ev.timings || (summary && summary.timings));
1568
+ showNextStep(summary);
1569
+ updateCounts();
1570
+ if (following) {
1571
+ requestAnimationFrame(function () {
1572
+ var first = null;
1573
+ for (var i = 0; i < order.length && !first; i++) {
1574
+ if (needsPerson(order[i].tone)) first = order[i];
1575
+ }
1576
+ if (first) first.root.scrollIntoView({ block: 'center' });
1577
+ else ui.scroll.scrollTop = 0;
1578
+ });
1579
+ }
1580
+ return;
1581
+ }
1582
+ // Anything else is something a newer version of Stays Fixed knows about
1583
+ // and this page does not. Ignoring it is the only safe thing to do.
1584
+ }
1585
+
1586
+ function finish(entry, kind, ev) {
1587
+ var tone = toneOf(ev.status);
1588
+ if (kind === 'picture') {
1589
+ entry.pics = {
1590
+ name: ev.name, status: ev.status,
1591
+ thumbnail: ev.thumbnail || (entry.pics && entry.pics.thumbnail) || '',
1592
+ approvedThumb: ev.approvedThumb || '',
1593
+ diffThumb: ev.diffThumb || '',
1594
+ diffPixels: ev.diffPixels, message: ev.message
1595
+ };
1596
+ if (!entry.pics.thumbnail && !entry.pics.approvedThumb && !entry.pics.diffThumb) entry.pics = null;
1597
+ }
1598
+ entry.outText = outcomeText(kind, ev);
1599
+ entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
1600
+ entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because) ? ev.because : '';
1601
+ if (typeof ev.durationMs === 'number') entry.time.textContent = fmt(ev.durationMs);
1602
+ entry.tone = tone;
1603
+ redraw(entry);
1604
+ setTone(entry, tone);
1605
+ // Only the checks that want a person open themselves. Everything else stays
1606
+ // one line, which is what keeps a clean run quiet.
1607
+ if (needsPerson(tone)) setOpen(entry, true);
1608
+ outcomes[keyFor(entry.kind, entry.name)] = tone || 'held';
1609
+ updateCounts();
1610
+ return tone;
1611
+ }
1612
+
1613
+ // The worst thing that happened, as a colour. Red beats amber beats blue
1614
+ // beats green, because a person should see the most serious state first.
1615
+ function worstTone() {
1616
+ var rank = { broke: 4, moved: 3, wait: 2, held: 1, skip: 0 };
1617
+ var worst = 'held';
1618
+ for (var key in outcomes) {
1619
+ var tone = outcomes[key];
1620
+ if ((rank[tone] || 0) > (rank[worst] || 0)) worst = tone;
1621
+ }
1622
+ return worst === 'skip' ? '' : worst;
1623
+ }
1624
+
1625
+ // Only used when a run finishes without the verdict the terminal printed —
1626
+ // an older run, or one that stopped early. It deliberately does not try to
1627
+ // reproduce those sentences; src/report/console.js owns them.
1628
+ function fallbackVerdict() {
1629
+ var total = order.length;
1630
+ var bad = 0;
1631
+ for (var key in outcomes) {
1632
+ if (outcomes[key] === 'broke' || outcomes[key] === 'moved' || outcomes[key] === 'wait') bad++;
1633
+ }
1634
+ if (!total) return 'finished';
1635
+ if (!bad) return 'Everything that worked still works.';
1636
+ return commas(bad) + ' of ' + commas(total) + ' ' + plural(total, 'check', 'checks') + ' needs a look.';
1637
+ }
1638
+
1639
+ // -------------------------------------------------------------------------
1640
+ // The one thing the run calls
1641
+ // -------------------------------------------------------------------------
1642
+
1643
+ window.__staysfixed_push = function (input) {
1644
+ try {
1645
+ var ev = typeof input === 'string' ? JSON.parse(input) : input;
1646
+ if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return;
1647
+ handle(ev);
1648
+ } catch (err) {
1649
+ // A watch window must never be the reason a run looks broken. Whatever
1650
+ // this event was, the next one still has to land.
1651
+ }
1652
+ };
1653
+
1654
+ // Called when the run lets go of the window, so the clock does not tick on
1655
+ // forever next to a result that is already final.
1656
+ window.__staysfixed_detach = function () {
1657
+ stopClock();
1658
+ runningItem = null;
1659
+ };
1660
+
1661
+ // Draw the plan before anything has happened, so the window is worth looking
1662
+ // at from the first frame instead of appearing empty.
1663
+ (function seed() {
1664
+ var i;
1665
+ for (i = 0; i < (plan.screens || []).length; i++) {
1666
+ addItem('picture', plan.screens[i].name, plan.screens[i].describe);
1667
+ }
1668
+ for (i = 0; i < (plan.guards || []).length; i++) {
1669
+ addItem('guard', plan.guards[i].name, plan.guards[i].describe);
1670
+ }
1671
+ updateCounts();
1672
+ if (!order.length) ui.shot.classList.add('empty');
1673
+ // Guards only: there will never be a picture, so the panel does not hold a
1674
+ // screen's worth of empty glass open waiting for one.
1675
+ if (!(plan.screens || []).length && (plan.guards || []).length) ui.stage.hidden = true;
1676
+ })();
1677
+ })();
1678
+ `;