@graphty/visual-review 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1762 @@
1
+ /*
2
+ * The review page. Plain JavaScript on purpose: this page is where the owner approves images, so
3
+ * it must stay small enough to read. Every text that came from a pull request (titles, reasons,
4
+ * console lines) is set with textContent, never parsed as HTML.
5
+ */
6
+
7
+ import pixelmatch from "/pixelmatch.mjs";
8
+
9
+ const token = new URLSearchParams(location.hash.slice(1)).get("token") ?? "";
10
+ const app = document.getElementById("app");
11
+ const crumbs = document.getElementById("crumbs");
12
+ const statusLine = document.getElementById("status");
13
+
14
+ const REVIEWABLE = ["changed", "new", "removed", "unstable", "failed"];
15
+ const ACCEPTABLE = ["changed", "new", "removed"];
16
+ // A story with no baseline that this pull request did not change: shown, never a decision here.
17
+ const UNSEEDED = "unseeded";
18
+ const NO_BASELINE = "no baseline yet";
19
+ const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
20
+ // Errors first (their own list), then what changed, then new, unstable and removed stories.
21
+ const RANK = { failed: 0, changed: 1, new: 2, unstable: 3, removed: 4, unseeded: 5 };
22
+ const FLASH_MS = 333; // one image each third of a second: about 1.5 full cycles a second
23
+ // "fit" (the default) shows the whole of both images in their panes, at one scale, never above real
24
+ // size; 1 is real size: one CSS pixel per CSS pixel the story was drawn at, scrolling when larger.
25
+ const ZOOMS = ["fit", 1, 2, 4, 8];
26
+ const CRISP_FROM = 4; // from this zoom on, pixels are drawn as hard squares
27
+ const GROW = 10; // image pixels the spotlight and the changed boxes grow each changed pixel by
28
+ const SPOT_ALPHA = 190; // the spotlight's dimming, out of 255, as Chromatic's focus mask
29
+ const RE_REVIEW = "re-review: your earlier accept was replaced by master's baseline";
30
+ // The grid's decision filters, and how a decision reads on a tile.
31
+ const DECISIONS = { accept: "Accepted", reject: "Rejected", exclude: "Excluded" };
32
+
33
+ const state = {
34
+ targets: [],
35
+ target: null, // summary of the pull request (or master) being reviewed
36
+ project: null,
37
+ data: null, // GET /api/pr/:id/:project
38
+ filter: "undecided", // the grid opens on what still needs a decision
39
+ text: "", // the grid's text filter
40
+ index: 0, // the item shown, in `sequence`
41
+ // The files of this pass through the stories, frozen when a story is opened from the grid:
42
+ // deciding one never drops it, so Previous goes back to it and its Undo.
43
+ sequence: [],
44
+ lastFile: null, // the item last opened, highlighted when the grid comes back
45
+ view: "side", // side | flash | highlight | spotlight
46
+ zoom: "fit",
47
+ box: 0, // which changed box "next changed box" is on
48
+ held: null, // the view to return to when Space is released
49
+ pending: "reject", // what Enter in the reason box does
50
+ screen: "targets",
51
+ job: null, // the newest Finish, from GET /api/finish-status
52
+ armed: null, // the bulk Undo button pressed once: its second press undoes
53
+ };
54
+ const running = () => state.job?.running === true;
55
+ const VIEWS = ["side", "flash", "highlight", "spotlight"];
56
+ const FILTERS = ["undecided", "all", ...REVIEWABLE, UNSEEDED, ...Object.keys(DECISIONS)];
57
+ let routing = false; // true while the page follows the address (a link opened, Back, Forward)
58
+ const images = new Map();
59
+ const diffs = new Map();
60
+ let flashTimer = null;
61
+ let factor = 1; // CSS pixels per image pixel of the pictures on the stage
62
+ let shownBoxes = []; // the changed boxes of the item on the stage
63
+ let stageKey = null; // "file|zoom" of what is on the stage, to keep its scroll across view changes
64
+ // At fit, the stage refits when the window (or an iPad's orientation) changes its size.
65
+ const refit = new ResizeObserver(() => {
66
+ const stage = document.getElementById("stage");
67
+ if (state.screen === "story" && state.zoom === "fit" && stage?.querySelector(".sheet")) {
68
+ fit(stage);
69
+ showBox(stage, shownBoxes, false);
70
+ }
71
+ });
72
+
73
+ // ---------------------------------------------------------------- helpers
74
+
75
+ function el(tag, attrs = {}, ...children) {
76
+ const node = document.createElement(tag);
77
+ for (const [key, value] of Object.entries(attrs)) {
78
+ if (key.startsWith("on")) {
79
+ node.addEventListener(key.slice(2), value);
80
+ } else if (value !== false && value !== null && value !== undefined) {
81
+ node.setAttribute(key, value === true ? "" : value);
82
+ }
83
+ }
84
+ node.append(...children.flat().filter((c) => c !== null && c !== undefined && c !== false));
85
+ return node;
86
+ }
87
+
88
+ // Like replaceChildren, but a null child (a section that does not apply) is left out.
89
+ function render(...children) {
90
+ app.replaceChildren(...children.filter((c) => c !== null && c !== undefined && c !== false));
91
+ }
92
+
93
+ // Clearing the status line during a Finish shows the Finish's step instead, so a screen that
94
+ // opens while it runs (a reload, Visual review, a link) never blanks its progress.
95
+ function say(text, isError = false) {
96
+ statusLine.textContent = text === "" && running() ? finishing() : text;
97
+ statusLine.className = isError ? "error" : "";
98
+ }
99
+
100
+ async function api(path, body) {
101
+ const res = await fetch(path, {
102
+ method: body ? "POST" : "GET",
103
+ headers: { "x-review-token": token, ...(body ? { "content-type": "application/json" } : {}) },
104
+ body: body ? JSON.stringify(body) : undefined,
105
+ });
106
+ const json = await res.json().catch(() => ({}));
107
+ if (!res.ok) {
108
+ throw new Error(json.error ?? `${res.status} ${res.statusText}`);
109
+ }
110
+ return json;
111
+ }
112
+
113
+ // Loads an image through the API (it needs the token header) and returns an object URL.
114
+ function image(kind, file) {
115
+ const key = `${state.target.id}/${state.project}/${kind}/${file}`;
116
+ if (!images.has(key)) {
117
+ const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
118
+ images.set(
119
+ key,
120
+ fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
121
+ if (!res.ok) {
122
+ throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
123
+ }
124
+ return URL.createObjectURL(await res.blob());
125
+ }),
126
+ );
127
+ }
128
+ return images.get(key);
129
+ }
130
+
131
+ function loaded(url) {
132
+ return new Promise((resolve, reject) => {
133
+ const img = new Image();
134
+ img.onload = () => resolve(img);
135
+ img.onerror = reject;
136
+ img.src = url;
137
+ });
138
+ }
139
+
140
+ const componentOf = (id) => id.split("--")[0];
141
+ const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
142
+ const short = (sha) => (sha ? sha.slice(0, 10) : "none");
143
+ const decisionOf = (item) => state.data?.decisions[item.file] ?? null;
144
+ const isLocal = () => state.target?.local === true;
145
+ // Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
146
+ const scale = () => state.data?.results.scale ?? 1;
147
+
148
+ // Errors first, then components by their most urgent item, stories the same way within one, and
149
+ // the modes of a story together. Tile numbers and the story's "N of M" both follow this order.
150
+ function ordered(items) {
151
+ const best = new Map();
152
+ const keep = (key, item) => best.set(key, Math.min(best.get(key) ?? 9, RANK[item.status] ?? 9));
153
+ for (const item of items) {
154
+ keep(`c:${componentOf(item.id)}`, item);
155
+ keep(`s:${item.id}`, item);
156
+ }
157
+ const key = (i) => [
158
+ i.status === "failed" ? 0 : 1,
159
+ best.get(`c:${componentOf(i.id)}`),
160
+ componentOf(i.id),
161
+ best.get(`s:${i.id}`),
162
+ i.id,
163
+ i.mode ?? "",
164
+ ];
165
+ return [...items].sort((a, b) => {
166
+ const [ka, kb] = [key(a), key(b)];
167
+ for (let n = 0; n < ka.length; n++) {
168
+ if (ka[n] !== kb[n]) {
169
+ return ka[n] < kb[n] ? -1 : 1;
170
+ }
171
+ }
172
+ return 0;
173
+ });
174
+ }
175
+
176
+ function visibleItems() {
177
+ const text = state.text.trim().toLowerCase();
178
+ let items;
179
+ if (state.filter === UNSEEDED) {
180
+ items = state.data.items.filter((i) => i.status === UNSEEDED);
181
+ } else {
182
+ items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
183
+ if (state.filter === "undecided") {
184
+ items = items.filter((i) => !decisionOf(i));
185
+ } else if (Object.hasOwn(DECISIONS, state.filter)) {
186
+ items = items.filter((i) => decisionOf(i)?.decision === state.filter);
187
+ } else if (state.filter !== "all") {
188
+ items = items.filter((i) => i.status === state.filter);
189
+ }
190
+ }
191
+ return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
192
+ }
193
+
194
+ // The items of the frozen pass, in its order; an item gone from a reloaded project is left out.
195
+ function passItems() {
196
+ const byFile = new Map(state.data.items.map((i) => [i.file, i]));
197
+ return state.sequence.map((f) => byFile.get(f)).filter(Boolean);
198
+ }
199
+
200
+ const current = () => passItems()[state.index];
201
+
202
+ function progress() {
203
+ const items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
204
+ return `${items.filter(decisionOf).length} / ${items.length} reviewed`;
205
+ }
206
+
207
+ function setCrumbs(...parts) {
208
+ crumbs.replaceChildren(...parts.flatMap((p, i) => (i === 0 ? [p] : [" / ", p])));
209
+ }
210
+
211
+ function stopFlash() {
212
+ clearInterval(flashTimer);
213
+ flashTimer = null;
214
+ }
215
+
216
+ // ---------------------------------------------------------------- screen: targets
217
+
218
+ async function loadTargets() {
219
+ say("Loading pull requests and captures...");
220
+ try {
221
+ const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
222
+ state.targets = prs.targets;
223
+ state.job = status.job;
224
+ say("");
225
+ if (running()) {
226
+ // A reload during a Finish, on any screen: follow the running one, never offer a second.
227
+ watchFinish();
228
+ }
229
+ return true;
230
+ } catch (err) {
231
+ say(err.message, true);
232
+ return false;
233
+ }
234
+ }
235
+
236
+ async function showTargets(notice = "") {
237
+ stopFlash();
238
+ state.screen = "targets";
239
+ setCrumbs();
240
+ if (!(await loadTargets())) {
241
+ return;
242
+ }
243
+ remember();
244
+ say(notice);
245
+ if (state.targets.length === 0) {
246
+ render(finishOutcome(), el("p", {}, "No open pull request has a CI run, and no master run was given."));
247
+ return;
248
+ }
249
+ const finishable = state.targets.find((t) => !t.local);
250
+ render(finishOutcome(), finishable ? signerBlock(finishable) : null, ...state.targets.map(targetCard));
251
+ }
252
+
253
+ // The key Finish signs with comes from the server's environment, which is an agent's when an
254
+ // agent started the server.
255
+ function signerLine(s) {
256
+ if (!s) {
257
+ return "Finish's signing key is unknown.";
258
+ }
259
+ if (!s.signs) {
260
+ return "Finish commits are NOT signed (commit.gpgsign is off).";
261
+ }
262
+ const key = s.key ?? "git's default key for the committer's email";
263
+ const from = s.keyFrom ? `, set in ${s.keyFrom}` : "";
264
+ const who = s.author ? ` as ${s.author}` : "";
265
+ const env = s.fromEnv
266
+ ? " This comes from the environment of whoever started this server (for example an agent through" +
267
+ " servherd), not from your own git config."
268
+ : "";
269
+ return `Finish commits are signed with ${s.format} key ${key}${from}${who}.${env}`;
270
+ }
271
+
272
+ function signerBlock(t) {
273
+ return el(
274
+ "section",
275
+ { class: "card signer" },
276
+ el("p", {}, signerLine(t.signer)),
277
+ t.startCommand
278
+ ? el(
279
+ "p",
280
+ {},
281
+ "To sign as yourself, stop this server and start it from your own shell, in your own terminal:",
282
+ )
283
+ : null,
284
+ t.startCommand ? el("pre", { class: "console" }, t.startCommand) : null,
285
+ );
286
+ }
287
+
288
+ function targetCard(t) {
289
+ const decided = t.projects.reduce((n, p) => n + p.decided, 0);
290
+ const heading = t.local ? "Local preview " : t.pr === null ? "master (seed) " : `#${t.pr} `;
291
+ return el(
292
+ "section",
293
+ { class: "card" },
294
+ el(
295
+ "h2",
296
+ {},
297
+ heading,
298
+ t.url
299
+ ? el("a", { href: t.url, target: "_blank", rel: "noreferrer" }, t.title)
300
+ : t.pr === null
301
+ ? null
302
+ : t.title,
303
+ ),
304
+ t.local
305
+ ? el("p", { class: "meta" }, t.title)
306
+ : el(
307
+ "p",
308
+ { class: "meta" },
309
+ `CI run ${t.runId ?? "none"}, attempt ${t.runAttempt ?? "-"}; captured ${short(t.pr === null ? t.commit : t.headSha)}`,
310
+ t.branch ? ` on ${t.branch}` : "",
311
+ " ",
312
+ t.runUrl ? el("a", { href: t.runUrl, target: "_blank", rel: "noreferrer" }, "run") : null,
313
+ ),
314
+ el(
315
+ "p",
316
+ { class: "badges" },
317
+ t.mergeMasterFirst ? el("span", { class: "badge warn" }, "merge master first") : null,
318
+ t.local
319
+ ? el(
320
+ "span",
321
+ { class: "badge warn" },
322
+ "local preview: look only; only a CI capture of a pushed commit can be decided",
323
+ )
324
+ : null,
325
+ ),
326
+ el(
327
+ "table",
328
+ {},
329
+ el("tr", {}, el("th", {}, "Project"), el("th", {}, "Found"), el("th", {}, "Reviewed"), el("th", {})),
330
+ t.projects.map((p) =>
331
+ el(
332
+ "tr",
333
+ {},
334
+ el(
335
+ "td",
336
+ {},
337
+ p.project,
338
+ p.acceptable || t.local ? null : el("span", { class: "badge" }, "not seeded from master"),
339
+ ),
340
+ el(
341
+ "td",
342
+ {},
343
+ p.problem ? el("span", { class: "badge warn" }, p.problem) : null,
344
+ p.problem && p.logUrl
345
+ ? el("a", { href: p.logUrl, target: "_blank", rel: "noreferrer" }, " job log")
346
+ : null,
347
+ " ",
348
+ Object.entries(p.counts)
349
+ .map(([s, n]) => `${n} ${statusLabel(s)}`)
350
+ .join(", "),
351
+ ),
352
+ el("td", {}, t.local ? "-" : `${p.decided} / ${p.reviewable}`),
353
+ el(
354
+ "td",
355
+ {},
356
+ p.reviewable > 0 || p.counts[UNSEEDED]
357
+ ? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
358
+ : null,
359
+ ),
360
+ ),
361
+ ),
362
+ ),
363
+ t.local
364
+ ? null
365
+ : running() && state.job.target === t.id
366
+ ? el("p", { class: "finish-running" }, `Finish is running: ${state.job.step}...`)
367
+ : el(
368
+ "p",
369
+ {},
370
+ el(
371
+ "button",
372
+ {
373
+ type: "button",
374
+ class: "primary",
375
+ disabled: decided === 0 || running(),
376
+ onclick: () => finishTarget(t),
377
+ },
378
+ `Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
379
+ ),
380
+ ),
381
+ );
382
+ }
383
+
384
+ // ---------------------------------------------------------------- screen: project grid
385
+
386
+ async function openProject(target, project) {
387
+ state.target = target;
388
+ state.project = project;
389
+ state.filter = "undecided";
390
+ state.text = "";
391
+ state.lastFile = null;
392
+ state.sequence = [];
393
+ say("Loading...");
394
+ try {
395
+ await reload();
396
+ say("");
397
+ } catch (err) {
398
+ say(err.message, true);
399
+ return;
400
+ }
401
+ // Nothing left to decide (or a local preview): show everything instead of an empty grid.
402
+ if (visibleItems().length === 0) {
403
+ state.filter = "all";
404
+ }
405
+ showGrid();
406
+ }
407
+
408
+ async function reload() {
409
+ state.data = await api(`/api/pr/${encodeURIComponent(state.target.id)}/${encodeURIComponent(state.project)}`);
410
+ }
411
+
412
+ function targetLabel() {
413
+ return state.target.local ? "local preview" : state.target.pr === null ? "master" : `#${state.target.pr}`;
414
+ }
415
+
416
+ const thumbs = new IntersectionObserver((entries) => {
417
+ for (const e of entries) {
418
+ if (e.isIntersecting) {
419
+ thumbs.unobserve(e.target);
420
+ const { kind, file } = e.target.dataset;
421
+ image(kind, file).then(
422
+ (url) => (e.target.src = url),
423
+ (err) => (e.target.alt = err.message),
424
+ );
425
+ }
426
+ }
427
+ });
428
+
429
+ // Opens item `index` of the grid, and freezes what the grid shows as the pass Next and Previous
430
+ // walk through.
431
+ function openItem(index) {
432
+ say("");
433
+ state.sequence = visibleItems().map((i) => i.file);
434
+ state.index = index;
435
+ state.box = 0;
436
+ showStory();
437
+ }
438
+
439
+ function tile(item, number) {
440
+ const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
441
+ const img = kind
442
+ ? el("img", { "data-kind": kind, "data-file": item.file, alt: itemName(item) })
443
+ : el("div", { class: "noimage" }, item.status);
444
+ if (kind) {
445
+ thumbs.observe(img);
446
+ }
447
+ const d = decisionOf(item);
448
+ return el(
449
+ "div",
450
+ { class: "tile-box" },
451
+ el(
452
+ "button",
453
+ {
454
+ type: "button",
455
+ class: `tile ${d ? `decided ${d.decision}` : ""} ${item.file === state.lastFile ? "current" : ""}`,
456
+ "data-file": item.file,
457
+ onclick: () => openItem(number - 1),
458
+ },
459
+ img,
460
+ el("span", { class: "name" }, el("span", { class: "number" }, `${number}`), " ", item.mode ?? ""),
461
+ el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
462
+ item.reReview ? el("span", { class: "badge warn", title: RE_REVIEW }, "re-review") : null,
463
+ ),
464
+ decisionLine(item),
465
+ );
466
+ }
467
+
468
+ // An item's decision, with its reason, and an Undo that clears it without opening the story. A
469
+ // reject an earlier Finish already posted stays: it is shown, never undone from the grid.
470
+ function decisionLine(item) {
471
+ const d = decisionOf(item);
472
+ if (!d) {
473
+ return null;
474
+ }
475
+ const text = `${DECISIONS[d.decision]}${d.bulk ? " (not opened)" : ""}${d.reason ? `: ${d.reason}` : ""}`;
476
+ return el(
477
+ "div",
478
+ { class: `decision ${d.decision}`, "data-file": item.file },
479
+ el("span", { class: "what", title: text }, text),
480
+ d.posted
481
+ ? el("span", { class: "meta" }, "Posted by Finish: stays")
482
+ : el(
483
+ "button",
484
+ {
485
+ type: "button",
486
+ title: `Undo the ${d.decision} of ${itemName(item)}`,
487
+ onclick: () => undo([item.file], itemName(item), true),
488
+ },
489
+ "Undo",
490
+ ),
491
+ );
492
+ }
493
+
494
+ // A failed capture: its reason, and the console and stack output, in the errors list.
495
+ function errorRow(item, number) {
496
+ return el(
497
+ "li",
498
+ { class: item.file === state.lastFile ? "current" : null, "data-file": item.file },
499
+ el(
500
+ "button",
501
+ { type: "button", class: "link", onclick: () => openItem(number - 1) },
502
+ el("span", { class: "number" }, `${number}`),
503
+ ` ${itemName(item)}`,
504
+ ),
505
+ decisionLine(item),
506
+ el("div", { class: "reason" }, item.reason ?? "no reason recorded"),
507
+ item.console.length > 0
508
+ ? el(
509
+ "details",
510
+ {},
511
+ el("summary", {}, `console and stack (${item.console.length} lines)`),
512
+ el("pre", { class: "console" }, item.console.join("\n")),
513
+ )
514
+ : null,
515
+ );
516
+ }
517
+
518
+ // How many items of `component` Accept would take without opening them.
519
+ const undecidedIn = (component) =>
520
+ state.data.items.filter(
521
+ (i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
522
+ ).length;
523
+
524
+ // The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
525
+ // decision not yet posted by Finish.
526
+ const undoableIn = (component) =>
527
+ state.data.items
528
+ .filter((i) => decisionOf(i) && !decisionOf(i).posted && (!component || componentOf(i.id) === component))
529
+ .map((i) => i.file);
530
+
531
+ // A bulk Undo asks by a second press: the first arms it, and any other redraw of the grid disarms it.
532
+ function undoButton(component, label) {
533
+ const files = undoableIn(component);
534
+ if (isLocal() || files.length === 0) {
535
+ return null;
536
+ }
537
+ const key = component ?? "";
538
+ const where = component ? `the component ${component}` : state.project;
539
+ const armed = state.armed === key;
540
+ return el(
541
+ "button",
542
+ {
543
+ type: "button",
544
+ class: `undo-all ${armed ? "reject" : ""}`,
545
+ "data-armed": String(armed),
546
+ onclick: () => {
547
+ if (armed) {
548
+ undo(files, where);
549
+ return;
550
+ }
551
+ state.armed = key;
552
+ showGrid();
553
+ say(`Press Confirm to undo the decisions of ${where}, or Escape to cancel.`);
554
+ },
555
+ },
556
+ armed ? `Confirm: undo ${files.length} ${files.length === 1 ? "decision" : "decisions"}` : label(files.length),
557
+ );
558
+ }
559
+
560
+ // Clears decisions one by one through the same request as the story screen's U.
561
+ // ponytail: one request per item; a bulk endpoint if a project ever holds thousands of decisions.
562
+ async function undo(files, where, one = false) {
563
+ state.armed = null;
564
+ let done = 0;
565
+ try {
566
+ for (const file of files) {
567
+ await api("/api/decide", {
568
+ id: state.target.id,
569
+ project: state.project,
570
+ file,
571
+ decision: null,
572
+ reason: null,
573
+ });
574
+ done++;
575
+ }
576
+ } catch (err) {
577
+ say(`Undid ${done} of ${files.length}, then: ${err.message}`, true);
578
+ }
579
+ try {
580
+ await reload();
581
+ } catch (err) {
582
+ say(err.message, true);
583
+ }
584
+ if (done === files.length) {
585
+ say(
586
+ one
587
+ ? `${where}: undone, undecided again`
588
+ : `Undid ${done} ${done === 1 ? "decision" : "decisions"} of ${where}.`,
589
+ );
590
+ }
591
+ showGrid();
592
+ }
593
+
594
+ function showGrid() {
595
+ stopFlash();
596
+ state.screen = "grid";
597
+ setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
598
+ const items = visibleItems();
599
+ const counts = {};
600
+ for (const i of state.data.items) {
601
+ counts[i.status] = (counts[i.status] ?? 0) + 1;
602
+ }
603
+ const filterButton = (f, label) =>
604
+ el(
605
+ "button",
606
+ {
607
+ type: "button",
608
+ "aria-pressed": String(state.filter === f),
609
+ onclick: () => {
610
+ state.filter = f;
611
+ showGrid();
612
+ },
613
+ },
614
+ label,
615
+ );
616
+ const errors = [];
617
+ const groups = new Map(); // component -> story id -> [[item, number]]
618
+ items.forEach((item, i) => {
619
+ if (item.status === "failed") {
620
+ errors.push(errorRow(item, i + 1));
621
+ return;
622
+ }
623
+ const c = componentOf(item.id);
624
+ if (!groups.has(c)) {
625
+ groups.set(c, new Map());
626
+ }
627
+ const stories = groups.get(c);
628
+ if (!stories.has(item.id)) {
629
+ stories.set(item.id, []);
630
+ }
631
+ stories.get(item.id).push(tile(item, i + 1));
632
+ });
633
+ const acceptable = state.data.acceptable && !isLocal();
634
+ const sections = [...groups].map(([component, stories]) => {
635
+ const n = undecidedIn(component);
636
+ return el(
637
+ "section",
638
+ { class: "component", "data-component": component },
639
+ el(
640
+ "h3",
641
+ {},
642
+ component,
643
+ acceptable && n > 0
644
+ ? el(
645
+ "button",
646
+ { type: "button", class: "accept", onclick: () => acceptAll(component) },
647
+ `Accept ${n} undecided`,
648
+ )
649
+ : null,
650
+ undoButton(component, (k) => `Undo ${k} ${k === 1 ? "decision" : "decisions"}`),
651
+ ),
652
+ el(
653
+ "div",
654
+ { class: "stories" },
655
+ [...stories].map(([id, tiles]) =>
656
+ el(
657
+ "div",
658
+ { class: "story" },
659
+ el("div", { class: "story-name" }, id.slice(component.length + 2) || id),
660
+ el("div", { class: "modes" }, tiles),
661
+ ),
662
+ ),
663
+ ),
664
+ );
665
+ });
666
+ const filterBox = el("input", {
667
+ id: "filter",
668
+ type: "search",
669
+ placeholder: "Filter by story id",
670
+ value: state.text,
671
+ oninput: (e) => {
672
+ state.text = e.target.value;
673
+ const at = e.target.selectionStart;
674
+ showGrid();
675
+ const box = document.getElementById("filter");
676
+ box.focus();
677
+ box.setSelectionRange(at, at);
678
+ },
679
+ });
680
+ const goto = el("input", {
681
+ id: "goto",
682
+ type: "text",
683
+ placeholder: "Go to: number or story id",
684
+ onkeydown: (e) => {
685
+ if (e.key === "Enter") {
686
+ goTo(e.target.value);
687
+ }
688
+ },
689
+ });
690
+ render(
691
+ el(
692
+ "div",
693
+ { class: "toolbar" },
694
+ el("strong", { id: "progress" }, progress()),
695
+ filterButton(
696
+ "undecided",
697
+ `Needs a decision (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && !decisionOf(i)).length})`,
698
+ ),
699
+ filterButton("all", `All (${state.data.items.filter((i) => REVIEWABLE.includes(i.status)).length})`),
700
+ ["changed", "new", "unstable", "removed", "failed"]
701
+ .filter((s) => counts[s])
702
+ .map((s) => filterButton(s, `${s} (${counts[s]})`)),
703
+ counts[UNSEEDED] ? filterButton(UNSEEDED, `${NO_BASELINE} (${counts[UNSEEDED]})`) : null,
704
+ isLocal()
705
+ ? null
706
+ : Object.entries(DECISIONS).map(([k, label]) =>
707
+ filterButton(
708
+ k,
709
+ `${label} (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && decisionOf(i)?.decision === k).length})`,
710
+ ),
711
+ ),
712
+ el("span", { class: "spacer" }),
713
+ filterBox,
714
+ goto,
715
+ acceptable
716
+ ? el(
717
+ "button",
718
+ { type: "button", class: "accept", onclick: () => acceptAll(null), title: "Shift+A" },
719
+ "Accept all",
720
+ )
721
+ : null,
722
+ undoButton(null, () => "Undo all decisions"),
723
+ finishButton(),
724
+ ),
725
+ isLocal()
726
+ ? el(
727
+ "p",
728
+ { class: "badge warn" },
729
+ "Local preview: look only. Nothing can be decided here; only a CI capture of a pushed commit can.",
730
+ )
731
+ : null,
732
+ items.length > 0
733
+ ? el(
734
+ "p",
735
+ { id: "shown", class: "meta" },
736
+ `Showing ${items.length}: ${errors.length} ${errors.length === 1 ? "error" : "errors"} ` +
737
+ `(listed first, never accepted) and ${items.length - errors.length} ` +
738
+ `${items.length - errors.length === 1 ? "image" : "images"} to compare.`,
739
+ )
740
+ : null,
741
+ errors.length > 0
742
+ ? el(
743
+ "section",
744
+ { class: "errors" },
745
+ el(
746
+ "h3",
747
+ {},
748
+ `Errors: ${errors.length} ${errors.length === 1 ? "story" : "stories"} failed to capture`,
749
+ ),
750
+ el(
751
+ "p",
752
+ { class: "meta" },
753
+ "An error is never accepted. Fix the story, re-run the visual job for a one-off timeout, " +
754
+ "or exclude the story with a reason.",
755
+ ),
756
+ el("ol", { class: "error-list" }, errors),
757
+ )
758
+ : null,
759
+ ...(items.length === 0 ? [el("p", {}, "Nothing here.")] : sections),
760
+ );
761
+ // An armed bulk Undo lasts until the next redraw: this render showed it, the next one does not.
762
+ state.armed = null;
763
+ remember();
764
+ // Back from a story: show where it is in the grid.
765
+ const current = app.querySelector(".current");
766
+ current?.scrollIntoView({ block: "center" });
767
+ }
768
+
769
+ // A number opens that item; anything else opens the first item whose id contains it.
770
+ function goTo(value) {
771
+ const v = value.trim().toLowerCase();
772
+ if (v === "") {
773
+ return;
774
+ }
775
+ let items = visibleItems();
776
+ let at = /^\d+$/.test(v) ? Number(v) - 1 : items.findIndex((i) => i.file.includes(v));
777
+ if (at < 0 || at >= items.length) {
778
+ if (/^\d+$/.test(v)) {
779
+ say(`There is no item ${v}: the list has ${items.length}.`, true);
780
+ return;
781
+ }
782
+ // Not in this filter: look in everything reviewable.
783
+ state.filter = "all";
784
+ state.text = "";
785
+ items = visibleItems();
786
+ at = items.findIndex((i) => i.file.includes(v));
787
+ if (at < 0) {
788
+ say(`No story id contains "${value.trim()}".`, true);
789
+ showGrid();
790
+ return;
791
+ }
792
+ }
793
+ openItem(at);
794
+ }
795
+
796
+ // ---------------------------------------------------------------- screen: story
797
+
798
+ // Why Flash, Highlight and Spotlight are off: they need two images.
799
+ function singleImageNote(item) {
800
+ if (item.baseline && item.capture) {
801
+ return null;
802
+ }
803
+ if (item.status === "removed") {
804
+ return "Only one image: this story was removed, so there is only its baseline.";
805
+ }
806
+ if (item.status === "failed") {
807
+ return "No image: the capture failed.";
808
+ }
809
+ return "New story, no baseline: there is only the new image.";
810
+ }
811
+
812
+ function showStory() {
813
+ stopFlash();
814
+ state.screen = "story";
815
+ const items = passItems();
816
+ if (items.length === 0) {
817
+ showGrid();
818
+ return;
819
+ }
820
+ state.index = Math.max(0, Math.min(state.index, items.length - 1));
821
+ const item = items[state.index];
822
+ state.lastFile = item.file;
823
+ const d = decisionOf(item);
824
+ if (d?.bulk && !isLocal()) {
825
+ // Opening an item Accept all decided counts it as opened.
826
+ delete d.bulk;
827
+ api("/api/decide", { id: state.target.id, project: state.project, file: item.file, ...d }).catch((err) =>
828
+ say(err.message, true),
829
+ );
830
+ }
831
+ const note = singleImageNote(item);
832
+ const view = note ? "side" : state.view;
833
+ const onlyExclude = item.status === "unstable" || item.status === "failed";
834
+ const unseeded = item.status === UNSEEDED;
835
+ const decidable = !isLocal() && !unseeded;
836
+ setCrumbs(
837
+ el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
838
+ el("span", {}, itemName(item)),
839
+ );
840
+ const sizeChanged =
841
+ item.size &&
842
+ item.baselineSize &&
843
+ (item.size[0] !== item.baselineSize[0] || item.size[1] !== item.baselineSize[1]);
844
+ const viewButton = (v, label, key) =>
845
+ el(
846
+ "button",
847
+ {
848
+ type: "button",
849
+ "aria-pressed": String(view === v),
850
+ disabled: Boolean(note) && v !== "side",
851
+ title: note && v !== "side" ? note : key,
852
+ onclick: () => {
853
+ state.view = v;
854
+ showStory();
855
+ },
856
+ },
857
+ label,
858
+ );
859
+ const zoomButton = (z) =>
860
+ el(
861
+ "button",
862
+ {
863
+ type: "button",
864
+ "aria-pressed": String(state.zoom === z),
865
+ title: "Z cycles the zoom",
866
+ onclick: () => {
867
+ state.zoom = z;
868
+ showStory();
869
+ },
870
+ },
871
+ z === "fit" ? "Fit to screen" : z === 1 ? "Real size (1x)" : `${z}x`,
872
+ );
873
+ const reason = el("input", {
874
+ id: "reason",
875
+ type: "text",
876
+ placeholder: "Reason (needed to reject or exclude)",
877
+ value: d?.reason ?? "",
878
+ maxlength: "2000",
879
+ onkeydown: (e) => {
880
+ if (e.key === "Enter") {
881
+ decide(state.pending);
882
+ }
883
+ },
884
+ });
885
+ const decisionButtons = d
886
+ ? [
887
+ el("span", { class: "meta" }, `Decided: ${d.decision}. To change it, undo it first.`),
888
+ el("button", { type: "button", onclick: () => decide(null), title: "U" }, "Undo"),
889
+ ]
890
+ : [
891
+ state.data.acceptable && !onlyExclude
892
+ ? el(
893
+ "button",
894
+ { type: "button", class: "accept", onclick: () => decide("accept"), title: "A" },
895
+ "Accept",
896
+ )
897
+ : null,
898
+ !onlyExclude
899
+ ? el(
900
+ "button",
901
+ { type: "button", class: "reject", onclick: () => decide("reject"), title: "R" },
902
+ "Reject",
903
+ )
904
+ : null,
905
+ state.data.acceptable
906
+ ? el(
907
+ "button",
908
+ {
909
+ type: "button",
910
+ onclick: () => decide("exclude"),
911
+ title: "E: stop capturing every mode of this story",
912
+ },
913
+ "Exclude",
914
+ )
915
+ : null,
916
+ reason,
917
+ ];
918
+ // Keep the panes' scroll when only the view changes (F, H, S, Space) on the same item and zoom.
919
+ const oldFrame = document.querySelector("#stage .sheet")?.parentElement;
920
+ const keep =
921
+ oldFrame && stageKey === `${item.file}|${state.zoom}` ? [oldFrame.scrollLeft, oldFrame.scrollTop] : null;
922
+ // One screen: the controls, then the two panes taking all the height left, then the decisions.
923
+ render(
924
+ el(
925
+ "div",
926
+ { class: "story-view" },
927
+ el(
928
+ "div",
929
+ { class: "toolbar" },
930
+ el("button", { type: "button", onclick: () => move(-1), title: "K" }, "Previous"),
931
+ el("strong", { id: "position" }, `${state.index + 1} of ${items.length}`),
932
+ el("button", { type: "button", onclick: () => move(1), title: "J" }, "Next"),
933
+ el("strong", { id: "progress" }, progress()),
934
+ el("span", { class: "spacer" }),
935
+ el("button", { type: "button", onclick: showGrid, title: "Escape" }, "Back to the grid"),
936
+ finishButton(),
937
+ ),
938
+ el(
939
+ "h2",
940
+ {},
941
+ itemName(item),
942
+ " ",
943
+ el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
944
+ d
945
+ ? el("span", { class: `badge ${d.decision}` }, `${d.decision}${d.reason ? `: ${d.reason}` : ""}`)
946
+ : null,
947
+ sizeChanged
948
+ ? el(
949
+ "span",
950
+ { class: "badge warn" },
951
+ `size changed ${item.baselineSize.join("x")} -> ${item.size.join("x")} image pixels`,
952
+ )
953
+ : null,
954
+ item.flaky ? el("span", { class: "badge" }, "flaky") : null,
955
+ item.reReview ? el("span", { class: "badge warn" }, RE_REVIEW) : null,
956
+ ),
957
+ el(
958
+ "p",
959
+ { class: "meta" },
960
+ item.changedPixels !== null ? `${item.changedPixels} changed image pixels` : "",
961
+ item.bbox ? ` in [${item.bbox.join(", ")}]` : "",
962
+ ` at threshold ${item.threshold}; captured at ${scale()} image pixels per CSS pixel`,
963
+ ),
964
+ el(
965
+ "div",
966
+ { class: "toolbar" },
967
+ viewButton("side", "Side by side", ""),
968
+ viewButton("flash", "Flash", "F, or hold Space"),
969
+ viewButton("highlight", "Highlight", "H"),
970
+ viewButton("spotlight", "Spotlight", "S"),
971
+ note ? el("span", { id: "single-note", class: "meta" }, note) : null,
972
+ el("span", { class: "spacer" }),
973
+ ZOOMS.map(zoomButton),
974
+ el(
975
+ "button",
976
+ {
977
+ type: "button",
978
+ id: "next-box",
979
+ onclick: nextBox,
980
+ title: "N",
981
+ disabled: !item.baseline || !item.capture,
982
+ },
983
+ "Next changed box",
984
+ ),
985
+ el("span", { id: "box-count", class: "meta" }),
986
+ ),
987
+ el("div", { id: "stage", class: `stage ${view} zoom-${state.zoom}` }),
988
+ item.console.length > 0
989
+ ? el(
990
+ "details",
991
+ {
992
+ class: `console-block ${item.status === "failed" ? "errors" : ""}`,
993
+ open: item.status === "failed",
994
+ },
995
+ el(
996
+ "summary",
997
+ {},
998
+ item.status === "failed" ? `Error: ${item.reason ?? "capture failed"}` : "Console",
999
+ ` (${item.console.length} lines)`,
1000
+ ),
1001
+ el("pre", { class: "console" }, item.console.join("\n")),
1002
+ )
1003
+ : null,
1004
+ el(
1005
+ "div",
1006
+ { class: "actions" },
1007
+ isLocal()
1008
+ ? el("p", {}, "Local preview: look only. Only a CI capture of a pushed commit can be decided.")
1009
+ : null,
1010
+ unseeded
1011
+ ? el(
1012
+ "p",
1013
+ {},
1014
+ "No baseline yet, and this pull request does not change it: it looks as on master. " +
1015
+ "Seed it from master's capture, or accept it on the pull request that changes it.",
1016
+ )
1017
+ : null,
1018
+ decidable ? decisionButtons : null,
1019
+ ),
1020
+ ),
1021
+ );
1022
+ renderStage(item, view, keep);
1023
+ remember();
1024
+ }
1025
+
1026
+ // The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
1027
+ // boxes around each separate grown region. Computed once per item.
1028
+ async function diffOf(item) {
1029
+ if (!diffs.has(item.file)) {
1030
+ diffs.set(
1031
+ item.file,
1032
+ (async () => {
1033
+ const [a, b] = await Promise.all([
1034
+ loaded(await image("baseline", item.file)),
1035
+ loaded(await image("capture", item.file)),
1036
+ ]);
1037
+ const w = Math.max(a.naturalWidth, b.naturalWidth);
1038
+ const h = Math.max(a.naturalHeight, b.naturalHeight);
1039
+ const pixels = (img) => {
1040
+ const ctx = new OffscreenCanvas(w, h).getContext("2d");
1041
+ ctx.drawImage(img, 0, 0);
1042
+ return ctx.getImageData(0, 0, w, h).data;
1043
+ };
1044
+ const [pa, pb] = [pixels(a), pixels(b)];
1045
+ const mask = new Uint8ClampedArray(w * h * 4);
1046
+ pixelmatch(pa, pb, mask, w, h, {
1047
+ threshold: item.threshold,
1048
+ includeAA: item.includeAA,
1049
+ diffMask: true,
1050
+ });
1051
+ const grown = grow(mask, w, h);
1052
+ return { w, h, a: pa, b: pb, grown, boxes: regions(grown, w, h) };
1053
+ })(),
1054
+ );
1055
+ }
1056
+ return diffs.get(item.file);
1057
+ }
1058
+
1059
+ // A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
1060
+ function grow(mask, w, h) {
1061
+ const on = new Uint8Array(w * h);
1062
+ for (let i = 0; i < w * h; i++) {
1063
+ on[i] = mask[i * 4 + 3] !== 0 ? 1 : 0;
1064
+ }
1065
+ const pass = (src, count, length, at) => {
1066
+ const out = new Uint8Array(w * h);
1067
+ for (let line = 0; line < count; line++) {
1068
+ let last = -Infinity;
1069
+ // Forward then backward: distance to the nearest set pixel along the line.
1070
+ for (let k = 0; k < length; k++) {
1071
+ if (src[at(line, k)]) {
1072
+ last = k;
1073
+ }
1074
+ if (k - last <= GROW) {
1075
+ out[at(line, k)] = 1;
1076
+ }
1077
+ }
1078
+ last = Infinity;
1079
+ for (let k = length - 1; k >= 0; k--) {
1080
+ if (src[at(line, k)]) {
1081
+ last = k;
1082
+ }
1083
+ if (last - k <= GROW) {
1084
+ out[at(line, k)] = 1;
1085
+ }
1086
+ }
1087
+ }
1088
+ return out;
1089
+ };
1090
+ const rows = pass(on, h, w, (y, x) => y * w + x);
1091
+ return pass(rows, w, h, (x, y) => y * w + x);
1092
+ }
1093
+
1094
+ // The bounding boxes [x, y, width, height] of the separate regions of a grown mask, largest first.
1095
+ function regions(grown, w, h) {
1096
+ const seen = new Uint8Array(w * h);
1097
+ const boxes = [];
1098
+ const stack = [];
1099
+ for (let start = 0; start < w * h; start++) {
1100
+ if (!grown[start] || seen[start]) {
1101
+ continue;
1102
+ }
1103
+ let [x0, y0, x1, y1] = [w, h, -1, -1];
1104
+ seen[start] = 1;
1105
+ stack.push(start);
1106
+ while (stack.length > 0) {
1107
+ const p = stack.pop();
1108
+ const x = p % w;
1109
+ const y = (p - x) / w;
1110
+ x0 = Math.min(x0, x);
1111
+ x1 = Math.max(x1, x);
1112
+ y0 = Math.min(y0, y);
1113
+ y1 = Math.max(y1, y);
1114
+ for (const q of [x > 0 ? p - 1 : -1, x < w - 1 ? p + 1 : -1, p - w, p + w]) {
1115
+ if (q >= 0 && q < w * h && grown[q] && !seen[q]) {
1116
+ seen[q] = 1;
1117
+ stack.push(q);
1118
+ }
1119
+ }
1120
+ }
1121
+ boxes.push([x0, y0, x1 - x0 + 1, y1 - y0 + 1]);
1122
+ }
1123
+ return boxes.sort((p, q) => q[2] * q[3] - p[2] * p[3]);
1124
+ }
1125
+
1126
+ // Sizes every picture on the stage at one scale, so equal-size images line up pixel for pixel in
1127
+ // their panes. At "fit" the largest of them fits its pane both ways (never above real size, one CSS
1128
+ // pixel per CSS pixel the story was drawn at); zoomed, it is real size times the zoom and the
1129
+ // panes scroll. Each pane's sheet is as large as the largest picture, so both scroll alike.
1130
+ function fit(stage) {
1131
+ const pics = [...stage.querySelectorAll(".sheet > img, .sheet > canvas")];
1132
+ const natural = (p) => [p.naturalWidth ?? p.width, p.naturalHeight ?? p.height];
1133
+ let [w, h] = [1, 1];
1134
+ for (const p of pics) {
1135
+ const [x, y] = natural(p);
1136
+ [w, h] = [Math.max(w, x), Math.max(h, y)];
1137
+ }
1138
+ const frame = stage.querySelector(".frame");
1139
+ factor =
1140
+ state.zoom === "fit"
1141
+ ? Math.min(1 / scale(), frame.clientWidth / w, frame.clientHeight / h)
1142
+ : state.zoom / scale();
1143
+ for (const p of pics) {
1144
+ const [x, y] = natural(p);
1145
+ p.style.width = `${x * factor}px`;
1146
+ p.style.height = `${y * factor}px`;
1147
+ p.classList.toggle("crisp", factor * scale() >= CRISP_FROM);
1148
+ }
1149
+ for (const sheet of stage.querySelectorAll(".sheet")) {
1150
+ sheet.style.width = `${w * factor}px`;
1151
+ sheet.style.height = `${h * factor}px`;
1152
+ }
1153
+ }
1154
+
1155
+ // Two panes, always: the baseline on the left and the new image (or the view's picture) on the
1156
+ // right. A missing image leaves its pane empty, the same size, so the other one never moves.
1157
+ async function renderStage(item, view, keep) {
1158
+ const stage = document.getElementById("stage");
1159
+ const pane = (text, ...pics) =>
1160
+ el(
1161
+ "figure",
1162
+ {},
1163
+ el("div", { class: "label", title: text }, text),
1164
+ el(
1165
+ "div",
1166
+ { class: pics.length ? "frame" : "frame empty" },
1167
+ pics.length ? el("div", { class: "sheet" }, pics) : null,
1168
+ ),
1169
+ );
1170
+ const imgOf = async (kind) => {
1171
+ const img = await loaded(await image(kind, item.file));
1172
+ img.alt = `${kind} of ${itemName(item)}`;
1173
+ return img;
1174
+ };
1175
+ try {
1176
+ const diff = item.baseline && item.capture ? await diffOf(item) : null;
1177
+ const left = item.baseline ? pane("Baseline", await imgOf("baseline")) : pane("No baseline");
1178
+ let right;
1179
+ if (!item.capture) {
1180
+ right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
1181
+ } else if (view === "side") {
1182
+ right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
1183
+ } else if (view === "flash") {
1184
+ // The two images themselves, one after the other in the same place: no overlay.
1185
+ const [base, next] = [await imgOf("baseline"), await imgOf("capture")];
1186
+ next.style.visibility = "hidden";
1187
+ right = pane("Flash: baseline", base, next);
1188
+ right.classList.add("flashing");
1189
+ const tag = right.querySelector(".label");
1190
+ let showingNew = false;
1191
+ flashTimer = setInterval(() => {
1192
+ showingNew = !showingNew;
1193
+ base.style.visibility = showingNew ? "hidden" : "visible";
1194
+ next.style.visibility = showingNew ? "visible" : "hidden";
1195
+ tag.textContent = showingNew ? "Flash: new" : "Flash: baseline";
1196
+ }, FLASH_MS);
1197
+ } else if (view === "highlight") {
1198
+ right = pane("Changed pixels in red over the dimmed baseline", highlight(item, diff));
1199
+ } else {
1200
+ right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
1201
+ }
1202
+ stage.replaceChildren(left, right);
1203
+ const frames = [...stage.querySelectorAll(".frame")];
1204
+ // Zoomed, scrolling one pane scrolls the other to the same place.
1205
+ for (const f of frames) {
1206
+ f.addEventListener("scroll", () => {
1207
+ for (const o of frames) {
1208
+ if (o !== f && (o.scrollLeft !== f.scrollLeft || o.scrollTop !== f.scrollTop)) {
1209
+ o.scrollLeft = f.scrollLeft;
1210
+ o.scrollTop = f.scrollTop;
1211
+ }
1212
+ }
1213
+ });
1214
+ }
1215
+ fit(stage);
1216
+ refit.disconnect();
1217
+ refit.observe(stage);
1218
+ shownBoxes = diff ? diff.boxes : [];
1219
+ stageKey = `${item.file}|${state.zoom}`;
1220
+ const count = document.getElementById("box-count");
1221
+ if (diff) {
1222
+ count.textContent =
1223
+ diff.boxes.length === 0
1224
+ ? "no changed box at this threshold"
1225
+ : `box ${Math.min(state.box, diff.boxes.length - 1) + 1} of ${diff.boxes.length}`;
1226
+ showBox(stage, diff.boxes, false);
1227
+ }
1228
+ if (keep) {
1229
+ for (const f of frames) {
1230
+ [f.scrollLeft, f.scrollTop] = keep;
1231
+ }
1232
+ }
1233
+ } catch (err) {
1234
+ stage.replaceChildren(el("p", { class: "error" }, err.message));
1235
+ }
1236
+ }
1237
+
1238
+ // Outlines the current changed box in every pane, and scrolls it into view. A pane opens at the
1239
+ // top left of its image; on opening (`jump` false) it scrolls only when the box is smaller than
1240
+ // the pane and out of sight. Next changed box (`jump` true) always brings the box into view, its
1241
+ // top left first when it is larger than the pane.
1242
+ function showBox(stage, boxes, jump) {
1243
+ if (boxes.length === 0) {
1244
+ return;
1245
+ }
1246
+ state.box = Math.min(state.box, boxes.length - 1);
1247
+ const [x, y, w, h] = boxes[state.box];
1248
+ const PAD = 16;
1249
+ for (const frame of stage.querySelectorAll(".frame")) {
1250
+ const sheet = frame.querySelector(".sheet");
1251
+ if (!sheet) {
1252
+ continue;
1253
+ }
1254
+ const [sw, sh] = [sheet.offsetWidth, sheet.offsetHeight];
1255
+ // The outline is drawn inside the image, so a box at an edge keeps all four sides.
1256
+ const [left, top] = [Math.max(0, x * factor - 2), Math.max(0, y * factor - 2)];
1257
+ const [right, bottom] = [Math.min(sw, (x + w) * factor + 2), Math.min(sh, (y + h) * factor + 2)];
1258
+ sheet.querySelector(".boxmark")?.remove();
1259
+ const mark = el("div", { class: "boxmark" });
1260
+ Object.assign(mark.style, {
1261
+ left: `${left}px`,
1262
+ top: `${top}px`,
1263
+ width: `${right - left}px`,
1264
+ height: `${bottom - top}px`,
1265
+ });
1266
+ sheet.append(mark);
1267
+ if (!jump) {
1268
+ frame.scrollLeft = 0;
1269
+ frame.scrollTop = 0;
1270
+ }
1271
+ const fits = right - left <= frame.clientWidth && bottom - top <= frame.clientHeight;
1272
+ if (!jump && !fits) {
1273
+ continue;
1274
+ }
1275
+ const reveal = (start, end, scroll, view) => {
1276
+ if (end - start > view || start < scroll) {
1277
+ return Math.max(0, start - PAD);
1278
+ }
1279
+ return end > scroll + view ? end - view + PAD : scroll;
1280
+ };
1281
+ const [ox, oy] = [sheet.offsetLeft, sheet.offsetTop];
1282
+ frame.scrollLeft = reveal(ox + left, ox + right, frame.scrollLeft, frame.clientWidth);
1283
+ frame.scrollTop = reveal(oy + top, oy + bottom, frame.scrollTop, frame.clientHeight);
1284
+ }
1285
+ }
1286
+
1287
+ async function nextBox() {
1288
+ const item = current();
1289
+ if (!item?.baseline || !item?.capture) {
1290
+ return;
1291
+ }
1292
+ const { boxes } = await diffOf(item);
1293
+ if (boxes.length === 0) {
1294
+ return;
1295
+ }
1296
+ state.box = (state.box + 1) % boxes.length;
1297
+ document.getElementById("box-count").textContent = `box ${state.box + 1} of ${boxes.length}`;
1298
+ showBox(document.getElementById("stage"), boxes, true);
1299
+ }
1300
+
1301
+ // pixelmatch's own picture: the changed pixels in red over the dimmed baseline.
1302
+ function highlight(item, diff) {
1303
+ const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
1304
+ const ctx = canvas.getContext("2d");
1305
+ const out = ctx.createImageData(diff.w, diff.h);
1306
+ pixelmatch(diff.a, diff.b, out.data, diff.w, diff.h, {
1307
+ threshold: item.threshold,
1308
+ includeAA: item.includeAA,
1309
+ alpha: 0.2,
1310
+ });
1311
+ ctx.putImageData(out, 0, 0);
1312
+ return canvas;
1313
+ }
1314
+
1315
+ // The new image with everything dimmed except the changed pixels grown by GROW pixels.
1316
+ function spotlight(diff) {
1317
+ const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
1318
+ const ctx = canvas.getContext("2d");
1319
+ const out = ctx.createImageData(diff.w, diff.h);
1320
+ const keep = 1 - SPOT_ALPHA / 255;
1321
+ for (let i = 0; i < diff.w * diff.h; i++) {
1322
+ const lit = diff.grown[i] === 1;
1323
+ for (let c = 0; c < 3; c++) {
1324
+ out.data[i * 4 + c] = lit ? diff.b[i * 4 + c] : diff.b[i * 4 + c] * keep;
1325
+ }
1326
+ out.data[i * 4 + 3] = lit ? diff.b[i * 4 + 3] : Math.max(diff.b[i * 4 + 3], SPOT_ALPHA);
1327
+ }
1328
+ ctx.putImageData(out, 0, 0);
1329
+ return canvas;
1330
+ }
1331
+
1332
+ async function acceptAll(component) {
1333
+ if (!state.data.acceptable || isLocal()) {
1334
+ say(`${state.project} cannot be accepted here`, true);
1335
+ return;
1336
+ }
1337
+ const n = undecidedIn(component);
1338
+ const where = component ? `the component ${component}` : state.project;
1339
+ if (n === 0) {
1340
+ say(`Nothing undecided to accept in ${where}.`);
1341
+ return;
1342
+ }
1343
+ if (!confirm(`Accept ${n} undecided items of ${where} without opening them?`)) {
1344
+ return;
1345
+ }
1346
+ try {
1347
+ await api("/api/accept-all", {
1348
+ id: state.target.id,
1349
+ project: state.project,
1350
+ ...(component ? { component } : {}),
1351
+ });
1352
+ await reload();
1353
+ } catch (err) {
1354
+ say(err.message, true);
1355
+ return;
1356
+ }
1357
+ say(`Accepted ${n} items in ${where}.`);
1358
+ (state.screen === "story" ? showStory : showGrid)();
1359
+ }
1360
+
1361
+ function move(step) {
1362
+ say("");
1363
+ const count = passItems().length;
1364
+ state.index = (state.index + step + count) % count;
1365
+ state.box = 0;
1366
+ showStory();
1367
+ }
1368
+
1369
+ async function decide(decision) {
1370
+ if (isLocal()) {
1371
+ say("A local preview is only looked at: nothing is decided on it.", true);
1372
+ return;
1373
+ }
1374
+ const items = passItems();
1375
+ const item = items[state.index];
1376
+ const before = decisionOf(item);
1377
+ if (decision === null && !before) {
1378
+ return;
1379
+ }
1380
+ // No key reverses a decision: changing one is an explicit Undo first.
1381
+ if (decision !== null && before) {
1382
+ const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
1383
+ say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
1384
+ return;
1385
+ }
1386
+ const reasonBox = document.getElementById("reason");
1387
+ const reason = reasonBox?.value.trim() ?? "";
1388
+ if ((decision === "reject" || decision === "exclude") && reason === "") {
1389
+ state.pending = decision;
1390
+ reasonBox?.focus();
1391
+ say(`A ${decision} needs a reason: type it, then press Enter.`);
1392
+ return;
1393
+ }
1394
+ if (
1395
+ decision === "exclude" &&
1396
+ !confirm(
1397
+ `Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
1398
+ "until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
1399
+ )
1400
+ ) {
1401
+ return;
1402
+ }
1403
+ try {
1404
+ await api("/api/decide", {
1405
+ id: state.target.id,
1406
+ project: state.project,
1407
+ file: item.file,
1408
+ decision,
1409
+ reason: reason === "" ? null : reason,
1410
+ });
1411
+ } catch (err) {
1412
+ say(err.message, true);
1413
+ return;
1414
+ }
1415
+ if (decision === null) {
1416
+ delete state.data.decisions[item.file];
1417
+ } else {
1418
+ state.data.decisions[item.file] = { decision, reason: reason === "" ? null : reason };
1419
+ }
1420
+ state.pending = "reject";
1421
+ say(`${itemName(item)}: ${decision ?? "undone, undecided again"}`);
1422
+ // The pass is frozen, so a decided item stays in it: a decision moves on to the next one, and
1423
+ // Previous comes back to it. Undo stays on the item.
1424
+ if (decision !== null) {
1425
+ state.index = Math.min(state.index + 1, items.length - 1);
1426
+ }
1427
+ state.box = 0;
1428
+ showStory();
1429
+ }
1430
+
1431
+ // ---------------------------------------------------------------- Finish
1432
+
1433
+ function finishButton() {
1434
+ if (isLocal()) {
1435
+ return null;
1436
+ }
1437
+ return el(
1438
+ "button",
1439
+ { type: "button", class: "primary", disabled: running(), onclick: () => finishTarget(state.target) },
1440
+ `Finish ${targetLabel()}`,
1441
+ );
1442
+ }
1443
+
1444
+ async function finishTarget(target) {
1445
+ const what =
1446
+ target.pr === null ? "push a seed branch and open its pull request" : `commit and push to ${target.branch}`;
1447
+ let fresh;
1448
+ try {
1449
+ fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
1450
+ } catch (err) {
1451
+ say(err.message, true);
1452
+ return;
1453
+ }
1454
+ const lines = [
1455
+ `Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
1456
+ ];
1457
+ const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
1458
+ if (notOpened > 0) {
1459
+ lines.push(`${notOpened} accepted without being opened.`);
1460
+ }
1461
+ const left = fresh.projects.filter((p) => p.undecided > 0);
1462
+ if (left.length > 0) {
1463
+ lines.push(
1464
+ `Warning: still undecided, left for a later round: ${left.map((p) => `${p.project}: ${p.undecided} undecided`).join(", ")}.`,
1465
+ );
1466
+ }
1467
+ lines.push(signerLine(fresh.signer));
1468
+ if (fresh.startCommand) {
1469
+ lines.push(
1470
+ `To sign as yourself instead, cancel and start the server from your own shell:\n${fresh.startCommand}`,
1471
+ );
1472
+ }
1473
+ lines.push("One commit status is posted when Finish completes.");
1474
+ if (!confirm(lines.join("\n\n"))) {
1475
+ return;
1476
+ }
1477
+ // Finish runs on the server and can take minutes; the page only starts it and then asks how it
1478
+ // is going, so a dropped connection or a reload loses nothing.
1479
+ say("Starting Finish...");
1480
+ try {
1481
+ state.job = (await api("/api/finish", { id: target.id })).job;
1482
+ } catch (err) {
1483
+ // The request may have been lost after the server started it: ask before calling it failed.
1484
+ state.job = await api("/api/finish-status")
1485
+ .then((s) => s.job)
1486
+ .catch(() => null);
1487
+ if (!running() || state.job.target !== target.id) {
1488
+ say("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
1489
+ app.prepend(el("pre", { class: "error" }, err.message));
1490
+ return;
1491
+ }
1492
+ }
1493
+ await watchFinish();
1494
+ }
1495
+
1496
+ const finishing = () =>
1497
+ `Finishing ${state.job.pr === null ? "the master seed" : `#${state.job.pr}`}: ${state.job.step}...`;
1498
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1499
+ let watching = false;
1500
+
1501
+ // Follows the running Finish to its end, then shows the targets with its outcome.
1502
+ async function watchFinish() {
1503
+ if (watching) {
1504
+ return;
1505
+ }
1506
+ watching = true;
1507
+ try {
1508
+ while (running()) {
1509
+ say(finishing());
1510
+ await sleep(1000);
1511
+ try {
1512
+ state.job = (await api("/api/finish-status")).job;
1513
+ } catch (err) {
1514
+ say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
1515
+ await sleep(2000);
1516
+ }
1517
+ }
1518
+ } finally {
1519
+ watching = false;
1520
+ }
1521
+ await showTargets();
1522
+ }
1523
+
1524
+ // What the newest Finish did, shown above the targets until the server restarts.
1525
+ function finishOutcome() {
1526
+ const job = state.job;
1527
+ if (!job || job.running) {
1528
+ return null;
1529
+ }
1530
+ const label = job.pr === null ? "the master seed" : `#${job.pr}`;
1531
+ if (job.error !== null) {
1532
+ return el(
1533
+ "section",
1534
+ { class: "card finish-outcome" },
1535
+ el(
1536
+ "p",
1537
+ { class: "error" },
1538
+ `Finish of ${label} failed; your decisions are kept. Fix the cause and press Finish again.`,
1539
+ ),
1540
+ el("pre", { class: "error" }, job.error),
1541
+ );
1542
+ }
1543
+ const out = job.result;
1544
+ const parts = [out.commit ? `Committed ${short(out.commit)} to ${out.branch}.` : "No commit (nothing accepted)."];
1545
+ if (out.rejects > 0) {
1546
+ parts.push(`${out.rejects} rejects posted.`);
1547
+ }
1548
+ if (out.issue) {
1549
+ parts.push(`Issue: ${out.issue}`);
1550
+ }
1551
+ if (out.pullRequest) {
1552
+ parts.push(`Pull request: ${out.pullRequest}`);
1553
+ }
1554
+ parts.push(out.statusError ? `The commit status failed: ${out.statusError}` : `Status: ${out.status}.`);
1555
+ return el(
1556
+ "section",
1557
+ { class: "card finish-outcome" },
1558
+ el("p", { class: out.statusError ? "error" : "" }, `Finish of ${label} done. `, parts.join(" ")),
1559
+ );
1560
+ }
1561
+
1562
+ // ---------------------------------------------------------------- the address
1563
+
1564
+ // Every screen is in the address, after the session token, so a copied link opens it again:
1565
+ // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit
1566
+ // Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
1567
+ function hashFor() {
1568
+ const p = new URLSearchParams({ token });
1569
+ if (state.screen !== "targets") {
1570
+ p.set("target", state.target.id);
1571
+ p.set("project", state.project);
1572
+ p.set("filter", state.filter);
1573
+ if (state.text) {
1574
+ p.set("q", state.text);
1575
+ }
1576
+ }
1577
+ if (state.screen === "story") {
1578
+ p.set("item", current().file);
1579
+ p.set("view", state.held ?? state.view);
1580
+ p.set("zoom", String(state.zoom));
1581
+ }
1582
+ return `#${p}`;
1583
+ }
1584
+
1585
+ // Writes the screen into the address: a new history entry when the screen changes (so Back
1586
+ // returns to the one before), in place when only the item, view, zoom or filter does.
1587
+ function remember() {
1588
+ const hash = hashFor();
1589
+ if (hash === location.hash) {
1590
+ return;
1591
+ }
1592
+ const was = new URLSearchParams(location.hash.slice(1));
1593
+ const screen = was.has("item") ? "story" : was.has("target") ? "grid" : "targets";
1594
+ const moved =
1595
+ screen !== state.screen ||
1596
+ (screen !== "targets" &&
1597
+ (was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
1598
+ const push = !routing && moved;
1599
+ history[push ? "pushState" : "replaceState"](null, "", hash);
1600
+ }
1601
+
1602
+ // Shows the screen the address names; what no longer exists (a closed pull request, a story gone
1603
+ // from a new CI run) lands on the nearest screen that does, with a line saying so.
1604
+ async function route() {
1605
+ const p = new URLSearchParams(location.hash.slice(1));
1606
+ routing = true;
1607
+ try {
1608
+ const id = p.get("target");
1609
+ if (!id) {
1610
+ await showTargets();
1611
+ return;
1612
+ }
1613
+ if (!(await loadTargets())) {
1614
+ return;
1615
+ }
1616
+ const target = state.targets.find((t) => String(t.id) === id);
1617
+ const project = p.get("project");
1618
+ if (!target || !target.projects.some((x) => x.project === project)) {
1619
+ const what = target ? `${project} is not a project of ${id}` : `${id} is no longer listed`;
1620
+ await showTargets(`${what}: showing every target.`);
1621
+ return;
1622
+ }
1623
+ if (state.data === null || state.target?.id !== target.id || state.project !== project) {
1624
+ state.sequence = [];
1625
+ state.target = target;
1626
+ state.project = project;
1627
+ try {
1628
+ await reload();
1629
+ } catch (err) {
1630
+ say(err.message, true);
1631
+ return;
1632
+ }
1633
+ }
1634
+ state.target = target;
1635
+ state.filter = FILTERS.includes(p.get("filter")) ? p.get("filter") : "undecided";
1636
+ state.text = p.get("q") ?? "";
1637
+ const file = p.get("item");
1638
+ if (!file) {
1639
+ showGrid();
1640
+ return;
1641
+ }
1642
+ const item = state.data.items.find((i) => i.file === file);
1643
+ if (!item) {
1644
+ showGrid();
1645
+ say(`${file} is not in this CI run any more: showing the grid.`);
1646
+ return;
1647
+ }
1648
+ // Back into the pass it came from keeps that pass; otherwise the grid's items, with this one.
1649
+ if (!state.sequence.includes(file)) {
1650
+ const shown = visibleItems();
1651
+ state.sequence = (shown.includes(item) ? shown : ordered([...shown, item])).map((i) => i.file);
1652
+ }
1653
+ state.index = state.sequence.indexOf(file);
1654
+ state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
1655
+ const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
1656
+ state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
1657
+ state.box = 0;
1658
+ say("");
1659
+ showStory();
1660
+ } finally {
1661
+ routing = false;
1662
+ }
1663
+ }
1664
+
1665
+ // ---------------------------------------------------------------- keys and start
1666
+
1667
+ // F, H and S switch to that view, or back to side by side when it is already shown.
1668
+ function toggleView(view) {
1669
+ state.view = state.view === view ? "side" : view;
1670
+ showStory();
1671
+ }
1672
+
1673
+ document.addEventListener("keydown", (e) => {
1674
+ if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
1675
+ return;
1676
+ }
1677
+ const inInput = e.target instanceof HTMLInputElement;
1678
+ if (e.key === "Escape") {
1679
+ // From a story, wherever focus is (the reason box included), back to the grid.
1680
+ e.preventDefault();
1681
+ if (inInput) {
1682
+ e.target.blur();
1683
+ }
1684
+ if (state.screen === "story" || app.querySelector('[data-armed="true"]')) {
1685
+ // From a story back to the grid; on the grid, it disarms a bulk Undo pressed once.
1686
+ say("");
1687
+ showGrid();
1688
+ }
1689
+ return;
1690
+ }
1691
+ if (inInput) {
1692
+ return;
1693
+ }
1694
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
1695
+ if (e.shiftKey && key === "a") {
1696
+ e.preventDefault();
1697
+ acceptAll(null);
1698
+ return;
1699
+ }
1700
+ if (state.screen !== "story") {
1701
+ return;
1702
+ }
1703
+ if (key === " ") {
1704
+ // Held: flash until released, then back to the view it came from.
1705
+ e.preventDefault();
1706
+ const item = current();
1707
+ if (!e.repeat && state.held === null && item?.baseline && item?.capture) {
1708
+ state.held = state.view;
1709
+ state.view = "flash";
1710
+ showStory();
1711
+ }
1712
+ return;
1713
+ }
1714
+ const keys = {
1715
+ j: () => move(1),
1716
+ k: () => move(-1),
1717
+ a: () => decide("accept"),
1718
+ r: () => decide("reject"),
1719
+ e: () => decide("exclude"),
1720
+ u: () => decide(null),
1721
+ f: () => toggleView("flash"),
1722
+ h: () => toggleView("highlight"),
1723
+ s: () => toggleView("spotlight"),
1724
+ n: nextBox,
1725
+ z: () => {
1726
+ state.zoom = ZOOMS[(ZOOMS.indexOf(state.zoom) + 1) % ZOOMS.length];
1727
+ showStory();
1728
+ },
1729
+ };
1730
+ const action = keys[key];
1731
+ if (action) {
1732
+ e.preventDefault();
1733
+ action();
1734
+ }
1735
+ });
1736
+ document.addEventListener("keyup", (e) => {
1737
+ if (e.key === " " && state.held !== null) {
1738
+ e.preventDefault();
1739
+ state.view = state.held;
1740
+ state.held = null;
1741
+ if (state.screen === "story") {
1742
+ showStory();
1743
+ }
1744
+ }
1745
+ });
1746
+ document.getElementById("home").addEventListener("click", () => showTargets());
1747
+ document.getElementById("copy-link").addEventListener("click", async () => {
1748
+ try {
1749
+ await navigator.clipboard.writeText(location.href);
1750
+ say("Link copied. It carries your session token: it opens this screen on any device.");
1751
+ } catch {
1752
+ // No clipboard (a page served over plain http to another device): the browser's own box.
1753
+ prompt("Copy this link:", location.href);
1754
+ }
1755
+ });
1756
+ window.addEventListener("popstate", () => route());
1757
+
1758
+ if (token === "") {
1759
+ render(el("p", { class: "error" }, "No session token: open the URL that visual-review serve printed."));
1760
+ } else {
1761
+ route();
1762
+ }