staysfixed 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/package.json +1 -1
- package/src/cli/check.js +71 -4
- package/src/cli/index.js +109 -6
- package/src/cli/walk.js +66 -3
- package/src/core/events.js +199 -0
- package/src/core/paths.js +8 -1
- package/src/drive/page.js +60 -4
- package/src/freeze/fonts.js +131 -41
- package/src/freeze/settle.js +177 -2
- package/src/guard/run.js +41 -2
- package/src/picture/capture.js +73 -8
- package/src/picture/compare.js +60 -0
- package/src/picture/run.js +209 -28
- package/src/picture/store.js +124 -0
- package/src/report/console.js +80 -1
- package/src/run.js +229 -39
- package/src/types.js +75 -0
- package/src/walk/run.js +103 -0
- package/src/watch/index.js +286 -0
- package/src/watch/panel.js +2445 -0
- package/src/watch/place.js +279 -0
- package/src/watch/window.js +1242 -0
|
@@ -0,0 +1,2445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page inside the watch window — the calm companion.
|
|
3
|
+
*
|
|
4
|
+
* This builds one self-contained HTML document as a string. It is written to a
|
|
5
|
+
* temp file and opened over a local file address, so it can fetch nothing: no
|
|
6
|
+
* fonts, no CDN, no framework, no build step. Everything it will ever need is
|
|
7
|
+
* in the string this file returns, including every icon, which are drawn here
|
|
8
|
+
* in SVG rather than downloaded from anywhere.
|
|
9
|
+
*
|
|
10
|
+
* It is fed one `RunEvent` at a time by `window.__staysfixed_push`, which the
|
|
11
|
+
* run calls over the debugging connection as things happen. The page keeps no
|
|
12
|
+
* state of its own beyond what those events tell it, so a panel that opens late
|
|
13
|
+
* and is handed the whole history catches up by replaying it.
|
|
14
|
+
*
|
|
15
|
+
* The design brief, in one line: this thing is pinned against a person's work
|
|
16
|
+
* all day, so it must be pleasant to have open and never once demand attention
|
|
17
|
+
* it has not earned. That gives four rules the whole layout follows.
|
|
18
|
+
*
|
|
19
|
+
* 1. The picture is the hero, and it is a REAL picture. The panel is itself a
|
|
20
|
+
* local page, so it loads the full-resolution PNG straight off the disk the
|
|
21
|
+
* run just wrote it to — `shotFile`, `approvedFile`, `diffFile`. The small
|
|
22
|
+
* preview that travels inside the event is only the frame or two before
|
|
23
|
+
* that file exists. A watch panel that shows you a blurred stamp of your
|
|
24
|
+
* own app is worse than useless: looking at the picture IS the product, so
|
|
25
|
+
* it has to survive being looked at closely.
|
|
26
|
+
*
|
|
27
|
+
* 2. Nothing is said twice, and detail is earned. At rest every check is one
|
|
28
|
+
* quiet line — a dot, a name, a time. The description, the outcome, the
|
|
29
|
+
* claim that failed and the story of why a guard exists all live one click
|
|
30
|
+
* away, and open by themselves only for the checks that need a person. A
|
|
31
|
+
* run where everything held is therefore almost silent, which is exactly
|
|
32
|
+
* how often it deserves to be read.
|
|
33
|
+
*
|
|
34
|
+
* 3. Colour is state and nothing else. One accent (blue: something is
|
|
35
|
+
* happening, or someone is needed), one green (held), one amber (moved),
|
|
36
|
+
* one red (broke). The timing breakdown, which is information rather than
|
|
37
|
+
* state, is drawn in shades of the text colour — so a screenful of colour
|
|
38
|
+
* always means a screenful of things to look at.
|
|
39
|
+
*
|
|
40
|
+
* 4. Everything moves, and nothing waves. State never snaps: pictures cross
|
|
41
|
+
* fade, rows settle, numbers count, the verdict arrives. Every one of those
|
|
42
|
+
* is between 150 and 320 milliseconds on one easing curve, every one of
|
|
43
|
+
* them is tied to something that really happened, and every one of them is
|
|
44
|
+
* switched off for anyone who has asked their computer for less motion.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { escapeHtml } from '../report/html.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One line in the list: a screen or a guard.
|
|
51
|
+
* @typedef {object} PanelRow
|
|
52
|
+
* @property {string} name
|
|
53
|
+
* @property {string} [describe]
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the panel is told before the run starts.
|
|
58
|
+
* @typedef {object} PanelPlan
|
|
59
|
+
* @property {string} [project] The project folder's name.
|
|
60
|
+
* @property {string} [app] What is being checked, in plain words.
|
|
61
|
+
* @property {PanelRow[]} [screens] The screens, in the order they will be photographed.
|
|
62
|
+
* @property {PanelRow[]} [guards] The guards, in the order they will run.
|
|
63
|
+
* @property {'dark'|'light'|'system'} [theme] Default 'dark'. Stated, not sniffed — a fresh
|
|
64
|
+
* browser profile always claims the computer is in light mode.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* JSON safe to sit inside a script tag. A closing tag inside a string would end
|
|
69
|
+
* the tag early and leave half the plan on the page as text, so the one
|
|
70
|
+
* character that can do that never survives.
|
|
71
|
+
* @param {unknown} value
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
function embedJson(value) {
|
|
75
|
+
return JSON.stringify(value ?? null)
|
|
76
|
+
.replace(/</g, '\\u003c');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keep only what the page uses. A screen config carries steps, masks and
|
|
81
|
+
* tolerances; none of that belongs in a window that is only drawing a list.
|
|
82
|
+
* A caller who has counted rather than listed hands us a number, which is not
|
|
83
|
+
* a list and is not an error either — it becomes an empty list here, and the
|
|
84
|
+
* counts arrive separately on `run:start`.
|
|
85
|
+
* @param {PanelRow[]|number|undefined} list
|
|
86
|
+
* @returns {{name: string, describe: string}[]}
|
|
87
|
+
*/
|
|
88
|
+
function tidyRows(list) {
|
|
89
|
+
if (!Array.isArray(list)) return [];
|
|
90
|
+
/** @type {{name: string, describe: string}[]} */
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const item of list) {
|
|
93
|
+
if (!item || typeof item !== 'object') continue;
|
|
94
|
+
const name = String(/** @type {any} */ (item).name ?? '').trim();
|
|
95
|
+
if (!name) continue;
|
|
96
|
+
const describe = String(/** @type {any} */ (item).describe ?? '').trim();
|
|
97
|
+
out.push({ name, describe });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The mark. A padlock with a tick inside it: the whole product in one shape —
|
|
104
|
+
* the thing that was already fixed is still shut. Monoline, drawn on the 24
|
|
105
|
+
* grid, inheriting its colour so it can never fight the theme.
|
|
106
|
+
*
|
|
107
|
+
* It carries no namespace attribute: inline SVG in an HTML document does not
|
|
108
|
+
* need one, and the panel is not allowed to name an address of any kind.
|
|
109
|
+
*/
|
|
110
|
+
const MARK = [
|
|
111
|
+
'<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
|
|
112
|
+
' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
|
|
113
|
+
'<rect x="4.2" y="10" width="15.6" height="10.4" rx="3.6"></rect>',
|
|
114
|
+
'<path d="M8.1 10V7.9a3.9 3.9 0 0 1 7.8 0V10"></path>',
|
|
115
|
+
'<path d="M9.7 15.2l1.8 1.9 2.9-3.5"></path>',
|
|
116
|
+
'</svg>',
|
|
117
|
+
].join('');
|
|
118
|
+
|
|
119
|
+
/** A chevron, used for every "there is more underneath this" control. */
|
|
120
|
+
const CHEVRON = [
|
|
121
|
+
'<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
|
|
122
|
+
' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
|
|
123
|
+
'<path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path>',
|
|
124
|
+
'</svg>',
|
|
125
|
+
].join('');
|
|
126
|
+
|
|
127
|
+
/** Two stacked sheets: copy. */
|
|
128
|
+
const COPY = [
|
|
129
|
+
'<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
|
|
130
|
+
' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
|
|
131
|
+
'<rect x="9" y="9" width="11" height="11" rx="3"></rect>',
|
|
132
|
+
'<path d="M15 6.2A2.2 2.2 0 0 0 12.8 4H7a3 3 0 0 0-3 3v5.8A2.2 2.2 0 0 0 6.2 15"></path>',
|
|
133
|
+
'</svg>',
|
|
134
|
+
].join('');
|
|
135
|
+
|
|
136
|
+
/** A cross: put the big picture away. */
|
|
137
|
+
const CLOSE = [
|
|
138
|
+
'<svg class="glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor"',
|
|
139
|
+
' stroke-width="1.7" stroke-linecap="round" aria-hidden="true">',
|
|
140
|
+
'<path d="M7 7l10 10M17 7L7 17"></path>',
|
|
141
|
+
'</svg>',
|
|
142
|
+
].join('');
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The whole panel document.
|
|
146
|
+
* @param {PanelPlan} [plan]
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function panelHtml(plan = {}) {
|
|
150
|
+
const project = String(plan.project ?? '').trim() || 'this project';
|
|
151
|
+
const app = String(plan.app ?? '').trim();
|
|
152
|
+
const screens = tidyRows(/** @type {any} */ (plan.screens));
|
|
153
|
+
const guards = tidyRows(/** @type {any} */ (plan.guards));
|
|
154
|
+
|
|
155
|
+
// Dark unless somebody asks otherwise — see the note above the light palette.
|
|
156
|
+
const wanted = String(plan.theme ?? 'dark');
|
|
157
|
+
const themeAttr = wanted === 'light' || wanted === 'system' ? wanted : 'dark';
|
|
158
|
+
|
|
159
|
+
const embedded = embedJson({ project, app, screens, guards });
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
'<!doctype html>',
|
|
163
|
+
`<html lang="en" data-theme="${themeAttr}">`,
|
|
164
|
+
'<head>',
|
|
165
|
+
'<meta charset="utf-8">',
|
|
166
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
167
|
+
// Just the name of the thing, because this string IS the window's title bar.
|
|
168
|
+
// "tdproof — Stays Fixed" reads as a browser tab; "Stays Fixed" reads as an
|
|
169
|
+
// application. What is being watched is said inside the panel, under the mark.
|
|
170
|
+
'<title>Stays Fixed</title>',
|
|
171
|
+
`<style>${STYLE}</style>`,
|
|
172
|
+
'</head>',
|
|
173
|
+
'<body>',
|
|
174
|
+
'<div class="aura" aria-hidden="true"></div>',
|
|
175
|
+
'<div class="panel">',
|
|
176
|
+
|
|
177
|
+
// --- the header: whose window this is, and where the run has got to -----
|
|
178
|
+
'<header class="top">',
|
|
179
|
+
'<div class="brand">',
|
|
180
|
+
`<span class="badge">${MARK}</span>`,
|
|
181
|
+
'<span class="wordmark">Stays Fixed</span>',
|
|
182
|
+
'<span class="elapsed mono" id="clock">0.0s</span>',
|
|
183
|
+
'</div>',
|
|
184
|
+
`<p class="target" id="target"><span class="mono" id="project">${escapeHtml(project)}</span>`,
|
|
185
|
+
`<span class="sep"${app ? '' : ' hidden'} id="targetsep">·</span>`,
|
|
186
|
+
`<span class="app" id="app"${app ? '' : ' hidden'}>${escapeHtml(app)}</span></p>`,
|
|
187
|
+
'<p class="state" id="state">getting ready</p>',
|
|
188
|
+
'<p class="note" id="note" hidden></p>',
|
|
189
|
+
'<div class="meter">',
|
|
190
|
+
'<div class="track" id="track"><div class="fill" id="fill"></div></div>',
|
|
191
|
+
'<span class="counts mono" id="counts"></span>',
|
|
192
|
+
'</div>',
|
|
193
|
+
'</header>',
|
|
194
|
+
|
|
195
|
+
// --- the two columns. One on a narrow panel, two on a wide one. ---------
|
|
196
|
+
'<div class="body">',
|
|
197
|
+
|
|
198
|
+
// --- the hero: the picture that was just taken -------------------------
|
|
199
|
+
'<section class="stage" id="stage">',
|
|
200
|
+
'<div class="shot" id="shot">',
|
|
201
|
+
'<img class="layer" id="layerA" alt="">',
|
|
202
|
+
'<img class="layer" id="layerB" alt="">',
|
|
203
|
+
'<span class="sweep" aria-hidden="true"></span>',
|
|
204
|
+
'<p class="blank" id="blank">The first picture appears the moment it is taken.</p>',
|
|
205
|
+
'<span class="closer" aria-hidden="true">click to look closer</span>',
|
|
206
|
+
'</div>',
|
|
207
|
+
'<div class="caption" id="caption">',
|
|
208
|
+
'<span class="shotname mono" id="shotname"></span>',
|
|
209
|
+
'<span class="shotout" id="shotout" hidden></span>',
|
|
210
|
+
'</div>',
|
|
211
|
+
'<div class="switch" id="tabs" hidden></div>',
|
|
212
|
+
'</section>',
|
|
213
|
+
|
|
214
|
+
// --- every check, one quiet line each ----------------------------------
|
|
215
|
+
'<div class="scroll" id="scroll">',
|
|
216
|
+
'<section class="group" id="groupScreens" hidden>',
|
|
217
|
+
'<p class="grouplabel">Screens<span class="mono" id="countScreens"></span></p>',
|
|
218
|
+
'<div class="items" id="listScreens"></div>',
|
|
219
|
+
'</section>',
|
|
220
|
+
'<section class="group" id="groupGuards" hidden>',
|
|
221
|
+
'<p class="grouplabel">Guards<span class="mono" id="countGuards"></span></p>',
|
|
222
|
+
'<div class="items" id="listGuards"></div>',
|
|
223
|
+
'</section>',
|
|
224
|
+
'<p class="nothing" id="nothing" hidden>Nothing is set up to be checked yet.<br>Run <span class="mono">staysfixed init</span> to pick the screens worth watching.</p>',
|
|
225
|
+
'</div>',
|
|
226
|
+
|
|
227
|
+
'</div>',
|
|
228
|
+
|
|
229
|
+
'<button class="follow" id="follow" type="button" hidden>Follow the run</button>',
|
|
230
|
+
|
|
231
|
+
// --- where the time went. One folded line, so it is always in sight and
|
|
232
|
+
// never in the way. ------------------------------------------------
|
|
233
|
+
'<section class="timing" id="timing" hidden>',
|
|
234
|
+
'<button class="tophead" id="thead" type="button" aria-expanded="false">',
|
|
235
|
+
'<span class="tlabel">Where the time went</span>',
|
|
236
|
+
'<span class="ttotal" id="ttotal"></span>',
|
|
237
|
+
`${CHEVRON}`,
|
|
238
|
+
'</button>',
|
|
239
|
+
'<div class="tbar" id="tbar"></div>',
|
|
240
|
+
'<ul class="tkey" id="tkey" hidden></ul>',
|
|
241
|
+
'</section>',
|
|
242
|
+
|
|
243
|
+
// --- the one thing left to do ------------------------------------------
|
|
244
|
+
'<footer class="foot" id="footer" hidden>',
|
|
245
|
+
'<p class="nextlabel" id="nextlabel">Your turn</p>',
|
|
246
|
+
'<div class="cmd">',
|
|
247
|
+
// The prompt mark is not decoration: it is what tells a person at a glance that
|
|
248
|
+
// this line is something to type, not something to read. It is never copied.
|
|
249
|
+
'<span class="prompt mono" aria-hidden="true">$</span>',
|
|
250
|
+
'<code class="mono" id="cmd"></code>',
|
|
251
|
+
`<button class="copy" id="copy" type="button" title="copy" aria-label="copy the command">${COPY}</button>`,
|
|
252
|
+
'</div>',
|
|
253
|
+
'</footer>',
|
|
254
|
+
|
|
255
|
+
'</div>',
|
|
256
|
+
|
|
257
|
+
// --- the viewer: the real picture, as big as the window goes ------------
|
|
258
|
+
//
|
|
259
|
+
// This is where a person decides whether to approve something, so it is the
|
|
260
|
+
// most carefully built thing on the page: the full-resolution file, zoom to
|
|
261
|
+
// eight times with the actual pixels showing rather than a smoothed guess,
|
|
262
|
+
// drag to move around, and — for a screen that moved — the approved picture
|
|
263
|
+
// and the new one under one draggable line, plus the difference.
|
|
264
|
+
'<div class="viewer" id="viewer" hidden>',
|
|
265
|
+
'<header class="vtop">',
|
|
266
|
+
'<span class="vname mono" id="vname"></span>',
|
|
267
|
+
'<div class="vmodes" id="vmodes"></div>',
|
|
268
|
+
'<span class="vright">',
|
|
269
|
+
'<span class="vzoom mono" id="vzoom" title="double-click the picture to fit it again">1.0×</span>',
|
|
270
|
+
`<button class="vclose" id="vclose" type="button" aria-label="close the picture">${CLOSE}</button>`,
|
|
271
|
+
'</span>',
|
|
272
|
+
'</header>',
|
|
273
|
+
'<div class="vstage" id="vstage">',
|
|
274
|
+
'<div class="vframe" id="vframe">',
|
|
275
|
+
'<img class="vlayer" id="vApproved" alt="the approved picture">',
|
|
276
|
+
'<img class="vlayer" id="vNow" alt="the picture just taken">',
|
|
277
|
+
'<img class="vlayer" id="vDiff" alt="the difference between them">',
|
|
278
|
+
'</div>',
|
|
279
|
+
'<div class="vwipe" id="vwipe" hidden><span class="vgrip" aria-hidden="true"></span></div>',
|
|
280
|
+
'</div>',
|
|
281
|
+
'<footer class="vfoot" id="vfoot" hidden>',
|
|
282
|
+
'<span class="vside">approved</span>',
|
|
283
|
+
'<input class="vblend" id="vblend" type="range" min="0" max="100" value="100" aria-label="fade between the approved picture and the new one">',
|
|
284
|
+
'<span class="vside">now</span>',
|
|
285
|
+
'</footer>',
|
|
286
|
+
'<p class="vhelp">scroll to zoom · drag to move · double-click to fit · esc to close</p>',
|
|
287
|
+
'</div>',
|
|
288
|
+
|
|
289
|
+
`<script type="application/json" id="staysfixed-plan">${embedded}</script>`,
|
|
290
|
+
`<script>${SCRIPT}</script>`,
|
|
291
|
+
'</body>',
|
|
292
|
+
'</html>',
|
|
293
|
+
'',
|
|
294
|
+
].join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const STYLE = `
|
|
298
|
+
/* --------------------------------------------------------------------------
|
|
299
|
+
Tokens.
|
|
300
|
+
|
|
301
|
+
Four colours carry meaning and nothing else does: accent blue for "this is
|
|
302
|
+
happening" and "you are needed", green for held, amber for moved, red for
|
|
303
|
+
broke. Everything structural is the ground, the ink, or a hairline. That is
|
|
304
|
+
what lets a single amber dot be seen from across a desk.
|
|
305
|
+
|
|
306
|
+
One type scale, five steps, used everywhere. One easing curve, used by every
|
|
307
|
+
moving thing on the page, so the whole panel moves like one object.
|
|
308
|
+
|
|
309
|
+
Dark is the design — this window lives beside an editor all day. Light is a
|
|
310
|
+
warm paper, never the white page with grey cards the brief rules out.
|
|
311
|
+
-------------------------------------------------------------------------- */
|
|
312
|
+
:root {
|
|
313
|
+
color-scheme: dark;
|
|
314
|
+
|
|
315
|
+
--ground: #0a0c12;
|
|
316
|
+
--lift: #12161f;
|
|
317
|
+
--card: rgba(255, 255, 255, 0.03);
|
|
318
|
+
--card-hover: rgba(255, 255, 255, 0.055);
|
|
319
|
+
--well: #070910;
|
|
320
|
+
--glass: rgba(10, 12, 18, 0.74);
|
|
321
|
+
|
|
322
|
+
--ink: #e7eaf2;
|
|
323
|
+
--soft: #939bad;
|
|
324
|
+
--faint: #616a7d;
|
|
325
|
+
|
|
326
|
+
--line: rgba(255, 255, 255, 0.06);
|
|
327
|
+
--line-firm: rgba(255, 255, 255, 0.13);
|
|
328
|
+
--sheen: rgba(255, 255, 255, 0.06);
|
|
329
|
+
--shadow: rgba(0, 0, 0, 0.66);
|
|
330
|
+
|
|
331
|
+
--accent: #6f9dff;
|
|
332
|
+
--held: #55bd8c;
|
|
333
|
+
--moved: #e2a84f;
|
|
334
|
+
--broke: #ef6a61;
|
|
335
|
+
--resting: rgba(255, 255, 255, 0.08);
|
|
336
|
+
|
|
337
|
+
--radius: 20px;
|
|
338
|
+
--radius-sm: 13px;
|
|
339
|
+
--radius-xs: 9px;
|
|
340
|
+
|
|
341
|
+
--pad: 18px;
|
|
342
|
+
--tint: var(--accent);
|
|
343
|
+
|
|
344
|
+
/* The type scale. Nothing on this page is set at a size that is not here. */
|
|
345
|
+
--t-label: 10px;
|
|
346
|
+
--t-meta: 10.5px;
|
|
347
|
+
--t-body: 11.5px;
|
|
348
|
+
--t-name: 12px;
|
|
349
|
+
--t-lead: 17px;
|
|
350
|
+
|
|
351
|
+
/* One curve. Everything that moves, moves on this. */
|
|
352
|
+
--ease: cubic-bezier(0.22, 0.72, 0.24, 1);
|
|
353
|
+
--quick: 170ms;
|
|
354
|
+
--calm: 260ms;
|
|
355
|
+
--slow: 320ms;
|
|
356
|
+
}
|
|
357
|
+
/*
|
|
358
|
+
* Light is opt-in, and that is deliberate.
|
|
359
|
+
*
|
|
360
|
+
* This window runs on a brand new browser profile, and a fresh profile answers
|
|
361
|
+
* "prefers-color-scheme" with "light" whatever the computer around it is set to. So the
|
|
362
|
+
* panel came up pale on a dark desktop, which is exactly the thing it was asked not to
|
|
363
|
+
* do. The look is therefore stated on the html element rather than sniffed: dark unless
|
|
364
|
+
* somebody asks for light, and 'system' for anyone who does want the browser's opinion.
|
|
365
|
+
*/
|
|
366
|
+
:root[data-theme='light'],
|
|
367
|
+
:root[data-theme='system'] {
|
|
368
|
+
color-scheme: light;
|
|
369
|
+
|
|
370
|
+
--ground: #e3ded3;
|
|
371
|
+
--lift: #fdfbf7;
|
|
372
|
+
--card: rgba(255, 255, 255, 0.78);
|
|
373
|
+
--card-hover: rgba(255, 255, 255, 1);
|
|
374
|
+
--well: #dad4c8;
|
|
375
|
+
--glass: rgba(231, 226, 217, 0.82);
|
|
376
|
+
|
|
377
|
+
--ink: #1c1a16;
|
|
378
|
+
--soft: #6b665c;
|
|
379
|
+
--faint: #8b8578;
|
|
380
|
+
|
|
381
|
+
--line: rgba(28, 24, 18, 0.09);
|
|
382
|
+
--line-firm: rgba(28, 24, 18, 0.19);
|
|
383
|
+
--sheen: rgba(255, 255, 255, 0.85);
|
|
384
|
+
--shadow: rgba(58, 47, 30, 0.34);
|
|
385
|
+
|
|
386
|
+
--accent: #2f57c9;
|
|
387
|
+
--held: #1f7a4d;
|
|
388
|
+
--moved: #94620a;
|
|
389
|
+
--broke: #b52f23;
|
|
390
|
+
--resting: rgba(28, 24, 18, 0.1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
* { box-sizing: border-box; }
|
|
394
|
+
[hidden] { display: none !important; }
|
|
395
|
+
html, body { margin: 0; padding: 0; height: 100%; }
|
|
396
|
+
body {
|
|
397
|
+
background: var(--ground);
|
|
398
|
+
color: var(--ink);
|
|
399
|
+
font: 13px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
400
|
+
-webkit-font-smoothing: antialiased;
|
|
401
|
+
overflow: hidden;
|
|
402
|
+
}
|
|
403
|
+
/* The terminal character of the thing: every name, number and command is set
|
|
404
|
+
in the monospace, and nothing else is. */
|
|
405
|
+
.mono, code {
|
|
406
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
407
|
+
font-variant-numeric: tabular-nums;
|
|
408
|
+
}
|
|
409
|
+
p, h2 { margin: 0; }
|
|
410
|
+
img { display: block; }
|
|
411
|
+
button { font: inherit; color: inherit; }
|
|
412
|
+
.glyph { width: 16px; height: 16px; flex: 0 0 auto; }
|
|
413
|
+
|
|
414
|
+
/* A single soft light from above, tinted by how the run is going. It is the
|
|
415
|
+
only decoration on the page, it has no edge you could point at, and it is
|
|
416
|
+
the thing that stops the dark reading as a hole in the screen. */
|
|
417
|
+
.aura {
|
|
418
|
+
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
|
419
|
+
background:
|
|
420
|
+
radial-gradient(120% 46% at 50% -8%, color-mix(in srgb, var(--tint) 15%, transparent), transparent 70%),
|
|
421
|
+
radial-gradient(100% 60% at 50% 112%, var(--shadow), transparent 66%);
|
|
422
|
+
transition: background 620ms var(--ease);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.panel {
|
|
426
|
+
position: relative; z-index: 1;
|
|
427
|
+
display: flex; flex-direction: column;
|
|
428
|
+
height: 100%;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* --- header -------------------------------------------------------------- */
|
|
432
|
+
.top {
|
|
433
|
+
flex: 0 0 auto;
|
|
434
|
+
padding: 16px var(--pad) 15px;
|
|
435
|
+
background: var(--glass);
|
|
436
|
+
backdrop-filter: blur(22px) saturate(150%);
|
|
437
|
+
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
|
438
|
+
}
|
|
439
|
+
.brand { display: flex; align-items: center; gap: 9px; }
|
|
440
|
+
.badge {
|
|
441
|
+
flex: 0 0 auto;
|
|
442
|
+
display: flex; align-items: center; justify-content: center;
|
|
443
|
+
width: 24px; height: 24px;
|
|
444
|
+
border-radius: 8px;
|
|
445
|
+
color: var(--accent);
|
|
446
|
+
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
|
447
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 24%, transparent);
|
|
448
|
+
}
|
|
449
|
+
.badge .glyph { width: 15px; height: 15px; }
|
|
450
|
+
.wordmark {
|
|
451
|
+
flex: 1 1 auto; min-width: 0;
|
|
452
|
+
font-size: var(--t-label); font-weight: 600;
|
|
453
|
+
letter-spacing: 0.2em; text-transform: uppercase;
|
|
454
|
+
color: var(--soft);
|
|
455
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
456
|
+
}
|
|
457
|
+
.elapsed { flex: 0 0 auto; font-size: var(--t-body); color: var(--faint); }
|
|
458
|
+
|
|
459
|
+
.target {
|
|
460
|
+
display: flex; align-items: baseline; gap: 6px;
|
|
461
|
+
margin-top: 13px;
|
|
462
|
+
font-size: var(--t-name);
|
|
463
|
+
white-space: nowrap; overflow: hidden;
|
|
464
|
+
}
|
|
465
|
+
.target .mono { color: var(--ink); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; }
|
|
466
|
+
.target .sep { color: var(--faint); flex: 0 0 auto; }
|
|
467
|
+
.target .app { color: var(--faint); flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; font-size: var(--t-body); }
|
|
468
|
+
|
|
469
|
+
/* The sentence. The largest text on the page after the picture, because it is
|
|
470
|
+
the one thing a person reads from four feet away. */
|
|
471
|
+
.state {
|
|
472
|
+
margin-top: 9px;
|
|
473
|
+
font-size: var(--t-lead); font-weight: 620; line-height: 1.32;
|
|
474
|
+
letter-spacing: -0.011em;
|
|
475
|
+
overflow-wrap: anywhere;
|
|
476
|
+
transition: color var(--slow) var(--ease);
|
|
477
|
+
}
|
|
478
|
+
.state.held { color: var(--ink); }
|
|
479
|
+
.state.moved { color: var(--moved); }
|
|
480
|
+
.state.broke { color: var(--broke); }
|
|
481
|
+
.state.wait { color: var(--accent); }
|
|
482
|
+
/* The verdict arrives rather than appearing: it is the one sentence the whole
|
|
483
|
+
run was for, and a run that ends by swapping a word looks like a page that
|
|
484
|
+
was not paying attention. */
|
|
485
|
+
.state.arrive { animation: arrive 340ms var(--ease) both; }
|
|
486
|
+
@keyframes arrive {
|
|
487
|
+
from { opacity: 0; transform: translateY(7px); filter: blur(4px); }
|
|
488
|
+
to { opacity: 1; transform: none; filter: blur(0); }
|
|
489
|
+
}
|
|
490
|
+
.note { margin-top: 5px; font-size: var(--t-body); color: var(--faint); overflow-wrap: anywhere; }
|
|
491
|
+
|
|
492
|
+
/* One hairline, not a row of blocks. Each finished check adds its own slice of
|
|
493
|
+
colour to a single continuous line, so progress and outcome are the same
|
|
494
|
+
object and there is one fewer thing on the page. */
|
|
495
|
+
.meter { display: flex; align-items: center; gap: 10px; margin-top: 15px; }
|
|
496
|
+
.track {
|
|
497
|
+
flex: 1 1 auto; min-width: 0;
|
|
498
|
+
height: 4px; border-radius: 999px;
|
|
499
|
+
background: var(--resting);
|
|
500
|
+
overflow: hidden;
|
|
501
|
+
}
|
|
502
|
+
.fill { display: flex; height: 100%; width: 100%; }
|
|
503
|
+
.slice {
|
|
504
|
+
min-width: 0; height: 100%;
|
|
505
|
+
background: var(--resting);
|
|
506
|
+
transition: background var(--slow) var(--ease), flex-basis var(--slow) var(--ease);
|
|
507
|
+
}
|
|
508
|
+
.slice.held { background: var(--held); }
|
|
509
|
+
.slice.moved { background: var(--moved); }
|
|
510
|
+
.slice.broke { background: var(--broke); }
|
|
511
|
+
.slice.wait { background: var(--accent); }
|
|
512
|
+
.slice.skip { background: color-mix(in srgb, var(--ink) 16%, transparent); }
|
|
513
|
+
.slice.running {
|
|
514
|
+
background: var(--accent);
|
|
515
|
+
animation: breathe 1.9s var(--ease) infinite;
|
|
516
|
+
}
|
|
517
|
+
/* A segment lights as it lands, once, and then it is just a colour. */
|
|
518
|
+
.slice.land { animation: land 520ms var(--ease) 1; }
|
|
519
|
+
@keyframes land { 0% { filter: brightness(2.1); } 100% { filter: none; } }
|
|
520
|
+
@keyframes breathe { 0%, 100% { opacity: 0.42; } 50% { opacity: 1; } }
|
|
521
|
+
.counts {
|
|
522
|
+
flex: 0 1 auto; min-width: 0; max-width: 66%;
|
|
523
|
+
font-size: var(--t-meta); color: var(--faint); letter-spacing: 0.02em;
|
|
524
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* --- the body: one column, or two when there is room --------------------- */
|
|
528
|
+
.body {
|
|
529
|
+
flex: 1 1 auto; min-height: 0;
|
|
530
|
+
display: flex; flex-direction: column;
|
|
531
|
+
gap: 4px;
|
|
532
|
+
padding: 0 var(--pad);
|
|
533
|
+
overflow: hidden;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* --- the hero ------------------------------------------------------------ */
|
|
537
|
+
.stage { flex: 0 0 auto; padding-top: 16px; }
|
|
538
|
+
.shot {
|
|
539
|
+
position: relative;
|
|
540
|
+
aspect-ratio: 16 / 10;
|
|
541
|
+
max-height: 40vh;
|
|
542
|
+
margin: 0 auto;
|
|
543
|
+
border-radius: var(--radius);
|
|
544
|
+
background: var(--well);
|
|
545
|
+
overflow: hidden;
|
|
546
|
+
display: flex; align-items: center; justify-content: center;
|
|
547
|
+
cursor: zoom-in;
|
|
548
|
+
/* No border. The picture is the hero, so nothing is drawn around it that
|
|
549
|
+
could compete with it — only a soft floor shadow and the faintest rim to
|
|
550
|
+
keep a white screenshot from bleeding into a light ground. */
|
|
551
|
+
box-shadow:
|
|
552
|
+
0 0 0 1px var(--line),
|
|
553
|
+
0 26px 50px -30px var(--shadow);
|
|
554
|
+
transition: box-shadow var(--slow) var(--ease);
|
|
555
|
+
}
|
|
556
|
+
.shot.empty { cursor: default; }
|
|
557
|
+
.layer {
|
|
558
|
+
position: absolute; inset: 0;
|
|
559
|
+
width: 100%; height: 100%;
|
|
560
|
+
object-fit: contain;
|
|
561
|
+
opacity: 0;
|
|
562
|
+
transform: scale(1.014);
|
|
563
|
+
transition: opacity var(--slow) var(--ease), transform 480ms var(--ease);
|
|
564
|
+
}
|
|
565
|
+
.layer.on { opacity: 1; transform: none; }
|
|
566
|
+
.layer.out { opacity: 0; transform: scale(0.996); }
|
|
567
|
+
.blank {
|
|
568
|
+
position: relative;
|
|
569
|
+
color: var(--faint); font-size: var(--t-body); line-height: 1.6;
|
|
570
|
+
text-align: center; padding: 0 30px; max-width: 260px;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* The shutter, made visible. One pass of soft light across the glass for
|
|
574
|
+
exactly as long as the app is really being photographed — it starts when the
|
|
575
|
+
screen starts and it is gone the instant the picture lands, so it is a report
|
|
576
|
+
of what is happening rather than a decoration that never stops. */
|
|
577
|
+
.sweep {
|
|
578
|
+
position: absolute; inset: 0;
|
|
579
|
+
pointer-events: none; opacity: 0;
|
|
580
|
+
background:
|
|
581
|
+
linear-gradient(100deg,
|
|
582
|
+
transparent 4%,
|
|
583
|
+
color-mix(in srgb, var(--accent) 10%, transparent) 30%,
|
|
584
|
+
color-mix(in srgb, var(--accent) 34%, transparent) 47%,
|
|
585
|
+
color-mix(in srgb, var(--accent) 82%, transparent) 50%,
|
|
586
|
+
color-mix(in srgb, var(--accent) 34%, transparent) 53%,
|
|
587
|
+
color-mix(in srgb, var(--accent) 10%, transparent) 70%,
|
|
588
|
+
transparent 96%);
|
|
589
|
+
transform: translateX(-100%);
|
|
590
|
+
}
|
|
591
|
+
.shot.scanning .sweep { opacity: 1; animation: sweep 1.6s var(--ease) infinite; }
|
|
592
|
+
.shot.scanning {
|
|
593
|
+
box-shadow:
|
|
594
|
+
0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent),
|
|
595
|
+
0 26px 50px -30px var(--shadow);
|
|
596
|
+
}
|
|
597
|
+
@keyframes sweep {
|
|
598
|
+
from { transform: translateX(-100%); }
|
|
599
|
+
to { transform: translateX(100%); }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* Said once, on hover, and never in the way of the picture. */
|
|
603
|
+
.closer {
|
|
604
|
+
position: absolute; right: 10px; bottom: 10px;
|
|
605
|
+
font-size: var(--t-label); letter-spacing: 0.02em;
|
|
606
|
+
color: var(--ink);
|
|
607
|
+
background: color-mix(in srgb, var(--ground) 72%, transparent);
|
|
608
|
+
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
609
|
+
border-radius: 999px; padding: 4px 10px;
|
|
610
|
+
opacity: 0; transform: translateY(4px);
|
|
611
|
+
transition: opacity var(--quick) var(--ease), transform var(--quick) var(--ease);
|
|
612
|
+
pointer-events: none;
|
|
613
|
+
}
|
|
614
|
+
.shot:hover .closer { opacity: 1; transform: none; }
|
|
615
|
+
.shot.empty .closer { display: none; }
|
|
616
|
+
|
|
617
|
+
.caption {
|
|
618
|
+
display: flex; align-items: baseline; gap: 10px;
|
|
619
|
+
margin-top: 12px; min-height: 18px;
|
|
620
|
+
}
|
|
621
|
+
.shotname { flex: 0 1 auto; font-size: var(--t-name); color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
622
|
+
.shotout { flex: 1 1 auto; min-width: 0; font-size: var(--t-body); color: var(--soft); text-align: right; overflow-wrap: anywhere; }
|
|
623
|
+
.shotout.moved { color: var(--moved); }
|
|
624
|
+
.shotout.broke { color: var(--broke); }
|
|
625
|
+
.shotout.wait { color: var(--accent); }
|
|
626
|
+
|
|
627
|
+
/* Approved / now / difference. Three quiet words with a line that slides
|
|
628
|
+
between them — no pill, no box, because a screen that moved already has
|
|
629
|
+
enough asking for the eye. */
|
|
630
|
+
.switch { display: flex; gap: 2px; margin-top: 10px; }
|
|
631
|
+
.switch button {
|
|
632
|
+
position: relative;
|
|
633
|
+
flex: 1 1 0; min-width: 0;
|
|
634
|
+
font-size: var(--t-body); letter-spacing: 0.01em;
|
|
635
|
+
color: var(--faint);
|
|
636
|
+
background: transparent; border: 0;
|
|
637
|
+
padding: 6px 4px 7px; cursor: pointer;
|
|
638
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
639
|
+
transition: color var(--quick) var(--ease);
|
|
640
|
+
}
|
|
641
|
+
.switch button::after {
|
|
642
|
+
content: ''; position: absolute; left: 50%; right: 50%; bottom: 0; height: 1.5px;
|
|
643
|
+
border-radius: 2px; background: var(--tone, var(--accent));
|
|
644
|
+
transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
|
|
645
|
+
opacity: 0;
|
|
646
|
+
}
|
|
647
|
+
.switch button:hover { color: var(--soft); }
|
|
648
|
+
.switch button.on { color: var(--ink); }
|
|
649
|
+
.switch button.on::after { left: 12%; right: 12%; opacity: 1; }
|
|
650
|
+
|
|
651
|
+
/* --- the list ------------------------------------------------------------ */
|
|
652
|
+
.scroll {
|
|
653
|
+
flex: 1 1 auto; min-height: 70px;
|
|
654
|
+
overflow-y: auto;
|
|
655
|
+
overscroll-behavior: contain;
|
|
656
|
+
scrollbar-width: thin;
|
|
657
|
+
scrollbar-color: color-mix(in srgb, var(--soft) 22%, transparent) transparent;
|
|
658
|
+
padding: 18px 5px 14px 0;
|
|
659
|
+
/* The list slides away under the header rather than being cut off by a box. */
|
|
660
|
+
mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
|
|
661
|
+
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 16px, #000 calc(100% - 12px), transparent 100%);
|
|
662
|
+
}
|
|
663
|
+
.scroll::-webkit-scrollbar { width: 10px; }
|
|
664
|
+
.scroll::-webkit-scrollbar-thumb {
|
|
665
|
+
background: color-mix(in srgb, var(--soft) 22%, transparent);
|
|
666
|
+
border-radius: 8px; border: 3px solid transparent; background-clip: padding-box;
|
|
667
|
+
}
|
|
668
|
+
.scroll::-webkit-scrollbar-track { background: transparent; }
|
|
669
|
+
|
|
670
|
+
.group + .group { margin-top: 20px; }
|
|
671
|
+
.grouplabel {
|
|
672
|
+
display: flex; align-items: baseline; gap: 9px;
|
|
673
|
+
padding: 0 6px 9px 4px;
|
|
674
|
+
font-size: var(--t-label); font-weight: 600;
|
|
675
|
+
letter-spacing: 0.16em; text-transform: uppercase;
|
|
676
|
+
color: var(--faint);
|
|
677
|
+
}
|
|
678
|
+
.grouplabel .mono { font-size: var(--t-label); letter-spacing: 0.04em; color: color-mix(in srgb, var(--faint) 70%, transparent); }
|
|
679
|
+
|
|
680
|
+
.items {
|
|
681
|
+
border-radius: var(--radius);
|
|
682
|
+
background: var(--card);
|
|
683
|
+
overflow: hidden;
|
|
684
|
+
}
|
|
685
|
+
.item + .item { box-shadow: inset 0 1px 0 var(--line); }
|
|
686
|
+
.item {
|
|
687
|
+
transition: background var(--calm) var(--ease);
|
|
688
|
+
}
|
|
689
|
+
.item.attention { background: color-mix(in srgb, var(--tone, var(--accent)) 5%, transparent); }
|
|
690
|
+
.item.running { background: color-mix(in srgb, var(--accent) 5%, transparent); }
|
|
691
|
+
/* Something broke. It is said once, with weight, and then it sits still — a
|
|
692
|
+
panel that keeps flashing beside your work is a panel you turn off. */
|
|
693
|
+
.item.alarm { animation: alarm 900ms var(--ease) 1; }
|
|
694
|
+
@keyframes alarm {
|
|
695
|
+
0% { background: color-mix(in srgb, var(--tone, var(--broke)) 26%, transparent); }
|
|
696
|
+
100% { background: color-mix(in srgb, var(--tone, var(--broke)) 5%, transparent); }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/* Big, quiet, touch-sized rows. At rest a check is a dot, a name and a time —
|
|
700
|
+
that is the whole of it. */
|
|
701
|
+
.row {
|
|
702
|
+
display: flex; align-items: center; gap: 11px;
|
|
703
|
+
width: 100%; min-height: 44px;
|
|
704
|
+
padding: 10px 14px;
|
|
705
|
+
text-align: left;
|
|
706
|
+
background: transparent; border: 0;
|
|
707
|
+
cursor: pointer;
|
|
708
|
+
transition: background var(--quick) var(--ease);
|
|
709
|
+
}
|
|
710
|
+
.row:hover { background: var(--card-hover); }
|
|
711
|
+
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
712
|
+
.item.plain .row { cursor: default; }
|
|
713
|
+
.item.showing .row { box-shadow: inset 2px 0 0 var(--line-firm); }
|
|
714
|
+
.item.showing .rname { color: var(--ink); }
|
|
715
|
+
.item.plain .row:hover { background: transparent; }
|
|
716
|
+
/* Rows settle in rather than appearing, and they do it one after another, which
|
|
717
|
+
is what makes a list of eleven screens read as one thing arriving. */
|
|
718
|
+
.item.fresh { animation: settle 300ms var(--ease) both; }
|
|
719
|
+
@keyframes settle {
|
|
720
|
+
from { opacity: 0; transform: translateY(7px); }
|
|
721
|
+
to { opacity: 1; transform: none; }
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.dot {
|
|
725
|
+
flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
|
|
726
|
+
background: var(--resting);
|
|
727
|
+
transition: background var(--calm) var(--ease), box-shadow var(--calm) var(--ease);
|
|
728
|
+
}
|
|
729
|
+
.item.held .dot { background: color-mix(in srgb, var(--held) 78%, transparent); }
|
|
730
|
+
.item.moved .dot { background: var(--moved); box-shadow: 0 0 0 4px color-mix(in srgb, var(--moved) 15%, transparent); }
|
|
731
|
+
.item.broke .dot { background: var(--broke); box-shadow: 0 0 0 4px color-mix(in srgb, var(--broke) 15%, transparent); }
|
|
732
|
+
.item.wait .dot { background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); }
|
|
733
|
+
.item.skip .dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--resting); }
|
|
734
|
+
.item.running .dot {
|
|
735
|
+
background: transparent;
|
|
736
|
+
box-shadow: inset 0 0 0 2px var(--accent);
|
|
737
|
+
animation: ping 1.8s var(--ease) infinite;
|
|
738
|
+
}
|
|
739
|
+
@keyframes ping {
|
|
740
|
+
0% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 0 color-mix(in srgb, var(--accent) 42%, transparent); }
|
|
741
|
+
70%, 100% { box-shadow: inset 0 0 0 2px var(--accent), 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
|
|
742
|
+
}
|
|
743
|
+
.item.running .rname { color: var(--ink); }
|
|
744
|
+
|
|
745
|
+
.rname {
|
|
746
|
+
flex: 1 1 auto; min-width: 0;
|
|
747
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
748
|
+
font-variant-numeric: tabular-nums;
|
|
749
|
+
font-size: var(--t-name); color: var(--ink);
|
|
750
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
751
|
+
}
|
|
752
|
+
.item.pending .rname, .item.skip .rname { color: var(--faint); }
|
|
753
|
+
.rtime {
|
|
754
|
+
flex: 0 0 auto;
|
|
755
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
756
|
+
font-variant-numeric: tabular-nums;
|
|
757
|
+
font-size: var(--t-meta); color: var(--faint);
|
|
758
|
+
}
|
|
759
|
+
.row .chev {
|
|
760
|
+
flex: 0 0 auto; width: 14px; height: 14px;
|
|
761
|
+
color: var(--faint); opacity: 0;
|
|
762
|
+
transition: transform var(--calm) var(--ease), opacity var(--quick) var(--ease);
|
|
763
|
+
}
|
|
764
|
+
.row:hover .chev, .item.open .chev { opacity: 1; }
|
|
765
|
+
.item.open .chev { transform: rotate(180deg); }
|
|
766
|
+
.item.plain .chev { display: none; }
|
|
767
|
+
|
|
768
|
+
/* Everything a person did not ask for lives here. */
|
|
769
|
+
.detail {
|
|
770
|
+
padding: 0 14px 13px 33px;
|
|
771
|
+
font-size: var(--t-body); line-height: 1.6;
|
|
772
|
+
overflow-wrap: anywhere;
|
|
773
|
+
}
|
|
774
|
+
.item.open .detail { animation: unfold 240ms var(--ease) both; }
|
|
775
|
+
@keyframes unfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
776
|
+
.detail .why { color: var(--soft); }
|
|
777
|
+
.detail .out { color: var(--soft); margin-top: 3px; }
|
|
778
|
+
.detail .out.moved { color: var(--moved); }
|
|
779
|
+
.detail .out.broke { color: var(--broke); }
|
|
780
|
+
.detail .out.wait { color: var(--accent); }
|
|
781
|
+
.detail .claim {
|
|
782
|
+
margin-top: 9px; padding: 9px 12px;
|
|
783
|
+
border-radius: var(--radius-xs);
|
|
784
|
+
background: var(--well);
|
|
785
|
+
box-shadow: inset 2px 0 0 var(--broke);
|
|
786
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
787
|
+
font-size: var(--t-body); color: var(--ink);
|
|
788
|
+
}
|
|
789
|
+
.detail .claim em { font-style: normal; color: var(--faint); }
|
|
790
|
+
.detail .story { margin-top: 7px; color: var(--faint); }
|
|
791
|
+
|
|
792
|
+
.nothing { padding: 26px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.7; text-align: center; }
|
|
793
|
+
.nothing .mono { color: var(--soft); }
|
|
794
|
+
|
|
795
|
+
/* --- where the time went ------------------------------------------------- */
|
|
796
|
+
.timing {
|
|
797
|
+
flex: 0 0 auto;
|
|
798
|
+
padding: 12px var(--pad) 13px;
|
|
799
|
+
border-top: 1px solid var(--line);
|
|
800
|
+
background: var(--glass);
|
|
801
|
+
backdrop-filter: blur(22px) saturate(150%);
|
|
802
|
+
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
|
803
|
+
}
|
|
804
|
+
.tophead {
|
|
805
|
+
display: flex; align-items: center; gap: 8px;
|
|
806
|
+
width: 100%; padding: 0 0 8px;
|
|
807
|
+
background: transparent; border: 0; cursor: pointer;
|
|
808
|
+
text-align: left;
|
|
809
|
+
}
|
|
810
|
+
.tlabel {
|
|
811
|
+
flex: 1 1 auto;
|
|
812
|
+
font-size: var(--t-label); font-weight: 600;
|
|
813
|
+
letter-spacing: 0.16em; text-transform: uppercase;
|
|
814
|
+
color: var(--faint);
|
|
815
|
+
}
|
|
816
|
+
.ttotal {
|
|
817
|
+
flex: 0 1 auto; min-width: 0;
|
|
818
|
+
font-size: var(--t-meta); color: var(--faint);
|
|
819
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
820
|
+
}
|
|
821
|
+
.ttotal b {
|
|
822
|
+
font-weight: 500; color: var(--soft);
|
|
823
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
824
|
+
font-variant-numeric: tabular-nums;
|
|
825
|
+
}
|
|
826
|
+
.tophead .chev { color: var(--faint); width: 13px; height: 13px; transition: transform var(--calm) var(--ease); }
|
|
827
|
+
.tophead[aria-expanded='true'] .chev { transform: rotate(180deg); }
|
|
828
|
+
.tophead:hover .tlabel, .tophead:hover .ttotal, .tophead:hover .chev { color: var(--soft); }
|
|
829
|
+
|
|
830
|
+
/* Deliberately not coloured. Where the time went is information, not state,
|
|
831
|
+
and colour on this page only ever means something needs a person. */
|
|
832
|
+
.tbar { display: flex; gap: 1px; height: 6px; border-radius: 999px; overflow: hidden; background: var(--resting); }
|
|
833
|
+
.tpart { min-width: 2px; transition: flex-grow var(--slow) var(--ease); }
|
|
834
|
+
.tkey { list-style: none; margin: 12px 0 1px; padding: 0; }
|
|
835
|
+
.tkey li { display: flex; align-items: center; gap: 9px; padding: 3px 4px; font-size: var(--t-body); color: var(--soft); }
|
|
836
|
+
.tkey .tswatch { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 2px; }
|
|
837
|
+
.tkey .tlabelled { flex: 1 1 auto; min-width: 0; }
|
|
838
|
+
.tkey b {
|
|
839
|
+
flex: 0 0 auto; font-weight: 500; color: var(--ink); font-size: var(--t-body);
|
|
840
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
841
|
+
font-variant-numeric: tabular-nums;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* --- follow ------------------------------------------------------------- */
|
|
845
|
+
.follow {
|
|
846
|
+
position: absolute; left: 50%; bottom: 16px; transform: translateX(-50%);
|
|
847
|
+
z-index: 4;
|
|
848
|
+
font-size: var(--t-body);
|
|
849
|
+
color: var(--ink);
|
|
850
|
+
background: var(--lift);
|
|
851
|
+
border: 1px solid var(--line-firm); border-radius: 999px;
|
|
852
|
+
padding: 6px 15px; cursor: pointer;
|
|
853
|
+
box-shadow: 0 12px 24px -14px var(--shadow);
|
|
854
|
+
animation: settle 240ms var(--ease) both;
|
|
855
|
+
transition: border-color var(--quick) var(--ease), color var(--quick) var(--ease);
|
|
856
|
+
}
|
|
857
|
+
.follow:hover { border-color: var(--accent); color: var(--accent); }
|
|
858
|
+
|
|
859
|
+
/* --- the one thing left to do ------------------------------------------- */
|
|
860
|
+
.foot {
|
|
861
|
+
flex: 0 0 auto;
|
|
862
|
+
padding: 14px var(--pad) 16px;
|
|
863
|
+
background: var(--glass);
|
|
864
|
+
backdrop-filter: blur(22px) saturate(150%);
|
|
865
|
+
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
|
866
|
+
border-top: 1px solid var(--line);
|
|
867
|
+
animation: arrive 340ms var(--ease) both;
|
|
868
|
+
}
|
|
869
|
+
.nextlabel {
|
|
870
|
+
font-size: var(--t-label); font-weight: 600;
|
|
871
|
+
letter-spacing: 0.16em; text-transform: uppercase;
|
|
872
|
+
color: var(--faint);
|
|
873
|
+
margin-bottom: 8px;
|
|
874
|
+
}
|
|
875
|
+
.cmd { display: flex; align-items: stretch; gap: 7px; }
|
|
876
|
+
.cmd code {
|
|
877
|
+
flex: 1 1 auto; min-width: 0;
|
|
878
|
+
display: flex; align-items: center;
|
|
879
|
+
background: var(--well);
|
|
880
|
+
border-radius: var(--radius-xs);
|
|
881
|
+
padding: 9px 11px; font-size: var(--t-body);
|
|
882
|
+
color: var(--ink);
|
|
883
|
+
overflow-wrap: anywhere;
|
|
884
|
+
user-select: all;
|
|
885
|
+
}
|
|
886
|
+
.prompt { flex: 0 0 auto; color: var(--held); opacity: 0.75; user-select: none; }
|
|
887
|
+
.copy {
|
|
888
|
+
flex: 0 0 auto; width: 36px;
|
|
889
|
+
display: flex; align-items: center; justify-content: center;
|
|
890
|
+
color: var(--soft); background: var(--well);
|
|
891
|
+
border: 0; border-radius: var(--radius-xs);
|
|
892
|
+
cursor: pointer;
|
|
893
|
+
transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
|
|
894
|
+
}
|
|
895
|
+
.copy:hover { color: var(--ink); background: var(--card-hover); }
|
|
896
|
+
.copy.done { color: var(--held); }
|
|
897
|
+
|
|
898
|
+
/* --------------------------------------------------------------------------
|
|
899
|
+
The viewer.
|
|
900
|
+
|
|
901
|
+
The one screen a person uses to decide whether to approve something, so it
|
|
902
|
+
gets the whole window, the real file at its real resolution, and — when a
|
|
903
|
+
screen moved — the approved picture and the new one under a line you drag.
|
|
904
|
+
Past about two times, the smoothing comes off: somebody zooming in is asking
|
|
905
|
+
to see the actual pixels, not a guess at what is between them.
|
|
906
|
+
-------------------------------------------------------------------------- */
|
|
907
|
+
.viewer {
|
|
908
|
+
position: fixed; inset: 0; z-index: 20;
|
|
909
|
+
display: flex; flex-direction: column;
|
|
910
|
+
background: color-mix(in srgb, var(--ground) 92%, #000);
|
|
911
|
+
animation: viewerin 220ms var(--ease) both;
|
|
912
|
+
}
|
|
913
|
+
@keyframes viewerin { from { opacity: 0; } to { opacity: 1; } }
|
|
914
|
+
|
|
915
|
+
.vtop {
|
|
916
|
+
flex: 0 0 auto;
|
|
917
|
+
display: flex; align-items: center; gap: 10px;
|
|
918
|
+
padding: 11px 12px 11px var(--pad);
|
|
919
|
+
}
|
|
920
|
+
/* Three parts, and the outer two share what is left over, so the words stay in
|
|
921
|
+
the middle and the way out stays hard right whether there are four pictures
|
|
922
|
+
to choose between or one. */
|
|
923
|
+
.vname {
|
|
924
|
+
flex: 1 1 0; min-width: 0;
|
|
925
|
+
font-size: var(--t-name); color: var(--ink);
|
|
926
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
927
|
+
}
|
|
928
|
+
.vmodes {
|
|
929
|
+
flex: 0 1 auto; min-width: 0;
|
|
930
|
+
display: flex; justify-content: center; gap: 3px;
|
|
931
|
+
overflow-x: auto; scrollbar-width: none;
|
|
932
|
+
}
|
|
933
|
+
.vmodes::-webkit-scrollbar { display: none; }
|
|
934
|
+
.vright { flex: 1 1 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
|
|
935
|
+
.vmodes button {
|
|
936
|
+
position: relative;
|
|
937
|
+
font-size: var(--t-body); color: var(--faint);
|
|
938
|
+
background: transparent; border: 0; cursor: pointer;
|
|
939
|
+
padding: 5px 9px 6px; white-space: nowrap;
|
|
940
|
+
transition: color var(--quick) var(--ease);
|
|
941
|
+
}
|
|
942
|
+
.vmodes button::after {
|
|
943
|
+
content: ''; position: absolute; left: 50%; right: 50%; bottom: 1px; height: 1.5px;
|
|
944
|
+
border-radius: 2px; background: var(--accent); opacity: 0;
|
|
945
|
+
transition: left var(--calm) var(--ease), right var(--calm) var(--ease), opacity var(--quick) var(--ease);
|
|
946
|
+
}
|
|
947
|
+
.vmodes button:hover { color: var(--soft); }
|
|
948
|
+
.vmodes button.on { color: var(--ink); }
|
|
949
|
+
.vmodes button.on::after { left: 14%; right: 14%; opacity: 1; }
|
|
950
|
+
.vzoom { flex: 0 0 auto; font-size: var(--t-meta); color: var(--faint); }
|
|
951
|
+
.vclose {
|
|
952
|
+
flex: 0 0 auto;
|
|
953
|
+
display: flex; align-items: center; justify-content: center;
|
|
954
|
+
width: 28px; height: 28px; border-radius: 8px;
|
|
955
|
+
color: var(--soft); background: transparent; border: 0; cursor: pointer;
|
|
956
|
+
transition: color var(--quick) var(--ease), background var(--quick) var(--ease);
|
|
957
|
+
}
|
|
958
|
+
.vclose:hover { color: var(--ink); background: var(--card-hover); }
|
|
959
|
+
|
|
960
|
+
.vstage {
|
|
961
|
+
position: relative;
|
|
962
|
+
flex: 1 1 auto; min-height: 0;
|
|
963
|
+
display: flex; align-items: center; justify-content: center;
|
|
964
|
+
overflow: hidden;
|
|
965
|
+
cursor: grab;
|
|
966
|
+
touch-action: none;
|
|
967
|
+
}
|
|
968
|
+
.vstage.dragging { cursor: grabbing; }
|
|
969
|
+
.vframe {
|
|
970
|
+
position: relative;
|
|
971
|
+
will-change: transform;
|
|
972
|
+
box-shadow: 0 0 0 1px var(--line-firm), 0 40px 80px -50px #000;
|
|
973
|
+
/* Opacity only. An animation that also names the transform wins against the
|
|
974
|
+
inline transform the zoom writes, and the picture would never move again. */
|
|
975
|
+
animation: viewerpic 260ms var(--ease) both;
|
|
976
|
+
}
|
|
977
|
+
@keyframes viewerpic { from { opacity: 0; } to { opacity: 1; } }
|
|
978
|
+
.vlayer {
|
|
979
|
+
position: absolute; inset: 0;
|
|
980
|
+
width: 100%; height: 100%;
|
|
981
|
+
object-fit: contain;
|
|
982
|
+
display: none;
|
|
983
|
+
}
|
|
984
|
+
.vlayer.show { display: block; }
|
|
985
|
+
.vframe.sharp .vlayer { image-rendering: pixelated; }
|
|
986
|
+
|
|
987
|
+
/* The line you drag. It lives in the window rather than in the picture, so it
|
|
988
|
+
stays one hair wide however far in you have zoomed. */
|
|
989
|
+
.vwipe {
|
|
990
|
+
position: absolute; top: 0; width: 22px;
|
|
991
|
+
margin-left: -11px;
|
|
992
|
+
display: flex; align-items: center; justify-content: center;
|
|
993
|
+
cursor: ew-resize;
|
|
994
|
+
touch-action: none;
|
|
995
|
+
}
|
|
996
|
+
.vwipe::before {
|
|
997
|
+
content: ''; position: absolute; top: 0; height: 100%; width: 1.5px;
|
|
998
|
+
background: color-mix(in srgb, #fff 78%, transparent);
|
|
999
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
|
1000
|
+
}
|
|
1001
|
+
.vgrip {
|
|
1002
|
+
position: relative;
|
|
1003
|
+
width: 22px; height: 22px; border-radius: 50%;
|
|
1004
|
+
background: color-mix(in srgb, #fff 88%, transparent);
|
|
1005
|
+
box-shadow: 0 2px 10px -2px rgba(0, 0, 0, 0.7);
|
|
1006
|
+
transition: transform var(--quick) var(--ease);
|
|
1007
|
+
}
|
|
1008
|
+
.vwipe:hover .vgrip, .vwipe.holding .vgrip { transform: scale(1.14); }
|
|
1009
|
+
|
|
1010
|
+
.vfoot {
|
|
1011
|
+
flex: 0 0 auto;
|
|
1012
|
+
display: flex; align-items: center; gap: 12px;
|
|
1013
|
+
padding: 12px var(--pad) 6px;
|
|
1014
|
+
}
|
|
1015
|
+
.vhelp {
|
|
1016
|
+
flex: 0 0 auto;
|
|
1017
|
+
padding: 10px var(--pad) 15px;
|
|
1018
|
+
text-align: center;
|
|
1019
|
+
font-size: var(--t-label); letter-spacing: 0.03em;
|
|
1020
|
+
color: var(--faint);
|
|
1021
|
+
}
|
|
1022
|
+
.vside { flex: 0 0 auto; font-size: var(--t-label); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); }
|
|
1023
|
+
.vblend {
|
|
1024
|
+
flex: 1 1 auto; min-width: 0;
|
|
1025
|
+
-webkit-appearance: none; appearance: none;
|
|
1026
|
+
height: 3px; border-radius: 999px;
|
|
1027
|
+
background: linear-gradient(to right, var(--moved), var(--accent));
|
|
1028
|
+
outline: none; cursor: pointer;
|
|
1029
|
+
}
|
|
1030
|
+
.vblend::-webkit-slider-thumb {
|
|
1031
|
+
-webkit-appearance: none; appearance: none;
|
|
1032
|
+
width: 15px; height: 15px; border-radius: 50%;
|
|
1033
|
+
background: var(--ink);
|
|
1034
|
+
box-shadow: 0 2px 8px -2px var(--shadow);
|
|
1035
|
+
transition: transform var(--quick) var(--ease);
|
|
1036
|
+
}
|
|
1037
|
+
.vblend:active::-webkit-slider-thumb { transform: scale(1.15); }
|
|
1038
|
+
|
|
1039
|
+
/* --- the range it has to survive ---------------------------------------- */
|
|
1040
|
+
|
|
1041
|
+
/* Dragged narrow. Everything stays, nothing wraps into a mess. */
|
|
1042
|
+
@media (max-width: 330px) {
|
|
1043
|
+
:root { --pad: 12px; --radius: 16px; --t-lead: 15px; }
|
|
1044
|
+
.target .app, .target .sep { display: none; }
|
|
1045
|
+
.row { gap: 9px; padding: 9px 11px; }
|
|
1046
|
+
.detail { padding: 0 11px 12px 28px; }
|
|
1047
|
+
.shot { max-height: 30vh; }
|
|
1048
|
+
.switch button { font-size: var(--t-meta); padding: 6px 2px 7px; }
|
|
1049
|
+
.ttotal { display: none; }
|
|
1050
|
+
.caption { display: block; }
|
|
1051
|
+
.shotname { display: block; white-space: normal; overflow-wrap: anywhere; }
|
|
1052
|
+
.shotout { display: block; text-align: left; margin-top: 2px; }
|
|
1053
|
+
.timing { padding: 10px var(--pad) 11px; }
|
|
1054
|
+
.vname { display: none; }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/* Pulled wide. Two columns rather than one very long thin one, so the picture
|
|
1058
|
+
gets the room and the list stays beside it instead of below the fold. */
|
|
1059
|
+
@media (min-width: 720px) {
|
|
1060
|
+
:root { --pad: 24px; --t-lead: 19px; }
|
|
1061
|
+
.body { flex-direction: row; gap: 28px; padding-bottom: 4px; }
|
|
1062
|
+
.stage { flex: 1 1 54%; min-width: 0; align-self: center; padding-top: 0; }
|
|
1063
|
+
.scroll { flex: 1 1 46%; min-width: 0; padding-top: 22px; }
|
|
1064
|
+
.shot { max-height: 58vh; }
|
|
1065
|
+
.timing { margin-top: 22px; }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1069
|
+
* { animation: none !important; transition: none !important; }
|
|
1070
|
+
}
|
|
1071
|
+
`;
|
|
1072
|
+
|
|
1073
|
+
const SCRIPT = `
|
|
1074
|
+
(function () {
|
|
1075
|
+
'use strict';
|
|
1076
|
+
|
|
1077
|
+
// This page only ever runs in the browser window Stays Fixed just opened, so
|
|
1078
|
+
// there is no compatibility question to answer and nothing to load.
|
|
1079
|
+
|
|
1080
|
+
var plan = { project: '', app: '', screens: [], guards: [] };
|
|
1081
|
+
try {
|
|
1082
|
+
var blob = document.getElementById('staysfixed-plan');
|
|
1083
|
+
if (blob && blob.textContent) plan = JSON.parse(blob.textContent) || plan;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
// A plan we cannot read costs the opening list, not the panel. Rows will
|
|
1086
|
+
// still appear one by one as the run reaches them.
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Point an image at a file, or at nothing at all.
|
|
1091
|
+
* @param {HTMLImageElement} img
|
|
1092
|
+
* @param {string|undefined|null} url
|
|
1093
|
+
*/
|
|
1094
|
+
function setSource(img, url) {
|
|
1095
|
+
if (!img) return;
|
|
1096
|
+
if (url) img.setAttribute('src', url);
|
|
1097
|
+
else img.removeAttribute('src');
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function el(id) { return document.getElementById(id); }
|
|
1101
|
+
|
|
1102
|
+
var ui = {
|
|
1103
|
+
clock: el('clock'), project: el('project'), app: el('app'), targetsep: el('targetsep'),
|
|
1104
|
+
state: el('state'), note: el('note'),
|
|
1105
|
+
track: el('track'), fill: el('fill'), counts: el('counts'),
|
|
1106
|
+
stage: el('stage'), shot: el('shot'), layerA: el('layerA'), layerB: el('layerB'), blank: el('blank'),
|
|
1107
|
+
caption: el('caption'), shotname: el('shotname'), shotout: el('shotout'), tabs: el('tabs'),
|
|
1108
|
+
scroll: el('scroll'), follow: el('follow'), nothing: el('nothing'),
|
|
1109
|
+
groupScreens: el('groupScreens'), listScreens: el('listScreens'), countScreens: el('countScreens'),
|
|
1110
|
+
groupGuards: el('groupGuards'), listGuards: el('listGuards'), countGuards: el('countGuards'),
|
|
1111
|
+
timing: el('timing'), thead: el('thead'), ttotal: el('ttotal'), tbar: el('tbar'), tkey: el('tkey'),
|
|
1112
|
+
footer: el('footer'), cmd: el('cmd'), copy: el('copy'),
|
|
1113
|
+
aura: document.querySelector('.aura'),
|
|
1114
|
+
viewer: el('viewer'), vname: el('vname'), vmodes: el('vmodes'), vzoom: el('vzoom'),
|
|
1115
|
+
vclose: el('vclose'), vstage: el('vstage'), vframe: el('vframe'),
|
|
1116
|
+
vApproved: el('vApproved'), vNow: el('vNow'), vDiff: el('vDiff'),
|
|
1117
|
+
vwipe: el('vwipe'), vfoot: el('vfoot'), vblend: el('vblend')
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
var CHEVRON = '<svg class="glyph chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8.5 10.5l3.5 3.5 3.5-3.5"></path></svg>';
|
|
1121
|
+
|
|
1122
|
+
// Somebody who has asked their computer for less movement gets none of it:
|
|
1123
|
+
// the CSS switches every animation off, and everything this file animates in
|
|
1124
|
+
// JavaScript jumps straight to its final value instead.
|
|
1125
|
+
var CALM = false;
|
|
1126
|
+
try { CALM = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { CALM = false; }
|
|
1127
|
+
|
|
1128
|
+
// -------------------------------------------------------------------------
|
|
1129
|
+
// Words. These match the terminal on purpose — the same run should not be
|
|
1130
|
+
// described two different ways depending on where you happen to be reading.
|
|
1131
|
+
// The originals live in src/report/console.js.
|
|
1132
|
+
// -------------------------------------------------------------------------
|
|
1133
|
+
|
|
1134
|
+
function commas(n) {
|
|
1135
|
+
var v = Math.round(Number(n) || 0);
|
|
1136
|
+
return v.toLocaleString('en-US');
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function fmt(ms) {
|
|
1140
|
+
var v = Number(ms);
|
|
1141
|
+
if (!isFinite(v) || v < 0) v = 0;
|
|
1142
|
+
if (v < 1000) return Math.round(v) + 'ms';
|
|
1143
|
+
if (v < 60000) return (v / 1000).toFixed(1) + 's';
|
|
1144
|
+
var m = Math.floor(v / 60000);
|
|
1145
|
+
var s = Math.round((v % 60000) / 1000);
|
|
1146
|
+
return m + 'm ' + s + 's';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function plural(n, one, many) { return n === 1 ? one : many; }
|
|
1150
|
+
|
|
1151
|
+
// The whole colour vocabulary, in one place: green held, amber moved, red
|
|
1152
|
+
// broke, blue is waiting for a person to look, and a check nobody ran has no
|
|
1153
|
+
// colour at all.
|
|
1154
|
+
function toneOf(status) {
|
|
1155
|
+
switch (status) {
|
|
1156
|
+
case 'running': return 'running';
|
|
1157
|
+
case 'passed': return 'held';
|
|
1158
|
+
case 'changed': case 'flaky': return 'moved';
|
|
1159
|
+
case 'failed': case 'missing': return 'broke';
|
|
1160
|
+
case 'new': return 'wait';
|
|
1161
|
+
case 'skipped': return 'skip';
|
|
1162
|
+
default: return '';
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// A tone that means "somebody has to look at this". These are the rows that
|
|
1167
|
+
// open themselves; everything else waits to be asked.
|
|
1168
|
+
function needsPerson(tone) { return tone === 'moved' || tone === 'broke' || tone === 'wait'; }
|
|
1169
|
+
|
|
1170
|
+
// The terse form, for the line beside the picture. The colour and the compare
|
|
1171
|
+
// control have already said most of it there.
|
|
1172
|
+
function outcomeShort(ev) {
|
|
1173
|
+
switch (ev.status) {
|
|
1174
|
+
case 'changed':
|
|
1175
|
+
var n = ev.diffPixels || 0;
|
|
1176
|
+
return commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' moved';
|
|
1177
|
+
case 'new': return 'not approved yet';
|
|
1178
|
+
case 'missing': return 'no approved picture';
|
|
1179
|
+
case 'failed': return ev.message || 'could not be photographed';
|
|
1180
|
+
case 'flaky': return 'changed between tries';
|
|
1181
|
+
default: return '';
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function outcomeText(kind, ev) {
|
|
1186
|
+
var status = ev.status;
|
|
1187
|
+
if (kind === 'guard') {
|
|
1188
|
+
if (status === 'passed') return 'still holds';
|
|
1189
|
+
if (status === 'skipped') return 'left out on purpose';
|
|
1190
|
+
return ev.message || 'this one is broken again';
|
|
1191
|
+
}
|
|
1192
|
+
switch (status) {
|
|
1193
|
+
case 'passed': return 'still the same';
|
|
1194
|
+
case 'changed':
|
|
1195
|
+
var n = ev.diffPixels || 0;
|
|
1196
|
+
return 'looks different — ' + commas(n) + ' ' + plural(n, 'pixel', 'pixels') + ' changed';
|
|
1197
|
+
case 'new': return 'nobody has approved this picture yet';
|
|
1198
|
+
case 'missing': return 'the approved picture is gone';
|
|
1199
|
+
case 'failed': return ev.message || 'could not be photographed';
|
|
1200
|
+
case 'flaky': return 'changed its mind between tries';
|
|
1201
|
+
case 'skipped': return 'left out on purpose';
|
|
1202
|
+
default: return ev.message || '';
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// -------------------------------------------------------------------------
|
|
1207
|
+
// Numbers count rather than jump.
|
|
1208
|
+
//
|
|
1209
|
+
// A tally that flicks from 3 to 7 has to be re-read; one that counts up to 7
|
|
1210
|
+
// has already been read by the time it stops. Four hundred milliseconds, the
|
|
1211
|
+
// same curve as everything else, and nothing at all for anyone who asked for
|
|
1212
|
+
// less motion.
|
|
1213
|
+
// -------------------------------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
function tweenTo(node, to, format) {
|
|
1216
|
+
var target = Number(to);
|
|
1217
|
+
if (!isFinite(target)) return;
|
|
1218
|
+
var from = typeof node.__from === 'number' ? node.__from : 0;
|
|
1219
|
+
if (node.__raf) { cancelAnimationFrame(node.__raf); node.__raf = 0; }
|
|
1220
|
+
if (CALM || from === target) {
|
|
1221
|
+
node.__from = target;
|
|
1222
|
+
node.textContent = format(target);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
var began = performance.now();
|
|
1226
|
+
var span = 420;
|
|
1227
|
+
function step(now) {
|
|
1228
|
+
var k = (now - began) / span;
|
|
1229
|
+
if (k > 1) k = 1;
|
|
1230
|
+
var eased = 1 - Math.pow(1 - k, 3);
|
|
1231
|
+
var value = k === 1 ? target : from + (target - from) * eased;
|
|
1232
|
+
node.__from = value;
|
|
1233
|
+
node.textContent = format(value);
|
|
1234
|
+
if (k < 1) node.__raf = requestAnimationFrame(step);
|
|
1235
|
+
else { node.__raf = 0; node.__from = target; }
|
|
1236
|
+
}
|
|
1237
|
+
node.__raf = requestAnimationFrame(step);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// -------------------------------------------------------------------------
|
|
1241
|
+
// Pictures.
|
|
1242
|
+
//
|
|
1243
|
+
// The panel is a local page, so it can load the real PNG the run just wrote
|
|
1244
|
+
// to disk. That is the whole difference between a picture you can look at and
|
|
1245
|
+
// a smudge: the small preview inside the event is 320 pixels wide, and this
|
|
1246
|
+
// window is wider than that before anybody zooms in. So the preview is only
|
|
1247
|
+
// ever what fills the frame for the moment before the file is on disk, and it
|
|
1248
|
+
// is replaced by the real thing the instant that file will load.
|
|
1249
|
+
// -------------------------------------------------------------------------
|
|
1250
|
+
|
|
1251
|
+
var known = Object.create(null); // src -> 1 loaded, 2 refused
|
|
1252
|
+
var held = []; // keep the decoded pictures alive
|
|
1253
|
+
|
|
1254
|
+
function preload(src, then) {
|
|
1255
|
+
if (!src) { if (then) then(false); return; }
|
|
1256
|
+
if (known[src] === 1) { if (then) then(true); return; }
|
|
1257
|
+
if (known[src] === 2) { if (then) then(false); return; }
|
|
1258
|
+
var img = new Image();
|
|
1259
|
+
img.onload = function () { known[src] = 1; if (then) then(true); };
|
|
1260
|
+
img.onerror = function () { known[src] = 2; if (then) then(false); };
|
|
1261
|
+
img.src = src;
|
|
1262
|
+
held.push(img);
|
|
1263
|
+
if (held.length > 24) held.shift();
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function preloadAll(p) {
|
|
1267
|
+
if (!p) return;
|
|
1268
|
+
preload(p.shotFile);
|
|
1269
|
+
preload(p.approvedFile);
|
|
1270
|
+
preload(p.diffFile);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// What to show for each of the three pictures: the real file when there is
|
|
1274
|
+
// one, the preview until then.
|
|
1275
|
+
function nowOf(p) { return (p && (p.shotFile || p.thumbnail)) || ''; }
|
|
1276
|
+
function approvedOf(p) { return (p && (p.approvedFile || p.approvedThumb)) || ''; }
|
|
1277
|
+
function diffOf(p) { return (p && (p.diffFile || p.diffThumb)) || ''; }
|
|
1278
|
+
|
|
1279
|
+
// -------------------------------------------------------------------------
|
|
1280
|
+
// The list. One line per check at rest; everything else on request.
|
|
1281
|
+
// -------------------------------------------------------------------------
|
|
1282
|
+
|
|
1283
|
+
var items = Object.create(null);
|
|
1284
|
+
var order = [];
|
|
1285
|
+
var outcomes = Object.create(null);
|
|
1286
|
+
var expected = { picture: 0, guard: 0 };
|
|
1287
|
+
var seeding = false;
|
|
1288
|
+
var TONES = ['held', 'moved', 'broke', 'wait', 'skip', 'running', 'pending'];
|
|
1289
|
+
|
|
1290
|
+
function keyFor(kind, name) { return kind + ':' + name; }
|
|
1291
|
+
|
|
1292
|
+
function addItem(kind, name, describe) {
|
|
1293
|
+
var root = document.createElement('div');
|
|
1294
|
+
root.className = 'item fresh pending';
|
|
1295
|
+
// Rows arrive one after another rather than all at once. Only while the
|
|
1296
|
+
// opening list is being drawn — a row that appears mid-run is on its own
|
|
1297
|
+
// and has nothing to be staggered against.
|
|
1298
|
+
var wait = seeding ? Math.min(order.length, 16) * 24 : 0;
|
|
1299
|
+
if (wait && !CALM) root.style.animationDelay = wait + 'ms';
|
|
1300
|
+
setTimeout(function () {
|
|
1301
|
+
root.classList.remove('fresh');
|
|
1302
|
+
root.style.animationDelay = '';
|
|
1303
|
+
}, 420 + wait);
|
|
1304
|
+
|
|
1305
|
+
var row = document.createElement('button');
|
|
1306
|
+
row.type = 'button';
|
|
1307
|
+
row.className = 'row';
|
|
1308
|
+
row.setAttribute('aria-expanded', 'false');
|
|
1309
|
+
|
|
1310
|
+
var dot = document.createElement('span');
|
|
1311
|
+
dot.className = 'dot';
|
|
1312
|
+
var rname = document.createElement('span');
|
|
1313
|
+
rname.className = 'rname';
|
|
1314
|
+
rname.textContent = name;
|
|
1315
|
+
var rtime = document.createElement('span');
|
|
1316
|
+
rtime.className = 'rtime';
|
|
1317
|
+
row.appendChild(dot);
|
|
1318
|
+
row.appendChild(rname);
|
|
1319
|
+
row.appendChild(rtime);
|
|
1320
|
+
row.insertAdjacentHTML('beforeend', CHEVRON);
|
|
1321
|
+
|
|
1322
|
+
var detail = document.createElement('div');
|
|
1323
|
+
detail.className = 'detail';
|
|
1324
|
+
detail.hidden = true;
|
|
1325
|
+
|
|
1326
|
+
root.appendChild(row);
|
|
1327
|
+
root.appendChild(detail);
|
|
1328
|
+
|
|
1329
|
+
var entry = {
|
|
1330
|
+
kind: kind, name: name, root: root, row: row, time: rtime,
|
|
1331
|
+
detail: detail, describe: describe || '', open: false, hasDetail: false
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
// One rule, so a click is never a surprise: the first click brings this
|
|
1335
|
+
// check forward — its picture on the glass, its detail open. The second
|
|
1336
|
+
// click, on the row that is already forward, folds it away again. That is
|
|
1337
|
+
// what makes the whole run browsable afterwards instead of only the last
|
|
1338
|
+
// screen photographed.
|
|
1339
|
+
row.addEventListener('click', function () {
|
|
1340
|
+
var forward = entry.pics && !entry.root.classList.contains('showing');
|
|
1341
|
+
if (forward) {
|
|
1342
|
+
recall(entry);
|
|
1343
|
+
setOpen(entry, true);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
toggle(entry);
|
|
1347
|
+
});
|
|
1348
|
+
redraw(entry);
|
|
1349
|
+
|
|
1350
|
+
(kind === 'guard' ? ui.listGuards : ui.listScreens).appendChild(root);
|
|
1351
|
+
(kind === 'guard' ? ui.groupGuards : ui.groupScreens).hidden = false;
|
|
1352
|
+
|
|
1353
|
+
items[keyFor(kind, name)] = entry;
|
|
1354
|
+
order.push(entry);
|
|
1355
|
+
return entry;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function ensureItem(kind, name, describe) {
|
|
1359
|
+
var entry = items[keyFor(kind, name)];
|
|
1360
|
+
if (!entry) return addItem(kind, name, describe);
|
|
1361
|
+
if (describe && !entry.describe) { entry.describe = describe; redraw(entry); }
|
|
1362
|
+
return entry;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Rebuild what is hidden under a row. Written out every time rather than
|
|
1367
|
+
* patched, because the same row is drawn twice at most and a rebuilt block
|
|
1368
|
+
* can never disagree with the event that caused it.
|
|
1369
|
+
*/
|
|
1370
|
+
function redraw(entry) {
|
|
1371
|
+
var d = entry.detail;
|
|
1372
|
+
d.textContent = '';
|
|
1373
|
+
|
|
1374
|
+
if (entry.describe) {
|
|
1375
|
+
var why = document.createElement('div');
|
|
1376
|
+
why.className = 'why';
|
|
1377
|
+
why.textContent = entry.describe;
|
|
1378
|
+
d.appendChild(why);
|
|
1379
|
+
}
|
|
1380
|
+
// "Still the same" is not worth a line of anybody's attention, so a check
|
|
1381
|
+
// that held says only what it is for.
|
|
1382
|
+
if (entry.outText && entry.tone !== 'held') {
|
|
1383
|
+
var out = document.createElement('div');
|
|
1384
|
+
out.className = 'out' + (entry.tone ? ' ' + entry.tone : '');
|
|
1385
|
+
out.textContent = entry.outText;
|
|
1386
|
+
d.appendChild(out);
|
|
1387
|
+
}
|
|
1388
|
+
if (entry.failedAt) {
|
|
1389
|
+
var claim = document.createElement('div');
|
|
1390
|
+
claim.className = 'claim';
|
|
1391
|
+
var label = document.createElement('em');
|
|
1392
|
+
label.textContent = 'expected ';
|
|
1393
|
+
claim.appendChild(label);
|
|
1394
|
+
claim.appendChild(document.createTextNode(entry.failedAt));
|
|
1395
|
+
d.appendChild(claim);
|
|
1396
|
+
}
|
|
1397
|
+
if (entry.story && entry.story !== entry.describe) {
|
|
1398
|
+
var story = document.createElement('div');
|
|
1399
|
+
story.className = 'story';
|
|
1400
|
+
story.textContent = 'Why this guard exists: ' + entry.story;
|
|
1401
|
+
d.appendChild(story);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
entry.hasDetail = !!d.firstChild;
|
|
1405
|
+
entry.root.classList.toggle('plain', !entry.hasDetail);
|
|
1406
|
+
if (!entry.hasDetail) setOpen(entry, false);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** Put a screen's pictures back in the hero, with the words that went with them. */
|
|
1410
|
+
function recall(entry) {
|
|
1411
|
+
var p = entry.pics;
|
|
1412
|
+
if (!p) return;
|
|
1413
|
+
var showed = false;
|
|
1414
|
+
if (p.status === 'changed') showed = comparePictures(p);
|
|
1415
|
+
if (!showed && nowOf(p)) {
|
|
1416
|
+
singlePicture(p, entry.name);
|
|
1417
|
+
showed = true;
|
|
1418
|
+
}
|
|
1419
|
+
if (!showed) return;
|
|
1420
|
+
nameTheShot(entry.name);
|
|
1421
|
+
sayOutcome(entry.tone, outcomeShort(p));
|
|
1422
|
+
markShowing(entry);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function setOpen(entry, open) {
|
|
1426
|
+
entry.open = !!open && entry.hasDetail;
|
|
1427
|
+
entry.detail.hidden = !entry.open;
|
|
1428
|
+
entry.root.classList.toggle('open', entry.open);
|
|
1429
|
+
entry.row.setAttribute('aria-expanded', entry.open ? 'true' : 'false');
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function toggle(entry) {
|
|
1433
|
+
if (!entry.hasDetail) return;
|
|
1434
|
+
setOpen(entry, !entry.open);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function setTone(entry, tone) {
|
|
1438
|
+
entry.tone = tone;
|
|
1439
|
+
var list = entry.root.classList;
|
|
1440
|
+
for (var i = 0; i < TONES.length; i++) list.remove(TONES[i]);
|
|
1441
|
+
list.add(tone || 'pending');
|
|
1442
|
+
list.toggle('attention', needsPerson(tone));
|
|
1443
|
+
entry.root.style.setProperty('--tone', tone ? 'var(--' + tone + ')' : 'var(--accent)');
|
|
1444
|
+
paintMeter();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// -------------------------------------------------------------------------
|
|
1448
|
+
// The meter: one hairline, sliced by outcome. Each slice lights once as it
|
|
1449
|
+
// lands, in its own colour, and then it is just a colour.
|
|
1450
|
+
// -------------------------------------------------------------------------
|
|
1451
|
+
|
|
1452
|
+
var painted = [];
|
|
1453
|
+
|
|
1454
|
+
function meterTotal() {
|
|
1455
|
+
var planned = (expected.picture || 0) + (expected.guard || 0);
|
|
1456
|
+
return Math.max(order.length, planned, 1);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function paintMeter() {
|
|
1460
|
+
var total = meterTotal();
|
|
1461
|
+
var width = (100 / total) + '%';
|
|
1462
|
+
var slices = ui.fill.children;
|
|
1463
|
+
// One slice per known check, in order. The rest of the track stays empty,
|
|
1464
|
+
// which is what tells you how much is still to come.
|
|
1465
|
+
while (slices.length > order.length) ui.fill.removeChild(ui.fill.lastChild);
|
|
1466
|
+
while (slices.length < order.length) {
|
|
1467
|
+
var span = document.createElement('span');
|
|
1468
|
+
span.className = 'slice';
|
|
1469
|
+
ui.fill.appendChild(span);
|
|
1470
|
+
}
|
|
1471
|
+
for (var i = 0; i < order.length; i++) {
|
|
1472
|
+
var tone = order[i].tone || '';
|
|
1473
|
+
var settled = tone && tone !== 'running';
|
|
1474
|
+
var lands = settled && painted[i] !== tone && !CALM;
|
|
1475
|
+
slices[i].className = 'slice' + (tone ? ' ' + tone : '') + (lands ? ' land' : '');
|
|
1476
|
+
slices[i].style.width = width;
|
|
1477
|
+
painted[i] = tone;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
var doneTicker = { __from: 0, textContent: '' };
|
|
1482
|
+
|
|
1483
|
+
function tally() {
|
|
1484
|
+
var counted = { done: 0, changed: 0, failed: 0, waiting: 0, skipped: 0 };
|
|
1485
|
+
for (var i = 0; i < order.length; i++) {
|
|
1486
|
+
var status = outcomes[keyFor(order[i].kind, order[i].name)];
|
|
1487
|
+
if (!status) continue;
|
|
1488
|
+
counted.done++;
|
|
1489
|
+
if (status === 'moved') counted.changed++;
|
|
1490
|
+
else if (status === 'broke') counted.failed++;
|
|
1491
|
+
else if (status === 'wait') counted.waiting++;
|
|
1492
|
+
else if (status === 'skip') counted.skipped++;
|
|
1493
|
+
}
|
|
1494
|
+
return counted;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Say what happened, not just how far along it is. "11 of 11" tells a person the
|
|
1498
|
+
// run finished and nothing else; they still have to read the whole list to find
|
|
1499
|
+
// out whether they can walk away. The tally answers that in four words.
|
|
1500
|
+
function countsLine(done, counted, total) {
|
|
1501
|
+
var parts = [];
|
|
1502
|
+
if (counted.failed) parts.push(commas(counted.failed) + ' broke');
|
|
1503
|
+
if (counted.changed) parts.push(commas(counted.changed) + ' moved');
|
|
1504
|
+
if (counted.waiting) parts.push(commas(counted.waiting) + ' needs you');
|
|
1505
|
+
if (counted.skipped) parts.push(commas(counted.skipped) + ' skipped');
|
|
1506
|
+
var line = commas(done) + ' of ' + commas(total);
|
|
1507
|
+
if (counted.done === total && total > 0 && parts.length === 0) line += ' — all held';
|
|
1508
|
+
return line + (parts.length ? ' · ' + parts.join(' · ') : '');
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function updateCounts() {
|
|
1512
|
+
var counted = tally();
|
|
1513
|
+
var total = meterTotal();
|
|
1514
|
+
if (!order.length) {
|
|
1515
|
+
ui.counts.textContent = '';
|
|
1516
|
+
doneTicker.__from = 0;
|
|
1517
|
+
} else {
|
|
1518
|
+
tweenTo(doneTicker, counted.done, function (value) {
|
|
1519
|
+
ui.counts.textContent = countsLine(Math.round(value), counted, total);
|
|
1520
|
+
return '';
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
ui.countScreens.textContent = countIn('picture');
|
|
1524
|
+
ui.countGuards.textContent = countIn('guard');
|
|
1525
|
+
ui.nothing.hidden = order.length > 0;
|
|
1526
|
+
paintMeter();
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function countIn(kind) {
|
|
1530
|
+
var n = 0;
|
|
1531
|
+
for (var i = 0; i < order.length; i++) if (order[i].kind === kind) n++;
|
|
1532
|
+
return n ? String(n) : '';
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Auto-scroll follows the running row, and stops the moment the person
|
|
1536
|
+
// scrolls for themselves. Nothing is more annoying than a list that yanks
|
|
1537
|
+
// itself back while you are reading it.
|
|
1538
|
+
var following = true;
|
|
1539
|
+
function stopFollowing() {
|
|
1540
|
+
if (!following) return;
|
|
1541
|
+
following = false;
|
|
1542
|
+
ui.follow.hidden = false;
|
|
1543
|
+
}
|
|
1544
|
+
['wheel', 'touchmove', 'keydown'].forEach(function (name) {
|
|
1545
|
+
ui.scroll.addEventListener(name, stopFollowing, { passive: true });
|
|
1546
|
+
});
|
|
1547
|
+
ui.follow.addEventListener('click', function () {
|
|
1548
|
+
following = true;
|
|
1549
|
+
ui.follow.hidden = true;
|
|
1550
|
+
if (runningItem) runningItem.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
/** Which row the picture on the glass belongs to. */
|
|
1554
|
+
function markShowing(entry) {
|
|
1555
|
+
for (var i = 0; i < order.length; i++) order[i].root.classList.remove('showing');
|
|
1556
|
+
if (entry) entry.root.classList.add('showing');
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
var runningItem = null;
|
|
1560
|
+
function markRunning(entry) {
|
|
1561
|
+
runningItem = entry;
|
|
1562
|
+
setTone(entry, 'running');
|
|
1563
|
+
if (following) entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// -------------------------------------------------------------------------
|
|
1567
|
+
// The hero
|
|
1568
|
+
// -------------------------------------------------------------------------
|
|
1569
|
+
|
|
1570
|
+
var frontIsA = false;
|
|
1571
|
+
var shownSrc = '';
|
|
1572
|
+
var shownAlt = '';
|
|
1573
|
+
var showSeq = 0;
|
|
1574
|
+
|
|
1575
|
+
// What is on the glass right now, so the viewer opens on the same thing.
|
|
1576
|
+
// Whether the person picked that picture themselves: it is the
|
|
1577
|
+
// difference between continuing what they were looking at and second-guessing
|
|
1578
|
+
// a default the panel chose for them.
|
|
1579
|
+
var onGlass = { pics: null, name: '', mode: 'now', touched: false };
|
|
1580
|
+
|
|
1581
|
+
function paint(src, alt) {
|
|
1582
|
+
if (!src) return;
|
|
1583
|
+
// A run that turned out to have pictures after all gets its glass back.
|
|
1584
|
+
ui.stage.hidden = false;
|
|
1585
|
+
ui.blank.hidden = true;
|
|
1586
|
+
ui.shot.classList.remove('empty');
|
|
1587
|
+
shownAlt = alt || '';
|
|
1588
|
+
if (src === shownSrc) return;
|
|
1589
|
+
shownSrc = src;
|
|
1590
|
+
var next = frontIsA ? ui.layerB : ui.layerA;
|
|
1591
|
+
var current = frontIsA ? ui.layerA : ui.layerB;
|
|
1592
|
+
next.classList.remove('on');
|
|
1593
|
+
next.classList.remove('out');
|
|
1594
|
+
next.src = src;
|
|
1595
|
+
next.alt = shownAlt;
|
|
1596
|
+
// Read a layout value so the browser starts the fade from where the layer
|
|
1597
|
+
// rests rather than skipping straight to the end of it.
|
|
1598
|
+
void next.offsetWidth;
|
|
1599
|
+
next.classList.add('on');
|
|
1600
|
+
current.classList.remove('on');
|
|
1601
|
+
current.classList.add('out');
|
|
1602
|
+
frontIsA = !frontIsA;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Show a picture: the real file if it will load, the small preview until it
|
|
1607
|
+
* does. The swap between the two is the same cross-fade as any other change
|
|
1608
|
+
* of picture, so a screen going from blurred to sharp reads as the panel
|
|
1609
|
+
* finishing a thought rather than as a glitch.
|
|
1610
|
+
*/
|
|
1611
|
+
function showPicture(sharp, preview, alt) {
|
|
1612
|
+
var mine = ++showSeq;
|
|
1613
|
+
if (sharp && known[sharp] === 1) { paint(sharp, alt); return; }
|
|
1614
|
+
if (preview) paint(preview, alt);
|
|
1615
|
+
if (!sharp || sharp === preview) return;
|
|
1616
|
+
preload(sharp, function (ok) {
|
|
1617
|
+
if (!ok || mine !== showSeq) return;
|
|
1618
|
+
paint(sharp, alt);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function nameTheShot(name) {
|
|
1623
|
+
ui.shotname.textContent = name || '';
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// The light behind everything takes the colour of the worst thing that has
|
|
1627
|
+
// happened so far. It is the one piece of the panel a person reads without
|
|
1628
|
+
// looking at it.
|
|
1629
|
+
function tint(tone) {
|
|
1630
|
+
var value = tone && tone !== 'held' && tone !== 'skip' ? 'var(--' + tone + ')' : 'var(--accent)';
|
|
1631
|
+
document.documentElement.style.setProperty('--tint', value);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// The shutter. On while the app is really being photographed, off the instant
|
|
1635
|
+
// the picture lands — never a loop that runs when nothing is happening.
|
|
1636
|
+
function scanning(on) {
|
|
1637
|
+
ui.shot.classList.toggle('scanning', !!on);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function clearTabs() {
|
|
1641
|
+
ui.tabs.hidden = true;
|
|
1642
|
+
ui.tabs.textContent = '';
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/** One picture, no comparison to make: the one that was just taken. */
|
|
1646
|
+
function singlePicture(p, name) {
|
|
1647
|
+
clearTabs();
|
|
1648
|
+
ui.shotout.hidden = true;
|
|
1649
|
+
onGlass.pics = p || null;
|
|
1650
|
+
onGlass.name = name || '';
|
|
1651
|
+
onGlass.mode = 'now';
|
|
1652
|
+
onGlass.touched = false;
|
|
1653
|
+
showPicture(p && p.shotFile, p && p.thumbnail, 'the picture taken of ' + (name || 'this screen'));
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* The line beside the picture's name. Silent when a screen simply held,
|
|
1658
|
+
* because a panel that congratulates you on every screen is a panel you stop
|
|
1659
|
+
* reading.
|
|
1660
|
+
*/
|
|
1661
|
+
function sayOutcome(tone, text) {
|
|
1662
|
+
if (!text || !tone || tone === 'held' || tone === 'skip') {
|
|
1663
|
+
ui.shotout.hidden = true;
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
ui.shotout.textContent = text;
|
|
1667
|
+
ui.shotout.className = 'shotout ' + tone;
|
|
1668
|
+
ui.shotout.hidden = false;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// A screen that moved is the one moment a person has a real decision to make,
|
|
1672
|
+
// so all three pictures are one click away and the difference is the one
|
|
1673
|
+
// already on screen — that is the picture that answers the question.
|
|
1674
|
+
function comparePictures(p) {
|
|
1675
|
+
var choices = [];
|
|
1676
|
+
if (approvedOf(p)) choices.push({ mode: 'approved', label: 'Approved', sharp: p.approvedFile, preview: p.approvedThumb });
|
|
1677
|
+
if (nowOf(p)) choices.push({ mode: 'now', label: 'Now', sharp: p.shotFile, preview: p.thumbnail });
|
|
1678
|
+
if (diffOf(p)) choices.push({ mode: 'diff', label: 'Difference', sharp: p.diffFile, preview: p.diffThumb });
|
|
1679
|
+
// Two pictures is the whole point; one on its own says nothing a person
|
|
1680
|
+
// could act on, so leave the live picture up instead.
|
|
1681
|
+
if (choices.length < 2) return false;
|
|
1682
|
+
|
|
1683
|
+
onGlass.pics = p;
|
|
1684
|
+
onGlass.name = p.name || '';
|
|
1685
|
+
onGlass.touched = false;
|
|
1686
|
+
ui.tabs.textContent = '';
|
|
1687
|
+
ui.tabs.style.setProperty('--tone', 'var(--moved)');
|
|
1688
|
+
|
|
1689
|
+
var buttons = [];
|
|
1690
|
+
function select(index) {
|
|
1691
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
1692
|
+
buttons[i].className = i === index ? 'on' : '';
|
|
1693
|
+
buttons[i].setAttribute('aria-pressed', i === index ? 'true' : 'false');
|
|
1694
|
+
}
|
|
1695
|
+
var choice = choices[index];
|
|
1696
|
+
onGlass.mode = choice.mode;
|
|
1697
|
+
showPicture(choice.sharp, choice.preview,
|
|
1698
|
+
choice.label.toLowerCase() + ' picture of ' + (p.name || 'this screen'));
|
|
1699
|
+
}
|
|
1700
|
+
choices.forEach(function (choice, index) {
|
|
1701
|
+
var button = document.createElement('button');
|
|
1702
|
+
button.type = 'button';
|
|
1703
|
+
button.textContent = choice.label;
|
|
1704
|
+
button.addEventListener('click', function () { onGlass.touched = true; select(index); });
|
|
1705
|
+
buttons.push(button);
|
|
1706
|
+
ui.tabs.appendChild(button);
|
|
1707
|
+
});
|
|
1708
|
+
ui.tabs.hidden = false;
|
|
1709
|
+
|
|
1710
|
+
var preferred = choices.length - 1;
|
|
1711
|
+
for (var i = 0; i < choices.length; i++) {
|
|
1712
|
+
if (choices[i].mode === 'diff') preferred = i;
|
|
1713
|
+
}
|
|
1714
|
+
select(preferred);
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// -------------------------------------------------------------------------
|
|
1719
|
+
// The viewer.
|
|
1720
|
+
//
|
|
1721
|
+
// The panel is a local page, so this is the real file at its real size. Wheel
|
|
1722
|
+
// or pinch to zoom, drag to move around, double-click to fit, Escape to put
|
|
1723
|
+
// it away. Past about twice the size it fits at, the smoothing comes off —
|
|
1724
|
+
// somebody who has zoomed in is asking to see the pixels themselves.
|
|
1725
|
+
//
|
|
1726
|
+
// For a screen that moved, this is also where the decision gets made, so the
|
|
1727
|
+
// approved picture and the new one sit under one line you can drag across
|
|
1728
|
+
// them, with a fade for the changes a hard edge hides, and the difference is
|
|
1729
|
+
// one word away.
|
|
1730
|
+
// -------------------------------------------------------------------------
|
|
1731
|
+
|
|
1732
|
+
var view = {
|
|
1733
|
+
open: false, mode: '', z: 1, px: 0, py: 0,
|
|
1734
|
+
natW: 0, natH: 0, frameW: 0, frameH: 0, fit: 1,
|
|
1735
|
+
wipe: 50, blend: 100, pics: null, name: ''
|
|
1736
|
+
};
|
|
1737
|
+
|
|
1738
|
+
function viewerSources(p) {
|
|
1739
|
+
return {
|
|
1740
|
+
now: p ? (p.shotFile || p.thumbnail || '') : '',
|
|
1741
|
+
approved: p ? (p.approvedFile || p.approvedThumb || '') : '',
|
|
1742
|
+
diff: p ? (p.diffFile || p.diffThumb || '') : ''
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
var MODE_WORDS = { now: 'Now', approved: 'Approved', compare: 'Before & after', diff: 'Difference' };
|
|
1747
|
+
|
|
1748
|
+
function openViewer(p, name, wanted) {
|
|
1749
|
+
var src = viewerSources(p);
|
|
1750
|
+
if (!src.now && !src.approved && !src.diff) return;
|
|
1751
|
+
|
|
1752
|
+
view.pics = p;
|
|
1753
|
+
view.name = name || '';
|
|
1754
|
+
view.wipe = 50;
|
|
1755
|
+
view.blend = 100;
|
|
1756
|
+
ui.vblend.value = '100';
|
|
1757
|
+
ui.vname.textContent = view.name;
|
|
1758
|
+
|
|
1759
|
+
// An empty src is not "no picture" — the browser resolves it against the page's
|
|
1760
|
+
// own address and fetches the panel document as though it were an image, which
|
|
1761
|
+
// fails and leaves a broken element sitting in the viewer. Take the attribute off.
|
|
1762
|
+
setSource(ui.vApproved, src.approved);
|
|
1763
|
+
setSource(ui.vNow, src.now);
|
|
1764
|
+
setSource(ui.vDiff, src.diff);
|
|
1765
|
+
|
|
1766
|
+
var modes = [];
|
|
1767
|
+
if (src.now) modes.push('now');
|
|
1768
|
+
if (src.approved) modes.push('approved');
|
|
1769
|
+
if (src.approved && src.now) modes.push('compare');
|
|
1770
|
+
if (src.diff) modes.push('diff');
|
|
1771
|
+
|
|
1772
|
+
ui.vmodes.textContent = '';
|
|
1773
|
+
modes.forEach(function (mode) {
|
|
1774
|
+
var button = document.createElement('button');
|
|
1775
|
+
button.type = 'button';
|
|
1776
|
+
button.dataset.mode = mode;
|
|
1777
|
+
button.textContent = MODE_WORDS[mode];
|
|
1778
|
+
button.addEventListener('click', function () { setMode(mode); });
|
|
1779
|
+
ui.vmodes.appendChild(button);
|
|
1780
|
+
});
|
|
1781
|
+
ui.vmodes.hidden = modes.length < 2;
|
|
1782
|
+
|
|
1783
|
+
// The size everything is laid out at: whichever real picture we have.
|
|
1784
|
+
var measuring = src.now || src.approved || src.diff;
|
|
1785
|
+
view.natW = 0; view.natH = 0;
|
|
1786
|
+
var probe = new Image();
|
|
1787
|
+
probe.onload = function () {
|
|
1788
|
+
view.natW = probe.naturalWidth || 0;
|
|
1789
|
+
view.natH = probe.naturalHeight || 0;
|
|
1790
|
+
fitPicture();
|
|
1791
|
+
};
|
|
1792
|
+
probe.onerror = function () { view.natW = 0; view.natH = 0; fitPicture(); };
|
|
1793
|
+
probe.src = measuring;
|
|
1794
|
+
|
|
1795
|
+
view.open = true;
|
|
1796
|
+
ui.viewer.hidden = false;
|
|
1797
|
+
|
|
1798
|
+
var start = wanted && modes.indexOf(wanted) >= 0 ? wanted
|
|
1799
|
+
: (modes.indexOf('compare') >= 0 ? 'compare' : modes[0]);
|
|
1800
|
+
setMode(start);
|
|
1801
|
+
resetZoom(true);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function closeViewer() {
|
|
1805
|
+
if (!view.open) return;
|
|
1806
|
+
view.open = false;
|
|
1807
|
+
ui.viewer.hidden = true;
|
|
1808
|
+
ui.vNow.style.clipPath = '';
|
|
1809
|
+
ui.vwipe.hidden = true;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function setMode(mode) {
|
|
1813
|
+
view.mode = mode;
|
|
1814
|
+
var buttons = ui.vmodes.children;
|
|
1815
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
1816
|
+
buttons[i].className = buttons[i].dataset.mode === mode ? 'on' : '';
|
|
1817
|
+
}
|
|
1818
|
+
var comparing = mode === 'compare';
|
|
1819
|
+
ui.vApproved.classList.toggle('show', mode === 'approved' || comparing);
|
|
1820
|
+
ui.vNow.classList.toggle('show', mode === 'now' || comparing);
|
|
1821
|
+
ui.vDiff.classList.toggle('show', mode === 'diff');
|
|
1822
|
+
ui.vwipe.hidden = !comparing;
|
|
1823
|
+
ui.vfoot.hidden = !comparing;
|
|
1824
|
+
ui.vNow.style.opacity = comparing ? String(view.blend / 100) : '1';
|
|
1825
|
+
ui.vNow.style.clipPath = comparing ? 'inset(0 0 0 ' + view.wipe + '%)' : '';
|
|
1826
|
+
placeWipe();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** How big the frame is when the whole picture fits in the window. */
|
|
1830
|
+
function fitPicture() {
|
|
1831
|
+
var wide = ui.vstage.clientWidth;
|
|
1832
|
+
var tall = ui.vstage.clientHeight;
|
|
1833
|
+
var natW = view.natW || 16;
|
|
1834
|
+
var natH = view.natH || 10;
|
|
1835
|
+
var room = 0.94;
|
|
1836
|
+
view.fit = Math.min((wide * room) / natW, (tall * room) / natH);
|
|
1837
|
+
view.frameW = Math.max(1, Math.round(natW * view.fit));
|
|
1838
|
+
view.frameH = Math.max(1, Math.round(natH * view.fit));
|
|
1839
|
+
ui.vframe.style.width = view.frameW + 'px';
|
|
1840
|
+
ui.vframe.style.height = view.frameH + 'px';
|
|
1841
|
+
applyView();
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function clampPan() {
|
|
1845
|
+
var wide = ui.vstage.clientWidth;
|
|
1846
|
+
var tall = ui.vstage.clientHeight;
|
|
1847
|
+
var w = view.frameW * view.z;
|
|
1848
|
+
var h = view.frameH * view.z;
|
|
1849
|
+
var mx = Math.max(0, (w - wide) / 2) + 20;
|
|
1850
|
+
var my = Math.max(0, (h - tall) / 2) + 20;
|
|
1851
|
+
if (view.px > mx) view.px = mx;
|
|
1852
|
+
if (view.px < -mx) view.px = -mx;
|
|
1853
|
+
if (view.py > my) view.py = my;
|
|
1854
|
+
if (view.py < -my) view.py = -my;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function applyView() {
|
|
1858
|
+
clampPan();
|
|
1859
|
+
ui.vframe.style.transform =
|
|
1860
|
+
'translate3d(' + Math.round(view.px) + 'px,' + Math.round(view.py) + 'px,0) scale(' + view.z + ')';
|
|
1861
|
+
ui.vzoom.textContent = view.z.toFixed(1) + '×';
|
|
1862
|
+
// Once one pixel of the picture is covering about two pixels of the screen,
|
|
1863
|
+
// the smoothing comes off and the real pixels show. Not a moment before: a
|
|
1864
|
+
// picture being made SMALLER, which is what a retina screenshot in a narrow
|
|
1865
|
+
// panel is doing even at four times, is wrecked by turning smoothing off.
|
|
1866
|
+
// The screen's own density counts, which is why it is in the sum.
|
|
1867
|
+
var dense = view.fit * view.z * (window.devicePixelRatio || 1);
|
|
1868
|
+
ui.vframe.classList.toggle('sharp', dense >= 1.9);
|
|
1869
|
+
placeWipe();
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// The line lives in the window rather than in the picture, so it stays one
|
|
1873
|
+
// hair wide however far in somebody has zoomed — which means its position has
|
|
1874
|
+
// to be worked out again every time the picture moves.
|
|
1875
|
+
function placeWipe() {
|
|
1876
|
+
if (ui.vwipe.hidden) return;
|
|
1877
|
+
var wide = ui.vstage.clientWidth;
|
|
1878
|
+
var tall = ui.vstage.clientHeight;
|
|
1879
|
+
var x = wide / 2 + view.px + view.z * (view.frameW * (view.wipe / 100) - view.frameW / 2);
|
|
1880
|
+
ui.vwipe.style.left = Math.round(x) + 'px';
|
|
1881
|
+
// It is only ever as long as the picture is, so it never draws a line
|
|
1882
|
+
// through the empty space above and below one.
|
|
1883
|
+
var height = view.frameH * view.z;
|
|
1884
|
+
var top = tall / 2 + view.py - height / 2;
|
|
1885
|
+
var bottom = top + height;
|
|
1886
|
+
if (top < 0) top = 0;
|
|
1887
|
+
if (bottom > tall) bottom = tall;
|
|
1888
|
+
ui.vwipe.style.top = Math.round(top) + 'px';
|
|
1889
|
+
ui.vwipe.style.height = Math.round(Math.max(0, bottom - top)) + 'px';
|
|
1890
|
+
ui.vNow.style.clipPath = 'inset(0 0 0 ' + view.wipe + '%)';
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
function resetZoom(quiet) {
|
|
1894
|
+
view.z = 1; view.px = 0; view.py = 0;
|
|
1895
|
+
if (!quiet && !CALM) {
|
|
1896
|
+
ui.vframe.style.transition = 'transform 260ms cubic-bezier(0.22,0.72,0.24,1)';
|
|
1897
|
+
setTimeout(function () { ui.vframe.style.transition = ''; }, 300);
|
|
1898
|
+
}
|
|
1899
|
+
applyView();
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function zoomAt(factor, clientX, clientY) {
|
|
1903
|
+
var rect = ui.vstage.getBoundingClientRect();
|
|
1904
|
+
var cx = clientX - rect.left - rect.width / 2;
|
|
1905
|
+
var cy = clientY - rect.top - rect.height / 2;
|
|
1906
|
+
var next = view.z * factor;
|
|
1907
|
+
if (next < 1) next = 1;
|
|
1908
|
+
if (next > 8) next = 8;
|
|
1909
|
+
if (next === view.z) return;
|
|
1910
|
+
// Keep whatever is under the pointer under the pointer.
|
|
1911
|
+
var fx = (cx - view.px) / view.z;
|
|
1912
|
+
var fy = (cy - view.py) / view.z;
|
|
1913
|
+
view.px = cx - next * fx;
|
|
1914
|
+
view.py = cy - next * fy;
|
|
1915
|
+
view.z = next;
|
|
1916
|
+
applyView();
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
ui.vstage.addEventListener('wheel', function (e) {
|
|
1920
|
+
if (!view.open) return;
|
|
1921
|
+
e.preventDefault();
|
|
1922
|
+
// A trackpad pinch arrives here as a wheel with the control key held, and
|
|
1923
|
+
// it wants a finer touch than a mouse wheel does.
|
|
1924
|
+
var rate = e.ctrlKey ? 0.011 : 0.0026;
|
|
1925
|
+
zoomAt(Math.exp(-e.deltaY * rate), e.clientX, e.clientY);
|
|
1926
|
+
}, { passive: false });
|
|
1927
|
+
|
|
1928
|
+
ui.vstage.addEventListener('dblclick', function () { resetZoom(false); });
|
|
1929
|
+
|
|
1930
|
+
var panning = null;
|
|
1931
|
+
ui.vstage.addEventListener('pointerdown', function (e) {
|
|
1932
|
+
if (!view.open || e.button !== 0) return;
|
|
1933
|
+
if (ui.vwipe.contains(e.target)) return;
|
|
1934
|
+
panning = { x: e.clientX, y: e.clientY, px: view.px, py: view.py, id: e.pointerId };
|
|
1935
|
+
ui.vstage.classList.add('dragging');
|
|
1936
|
+
try { ui.vstage.setPointerCapture(e.pointerId); } catch (err) { /* a pointer we cannot hold is still a pointer we can follow */ }
|
|
1937
|
+
});
|
|
1938
|
+
ui.vstage.addEventListener('pointermove', function (e) {
|
|
1939
|
+
if (!panning || e.pointerId !== panning.id) return;
|
|
1940
|
+
view.px = panning.px + (e.clientX - panning.x);
|
|
1941
|
+
view.py = panning.py + (e.clientY - panning.y);
|
|
1942
|
+
applyView();
|
|
1943
|
+
});
|
|
1944
|
+
function endPan(e) {
|
|
1945
|
+
if (!panning) return;
|
|
1946
|
+
ui.vstage.classList.remove('dragging');
|
|
1947
|
+
try { ui.vstage.releasePointerCapture(panning.id); } catch (err) { /* already gone */ }
|
|
1948
|
+
panning = null;
|
|
1949
|
+
}
|
|
1950
|
+
ui.vstage.addEventListener('pointerup', endPan);
|
|
1951
|
+
ui.vstage.addEventListener('pointercancel', endPan);
|
|
1952
|
+
|
|
1953
|
+
var wiping = null;
|
|
1954
|
+
ui.vwipe.addEventListener('pointerdown', function (e) {
|
|
1955
|
+
e.stopPropagation();
|
|
1956
|
+
wiping = e.pointerId;
|
|
1957
|
+
ui.vwipe.classList.add('holding');
|
|
1958
|
+
try { ui.vwipe.setPointerCapture(e.pointerId); } catch (err) { /* as above */ }
|
|
1959
|
+
});
|
|
1960
|
+
ui.vwipe.addEventListener('pointermove', function (e) {
|
|
1961
|
+
if (wiping === null || e.pointerId !== wiping) return;
|
|
1962
|
+
var rect = ui.vstage.getBoundingClientRect();
|
|
1963
|
+
var cx = e.clientX - rect.left - rect.width / 2;
|
|
1964
|
+
var inFrame = (cx - view.px) / view.z + view.frameW / 2;
|
|
1965
|
+
var pct = (inFrame / view.frameW) * 100;
|
|
1966
|
+
if (pct < 0) pct = 0;
|
|
1967
|
+
if (pct > 100) pct = 100;
|
|
1968
|
+
view.wipe = pct;
|
|
1969
|
+
placeWipe();
|
|
1970
|
+
});
|
|
1971
|
+
function endWipe(e) {
|
|
1972
|
+
if (wiping === null) return;
|
|
1973
|
+
ui.vwipe.classList.remove('holding');
|
|
1974
|
+
try { ui.vwipe.releasePointerCapture(wiping); } catch (err) { /* already gone */ }
|
|
1975
|
+
wiping = null;
|
|
1976
|
+
}
|
|
1977
|
+
ui.vwipe.addEventListener('pointerup', endWipe);
|
|
1978
|
+
ui.vwipe.addEventListener('pointercancel', endWipe);
|
|
1979
|
+
|
|
1980
|
+
ui.vblend.addEventListener('input', function () {
|
|
1981
|
+
view.blend = Number(ui.vblend.value) || 0;
|
|
1982
|
+
if (view.mode === 'compare') ui.vNow.style.opacity = String(view.blend / 100);
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
ui.vclose.addEventListener('click', closeViewer);
|
|
1986
|
+
|
|
1987
|
+
window.addEventListener('resize', function () {
|
|
1988
|
+
if (view.open) fitPicture();
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
// The picture on the glass opens at the same picture, so clicking it is a
|
|
1992
|
+
// continuation rather than a jump.
|
|
1993
|
+
ui.shot.addEventListener('click', function () {
|
|
1994
|
+
if (ui.shot.classList.contains('empty')) return;
|
|
1995
|
+
if (onGlass.pics) {
|
|
1996
|
+
// A screen that moved opens on the comparison, because that is the
|
|
1997
|
+
// question being asked — unless the person has already chosen which of
|
|
1998
|
+
// the three pictures they wanted to look at, in which case they get that.
|
|
1999
|
+
var wanted = onGlass.mode;
|
|
2000
|
+
if (!onGlass.touched && onGlass.pics.status === 'changed') wanted = 'compare';
|
|
2001
|
+
openViewer(onGlass.pics, onGlass.name, wanted);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (shownSrc) openViewer({ shotFile: shownSrc }, onGlass.name || ui.shotname.textContent, 'now');
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
document.addEventListener('keydown', function (e) {
|
|
2008
|
+
if (!view.open) return;
|
|
2009
|
+
if (e.key === 'Escape') { closeViewer(); return; }
|
|
2010
|
+
if (e.key === '0') { resetZoom(false); return; }
|
|
2011
|
+
if (e.key === '+' || e.key === '=') {
|
|
2012
|
+
var rect = ui.vstage.getBoundingClientRect();
|
|
2013
|
+
zoomAt(1.35, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (e.key === '-' || e.key === '_') {
|
|
2017
|
+
var box = ui.vstage.getBoundingClientRect();
|
|
2018
|
+
zoomAt(1 / 1.35, box.left + box.width / 2, box.top + box.height / 2);
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
// -------------------------------------------------------------------------
|
|
2023
|
+
// The clock
|
|
2024
|
+
// -------------------------------------------------------------------------
|
|
2025
|
+
|
|
2026
|
+
var origin = null;
|
|
2027
|
+
var ticking = 0;
|
|
2028
|
+
var finished = false;
|
|
2029
|
+
var lastPrinted = '';
|
|
2030
|
+
|
|
2031
|
+
function startClock(at) {
|
|
2032
|
+
if (origin !== null || finished) return;
|
|
2033
|
+
origin = performance.now() - (Number(at) || 0);
|
|
2034
|
+
var tick = function () {
|
|
2035
|
+
var text = fmt(performance.now() - origin);
|
|
2036
|
+
if (text !== lastPrinted) { lastPrinted = text; ui.clock.textContent = text; }
|
|
2037
|
+
ticking = requestAnimationFrame(tick);
|
|
2038
|
+
};
|
|
2039
|
+
ticking = requestAnimationFrame(tick);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function stopClock(finalMs) {
|
|
2043
|
+
if (ticking) { cancelAnimationFrame(ticking); ticking = 0; }
|
|
2044
|
+
finished = true;
|
|
2045
|
+
if (typeof finalMs !== 'number' || !isFinite(finalMs)) return;
|
|
2046
|
+
// It counts the last stretch out rather than jumping to the total, which is
|
|
2047
|
+
// what makes the end of a run read as an arrival instead of a cut.
|
|
2048
|
+
ui.clock.__from = origin === null ? finalMs : performance.now() - origin;
|
|
2049
|
+
tweenTo(ui.clock, finalMs, fmt);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// -------------------------------------------------------------------------
|
|
2053
|
+
// Where the time went. Folded shut, because it is a thing you go and look at
|
|
2054
|
+
// once in a while rather than something you need on screen all day.
|
|
2055
|
+
// -------------------------------------------------------------------------
|
|
2056
|
+
|
|
2057
|
+
var TIMING_PARTS = [
|
|
2058
|
+
['launch', 'opening the app'],
|
|
2059
|
+
['steps', 'clicking through the app'],
|
|
2060
|
+
['prepare', 'waiting for fonts and pictures'],
|
|
2061
|
+
['settle', 'waiting for the screen to hold still'],
|
|
2062
|
+
['compare', 'comparing the pictures'],
|
|
2063
|
+
['guards', 'running the guards'],
|
|
2064
|
+
['other', 'everything else']
|
|
2065
|
+
];
|
|
2066
|
+
|
|
2067
|
+
// Shades of the text colour, darkest first. Not a palette — a ramp, so the
|
|
2068
|
+
// breakdown reads as one object and never competes with the four colours
|
|
2069
|
+
// that actually mean something.
|
|
2070
|
+
function shade(index, count) {
|
|
2071
|
+
var top = 46;
|
|
2072
|
+
var bottom = 11;
|
|
2073
|
+
var step = count > 1 ? (top - bottom) / (count - 1) : 0;
|
|
2074
|
+
return 'color-mix(in srgb, var(--ink) ' + (top - step * index).toFixed(1) + '%, transparent)';
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
var timingOpen = false;
|
|
2078
|
+
ui.thead.addEventListener('click', function () {
|
|
2079
|
+
timingOpen = !timingOpen;
|
|
2080
|
+
ui.tkey.hidden = !timingOpen;
|
|
2081
|
+
ui.thead.setAttribute('aria-expanded', timingOpen ? 'true' : 'false');
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
function showTiming(timings) {
|
|
2085
|
+
if (!timings || typeof timings !== 'object') return;
|
|
2086
|
+
var parts = [];
|
|
2087
|
+
var sum = 0;
|
|
2088
|
+
TIMING_PARTS.forEach(function (part) {
|
|
2089
|
+
var ms = Number(timings[part[0]]);
|
|
2090
|
+
if (!isFinite(ms) || ms <= 0) return;
|
|
2091
|
+
sum += ms;
|
|
2092
|
+
parts.push({ label: part[1], ms: ms });
|
|
2093
|
+
});
|
|
2094
|
+
if (!parts.length || sum <= 0) return;
|
|
2095
|
+
|
|
2096
|
+
// Biggest first: the answer to "where did the time go" is the first row.
|
|
2097
|
+
parts.sort(function (a, b) { return b.ms - a.ms; });
|
|
2098
|
+
|
|
2099
|
+
ui.tbar.textContent = '';
|
|
2100
|
+
ui.tkey.textContent = '';
|
|
2101
|
+
parts.forEach(function (part, index) {
|
|
2102
|
+
var colour = shade(index, parts.length);
|
|
2103
|
+
|
|
2104
|
+
var bar = document.createElement('span');
|
|
2105
|
+
bar.className = 'tpart';
|
|
2106
|
+
bar.style.flex = String(part.ms / sum) + ' 1 0';
|
|
2107
|
+
bar.style.background = colour;
|
|
2108
|
+
bar.title = part.label + ' — ' + fmt(part.ms);
|
|
2109
|
+
ui.tbar.appendChild(bar);
|
|
2110
|
+
|
|
2111
|
+
var item = document.createElement('li');
|
|
2112
|
+
var swatch = document.createElement('span');
|
|
2113
|
+
swatch.className = 'tswatch';
|
|
2114
|
+
swatch.style.background = colour;
|
|
2115
|
+
var text = document.createElement('span');
|
|
2116
|
+
text.className = 'tlabelled';
|
|
2117
|
+
text.textContent = part.label;
|
|
2118
|
+
var value = document.createElement('b');
|
|
2119
|
+
value.textContent = fmt(part.ms);
|
|
2120
|
+
item.appendChild(swatch);
|
|
2121
|
+
item.appendChild(text);
|
|
2122
|
+
item.appendChild(value);
|
|
2123
|
+
ui.tkey.appendChild(item);
|
|
2124
|
+
});
|
|
2125
|
+
ui.ttotal.textContent = '';
|
|
2126
|
+
var most = document.createElement('b');
|
|
2127
|
+
most.textContent = fmt(parts[0].ms);
|
|
2128
|
+
ui.ttotal.appendChild(most);
|
|
2129
|
+
ui.ttotal.appendChild(document.createTextNode(' ' + parts[0].label));
|
|
2130
|
+
ui.timing.hidden = false;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// -------------------------------------------------------------------------
|
|
2134
|
+
// What to do next
|
|
2135
|
+
// -------------------------------------------------------------------------
|
|
2136
|
+
|
|
2137
|
+
function showNextStep(summary) {
|
|
2138
|
+
var pictures = (summary && summary.pictures) || [];
|
|
2139
|
+
var waiting = pictures.filter(function (p) {
|
|
2140
|
+
return p && (p.status === 'changed' || p.status === 'new' || p.status === 'missing');
|
|
2141
|
+
});
|
|
2142
|
+
// The verdict is already the biggest sentence on the page. The footer only
|
|
2143
|
+
// ever exists when there is something for a person to actually type.
|
|
2144
|
+
if (!waiting.length) { ui.footer.hidden = true; return; }
|
|
2145
|
+
ui.cmd.textContent = waiting.length === 1
|
|
2146
|
+
? 'staysfixed approve ' + waiting[0].name
|
|
2147
|
+
: 'staysfixed approve --all';
|
|
2148
|
+
ui.footer.hidden = false;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
ui.copy.addEventListener('click', function () {
|
|
2152
|
+
var text = ui.cmd.textContent || '';
|
|
2153
|
+
// A page opened from a file is not a secure context, so the modern
|
|
2154
|
+
// clipboard is usually refused here. The old way still works.
|
|
2155
|
+
var box = document.createElement('textarea');
|
|
2156
|
+
box.value = text;
|
|
2157
|
+
box.setAttribute('readonly', '');
|
|
2158
|
+
box.style.position = 'fixed';
|
|
2159
|
+
box.style.opacity = '0';
|
|
2160
|
+
document.body.appendChild(box);
|
|
2161
|
+
box.select();
|
|
2162
|
+
try {
|
|
2163
|
+
document.execCommand('copy');
|
|
2164
|
+
ui.copy.classList.add('done');
|
|
2165
|
+
setTimeout(function () { ui.copy.classList.remove('done'); }, 1300);
|
|
2166
|
+
} catch (err) { /* nothing to do */ }
|
|
2167
|
+
document.body.removeChild(box);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
// -------------------------------------------------------------------------
|
|
2171
|
+
// The sentence at the top
|
|
2172
|
+
// -------------------------------------------------------------------------
|
|
2173
|
+
|
|
2174
|
+
function setState(text, tone, arriving) {
|
|
2175
|
+
ui.state.textContent = text;
|
|
2176
|
+
ui.state.className = 'state' + (tone ? ' ' + tone : '');
|
|
2177
|
+
if (!arriving || CALM) return;
|
|
2178
|
+
// Restart the animation rather than letting a second class do nothing.
|
|
2179
|
+
ui.state.classList.remove('arrive');
|
|
2180
|
+
void ui.state.offsetWidth;
|
|
2181
|
+
ui.state.classList.add('arrive');
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function countOf(list) { return Array.isArray(list) ? list.length : 0; }
|
|
2185
|
+
|
|
2186
|
+
var announcedScreens = false;
|
|
2187
|
+
var announcedGuards = false;
|
|
2188
|
+
|
|
2189
|
+
// -------------------------------------------------------------------------
|
|
2190
|
+
// Events
|
|
2191
|
+
// -------------------------------------------------------------------------
|
|
2192
|
+
|
|
2193
|
+
function handle(ev) {
|
|
2194
|
+
var type = ev.type;
|
|
2195
|
+
if (typeof ev.at === 'number') startClock(ev.at);
|
|
2196
|
+
|
|
2197
|
+
if (type === 'run:start') {
|
|
2198
|
+
var planned = ev.plan || {};
|
|
2199
|
+
if (planned.project) ui.project.textContent = planned.project;
|
|
2200
|
+
if (planned.app) {
|
|
2201
|
+
ui.app.textContent = planned.app;
|
|
2202
|
+
ui.app.hidden = false;
|
|
2203
|
+
ui.targetsep.hidden = false;
|
|
2204
|
+
}
|
|
2205
|
+
// A caller who counted instead of listing still gets a truthful meter.
|
|
2206
|
+
if (typeof planned.screens === 'number') expected.picture = planned.screens;
|
|
2207
|
+
if (typeof planned.guards === 'number') expected.guard = planned.guards;
|
|
2208
|
+
setState('opening the app');
|
|
2209
|
+
updateCounts();
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (type === 'phase') {
|
|
2214
|
+
if (ev.message) setState(ev.message);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
if (type === 'note') {
|
|
2219
|
+
if (ev.message) { ui.note.textContent = ev.message; ui.note.hidden = false; }
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
if (type === 'screen:start') {
|
|
2224
|
+
if (typeof ev.total === 'number' && ev.total > expected.picture) expected.picture = ev.total;
|
|
2225
|
+
if (!announcedScreens) {
|
|
2226
|
+
announcedScreens = true;
|
|
2227
|
+
var screens = ev.total || expected.picture || countOf(plan.screens);
|
|
2228
|
+
setState(screens ? 'photographing ' + commas(screens) + ' ' + plural(screens, 'screen', 'screens') : 'photographing the screens');
|
|
2229
|
+
}
|
|
2230
|
+
// The picture is deliberately left alone here. Nothing has been
|
|
2231
|
+
// photographed yet, so renaming the frame now would put this screen's
|
|
2232
|
+
// name under the last screen's picture — a caption that lies for a
|
|
2233
|
+
// second is worse than one that is a second behind.
|
|
2234
|
+
markRunning(ensureItem('picture', String(ev.name || ''), ev.describe));
|
|
2235
|
+
scanning(true);
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
if (type === 'screen:shot') {
|
|
2240
|
+
scanning(false);
|
|
2241
|
+
var shotRow = ev.name ? ensureItem('picture', String(ev.name), ev.describe) : null;
|
|
2242
|
+
if (ev.thumbnail || ev.shotFile) {
|
|
2243
|
+
var taken = { name: ev.name, status: '', thumbnail: ev.thumbnail || '', shotFile: ev.shotFile || '' };
|
|
2244
|
+
if (shotRow) shotRow.pics = taken;
|
|
2245
|
+
singlePicture(taken, String(ev.name || ''));
|
|
2246
|
+
}
|
|
2247
|
+
if (ev.name) nameTheShot(ev.name);
|
|
2248
|
+
markShowing(shotRow);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
if (type === 'screen:done') {
|
|
2253
|
+
scanning(false);
|
|
2254
|
+
var done = ensureItem('picture', String(ev.name || ''), ev.describe);
|
|
2255
|
+
var tone = finish(done, 'picture', ev);
|
|
2256
|
+
// The name, the caption and the light behind the glass all move together
|
|
2257
|
+
// with the picture, or none of them move. A screen that could not be
|
|
2258
|
+
// photographed leaves the last real one up, correctly labelled, rather
|
|
2259
|
+
// than putting its own name under someone else's picture.
|
|
2260
|
+
var showed = false;
|
|
2261
|
+
var p = done.pics;
|
|
2262
|
+
if (p) {
|
|
2263
|
+
if (ev.status === 'changed') showed = comparePictures(p);
|
|
2264
|
+
if (!showed && nowOf(p)) {
|
|
2265
|
+
singlePicture(p, String(ev.name || ''));
|
|
2266
|
+
showed = true;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (showed) {
|
|
2270
|
+
markShowing(done);
|
|
2271
|
+
nameTheShot(ev.name);
|
|
2272
|
+
// Anything but "still the same" is said beside the picture as well as
|
|
2273
|
+
// in the list, because the picture is where the person is looking and
|
|
2274
|
+
// that line is what tells them there is something to decide.
|
|
2275
|
+
sayOutcome(tone, outcomeShort(ev));
|
|
2276
|
+
}
|
|
2277
|
+
tint(worstTone());
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
if (type === 'guard:start') {
|
|
2282
|
+
if (typeof ev.total === 'number' && ev.total > expected.guard) expected.guard = ev.total;
|
|
2283
|
+
if (!announcedGuards) {
|
|
2284
|
+
announcedGuards = true;
|
|
2285
|
+
var guards = ev.total || expected.guard || countOf(plan.guards);
|
|
2286
|
+
setState(guards ? 'running ' + commas(guards) + ' ' + plural(guards, 'guard', 'guards') : 'running the guards');
|
|
2287
|
+
}
|
|
2288
|
+
markRunning(ensureItem('guard', String(ev.name || ''), ev.describe));
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (type === 'guard:done') {
|
|
2293
|
+
finish(ensureItem('guard', String(ev.name || ''), ev.describe), 'guard', ev);
|
|
2294
|
+
tint(worstTone());
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
if (type === 'run:done') {
|
|
2299
|
+
runningItem = null;
|
|
2300
|
+
ui.follow.hidden = true;
|
|
2301
|
+
scanning(false);
|
|
2302
|
+
var summary = ev.summary || null;
|
|
2303
|
+
stopClock(typeof ev.at === 'number' ? ev.at : (summary && summary.durationMs));
|
|
2304
|
+
var verdict = ev.verdict || fallbackVerdict();
|
|
2305
|
+
var worst = worstTone();
|
|
2306
|
+
setState(verdict, worst, true);
|
|
2307
|
+
tint(worst);
|
|
2308
|
+
showTiming(ev.timings || (summary && summary.timings));
|
|
2309
|
+
showNextStep(summary);
|
|
2310
|
+
updateCounts();
|
|
2311
|
+
if (following) {
|
|
2312
|
+
requestAnimationFrame(function () {
|
|
2313
|
+
var first = null;
|
|
2314
|
+
for (var i = 0; i < order.length && !first; i++) {
|
|
2315
|
+
if (needsPerson(order[i].tone)) first = order[i];
|
|
2316
|
+
}
|
|
2317
|
+
if (first) first.root.scrollIntoView({ block: 'center', behavior: CALM ? 'auto' : 'smooth' });
|
|
2318
|
+
else ui.scroll.scrollTop = 0;
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
// Anything else is something a newer version of Stays Fixed knows about
|
|
2324
|
+
// and this page does not. Ignoring it is the only safe thing to do.
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function finish(entry, kind, ev) {
|
|
2328
|
+
var tone = toneOf(ev.status);
|
|
2329
|
+
if (kind === 'picture') {
|
|
2330
|
+
var before = entry.pics || {};
|
|
2331
|
+
entry.pics = {
|
|
2332
|
+
name: ev.name, status: ev.status,
|
|
2333
|
+
thumbnail: ev.thumbnail || before.thumbnail || '',
|
|
2334
|
+
shotFile: ev.shotFile || before.shotFile || '',
|
|
2335
|
+
approvedThumb: ev.approvedThumb || '',
|
|
2336
|
+
approvedFile: ev.approvedFile || '',
|
|
2337
|
+
diffThumb: ev.diffThumb || '',
|
|
2338
|
+
diffFile: ev.diffFile || '',
|
|
2339
|
+
diffPixels: ev.diffPixels, message: ev.message
|
|
2340
|
+
};
|
|
2341
|
+
if (!nowOf(entry.pics) && !approvedOf(entry.pics) && !diffOf(entry.pics)) entry.pics = null;
|
|
2342
|
+
// Everything this screen owns is fetched now, so the viewer opens on a
|
|
2343
|
+
// picture that is already decoded and the swap never flashes.
|
|
2344
|
+
preloadAll(entry.pics);
|
|
2345
|
+
}
|
|
2346
|
+
entry.outText = outcomeText(kind, ev);
|
|
2347
|
+
entry.failedAt = (kind === 'guard' && ev.status === 'failed' && ev.failedAt) ? ev.failedAt : '';
|
|
2348
|
+
entry.story = (kind === 'guard' && ev.status === 'failed' && ev.because) ? ev.because : '';
|
|
2349
|
+
if (typeof ev.durationMs === 'number') tweenTo(entry.time, ev.durationMs, fmt);
|
|
2350
|
+
entry.tone = tone;
|
|
2351
|
+
redraw(entry);
|
|
2352
|
+
setTone(entry, tone);
|
|
2353
|
+
// Only the checks that want a person open themselves. Everything else stays
|
|
2354
|
+
// one line, which is what keeps a clean run quiet. A row that has just
|
|
2355
|
+
// opened is worth seeing all of, so the list makes room for it — unless the
|
|
2356
|
+
// person has taken hold of the list, in which case nothing moves.
|
|
2357
|
+
if (needsPerson(tone)) {
|
|
2358
|
+
setOpen(entry, true);
|
|
2359
|
+
if (following) {
|
|
2360
|
+
requestAnimationFrame(function () {
|
|
2361
|
+
entry.root.scrollIntoView({ block: 'nearest', behavior: CALM ? 'auto' : 'smooth' });
|
|
2362
|
+
});
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
// Something that broke says so once, with weight, and then sits still.
|
|
2366
|
+
if (tone === 'broke' && !CALM) {
|
|
2367
|
+
entry.root.classList.remove('alarm');
|
|
2368
|
+
void entry.root.offsetWidth;
|
|
2369
|
+
entry.root.classList.add('alarm');
|
|
2370
|
+
setTimeout(function () { entry.root.classList.remove('alarm'); }, 1000);
|
|
2371
|
+
}
|
|
2372
|
+
outcomes[keyFor(entry.kind, entry.name)] = tone || 'held';
|
|
2373
|
+
updateCounts();
|
|
2374
|
+
return tone;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
// The worst thing that happened, as a colour. Red beats amber beats blue
|
|
2378
|
+
// beats green, because a person should see the most serious state first.
|
|
2379
|
+
function worstTone() {
|
|
2380
|
+
var rank = { broke: 4, moved: 3, wait: 2, held: 1, skip: 0 };
|
|
2381
|
+
var worst = 'held';
|
|
2382
|
+
for (var key in outcomes) {
|
|
2383
|
+
var tone = outcomes[key];
|
|
2384
|
+
if ((rank[tone] || 0) > (rank[worst] || 0)) worst = tone;
|
|
2385
|
+
}
|
|
2386
|
+
return worst === 'skip' ? '' : worst;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// Only used when a run finishes without the verdict the terminal printed —
|
|
2390
|
+
// an older run, or one that stopped early. It deliberately does not try to
|
|
2391
|
+
// reproduce those sentences; src/report/console.js owns them.
|
|
2392
|
+
function fallbackVerdict() {
|
|
2393
|
+
var total = order.length;
|
|
2394
|
+
var bad = 0;
|
|
2395
|
+
for (var key in outcomes) {
|
|
2396
|
+
if (outcomes[key] === 'broke' || outcomes[key] === 'moved' || outcomes[key] === 'wait') bad++;
|
|
2397
|
+
}
|
|
2398
|
+
if (!total) return 'finished';
|
|
2399
|
+
if (!bad) return 'Everything that worked still works.';
|
|
2400
|
+
return commas(bad) + ' of ' + commas(total) + ' ' + plural(total, 'check', 'checks') + ' needs a look.';
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// -------------------------------------------------------------------------
|
|
2404
|
+
// The one thing the run calls
|
|
2405
|
+
// -------------------------------------------------------------------------
|
|
2406
|
+
|
|
2407
|
+
window.__staysfixed_push = function (input) {
|
|
2408
|
+
try {
|
|
2409
|
+
var ev = typeof input === 'string' ? JSON.parse(input) : input;
|
|
2410
|
+
if (!ev || typeof ev !== 'object' || typeof ev.type !== 'string') return;
|
|
2411
|
+
handle(ev);
|
|
2412
|
+
} catch (err) {
|
|
2413
|
+
// A watch window must never be the reason a run looks broken. Whatever
|
|
2414
|
+
// this event was, the next one still has to land.
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
|
|
2418
|
+
// Called when the run lets go of the window, so the clock does not tick on
|
|
2419
|
+
// forever next to a result that is already final.
|
|
2420
|
+
window.__staysfixed_detach = function () {
|
|
2421
|
+
stopClock();
|
|
2422
|
+
scanning(false);
|
|
2423
|
+
runningItem = null;
|
|
2424
|
+
};
|
|
2425
|
+
|
|
2426
|
+
// Draw the plan before anything has happened, so the window is worth looking
|
|
2427
|
+
// at from the first frame instead of appearing empty.
|
|
2428
|
+
(function seed() {
|
|
2429
|
+
var i;
|
|
2430
|
+
seeding = true;
|
|
2431
|
+
for (i = 0; i < (plan.screens || []).length; i++) {
|
|
2432
|
+
addItem('picture', plan.screens[i].name, plan.screens[i].describe);
|
|
2433
|
+
}
|
|
2434
|
+
for (i = 0; i < (plan.guards || []).length; i++) {
|
|
2435
|
+
addItem('guard', plan.guards[i].name, plan.guards[i].describe);
|
|
2436
|
+
}
|
|
2437
|
+
seeding = false;
|
|
2438
|
+
updateCounts();
|
|
2439
|
+
if (!order.length) ui.shot.classList.add('empty');
|
|
2440
|
+
// Guards only: there will never be a picture, so the panel does not hold a
|
|
2441
|
+
// screen's worth of empty glass open waiting for one.
|
|
2442
|
+
if (!(plan.screens || []).length && (plan.guards || []).length) ui.stage.hidden = true;
|
|
2443
|
+
})();
|
|
2444
|
+
})();
|
|
2445
|
+
`;
|