staysfixed 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1660 @@
1
+ /**
2
+ * The window that shows a product being proved unchanged.
3
+ *
4
+ * Version 1 photographed screens and compared pixels, and its panel showed exactly that: a
5
+ * picture, a list of screens, a tick against each one. Version 2 is a different tool, so this
6
+ * is a different window. It has different things to say.
7
+ *
8
+ * WHAT IS BEING CHECKED, AND ON WHAT. One repository can build five products through five
9
+ * toolchains, and a change in shared code breaks the phone. So the walk is grouped by
10
+ * surface — a website, a desktop app, a phone in a simulator, a command-line tool, a server —
11
+ * and every journey says which one it is on.
12
+ *
13
+ * THE REFERENCE, AND HOW GOOD IT IS. The build he last shipped, walked live on this machine
14
+ * in this minute, or the weaker fallback of a record stored the last time the old build ran.
15
+ * When it is the weaker one this window says so where it cannot be missed. A tool admitting
16
+ * it is less certain than usual is worth more than a tool that looks confident.
17
+ *
18
+ * THE WOBBLE. How many addresses this build cannot answer the same way twice, measured by
19
+ * running it twice rather than guessed at with a tolerance, and subtracted arithmetically.
20
+ * It is the number that explains why the tool is quiet, and no other tool has it, so it gets
21
+ * a card of its own rather than a line in a log.
22
+ *
23
+ * WHAT SURVIVED. Findings, ranked, worst first, clustered. One finding can stand for five
24
+ * hundred differences: the count is shown, the five hundred are not.
25
+ *
26
+ * WHAT WAS NOT CHECKED. In counts and named gaps, never a percentage — a percentage invites
27
+ * a target and a target invites gaming. A green run on a product with hundreds of unopened
28
+ * doors says so in the same breath as the good news.
29
+ *
30
+ * WHAT NEEDS A PERSON. The sealed classes an agent may never wave through, and nothing else.
31
+ *
32
+ * Two rules hold the shape of it, and both come straight from the owner.
33
+ *
34
+ * IT MUST NEVER TAKE THE SCREEN. It opens, it sits there, and it never asks for attention it
35
+ * has not earned. Nothing here brings itself to the front, and minimising it changes nothing
36
+ * about the check: the run does not wait for a window, does not slow down without one, and
37
+ * does not notice when one goes away.
38
+ *
39
+ * IT MUST LOOK LIKE THE ONE HE PICKED. Near-monochrome on a neutral black ground, flat
40
+ * surfaces, hairlines, small radii, no shadows and no tint. Colour only where something needs
41
+ * a person: green held, red broke, amber doubtful, sky blue waiting on you, and grey for what
42
+ * never ran. Monospace for names, numbers and commands. Everything switched off for anyone
43
+ * who has asked their computer for less movement.
44
+ *
45
+ * The document is self-contained: no address of any kind, no font downloaded, no framework, no
46
+ * build step. Everything it will ever need is in the string this file returns. The one thing it
47
+ * does load from outside is evidence — a picture, a before-and-after — and only when somebody
48
+ * asks for it, off the local disk, because in version 2 pictures are the seventh channel and
49
+ * the last one.
50
+ */
51
+
52
+ import { SURFACE_WORDS, SOURCE_WORDS, surfaceWord } from './events.js';
53
+
54
+ /** @typedef {import('./events.js').PanelPlanShape} PanelPlan */
55
+ /** @typedef {import('./events.js').PanelJourney} PanelJourney */
56
+
57
+ /**
58
+ * Text that is safe to put inside an element.
59
+ *
60
+ * Small and local on purpose: this file is the whole window, and a window that cannot be
61
+ * rendered because a helper moved is not worth the shared line of code.
62
+ *
63
+ * @param {unknown} value
64
+ * @returns {string}
65
+ */
66
+ function escapeHtml(value) {
67
+ return String(value ?? '')
68
+ .replace(/&/g, '&')
69
+ .replace(/</g, '&lt;')
70
+ .replace(/>/g, '&gt;')
71
+ .replace(/"/g, '&quot;');
72
+ }
73
+
74
+ /**
75
+ * JSON safe to sit inside a script tag. A closing tag inside a string would end the tag early
76
+ * and leave half the plan on the page as text, so the one character that can do that never
77
+ * survives.
78
+ * @param {unknown} value
79
+ * @returns {string}
80
+ */
81
+ function embedJson(value) {
82
+ return JSON.stringify(value ?? null).replace(/</g, '\\u003c');
83
+ }
84
+
85
+ /**
86
+ * Keep only what the window draws, and only in shapes it can trust.
87
+ * @param {PanelJourney[]|undefined} list
88
+ * @returns {PanelJourney[]}
89
+ */
90
+ function tidyJourneys(list) {
91
+ if (!Array.isArray(list)) return [];
92
+ /** @type {PanelJourney[]} */
93
+ const out = [];
94
+ for (const item of list) {
95
+ if (!item || typeof item !== 'object') continue;
96
+ const name = String(item.name ?? '').trim();
97
+ if (!name) continue;
98
+ out.push({
99
+ name,
100
+ describe: item.describe ? String(item.describe) : undefined,
101
+ surface: item.surface,
102
+ surfaceWord: item.surfaceWord ? String(item.surfaceWord) : undefined,
103
+ source: item.source ? String(item.source) : undefined,
104
+ sourceWord: item.sourceWord ? String(item.sourceWord) : undefined,
105
+ skip: item.skip ? String(item.skip) : undefined,
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * The reference, in the one shape the window draws, whatever the host had to hand.
113
+ *
114
+ * @param {PanelPlan} plan
115
+ * @returns {import('./events.js').PanelReference|null}
116
+ */
117
+ function tidyReference(plan) {
118
+ const given = plan.reference;
119
+ const weak = plan.mode === 'stored-record';
120
+ if (given && typeof given === 'object') {
121
+ return { ...given, weak: given.weak ?? weak, warning: given.warning ?? plan.modeWarning };
122
+ }
123
+ const sentence = typeof given === 'string' ? given.trim() : '';
124
+ if (!sentence && !plan.mode) return null;
125
+ // The sentence is the explanation, never also the name: printing the same words twice, once
126
+ // in the monospace and once underneath, reads as a mistake rather than as emphasis.
127
+ return {
128
+ name: weak ? 'a stored record' : 'the build you last shipped',
129
+ mode: plan.mode ?? 'paired',
130
+ weak,
131
+ how: sentence,
132
+ warning: plan.modeWarning,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * The mark. A padlock with a tick inside it: the whole product in one shape — the thing that
138
+ * was already fixed is still shut. Monoline, drawn on the 24 grid, inheriting its colour so it
139
+ * can never fight the theme. No namespace attribute: inline SVG in an HTML document does not
140
+ * need one, and this page is not allowed to name an address of any kind.
141
+ */
142
+ const MARK = [
143
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
144
+ ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
145
+ '<rect x="4.2" y="10" width="15.6" height="10.4" rx="3.6"></rect>',
146
+ '<path d="M8.1 10V7.9a3.9 3.9 0 0 1 7.8 0V10"></path>',
147
+ '<path d="M9.7 15.2l1.8 1.9 2.9-3.5"></path>',
148
+ '</svg>',
149
+ ].join('');
150
+
151
+ /** A cross: put the evidence away. */
152
+ const CLOSE = [
153
+ '<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
154
+ ' stroke-width="1.7" stroke-linecap="round" aria-hidden="true">',
155
+ '<path d="M7 7l10 10M17 7L7 17"></path>',
156
+ '</svg>',
157
+ ].join('');
158
+
159
+ /**
160
+ * The whole panel document.
161
+ *
162
+ * @param {PanelPlan} [plan]
163
+ * @returns {string}
164
+ */
165
+ export function panelHtml(plan = {}) {
166
+ const product = String(plan.product ?? '').trim() || 'this product';
167
+ const project = String(plan.project ?? '').trim();
168
+ const journeys = tidyJourneys(plan.journeys);
169
+
170
+ /** @type {string[]} */
171
+ const surfaces = [];
172
+ for (const word of plan.surfaces ?? []) {
173
+ const clean = String(word ?? '').trim();
174
+ if (clean && !surfaces.includes(clean)) surfaces.push(clean);
175
+ }
176
+ // A check that walks exactly one surface can just name it, rather than building a list of one.
177
+ if (plan.surface) {
178
+ const one = surfaceWord(String(plan.surface));
179
+ if (!surfaces.includes(one)) surfaces.push(one);
180
+ }
181
+ for (const j of journeys) {
182
+ if (j.surfaceWord && !surfaces.includes(j.surfaceWord)) surfaces.push(j.surfaceWord);
183
+ }
184
+
185
+ // The reference, however the host has it. A whole shape is drawn in full; a bare sentence is
186
+ // shown as the sentence, and is treated as the weaker kind whenever the mode says so —
187
+ // a run measured against a stored record must never look like the strong one.
188
+ const reference = tidyReference(plan);
189
+
190
+ // Dark unless somebody asks otherwise. This window opens on a brand new browser profile, and
191
+ // a fresh profile insists the computer is in light mode however it is really set — so the
192
+ // look is stated rather than sniffed.
193
+ const wanted = String(plan.theme ?? 'dark');
194
+ const themeAttr = wanted === 'light' || wanted === 'system' ? wanted : 'dark';
195
+
196
+ const embedded = embedJson({
197
+ product,
198
+ project,
199
+ journeys,
200
+ surfaces,
201
+ reference,
202
+ words: SURFACE_WORDS,
203
+ sources: SOURCE_WORDS,
204
+ });
205
+
206
+ const subtitle = surfaces.length ? surfaces.join(' · ') : '';
207
+
208
+ return [
209
+ '<!doctype html>',
210
+ `<html lang="en" data-theme="${themeAttr}">`,
211
+ '<head>',
212
+ '<meta charset="utf-8">',
213
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
214
+ // Just the name of the thing, because this string IS the window's title bar.
215
+ '<title>Stays Fixed</title>',
216
+ `<style>${STYLE}</style>`,
217
+ '</head>',
218
+ '<body>',
219
+ '<div class="aura" aria-hidden="true"></div>',
220
+ '<div class="panel">',
221
+
222
+ // --- the header: whose window this is, and where the check has got to ---
223
+ '<header class="top">',
224
+ '<div class="brand">',
225
+ `<span class="badge">${MARK}</span>`,
226
+ '<span class="wordmark">Stays Fixed</span>',
227
+ '<span class="elapsed mono" id="clock">0.0s</span>',
228
+ '</div>',
229
+ `<p class="target"><span class="mono" id="product">${escapeHtml(product)}</span>`,
230
+ `<span class="sep" id="targetsep"${subtitle ? '' : ' hidden'}>&#183;</span>`,
231
+ `<span class="app" id="surfaces"${subtitle ? '' : ' hidden'}>${escapeHtml(subtitle)}</span></p>`,
232
+ // The one sentence a person reads from four feet away.
233
+ '<p class="state" id="state">getting ready</p>',
234
+ '<p class="what" id="what"></p>',
235
+ '<div class="meter">',
236
+ '<div class="track" id="track"><div class="fill" id="fill"></div></div>',
237
+ '<span class="counts mono" id="counts"></span>',
238
+ '</div>',
239
+ '</header>',
240
+
241
+ '<div class="scroll" id="scroll">',
242
+
243
+ // --- what this is being measured against, and how good that is ---------
244
+ //
245
+ // High on the page and never folded away. Everything below it is only worth what this
246
+ // line says it is worth, and a run compared against a stored record is worth less.
247
+ '<section class="reference" id="refstrip" hidden>',
248
+ '<div class="refrow">',
249
+ '<span class="refdot" id="refdot" aria-hidden="true"></span>',
250
+ '<p class="reflabel">Measured against</p>',
251
+ '<span class="refname mono" id="refname"></span>',
252
+ '</div>',
253
+ '<p class="refhow" id="refhow"></p>',
254
+ '<p class="refwarn" id="refwarn" hidden></p>',
255
+ '</section>',
256
+
257
+ // --- the walk, live ----------------------------------------------------
258
+ '<section class="group" id="groupWalk" hidden>',
259
+ '<p class="grouplabel"><span class="glabel">The walk</span><span class="gcount mono" id="countWalk"></span></p>',
260
+ '<div id="surfaceList"></div>',
261
+ '</section>',
262
+
263
+ // --- the wobble --------------------------------------------------------
264
+ '<section class="group" id="groupWobble" hidden>',
265
+ '<p class="grouplabel"><span class="glabel">This build against itself</span></p>',
266
+ '<div class="card">',
267
+ '<div class="figures">',
268
+ '<div class="figure" id="wSteadyWrap"><b class="mono" id="wSteady">0</b><span>answered the same way twice</span></div>',
269
+ '<div class="figure doubt" id="wUnstableWrap"><b class="mono" id="wUnstable">0</b><span>would not sit still &#8212; subtracted, not counted</span></div>',
270
+ '<div class="figure doubt" id="wNewWrap" hidden><b class="mono" id="wNew">0</b><span>were steady before this change and wobble now</span></div>',
271
+ '</div>',
272
+ '<p class="cardnote" id="wobbleNote"></p>',
273
+ '<ul class="paths mono" id="wobblePaths" hidden></ul>',
274
+ '</div>',
275
+ '</section>',
276
+
277
+ // --- what survived -----------------------------------------------------
278
+ '<section class="group" id="groupFindings" hidden>',
279
+ '<p class="grouplabel"><span class="glabel">What survived</span><span class="gcount mono" id="countFindings"></span></p>',
280
+ '<div class="items" id="findingList"></div>',
281
+ '</section>',
282
+
283
+ // --- what was not checked ----------------------------------------------
284
+ '<section class="group" id="groupCoverage" hidden>',
285
+ '<p class="grouplabel"><span class="glabel">What was not checked</span></p>',
286
+ '<div class="card">',
287
+ '<div class="figures" id="covFigures"></div>',
288
+ '<p class="cardnote" id="covNote"></p>',
289
+ '<ul class="gaps" id="gapList"></ul>',
290
+ '<p class="cardnote" id="gapMore" hidden></p>',
291
+ '</div>',
292
+ '</section>',
293
+
294
+ '<p class="nothing" id="nothing">Nothing has been walked yet.<br>The window fills in as the check runs.</p>',
295
+ '</div>',
296
+
297
+ // --- the only thing that ever reaches a person -------------------------
298
+ '<footer class="foot" id="footer" hidden>',
299
+ '<p class="nextlabel">Needs a person</p>',
300
+ '<div id="needs"></div>',
301
+ '<p class="footwhy">No agent is allowed to wave these through.</p>',
302
+ '</footer>',
303
+
304
+ '</div>',
305
+
306
+ // --- evidence, fetched rather than pushed -------------------------------
307
+ //
308
+ // Version 2 is mostly not about pictures, so nothing here loads until somebody asks. The
309
+ // address is a local file the run has already written; the window opens it at full size.
310
+ '<div class="viewer" id="viewer" hidden>',
311
+ '<header class="vtop">',
312
+ '<span class="vname mono" id="vname"></span>',
313
+ `<button class="vclose" id="vclose" type="button" aria-label="close the evidence">${CLOSE}</button>`,
314
+ '</header>',
315
+ '<div class="vstage" id="vstage"><img class="vimg" id="vimg" alt="evidence for this finding"></div>',
316
+ '<p class="vhelp">esc to close</p>',
317
+ '</div>',
318
+
319
+ `<script type="application/json" id="staysfixed-plan">${embedded}</script>`,
320
+ `<script>${SCRIPT}</script>`,
321
+ '</body>',
322
+ '</html>',
323
+ '',
324
+ ].join('\n');
325
+ }
326
+
327
+ const STYLE = `
328
+ /* --------------------------------------------------------------------------
329
+ Tokens — graphite and signal. Taken from the panel the owner picked, whole.
330
+
331
+ The page is graphite. Ground, cards and every piece of furniture on them are
332
+ one family of greys, separated only by how much light they carry and by a
333
+ hairline where two of them meet. Type does the rest: one monospace for names
334
+ and numbers, one text face for sentences, one scale of six sizes.
335
+
336
+ Colour is not decoration and it is not a mood. It is reserved for the things
337
+ a person has to be told: something BROKE, something is DOUBTFUL, something is
338
+ WAITING for them. A check that held wears green because a person scanning a
339
+ list wants to SEE that it held rather than infer it from an absence. Grey is
340
+ only for what never ran.
341
+ -------------------------------------------------------------------------- */
342
+ :root {
343
+ color-scheme: dark;
344
+
345
+ /* Neutral black. No warm cast, no cool cast. */
346
+ --ground: #101010;
347
+ --lift: #191919;
348
+ --card: rgba(255, 255, 255, 0.035);
349
+ --card-hover: rgba(255, 255, 255, 0.06);
350
+ --well: #0a0a0a;
351
+ --glass: rgba(16, 16, 16, 0.9);
352
+
353
+ --ink: #ededed;
354
+ --soft: #b2b2b2;
355
+ --faint: #8d8d8d;
356
+ --faintest: #6d6d6d;
357
+ --doubt: #e8b85c;
358
+
359
+ --line: rgba(255, 255, 255, 0.055);
360
+ --line-firm: rgba(255, 255, 255, 0.17);
361
+ --shadow: rgba(0, 0, 0, 0.5);
362
+
363
+ /* The brand mark, the thing that is running, and whatever wants a person are
364
+ all this one colour — so on a page that is otherwise grey, this means you. */
365
+ --accent: #4fb3f0;
366
+ --held: #25d366;
367
+ --broke: #ff4438;
368
+ --wait: #4fb3f0;
369
+ --moved: #ffc24d;
370
+
371
+ --resting: rgba(255, 255, 255, 0.09);
372
+ --running: var(--accent);
373
+
374
+ --radius: 10px;
375
+ --radius-sm: 8px;
376
+ --radius-xs: 6px;
377
+
378
+ --pad: 16px;
379
+ --tint: var(--accent);
380
+
381
+ --t-label: 10px;
382
+ --t-meta: 10.5px;
383
+ --t-small: 11px;
384
+ --t-body: 11.5px;
385
+ --t-name: 12px;
386
+ --t-fig: 21px;
387
+ --t-lead: 18px;
388
+
389
+ --ease: cubic-bezier(0.22, 0.72, 0.24, 1);
390
+ --quick: 170ms;
391
+ --calm: 260ms;
392
+ --slow: 320ms;
393
+ }
394
+ /*
395
+ * Light is opt-in. This window opens on a brand new browser profile, and a fresh profile
396
+ * answers "prefers-color-scheme" with "light" whatever the computer around it is set to —
397
+ * so the look is stated on the html element rather than sniffed. The same rock lit from the
398
+ * other side: a stone ground, cards a shade brighter than it, the signal colours taken down
399
+ * until they hold on paper.
400
+ */
401
+ :root[data-theme='light'],
402
+ :root[data-theme='system'] {
403
+ color-scheme: light;
404
+
405
+ --ground: #d9dade;
406
+ --lift: #eef0f3;
407
+ --card: rgba(255, 255, 255, 0.6);
408
+ --card-hover: #ffffff;
409
+ --well: #c7c9cf;
410
+ --glass: rgba(232, 234, 238, 0.9);
411
+
412
+ --ink: #14161a;
413
+ --soft: #545a62;
414
+ --faint: #5e646c;
415
+ --faintest: #6f757d;
416
+ --doubt: #8a5f06;
417
+
418
+ --line: rgba(20, 22, 26, 0.11);
419
+ --line-firm: rgba(20, 22, 26, 0.24);
420
+ --shadow: rgba(26, 30, 38, 0.34);
421
+
422
+ --accent: #474d56;
423
+ --held: #757b83;
424
+ --broke: #7e1105;
425
+ --wait: #0e5588;
426
+ --moved: #8a5f06;
427
+
428
+ --resting: rgba(20, 22, 26, 0.13);
429
+ }
430
+
431
+ * { box-sizing: border-box; }
432
+ [hidden] { display: none !important; }
433
+ html, body { margin: 0; padding: 0; height: 100%; }
434
+ body {
435
+ background: var(--ground);
436
+ color: var(--ink);
437
+ font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
438
+ -webkit-font-smoothing: antialiased;
439
+ overflow: hidden;
440
+ }
441
+ /* The terminal character of the thing: every name, number and address is set in
442
+ the monospace, and nothing else is. */
443
+ .mono, code {
444
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
445
+ font-variant-numeric: tabular-nums;
446
+ }
447
+ p, h2, ul { margin: 0; }
448
+ ul { padding: 0; list-style: none; }
449
+ img { display: block; }
450
+ button { font: inherit; color: inherit; }
451
+ .glyph { width: 16px; height: 16px; flex: 0 0 auto; }
452
+
453
+ /* One light above, one shadow below, and the tint of the worst thing that has
454
+ happened — which on a run where nothing broke is no colour at all. */
455
+ .aura {
456
+ position: fixed; inset: 0; pointer-events: none; z-index: 0;
457
+ background:
458
+ radial-gradient(126% 42% at 50% -12%, color-mix(in srgb, var(--tint) 12%, transparent), transparent 70%),
459
+ radial-gradient(128% 56% at 50% 116%, var(--shadow), transparent 62%);
460
+ transition: background 620ms var(--ease);
461
+ }
462
+
463
+ .panel { position: relative; z-index: 1; display: flex; flex-direction: column; height: 100%; }
464
+
465
+ /* --- header -------------------------------------------------------------- */
466
+ .top {
467
+ flex: 0 0 auto;
468
+ padding: 16px var(--pad) 15px;
469
+ background: var(--glass);
470
+ backdrop-filter: blur(22px) saturate(120%);
471
+ -webkit-backdrop-filter: blur(22px) saturate(120%);
472
+ box-shadow: 0 1px 0 var(--line);
473
+ }
474
+ .brand { display: flex; align-items: center; gap: 9px; }
475
+ .badge {
476
+ flex: 0 0 auto;
477
+ display: flex; align-items: center; justify-content: center;
478
+ width: 24px; height: 24px;
479
+ border-radius: 8px;
480
+ color: var(--ink);
481
+ background: var(--lift);
482
+ box-shadow: inset 0 0 0 1px var(--line-firm);
483
+ }
484
+ .badge .glyph { width: 15px; height: 15px; }
485
+ .wordmark {
486
+ flex: 1 1 auto; min-width: 0;
487
+ font-size: var(--t-label); font-weight: 600;
488
+ letter-spacing: 0.22em; text-transform: uppercase;
489
+ color: var(--soft);
490
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
491
+ }
492
+ .elapsed { flex: 0 0 auto; font-size: var(--t-body); color: var(--faint); }
493
+
494
+ .target {
495
+ display: flex; align-items: baseline; gap: 6px;
496
+ margin-top: 13px; font-size: var(--t-name);
497
+ white-space: nowrap; overflow: hidden;
498
+ }
499
+ .target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
500
+ .target .sep { color: var(--faintest); flex: 0 0 auto; }
501
+ .target .app {
502
+ color: var(--faint); flex: 1 1 auto; min-width: 0;
503
+ overflow: hidden; text-overflow: ellipsis; font-size: var(--t-body);
504
+ }
505
+
506
+ /* The sentence. The largest text on the page, because it is the one thing read
507
+ from four feet away. It stays white; a mark in front of it carries the news. */
508
+ .state {
509
+ margin-top: 10px;
510
+ font-size: var(--t-lead); font-weight: 600; line-height: 1.3;
511
+ letter-spacing: -0.014em; overflow-wrap: anywhere;
512
+ transition: color var(--slow) var(--ease);
513
+ }
514
+ .state.moved::before, .state.wait::before, .state.broke::before {
515
+ content: ''; display: inline-block;
516
+ width: 10px; height: 10px; border-radius: 50%;
517
+ margin-right: 13px; vertical-align: 0.1em;
518
+ }
519
+ .state.moved::before { background: var(--moved); box-shadow: 0 0 0 5px color-mix(in srgb, var(--moved) 17%, transparent); }
520
+ .state.wait::before {
521
+ background: var(--wait);
522
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--wait) 42%, transparent), 0 0 0 5px color-mix(in srgb, var(--wait) 12%, transparent);
523
+ }
524
+ .state.broke::before {
525
+ background: var(--broke); border-radius: 2px; transform: rotate(45deg);
526
+ box-shadow: 0 0 0 5px color-mix(in srgb, var(--broke) 17%, transparent);
527
+ }
528
+ .state.arrive { animation: arrive 340ms var(--ease) both; }
529
+ @keyframes arrive {
530
+ from { opacity: 0; transform: translateY(7px); filter: blur(4px); }
531
+ to { opacity: 1; transform: none; filter: blur(0); }
532
+ }
533
+ .what { margin: 7px 0 0; font-size: var(--t-small); color: var(--faint); line-height: 1.5; overflow-wrap: anywhere; }
534
+
535
+ /* One hairline, not a row of blocks: each journey adds its own slice, so
536
+ progress and outcome are the same object. A walk where everything held ends
537
+ as one unbroken green rule. */
538
+ .meter { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
539
+ .track { flex: 1 1 auto; min-width: 0; height: 4px; border-radius: 999px; background: var(--resting); overflow: hidden; }
540
+ .fill { display: flex; height: 100%; width: 100%; }
541
+ .slice {
542
+ min-width: 0; height: 100%; background: var(--resting);
543
+ transition: background var(--slow) var(--ease), flex-grow var(--slow) var(--ease);
544
+ }
545
+ .slice.held { background: var(--held); }
546
+ .slice.moved { background: var(--moved); }
547
+ .slice.broke { background: var(--broke); }
548
+ .slice.wait { background: var(--wait); }
549
+ .slice.running { background: var(--accent); animation: breathe 1.9s var(--ease) infinite; }
550
+ @keyframes breathe { 0%, 100% { opacity: 0.34; } 50% { opacity: 1; } }
551
+ .counts {
552
+ flex: 0 1 auto; min-width: 0; max-width: 68%;
553
+ font-size: var(--t-meta); color: var(--faint); letter-spacing: 0.02em;
554
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
555
+ }
556
+
557
+ /* --- the scrolling body -------------------------------------------------- */
558
+ .scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 0 var(--pad) 18px; }
559
+ .scroll::-webkit-scrollbar { width: 9px; }
560
+ .scroll::-webkit-scrollbar-thumb { background: var(--resting); border-radius: 999px; }
561
+ .scroll::-webkit-scrollbar-track { background: transparent; }
562
+
563
+ .group { margin-top: 18px; }
564
+ .grouplabel {
565
+ display: flex; align-items: baseline; gap: 8px;
566
+ margin-bottom: 8px; padding: 0 2px;
567
+ font-size: var(--t-label); font-weight: 600;
568
+ letter-spacing: 0.2em; text-transform: uppercase; color: var(--faint);
569
+ }
570
+ .grouplabel .glabel { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
571
+ .grouplabel .gcount { flex: 0 0 auto; font-weight: 500; letter-spacing: 0.02em; text-transform: none; color: var(--faintest); }
572
+
573
+ /* --- the reference ------------------------------------------------------- */
574
+ /* Quiet when the old build was booted live on this machine in this minute.
575
+ Loud, in amber, when it was not — a run measured against a stored record is
576
+ genuinely weaker, and it must never look like the strong one. */
577
+ .reference {
578
+ margin-top: 16px; padding: 12px 14px;
579
+ border-radius: var(--radius);
580
+ background: var(--card);
581
+ box-shadow: inset 0 0 0 1px var(--line);
582
+ transition: box-shadow var(--calm) var(--ease), background var(--calm) var(--ease);
583
+ }
584
+ .reference.weak {
585
+ background: color-mix(in srgb, var(--doubt) 7%, transparent);
586
+ box-shadow: inset 3px 0 0 var(--doubt), inset 0 0 0 1px var(--line);
587
+ }
588
+ .refrow { display: flex; align-items: center; gap: 9px; }
589
+ .refdot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--held); }
590
+ .reference.weak .refdot { background: var(--doubt); box-shadow: 0 0 0 4px color-mix(in srgb, var(--doubt) 17%, transparent); }
591
+ .reflabel {
592
+ flex: 0 0 auto; font-size: var(--t-label); font-weight: 600;
593
+ letter-spacing: 0.2em; text-transform: uppercase; color: var(--faint);
594
+ }
595
+ .refname {
596
+ flex: 1 1 auto; min-width: 0; text-align: right;
597
+ font-size: var(--t-name); color: var(--ink);
598
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
599
+ }
600
+ .refhow { margin-top: 7px; font-size: var(--t-body); color: var(--faint); line-height: 1.55; }
601
+ .refwarn { margin-top: 8px; font-size: var(--t-name); color: var(--ink); font-weight: 560; line-height: 1.5; }
602
+
603
+ /* --- surfaces and journeys ----------------------------------------------- */
604
+ /* A repository builds a website and a phone app and a command-line tool at
605
+ once. Grouping the walk by surface is the difference between a list of names
606
+ and a picture of what is being proved. */
607
+ .surface + .surface { margin-top: 12px; }
608
+ .surfacehead {
609
+ display: flex; align-items: baseline; gap: 8px;
610
+ padding: 0 3px 6px;
611
+ }
612
+ .surfacename { flex: 0 0 auto; font-size: var(--t-name); font-weight: 600; color: var(--soft); }
613
+ .surfacenote {
614
+ flex: 1 1 auto; min-width: 0; text-align: right;
615
+ font-size: var(--t-meta); color: var(--faintest);
616
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
617
+ }
618
+ .items { border-radius: var(--radius); background: var(--card); overflow: hidden; box-shadow: inset 0 0 0 1px var(--line); }
619
+ .item + .item { box-shadow: inset 0 1px 0 var(--line); }
620
+ .item { transition: background var(--calm) var(--ease); }
621
+ .item.running { background: color-mix(in srgb, var(--accent) 4%, transparent); }
622
+ .item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 3.5%, transparent); }
623
+ .item.attention .row { box-shadow: inset 2px 0 0 var(--tone, var(--accent)); }
624
+ .item.fresh { animation: settle 300ms var(--ease) both; }
625
+ @keyframes settle { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } }
626
+
627
+ .row {
628
+ display: flex; align-items: center; gap: 11px;
629
+ width: 100%; min-height: 42px; padding: 9px 13px;
630
+ text-align: left; background: transparent; border: 0; cursor: pointer;
631
+ transition: background var(--quick) var(--ease);
632
+ }
633
+ .row:hover { background: var(--card-hover); }
634
+ .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
635
+ .item.plain .row { cursor: default; }
636
+ .item.plain .row:hover { background: transparent; }
637
+
638
+ /* Five states, five silhouettes, five rungs of a lightness ladder — readable
639
+ with no colour vision at all:
640
+ nobody ran it a hollow ring, barely there
641
+ it held a plain green disc
642
+ it is waiting a disc inside a crisp ring
643
+ it is doubtful a disc in a soft wide halo
644
+ it broke a diamond in a soft wide halo */
645
+ .dot {
646
+ flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
647
+ background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting);
648
+ transition: background var(--calm) var(--ease), box-shadow var(--calm) var(--ease);
649
+ }
650
+ .item.held .dot { width: 7px; height: 7px; background: var(--held); box-shadow: none; }
651
+ .item.wait .dot {
652
+ background: var(--wait);
653
+ box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--wait) 42%, transparent), 0 0 0 4px color-mix(in srgb, var(--wait) 12%, transparent);
654
+ }
655
+ .item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 17%, transparent); }
656
+ .item.broke .dot {
657
+ background: var(--broke); border-radius: 2px; transform: rotate(45deg);
658
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 17%, transparent);
659
+ }
660
+ .item.running .dot {
661
+ background: transparent; box-shadow: inset 0 0 0 2px var(--accent);
662
+ animation: ping 1.8s var(--ease) infinite;
663
+ }
664
+ @keyframes ping {
665
+ 0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 34%, transparent); }
666
+ 70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
667
+ }
668
+
669
+ .rname {
670
+ flex: 1 1 auto; min-width: 0;
671
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
672
+ font-variant-numeric: tabular-nums;
673
+ font-size: var(--t-name); color: var(--ink);
674
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
675
+ }
676
+ .item.pending .rname { color: var(--faint); }
677
+ .item.running .rname, .item.held .rname { color: var(--ink); }
678
+ /* The address count, ticking up while a journey walks. On a run where nothing
679
+ is wrong this is the only thing moving, and it is the proof that anything is
680
+ happening at all. */
681
+ .rcount {
682
+ flex: 0 0 auto; font-size: var(--t-meta); color: var(--faint);
683
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums;
684
+ }
685
+ .rtime { flex: 0 0 auto; margin-left: 2px; font-size: var(--t-meta); color: var(--faintest);
686
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
687
+ .row .chev { flex: 0 0 auto; width: 14px; height: 14px; color: var(--faintest); opacity: 0;
688
+ transition: transform var(--calm) var(--ease), opacity var(--quick) var(--ease); }
689
+ .row:hover .chev, .item.open .chev { opacity: 1; }
690
+ .item.open .chev { transform: rotate(180deg); }
691
+ .item.plain .chev { display: none; }
692
+
693
+ .detail { padding: 0 14px 12px 32px; font-size: var(--t-body); line-height: 1.6; overflow-wrap: anywhere; }
694
+ .item.open .detail { animation: unfold 240ms var(--ease) both; }
695
+ @keyframes unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
696
+ .detail .why { color: var(--faint); }
697
+ .detail .out { color: var(--soft); margin-top: 4px; }
698
+ .detail .meta { margin-top: 6px; font-size: var(--t-meta); color: var(--faintest); }
699
+
700
+ /* --- cards: the wobble and the coverage ---------------------------------- */
701
+ .card { border-radius: var(--radius); background: var(--card); box-shadow: inset 0 0 0 1px var(--line); padding: 14px; }
702
+ .figures { display: flex; flex-wrap: wrap; gap: 14px 20px; }
703
+ .figure { flex: 1 1 120px; min-width: 110px; }
704
+ .figure b {
705
+ display: block; font-size: var(--t-fig); font-weight: 600; line-height: 1.15;
706
+ letter-spacing: -0.02em; color: var(--ink);
707
+ }
708
+ .figure span { display: block; margin-top: 3px; font-size: var(--t-small); color: var(--faint); line-height: 1.45; }
709
+ /* An amber number only when there is something amber to say. A wobble of zero
710
+ is not doubtful, it is excellent, so it is not coloured. */
711
+ .figure.doubt.on b { color: var(--doubt); }
712
+ .figure.wait.on b { color: var(--wait); }
713
+ .cardnote { margin-top: 12px; font-size: var(--t-body); color: var(--faint); line-height: 1.55; }
714
+ .paths { margin-top: 10px; }
715
+ .paths li {
716
+ padding: 4px 0 4px 11px; box-shadow: inset 1px 0 0 var(--line);
717
+ font-size: var(--t-meta); color: var(--faint);
718
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
719
+ }
720
+
721
+ /* --- what was not checked ------------------------------------------------ */
722
+ .gaps { margin-top: 12px; }
723
+ .gap { padding: 9px 0 9px 12px; box-shadow: inset 1.5px 0 0 var(--resting); }
724
+ .gap + .gap { margin-top: 2px; }
725
+ .gapwhat { font-size: var(--t-name); color: var(--soft); line-height: 1.5; }
726
+ .gapwhy { margin-top: 2px; font-size: var(--t-body); color: var(--faintest); line-height: 1.5; }
727
+ /* What would fix it, in the words an agent can act on without being taught.
728
+ Blue, because it is the one thing on the card that is waiting on somebody. */
729
+ .gapfix { margin-top: 4px; font-size: var(--t-body); color: var(--wait); line-height: 1.5; }
730
+ /* Some holes are permanent and deliberate — money is watched at the call and never at the
731
+ effect, and always will be. Painting that blue would offer somebody an action that does
732
+ not exist. */
733
+ .gapfix.permanent { color: var(--faintest); }
734
+ .gapdoors { margin-left: 6px; font-size: var(--t-meta); color: var(--faintest); }
735
+
736
+ /* --- findings ------------------------------------------------------------ */
737
+ .fclass {
738
+ flex: 0 0 auto; padding: 2px 7px; border-radius: 999px;
739
+ font-size: var(--t-label); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
740
+ color: var(--faint); box-shadow: inset 0 0 0 1px var(--line-firm);
741
+ }
742
+ .item.sealed .fclass { color: var(--wait); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--wait) 45%, transparent); }
743
+ .item.broke .fclass { color: var(--broke); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--broke) 45%, transparent); }
744
+ .ftitle {
745
+ flex: 1 1 auto; min-width: 0; font-size: var(--t-name); color: var(--ink);
746
+ line-height: 1.45; font-weight: 560;
747
+ }
748
+ /* One finding can stand for five hundred differences. The count is shown; the
749
+ five hundred are not — a list nobody can read is where information hides. */
750
+ .fcount { flex: 0 0 auto; font-size: var(--t-meta); color: var(--faintest);
751
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
752
+ .frow { align-items: flex-start; }
753
+ .frow .dot { margin-top: 5px; }
754
+ .evidence {
755
+ margin-top: 9px; padding: 5px 11px; border: 0; border-radius: 999px;
756
+ font-size: var(--t-body); color: var(--soft); background: var(--well);
757
+ box-shadow: inset 0 0 0 1px var(--line); cursor: pointer;
758
+ transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
759
+ }
760
+ .evidence:hover { color: var(--ink); background: var(--card-hover); }
761
+
762
+ .nothing { padding: 30px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.8; text-align: center; }
763
+
764
+ /* --- the only thing that ever reaches a person --------------------------- */
765
+ .foot {
766
+ flex: 0 0 auto;
767
+ padding: 13px var(--pad) 15px;
768
+ background: var(--glass);
769
+ backdrop-filter: blur(22px) saturate(120%);
770
+ -webkit-backdrop-filter: blur(22px) saturate(120%);
771
+ border-top: 1px solid var(--line);
772
+ animation: arrive 340ms var(--ease) both;
773
+ }
774
+ .nextlabel {
775
+ font-size: var(--t-label); font-weight: 600; letter-spacing: 0.2em;
776
+ text-transform: uppercase; color: var(--wait); margin-bottom: 8px;
777
+ }
778
+ .need { display: flex; align-items: flex-start; gap: 10px; padding: 5px 0; }
779
+ .need .dot {
780
+ margin-top: 5px; background: var(--wait);
781
+ box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--wait) 42%, transparent), 0 0 0 4px color-mix(in srgb, var(--wait) 12%, transparent);
782
+ }
783
+ .needtext { flex: 1 1 auto; min-width: 0; font-size: var(--t-name); color: var(--ink); line-height: 1.5; }
784
+ .needwhy { display: block; margin-top: 2px; font-size: var(--t-body); color: var(--faint); }
785
+ .footwhy { margin-top: 8px; font-size: var(--t-meta); color: var(--faintest); }
786
+
787
+ /* --- evidence, at full size ---------------------------------------------- */
788
+ .viewer {
789
+ position: fixed; inset: 0; z-index: 20; display: flex; flex-direction: column;
790
+ background: color-mix(in srgb, var(--ground) 92%, #000);
791
+ animation: arrive 220ms var(--ease) both;
792
+ }
793
+ .vtop { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; padding: 11px var(--pad); border-bottom: 1px solid var(--line); }
794
+ .vname { flex: 1 1 auto; min-width: 0; font-size: var(--t-name); color: var(--soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
795
+ .vclose {
796
+ flex: 0 0 auto; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
797
+ color: var(--soft); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer;
798
+ }
799
+ .vclose:hover { color: var(--ink); background: var(--card-hover); }
800
+ .vstage { flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 14px; overflow: auto; }
801
+ .vimg { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-xs); }
802
+ .vhelp { flex: 0 0 auto; padding: 0 var(--pad) 12px; text-align: center; font-size: var(--t-meta); color: var(--faintest); }
803
+
804
+ /* Two columns when the window is dragged wide. The walk on the left, what came
805
+ out of it on the right. */
806
+ @media (min-width: 900px) {
807
+ .scroll { display: grid; grid-template-columns: 1fr 1fr; gap: 0 26px; align-content: start; }
808
+ #refstrip { grid-column: 1 / -1; }
809
+ #groupWalk { grid-column: 1; }
810
+ #groupWobble { grid-column: 1; }
811
+ #groupFindings { grid-column: 2; }
812
+ #groupCoverage { grid-column: 2; }
813
+ #nothing { grid-column: 1 / -1; }
814
+ }
815
+
816
+ @media (prefers-reduced-motion: reduce) {
817
+ * { animation: none !important; transition: none !important; }
818
+ }
819
+ `;
820
+
821
+ const SCRIPT = `
822
+ (function () {
823
+ 'use strict';
824
+
825
+ // This page only ever runs in the window Stays Fixed just opened, so there is no
826
+ // compatibility question to answer and nothing to load.
827
+
828
+ var plan = { product: '', project: '', journeys: [], surfaces: [], reference: null, words: {}, sources: {} };
829
+ try {
830
+ var blob = document.getElementById('staysfixed-plan');
831
+ if (blob && blob.textContent) plan = JSON.parse(blob.textContent) || plan;
832
+ } catch (err) {
833
+ // A plan we cannot read costs the opening list, not the window. Rows still appear one
834
+ // by one as the check reaches them.
835
+ }
836
+
837
+ function el(id) { return document.getElementById(id); }
838
+
839
+ var ui = {
840
+ clock: el('clock'), product: el('product'), surfaces: el('surfaces'), targetsep: el('targetsep'),
841
+ state: el('state'), what: el('what'), track: el('track'), fill: el('fill'), counts: el('counts'),
842
+ refstrip: el('refstrip'), refname: el('refname'), refhow: el('refhow'), refwarn: el('refwarn'),
843
+ groupWalk: el('groupWalk'), surfaceList: el('surfaceList'), countWalk: el('countWalk'),
844
+ groupWobble: el('groupWobble'), wSteady: el('wSteady'), wUnstable: el('wUnstable'),
845
+ wUnstableWrap: el('wUnstableWrap'), wNew: el('wNew'), wNewWrap: el('wNewWrap'),
846
+ wobbleNote: el('wobbleNote'), wobblePaths: el('wobblePaths'), wSteadyWrap: el('wSteadyWrap'),
847
+ groupFindings: el('groupFindings'), findingList: el('findingList'), countFindings: el('countFindings'),
848
+ groupCoverage: el('groupCoverage'), covFigures: el('covFigures'), covNote: el('covNote'),
849
+ gapList: el('gapList'), gapMore: el('gapMore'),
850
+ nothing: el('nothing'), scroll: el('scroll'),
851
+ footer: el('footer'), needs: el('needs'),
852
+ aura: document.querySelector('.aura'),
853
+ viewer: el('viewer'), vname: el('vname'), vimg: el('vimg'), vclose: el('vclose')
854
+ };
855
+
856
+ 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>';
857
+
858
+ // Somebody who has asked their computer for less movement gets none of it: the CSS
859
+ // switches every animation off, and everything animated here jumps to its final value.
860
+ var CALM = false;
861
+ try { CALM = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { CALM = false; }
862
+
863
+ // -------------------------------------------------------------------------
864
+ // Words and numbers
865
+ // -------------------------------------------------------------------------
866
+
867
+ function commas(n) {
868
+ var v = Math.round(Number(n) || 0);
869
+ if (!isFinite(v)) v = 0;
870
+ return v.toLocaleString('en-US');
871
+ }
872
+
873
+ function fmt(ms) {
874
+ var v = Number(ms);
875
+ if (!isFinite(v) || v < 0) v = 0;
876
+ if (v < 1000) return Math.round(v) + 'ms';
877
+ if (v < 60000) return (v / 1000).toFixed(1) + 's';
878
+ var m = Math.floor(v / 60000);
879
+ var s = Math.round((v % 60000) / 1000);
880
+ return m + 'm ' + s + 's';
881
+ }
882
+
883
+ function plural(n, one, many) { return (Number(n) === 1) ? one : many; }
884
+
885
+ function text(node, value) { if (node) node.textContent = String(value == null ? '' : value); }
886
+
887
+ function show(node, on) { if (node) node.hidden = !on; }
888
+
889
+ function surfaceWord(surface, given) {
890
+ if (given) return String(given);
891
+ if (!surface) return 'Everything else';
892
+ return (plan.words && plan.words[surface]) || String(surface);
893
+ }
894
+
895
+ function sourceWord(source, given) {
896
+ if (given) return String(given);
897
+ if (!source) return '';
898
+ return (plan.sources && plan.sources[source]) || String(source);
899
+ }
900
+
901
+ // A number that counts rather than jumps. It is the one piece of movement on a clean
902
+ // run, and it is tied to something that really happened: an address really was watched.
903
+ function countTo(node, to) {
904
+ if (!node) return;
905
+ var target = Math.round(Number(to) || 0);
906
+ if (CALM) { text(node, commas(target)); return; }
907
+ var from = Number(node.getAttribute('data-n') || 0);
908
+ node.setAttribute('data-n', String(target));
909
+ if (from === target) { text(node, commas(target)); return; }
910
+ var started = 0;
911
+ var span = 380;
912
+ function step(stamp) {
913
+ if (!started) started = stamp;
914
+ var k = Math.min(1, (stamp - started) / span);
915
+ var eased = 1 - Math.pow(1 - k, 3);
916
+ text(node, commas(from + (target - from) * eased));
917
+ if (k < 1 && Number(node.getAttribute('data-n')) === target) requestAnimationFrame(step);
918
+ }
919
+ requestAnimationFrame(step);
920
+ }
921
+
922
+ // -------------------------------------------------------------------------
923
+ // The state of the window
924
+ // -------------------------------------------------------------------------
925
+
926
+ var startedAt = Date.now();
927
+ var elapsedMs = 0;
928
+ var ticking = null;
929
+ var journeys = {}; // name -> row record
930
+ var order = []; // names, in the order they were first seen
931
+ var surfaces = {}; // word -> { box, items }
932
+ var watched = 0;
933
+ var findings = [];
934
+ var sealedFindings = [];
935
+ var finished = false;
936
+ var walkingName = '';
937
+
938
+ function startClock() {
939
+ if (ticking) return;
940
+ ticking = setInterval(function () {
941
+ if (finished) return;
942
+ elapsedMs = Date.now() - startedAt;
943
+ text(ui.clock, fmt(elapsedMs));
944
+ }, 100);
945
+ }
946
+
947
+ function stopClock() {
948
+ if (ticking) { clearInterval(ticking); ticking = null; }
949
+ }
950
+
951
+ function setState(sentence, tone) {
952
+ if (!ui.state) return;
953
+ ui.state.className = 'state' + (tone ? ' ' + tone : '');
954
+ text(ui.state, sentence);
955
+ if (!CALM) {
956
+ ui.state.classList.remove('arrive');
957
+ void ui.state.offsetWidth;
958
+ ui.state.classList.add('arrive');
959
+ }
960
+ tintAura(tone);
961
+ }
962
+
963
+ // The worst thing that has happened, as the faintest wash at the top of the page. On a
964
+ // run where nothing is wrong it is the brand colour, which is to say no news at all.
965
+ function tintAura(tone) {
966
+ if (!ui.aura) return;
967
+ var map = { broke: 'var(--broke)', moved: 'var(--moved)', wait: 'var(--wait)', held: 'var(--held)' };
968
+ ui.aura.style.setProperty('--tint', map[tone] || 'var(--accent)');
969
+ }
970
+
971
+ function setWhat(sentence) {
972
+ text(ui.what, sentence || '');
973
+ show(ui.what, !!sentence);
974
+ }
975
+
976
+ function updateCounts() {
977
+ var done = 0;
978
+ var i;
979
+ for (i = 0; i < order.length; i++) if (journeys[order[i]].done) done++;
980
+ var total = order.length;
981
+ var bits = [];
982
+ bits.push(commas(watched) + ' ' + plural(watched, 'address', 'addresses') + ' watched');
983
+ if (total) bits.push(commas(done) + ' of ' + commas(total) + ' ' + plural(total, 'journey', 'journeys'));
984
+ text(ui.counts, bits.join(' · '));
985
+ // The header already counts the journeys. This says the thing it does not: how many
986
+ // separate products this one repository is being proved across.
987
+ var kinds = 0;
988
+ for (var k in surfaces) if (Object.prototype.hasOwnProperty.call(surfaces, k)) kinds++;
989
+ text(ui.countWalk, kinds ? commas(kinds) + ' ' + plural(kinds, 'surface', 'surfaces') : '');
990
+ }
991
+
992
+ // -------------------------------------------------------------------------
993
+ // The meter: one slice per journey
994
+ // -------------------------------------------------------------------------
995
+
996
+ function sliceFor(record) {
997
+ if (record.slice) return record.slice;
998
+ var s = document.createElement('div');
999
+ s.className = 'slice';
1000
+ s.style.flex = '1 1 0';
1001
+ if (ui.fill) ui.fill.appendChild(s);
1002
+ record.slice = s;
1003
+ return s;
1004
+ }
1005
+
1006
+ function paintSlice(record, tone) {
1007
+ var s = sliceFor(record);
1008
+ s.className = 'slice' + (tone ? ' ' + tone : '');
1009
+ }
1010
+
1011
+ // -------------------------------------------------------------------------
1012
+ // The walk
1013
+ // -------------------------------------------------------------------------
1014
+
1015
+ function surfaceBox(word, note) {
1016
+ if (surfaces[word]) return surfaces[word];
1017
+ var box = document.createElement('section');
1018
+ box.className = 'surface';
1019
+ var head = document.createElement('div');
1020
+ head.className = 'surfacehead';
1021
+ var name = document.createElement('span');
1022
+ name.className = 'surfacename';
1023
+ name.textContent = word;
1024
+ var hint = document.createElement('span');
1025
+ hint.className = 'surfacenote';
1026
+ hint.textContent = note || '';
1027
+ head.appendChild(name);
1028
+ head.appendChild(hint);
1029
+ var items = document.createElement('div');
1030
+ items.className = 'items';
1031
+ box.appendChild(head);
1032
+ box.appendChild(items);
1033
+ if (ui.surfaceList) ui.surfaceList.appendChild(box);
1034
+ show(ui.groupWalk, true);
1035
+ show(ui.nothing, false);
1036
+ surfaces[word] = { box: box, items: items, hint: hint, count: 0 };
1037
+ return surfaces[word];
1038
+ }
1039
+
1040
+ function journeyRow(name, meta) {
1041
+ if (journeys[name]) return journeys[name];
1042
+ var info = meta || {};
1043
+ var word = surfaceWord(info.surface, info.surfaceWord);
1044
+ var group = surfaceBox(word);
1045
+ group.count++;
1046
+ group.hint.textContent = commas(group.count) + ' ' + plural(group.count, 'journey', 'journeys');
1047
+
1048
+ var item = document.createElement('div');
1049
+ item.className = 'item pending fresh';
1050
+ var row = document.createElement('button');
1051
+ row.type = 'button';
1052
+ row.className = 'row';
1053
+
1054
+ var dot = document.createElement('span');
1055
+ dot.className = 'dot';
1056
+ var rname = document.createElement('span');
1057
+ rname.className = 'rname';
1058
+ rname.textContent = name;
1059
+ var rcount = document.createElement('span');
1060
+ rcount.className = 'rcount';
1061
+ rcount.textContent = '';
1062
+ var rtime = document.createElement('span');
1063
+ rtime.className = 'rtime';
1064
+ rtime.textContent = '';
1065
+ row.appendChild(dot);
1066
+ row.appendChild(rname);
1067
+ row.appendChild(rcount);
1068
+ row.appendChild(rtime);
1069
+ row.insertAdjacentHTML('beforeend', CHEVRON);
1070
+
1071
+ var detail = document.createElement('div');
1072
+ detail.className = 'detail';
1073
+ detail.hidden = true;
1074
+
1075
+ item.appendChild(row);
1076
+ item.appendChild(detail);
1077
+ group.items.appendChild(item);
1078
+
1079
+ var record = {
1080
+ name: name, item: item, row: row, dot: dot, count: rcount, time: rtime,
1081
+ detail: detail, addresses: 0, done: false, slice: null, meta: info, open: false
1082
+ };
1083
+ row.addEventListener('click', function () {
1084
+ record.open = !record.open;
1085
+ detail.hidden = !record.open;
1086
+ item.classList.toggle('open', record.open);
1087
+ });
1088
+ fillJourneyDetail(record);
1089
+ journeys[name] = record;
1090
+ order.push(name);
1091
+ sliceFor(record);
1092
+ return record;
1093
+ }
1094
+
1095
+ function fillJourneyDetail(record) {
1096
+ var info = record.meta || {};
1097
+ record.detail.textContent = '';
1098
+ if (info.describe) {
1099
+ var why = document.createElement('p');
1100
+ why.className = 'why';
1101
+ why.textContent = info.describe;
1102
+ record.detail.appendChild(why);
1103
+ }
1104
+ var bits = [];
1105
+ var word = surfaceWord(info.surface, info.surfaceWord);
1106
+ if (word) bits.push(word);
1107
+ var src = sourceWord(info.source, info.sourceWord);
1108
+ if (src) bits.push(src);
1109
+ if (bits.length) {
1110
+ var meta = document.createElement('p');
1111
+ meta.className = 'meta';
1112
+ meta.textContent = bits.join(' · ');
1113
+ record.detail.appendChild(meta);
1114
+ }
1115
+ if (info.skip) {
1116
+ var skip = document.createElement('p');
1117
+ skip.className = 'out';
1118
+ skip.textContent = 'Not walked: ' + info.skip;
1119
+ record.detail.appendChild(skip);
1120
+ }
1121
+ record.row.parentNode.classList.toggle('plain', !record.detail.childNodes.length);
1122
+ }
1123
+
1124
+ function markJourney(record, tone, attention) {
1125
+ var cls = 'item ' + tone;
1126
+ if (attention) cls += ' attention';
1127
+ if (record.open) cls += ' open';
1128
+ record.item.className = cls;
1129
+ if (tone === 'moved' || tone === 'broke' || tone === 'wait') {
1130
+ record.item.style.setProperty('--tone', 'var(--' + tone + ')');
1131
+ } else {
1132
+ record.item.style.removeProperty('--tone');
1133
+ }
1134
+ record.tone = tone;
1135
+ paintSlice(record, tone === 'running' ? 'running' : tone);
1136
+ }
1137
+
1138
+ // -------------------------------------------------------------------------
1139
+ // The events
1140
+ // -------------------------------------------------------------------------
1141
+
1142
+ function handle(ev) {
1143
+ switch (ev.type) {
1144
+ case 'plan': return onPlan(ev);
1145
+ case 'check:start': return onStart(ev);
1146
+ case 'reference': return onReference(ev);
1147
+ case 'journey:start': return onJourneyStart(ev);
1148
+ case 'journey:addresses': return onAddresses(ev);
1149
+ case 'journey:done': return onJourneyDone(ev);
1150
+ case 'wobble': return onWobble(ev);
1151
+ case 'suspicion': case 'proof:start': case 'proof:done': case 'cluster': case 'note':
1152
+ return onNote(ev);
1153
+ case 'finding': return onFinding(ev);
1154
+ case 'coverage': return onCoverage(ev);
1155
+ case 'check:done': return onDone(ev);
1156
+ default: return undefined; // not our vocabulary, and not an error either
1157
+ }
1158
+ }
1159
+
1160
+ function onPlan(ev) {
1161
+ var next = ev.plan || {};
1162
+ if (next.product) { text(ui.product, next.product); plan.product = next.product; }
1163
+ if (next.words) plan.words = next.words;
1164
+ if (next.sources) plan.sources = next.sources;
1165
+ var list = next.journeys || [];
1166
+ for (var i = 0; i < list.length; i++) {
1167
+ var row = journeyRow(list[i].name, list[i]);
1168
+ row.meta = list[i];
1169
+ fillJourneyDetail(row);
1170
+ }
1171
+ if (next.surfaces && next.surfaces.length) {
1172
+ text(ui.surfaces, next.surfaces.join(' · '));
1173
+ show(ui.surfaces, true);
1174
+ show(ui.targetsep, true);
1175
+ }
1176
+ updateCounts();
1177
+ }
1178
+
1179
+ function onStart(ev) {
1180
+ startedAt = Date.now() - (Number(ev.at) || 0);
1181
+ startClock();
1182
+ setState(ev.message || 'Checking that nothing which already worked has changed.', '');
1183
+ }
1184
+
1185
+ function onReference(ev) {
1186
+ var r = ev.reference;
1187
+ show(ui.refstrip, true);
1188
+ if (!r) {
1189
+ text(ui.refname, '');
1190
+ text(ui.refhow, ev.message || '');
1191
+ show(ui.refwarn, false);
1192
+ return;
1193
+ }
1194
+ text(ui.refname, r.name || '');
1195
+ text(ui.refhow, r.how || '');
1196
+ ui.refstrip.classList.toggle('weak', !!r.weak);
1197
+ if (r.weak && r.warning) {
1198
+ text(ui.refwarn, r.warning);
1199
+ show(ui.refwarn, true);
1200
+ } else {
1201
+ show(ui.refwarn, false);
1202
+ }
1203
+ }
1204
+
1205
+ function onJourneyStart(ev) {
1206
+ if (!ev.journey) return;
1207
+ var record = journeyRow(ev.journey, ev);
1208
+ if (ev.describe || ev.surface || ev.source) {
1209
+ record.meta = { describe: ev.describe || (record.meta || {}).describe,
1210
+ surface: ev.surface || (record.meta || {}).surface,
1211
+ surfaceWord: ev.surfaceWord || (record.meta || {}).surfaceWord,
1212
+ source: ev.source || (record.meta || {}).source,
1213
+ sourceWord: ev.sourceWord || (record.meta || {}).sourceWord };
1214
+ fillJourneyDetail(record);
1215
+ }
1216
+ walkingName = ev.journey;
1217
+ markJourney(record, 'running');
1218
+ setState(ev.message || ('Walking ' + ev.journey + '.'), '');
1219
+ var where = [];
1220
+ if (ev.index && ev.total) where.push('journey ' + ev.index + ' of ' + ev.total);
1221
+ if (ev.run === 'a') where.push('first pass');
1222
+ if (ev.run === 'b') where.push('second pass of the same build, to measure its own wobble');
1223
+ if (ev.run === 'single') where.push('the old build, booted live');
1224
+ setWhat(where.join(' · '));
1225
+ updateCounts();
1226
+ }
1227
+
1228
+ function onAddresses(ev) {
1229
+ if (!ev.journey) return;
1230
+ var record = journeys[ev.journey] || journeyRow(ev.journey, ev);
1231
+ record.addresses = Number(ev.count) || 0;
1232
+ text(record.count, commas(record.addresses));
1233
+ if (typeof ev.watched === 'number') watched = ev.watched;
1234
+ updateCounts();
1235
+ }
1236
+
1237
+ function onJourneyDone(ev) {
1238
+ if (!ev.journey) return;
1239
+ var record = journeys[ev.journey] || journeyRow(ev.journey, ev);
1240
+ record.addresses = Number(ev.count) || record.addresses;
1241
+ record.done = true;
1242
+ text(record.count, commas(record.addresses));
1243
+ if (ev.durationMs) text(record.time, fmt(ev.durationMs));
1244
+ if (typeof ev.watched === 'number') watched = ev.watched;
1245
+ markJourney(record, 'held');
1246
+ if (walkingName === ev.journey) walkingName = '';
1247
+ updateCounts();
1248
+ }
1249
+
1250
+ function onWobble(ev) {
1251
+ var w = ev.wobble || {};
1252
+ show(ui.groupWobble, true);
1253
+ if (!finished) setState('Working out what actually changed.', '');
1254
+ // A build that was only run once has no wobble to show. Two noughts would read as
1255
+ // "nothing wobbled", which is the opposite of what happened: nothing was measured.
1256
+ var measured = w.measured !== false;
1257
+ show(ui.wSteadyWrap, measured);
1258
+ show(ui.wUnstableWrap, measured);
1259
+ countTo(ui.wSteady, w.steady || 0);
1260
+ countTo(ui.wUnstable, w.unstable || 0);
1261
+ if (ui.wUnstableWrap) ui.wUnstableWrap.classList.toggle('on', (w.unstable || 0) > 0);
1262
+ var newly = Number(w.newlyUnstable || 0);
1263
+ show(ui.wNewWrap, measured && (newly > 0 || w.couldTellNewly === false));
1264
+ if (ui.wNewWrap) ui.wNewWrap.classList.toggle('on', newly > 0);
1265
+ var newLabel = ui.wNewWrap ? ui.wNewWrap.querySelector('span') : null;
1266
+ if (w.couldTellNewly === false) {
1267
+ // A bare nought here would read as good news. Nothing was measured, so nothing is
1268
+ // claimed: the figure says so rather than showing a number nobody earned.
1269
+ text(ui.wNew, '—');
1270
+ if (newLabel) text(newLabel, 'could not be told — nothing on record about what the old build held steady');
1271
+ } else {
1272
+ countTo(ui.wNew, newly);
1273
+ if (newLabel) text(newLabel, 'were steady before this change and wobble now');
1274
+ }
1275
+ // The window says this in its own words when nobody handed it any. An empty card where
1276
+ // the wobble should be is the one place this thing must never go quiet: it is the
1277
+ // number that explains why everything else on the page is so short.
1278
+ var note = w.note || ev.message || wobbleWords(w);
1279
+ text(ui.wobbleNote, note);
1280
+ var paths = w.newlyUnstablePaths || [];
1281
+ ui.wobblePaths.textContent = '';
1282
+ for (var i = 0; i < paths.length; i++) {
1283
+ var li = document.createElement('li');
1284
+ li.textContent = paths[i];
1285
+ ui.wobblePaths.appendChild(li);
1286
+ }
1287
+ show(ui.wobblePaths, paths.length > 0);
1288
+ }
1289
+
1290
+ function wobbleWords(w) {
1291
+ if (w.measured === false) {
1292
+ return 'This build was only run once, so its own wobble was never measured. Anything below could be the product arguing with itself rather than something you changed.';
1293
+ }
1294
+ var n = Number(w.unstable || 0);
1295
+ if (!n) return 'This build gives the same answer twice, everywhere.';
1296
+ return commas(n) + ' ' + plural(n, 'address', 'addresses') + ' this build cannot answer the same way twice. Subtracted, not counted.';
1297
+ }
1298
+
1299
+ function onNote(ev) {
1300
+ if (!ev.message) return;
1301
+ if (!finished) setWhat(ev.message);
1302
+ }
1303
+
1304
+ function onFinding(ev) {
1305
+ var f = ev.finding;
1306
+ if (!f) return;
1307
+ for (var i = 0; i < findings.length; i++) if (findings[i].id === f.id) return;
1308
+ findings.push(f);
1309
+ if (f.sealed) sealedFindings.push(f);
1310
+ show(ui.groupFindings, true);
1311
+ show(ui.nothing, false);
1312
+ ui.findingList.appendChild(findingRow(f));
1313
+ text(ui.countFindings, commas(findings.length));
1314
+ renderNeeds();
1315
+
1316
+ // The walk and the findings speak one language: the journey a finding was found on wears
1317
+ // the same mark the finding does, so a person scanning the list upstairs sees where the
1318
+ // trouble is without reading a word.
1319
+ var tone = f.sealed ? 'wait' : (f['class'] === 'crash' ? 'broke' : 'moved');
1320
+ var on = f.journey && journeys[f.journey];
1321
+ if (on && on.done && rank(tone) > rank(on.tone)) markJourney(on, tone, true);
1322
+
1323
+ if (!finished) {
1324
+ var sealedNow = sealedFindings.length;
1325
+ if (sealedNow > 0) setState(commas(sealedNow) + ' ' + plural(sealedNow, 'thing needs', 'things need') + ' you.', 'wait');
1326
+ else setState(commas(findings.length) + ' ' + plural(findings.length, 'finding', 'findings') + ' so far.', 'moved');
1327
+ }
1328
+ if (walkingName && journeys[walkingName] && !journeys[walkingName].done) markJourney(journeys[walkingName], 'running');
1329
+ }
1330
+
1331
+ // Red beats blue beats amber beats green, because a person should see the most serious
1332
+ // state first and nothing should ever be quietly downgraded.
1333
+ function rank(tone) {
1334
+ var order = { broke: 4, wait: 3, moved: 2, held: 1, running: 0, pending: 0 };
1335
+ return order[tone] || 0;
1336
+ }
1337
+
1338
+ function findingRow(f) {
1339
+ var tone = f.sealed ? 'wait' : (f['class'] === 'crash' ? 'broke' : 'moved');
1340
+ var item = document.createElement('div');
1341
+ item.className = 'item fresh attention ' + tone + (f.sealed ? ' sealed' : '');
1342
+ item.style.setProperty('--tone', 'var(--' + (tone === 'wait' ? 'wait' : tone === 'broke' ? 'broke' : 'moved') + ')');
1343
+
1344
+ var row = document.createElement('button');
1345
+ row.type = 'button';
1346
+ row.className = 'row frow';
1347
+ var dot = document.createElement('span');
1348
+ dot.className = 'dot';
1349
+ var title = document.createElement('span');
1350
+ title.className = 'ftitle';
1351
+ title.textContent = f.title || 'Something changed.';
1352
+ var count = document.createElement('span');
1353
+ count.className = 'fcount';
1354
+ count.textContent = f.count > 1 ? commas(f.count) : '';
1355
+ row.appendChild(dot);
1356
+ row.appendChild(title);
1357
+ row.appendChild(count);
1358
+ row.insertAdjacentHTML('beforeend', CHEVRON);
1359
+
1360
+ var detail = document.createElement('div');
1361
+ detail.className = 'detail';
1362
+ detail.hidden = true;
1363
+
1364
+ if (f.why) {
1365
+ var why = document.createElement('p');
1366
+ why.className = 'why';
1367
+ why.textContent = f.why;
1368
+ detail.appendChild(why);
1369
+ }
1370
+ if (f.count > 1) {
1371
+ var stands = document.createElement('p');
1372
+ stands.className = 'out';
1373
+ stands.textContent = 'One cause behind ' + commas(f.count) + ' differences.';
1374
+ detail.appendChild(stands);
1375
+ }
1376
+ if (f.sample) {
1377
+ var sample = document.createElement('ul');
1378
+ sample.className = 'paths mono';
1379
+ var one = document.createElement('li');
1380
+ one.textContent = f.sample;
1381
+ sample.appendChild(one);
1382
+ detail.appendChild(sample);
1383
+ }
1384
+ // The addresses, minus the one already written out above it. A finding that stands for a
1385
+ // single difference has already been shown whole, and printing its address again
1386
+ // underneath is a window repeating itself.
1387
+ var others = [];
1388
+ for (var p = 0; p < (f.paths || []).length; p++) {
1389
+ if (f.sample && String(f.sample).indexOf(f.paths[p]) === 0) continue;
1390
+ others.push(f.paths[p]);
1391
+ }
1392
+ if (others.length && f.count > 1) {
1393
+ var list = document.createElement('ul');
1394
+ list.className = 'paths mono';
1395
+ for (var i = 0; i < others.length; i++) {
1396
+ var li = document.createElement('li');
1397
+ li.textContent = others[i];
1398
+ list.appendChild(li);
1399
+ }
1400
+ if (f.count > others.length + (f.sample ? 1 : 0)) {
1401
+ var more = document.createElement('li');
1402
+ more.textContent = 'and ' + commas(f.count - others.length - (f.sample ? 1 : 0)) + ' more';
1403
+ list.appendChild(more);
1404
+ }
1405
+ detail.appendChild(list);
1406
+ }
1407
+ var meta = [];
1408
+ if (f['class'] && f['class'] !== 'ordinary') meta.push('touches ' + classWord(f['class']));
1409
+ if (f.nearFiles && f.nearFiles.length) meta.push('nearest code: ' + f.nearFiles.join(', '));
1410
+ if (f.journey) meta.push('found while walking ' + f.journey);
1411
+ if (meta.length) {
1412
+ var m = document.createElement('p');
1413
+ m.className = 'meta';
1414
+ m.textContent = meta.join(' · ');
1415
+ detail.appendChild(m);
1416
+ }
1417
+ // Evidence is fetched, never pushed: version 2 is mostly not about pictures, so the
1418
+ // file is only opened when somebody asks to look at it.
1419
+ if (f.evidence) {
1420
+ var look = document.createElement('button');
1421
+ look.type = 'button';
1422
+ look.className = 'evidence';
1423
+ look.textContent = 'Look at the evidence';
1424
+ look.addEventListener('click', function (e) {
1425
+ e.stopPropagation();
1426
+ openEvidence(f.title || 'evidence', f.evidence);
1427
+ });
1428
+ detail.appendChild(look);
1429
+ }
1430
+
1431
+ item.appendChild(row);
1432
+ item.appendChild(detail);
1433
+ var open = false;
1434
+ row.addEventListener('click', function () {
1435
+ open = !open;
1436
+ detail.hidden = !open;
1437
+ item.classList.toggle('open', open);
1438
+ });
1439
+ // Anything a person has to decide opens itself. Everything else waits to be asked.
1440
+ if (f.sealed) {
1441
+ open = true;
1442
+ detail.hidden = false;
1443
+ item.classList.add('open');
1444
+ }
1445
+ return item;
1446
+ }
1447
+
1448
+ function classWord(name) {
1449
+ var words = {
1450
+ money: 'money', 'sign-in': 'signing in', 'data-loss': 'losing data',
1451
+ crash: 'a crash', guard: 'a bug already reported once'
1452
+ };
1453
+ return words[name] || name;
1454
+ }
1455
+
1456
+ function onCoverage(ev) {
1457
+ var c = ev.coverage;
1458
+ if (!c) return;
1459
+ show(ui.groupCoverage, true);
1460
+ show(ui.nothing, false);
1461
+ ui.covFigures.textContent = '';
1462
+ addFigure(commas(c.paths), plural(c.paths, 'address watched', 'addresses watched'));
1463
+ addFigure(commas(c.journeys), plural(c.journeys, 'journey walked', 'journeys walked'));
1464
+ if (typeof c.doorsUnopened === 'number') {
1465
+ // The number that has to arrive in the same breath as the good news: doors the code
1466
+ // declares that no journey has ever opened. Never a percentage — a percentage invites
1467
+ // a target, and a target invites gaming.
1468
+ addFigure(commas(c.doorsUnopened), plural(c.doorsUnopened, 'door in the code never opened', 'doors in the code never opened'), c.doorsUnopened > 0);
1469
+ }
1470
+ text(ui.covNote, ev.message || '');
1471
+ ui.gapList.textContent = '';
1472
+ var gaps = c.gaps || [];
1473
+ for (var i = 0; i < gaps.length; i++) ui.gapList.appendChild(gapRow(gaps[i]));
1474
+ if (c.gapsHidden) {
1475
+ text(ui.gapMore, commas(c.gapsHidden) + ' more ' + plural(c.gapsHidden, 'gap', 'gaps') + ' not listed here.');
1476
+ show(ui.gapMore, true);
1477
+ } else {
1478
+ show(ui.gapMore, false);
1479
+ }
1480
+ }
1481
+
1482
+ function addFigure(value, label, doubtful) {
1483
+ var box = document.createElement('div');
1484
+ box.className = 'figure doubt' + (doubtful ? ' on' : '');
1485
+ var b = document.createElement('b');
1486
+ b.className = 'mono';
1487
+ b.textContent = value;
1488
+ var s = document.createElement('span');
1489
+ s.textContent = label;
1490
+ box.appendChild(b);
1491
+ box.appendChild(s);
1492
+ ui.covFigures.appendChild(box);
1493
+ }
1494
+
1495
+ function gapRow(gap) {
1496
+ var li = document.createElement('li');
1497
+ li.className = 'gap';
1498
+ var what = document.createElement('p');
1499
+ what.className = 'gapwhat';
1500
+ what.textContent = gap.what || '';
1501
+ if (gap.doors) {
1502
+ var doors = document.createElement('span');
1503
+ doors.className = 'gapdoors mono';
1504
+ doors.textContent = commas(gap.doors) + ' ' + plural(gap.doors, 'door', 'doors');
1505
+ what.appendChild(doors);
1506
+ }
1507
+ li.appendChild(what);
1508
+ if (gap.why) {
1509
+ var why = document.createElement('p');
1510
+ why.className = 'gapwhy';
1511
+ why.textContent = gap.why;
1512
+ li.appendChild(why);
1513
+ }
1514
+ if (gap.unlockedBy) {
1515
+ // No escape sequences in here: this whole script is carried inside a template literal,
1516
+ // where a backslash-b would be read as a backspace long before the regular expression
1517
+ // ever sees it. It cost one silent failure to find that out.
1518
+ var permanent = /^nothing[^a-z]/i.test(String(gap.unlockedBy).trim() + ' ');
1519
+ var fix = document.createElement('p');
1520
+ fix.className = 'gapfix' + (permanent ? ' permanent' : '');
1521
+ fix.textContent = permanent ? gap.unlockedBy : 'Would be covered by: ' + gap.unlockedBy;
1522
+ li.appendChild(fix);
1523
+ }
1524
+ return li;
1525
+ }
1526
+
1527
+ function renderNeeds() {
1528
+ if (!sealedFindings.length) { show(ui.footer, false); return; }
1529
+ ui.needs.textContent = '';
1530
+ for (var i = 0; i < sealedFindings.length; i++) {
1531
+ var f = sealedFindings[i];
1532
+ var need = document.createElement('div');
1533
+ need.className = 'need';
1534
+ var dot = document.createElement('span');
1535
+ dot.className = 'dot';
1536
+ var body = document.createElement('p');
1537
+ body.className = 'needtext';
1538
+ body.textContent = f.title || 'Something changed.';
1539
+ var why = document.createElement('span');
1540
+ why.className = 'needwhy';
1541
+ why.textContent = 'It touches ' + classWord(f['class']) + '.';
1542
+ body.appendChild(why);
1543
+ need.appendChild(dot);
1544
+ need.appendChild(body);
1545
+ ui.needs.appendChild(need);
1546
+ }
1547
+ show(ui.footer, true);
1548
+ }
1549
+
1550
+ function onDone(ev) {
1551
+ finished = true;
1552
+ stopClock();
1553
+ if (typeof ev.durationMs === 'number') text(ui.clock, fmt(ev.durationMs));
1554
+ else if (typeof ev.at === 'number') text(ui.clock, fmt(ev.at));
1555
+
1556
+ // Anything still marked as running never finished. Saying so is the honest thing: a
1557
+ // row left breathing beside a final verdict is a window lying about its own state.
1558
+ for (var i = 0; i < order.length; i++) {
1559
+ var record = journeys[order[i]];
1560
+ if (!record.done) markJourney(record, 'pending');
1561
+ }
1562
+
1563
+ var v = ev.verdict;
1564
+ if (!v) {
1565
+ setState(ev.message || 'Finished.', '');
1566
+ setWhat('');
1567
+ return;
1568
+ }
1569
+
1570
+ var tone = v.sealed > 0 ? 'wait' : (v.findings > 0 ? 'moved' : 'held');
1571
+ var sentence;
1572
+ if (v.sealed > 0) {
1573
+ sentence = commas(v.sealed) + ' ' + plural(v.sealed, 'thing needs', 'things need') + ' you.';
1574
+ } else if (v.findings > 0) {
1575
+ sentence = commas(v.findings) + ' ' + plural(v.findings, 'finding', 'findings') + ' the agent has to deal with.';
1576
+ } else {
1577
+ sentence = 'Everything that worked still works.';
1578
+ }
1579
+ setState(sentence, tone);
1580
+
1581
+ // The caveat travels with the good news, never after it. A green run on a product with
1582
+ // hundreds of unopened doors, or measured against a stored record, is a smaller claim
1583
+ // than it looks, and the smaller claim is the true one.
1584
+ var caveats = [];
1585
+ if (v.modeWarning) caveats.push(v.modeWarning);
1586
+ if (v.differencesNoise) {
1587
+ caveats.push(commas(v.differencesNoise) + ' ' + plural(v.differencesNoise, 'difference was', 'differences were') + ' this build arguing with itself, and were subtracted.');
1588
+ }
1589
+ if (v.summary) caveats.unshift(v.summary);
1590
+ setWhat(caveats.join(' '));
1591
+ renderNeeds();
1592
+ }
1593
+
1594
+ // -------------------------------------------------------------------------
1595
+ // Evidence, at full size
1596
+ // -------------------------------------------------------------------------
1597
+
1598
+ function openEvidence(name, address) {
1599
+ if (!address) return;
1600
+ text(ui.vname, name);
1601
+ ui.vimg.setAttribute('src', address);
1602
+ show(ui.viewer, true);
1603
+ }
1604
+
1605
+ function closeEvidence() {
1606
+ show(ui.viewer, false);
1607
+ ui.vimg.removeAttribute('src');
1608
+ }
1609
+
1610
+ if (ui.vclose) ui.vclose.addEventListener('click', closeEvidence);
1611
+ document.addEventListener('keydown', function (e) {
1612
+ if (e.key === 'Escape' && ui.viewer && !ui.viewer.hidden) closeEvidence();
1613
+ });
1614
+
1615
+ // -------------------------------------------------------------------------
1616
+ // The one thing the check calls
1617
+ // -------------------------------------------------------------------------
1618
+
1619
+ window.__staysfixed_push = function (input) {
1620
+ try {
1621
+ var ev = typeof input === 'string' ? JSON.parse(input) : input;
1622
+ if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return;
1623
+ handle(ev);
1624
+ } catch (err) {
1625
+ // A window must never be the reason a check looks broken. Whatever this event was,
1626
+ // the next one still has to land. It is said out loud where anyone would look for
1627
+ // it — a window that swallows its own mistakes is a window nobody can mend.
1628
+ if (window.console && console.error) console.error('stays fixed: ' + (err && err.message ? err.message : err));
1629
+ }
1630
+ };
1631
+
1632
+ // Called when the check lets go of the window, so the clock does not tick on forever
1633
+ // next to a result that is already final.
1634
+ window.__staysfixed_detach = function () {
1635
+ finished = true;
1636
+ stopClock();
1637
+ for (var i = 0; i < order.length; i++) {
1638
+ var record = journeys[order[i]];
1639
+ if (!record.done) markJourney(record, 'pending');
1640
+ }
1641
+ };
1642
+
1643
+ // Draw the plan before anything has happened, so the window is worth looking at from the
1644
+ // first frame instead of appearing empty.
1645
+ (function seed() {
1646
+ if (plan.product) text(ui.product, plan.product);
1647
+ if (plan.surfaces && plan.surfaces.length) {
1648
+ text(ui.surfaces, plan.surfaces.join(' · '));
1649
+ show(ui.surfaces, true);
1650
+ show(ui.targetsep, true);
1651
+ }
1652
+ var list = plan.journeys || [];
1653
+ for (var i = 0; i < list.length; i++) journeyRow(list[i].name, list[i]);
1654
+ if (plan.reference) onReference({ type: 'reference', at: 0, reference: plan.reference });
1655
+ updateCounts();
1656
+ show(ui.nothing, order.length === 0);
1657
+ startClock();
1658
+ })();
1659
+ })();
1660
+ `;