@graphty/visual-review 0.1.3 → 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.
@@ -11,9 +11,10 @@ const app = document.getElementById("app");
11
11
  const crumbs = document.getElementById("crumbs");
12
12
  const statusLine = document.getElementById("status");
13
13
 
14
- const REVIEWABLE = ["changed", "moved", "new", "removed", "unstable", "failed"];
15
- const ACCEPTABLE = ["changed", "moved", "new", "removed"];
16
- // A story with no baseline that this pull request did not change: shown, never a decision here.
14
+ const REVIEWABLE = ["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"];
15
+ const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
16
+ // A story with no baseline that this pull request did not change. It blocks like `new`: accepting it
17
+ // creates its first baseline.
17
18
  const UNSEEDED = "unseeded";
18
19
  const NO_BASELINE = "no baseline yet";
19
20
  const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
@@ -50,6 +51,7 @@ const state = {
50
51
  box: 0, // which changed box "next changed box" is on
51
52
  showBox: saved.showBox ?? true, // outline the changed box (B)
52
53
  blink: saved.blink ?? false, // blink the changed pixels Highlight lays over the images (L)
54
+ spotFlash: saved.spotFlash ?? false, // Spotlight flashes baseline and new (F in Spotlight)
53
55
  held: null, // the view to return to when Space is released
54
56
  pending: "reject", // what Enter in the reason box does
55
57
  screen: "targets",
@@ -58,10 +60,22 @@ const state = {
58
60
  };
59
61
  const running = () => state.job?.running === true;
60
62
  const VIEWS = ["side", "flash", "highlight", "spotlight"];
61
- const FILTERS = ["undecided", "all", ...REVIEWABLE, UNSEEDED, ...Object.keys(DECISIONS)];
62
- let routing = false; // true while the page follows the address (a link opened, Back, Forward)
63
+ const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
64
+ let routes = 0; // how many routes are running: the page follows the address (a link, Back, Forward)
65
+ let nav = 0; // bumped by each screen change that waits on the server: a superseded one stops there
66
+ // The least recently used last-opened images and diffs are dropped past these, so a long session
67
+ // stays small: an object URL holds its image, and a diff about 13 bytes per image pixel.
63
68
  const images = new Map();
69
+ const IMAGES_KEPT = 50;
64
70
  const diffs = new Map();
71
+ const DIFFS_KEPT = 4;
72
+ let stageRender = 0; // the newest renderStage: an older one finishing late writes nothing
73
+ let stageShown = null; // the file whose two panes are on the stage: Accept waits for it
74
+ let deciding = false; // a decision is on its way to the server
75
+ // A decision moves on to the next item, whose buttons are in the same place: the second click of
76
+ // a double click, this soon after the first, lands there and is ignored.
77
+ const DOUBLE_CLICK_MS = 500;
78
+ let clickedAt = -Infinity; // when a decision button was last clicked
65
79
  let flashTimer = null;
66
80
  let factor = 1; // CSS pixels per image pixel of the pictures on the stage
67
81
  let shownBoxes = []; // the changed boxes of the item on the stage
@@ -102,6 +116,32 @@ function say(text, isError = false) {
102
116
  statusLine.className = isError ? "error" : "";
103
117
  }
104
118
 
119
+ // Asks in the page, never with confirm(): once a browser stops a page's dialogs ("prevent this
120
+ // page from creating additional dialogs"), confirm() returns false without showing anything, and
121
+ // the button pressed seems to do nothing. Resolves true for `yes`, false for Cancel or Escape.
122
+ function ask(message, yes) {
123
+ // Focus on the box, not a button: the Enter that asked (in the reason box) must not answer it.
124
+ const dialog = el("dialog", { class: "ask", tabindex: "-1" }, el("p", {}, message));
125
+ const answer = (value) => () => dialog.close(value);
126
+ dialog.append(
127
+ el(
128
+ "p",
129
+ { class: "actions" },
130
+ el("button", { type: "button", onclick: answer("no") }, "Cancel"),
131
+ el("button", { type: "button", class: "primary", onclick: answer("yes") }, yes),
132
+ ),
133
+ );
134
+ document.body.append(dialog);
135
+ dialog.showModal();
136
+ dialog.focus();
137
+ return new Promise((resolve) =>
138
+ dialog.addEventListener("close", () => {
139
+ dialog.remove();
140
+ resolve(dialog.returnValue === "yes");
141
+ }),
142
+ );
143
+ }
144
+
105
145
  async function api(path, body) {
106
146
  const res = await fetch(path, {
107
147
  method: body ? "POST" : "GET",
@@ -115,29 +155,55 @@ async function api(path, body) {
115
155
  return json;
116
156
  }
117
157
 
118
- // Loads an image through the API (it needs the token header) and returns an object URL.
119
- function image(kind, file) {
120
- const key = `${state.target.id}/${state.project}/${kind}/${file}`;
121
- if (!images.has(key)) {
122
- const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
123
- images.set(
124
- key,
125
- fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
158
+ // The promise `make` returns for `key`, kept in `map` with the least recently used dropped past
159
+ // `limit` (and handed to `drop`). A promise that fails is dropped at once, so the next use retries.
160
+ function cached(map, key, limit, make, drop = () => {}) {
161
+ let value = map.get(key);
162
+ if (value === undefined) {
163
+ value = make();
164
+ value.catch(() => {
165
+ if (map.get(key) === value) {
166
+ map.delete(key);
167
+ }
168
+ });
169
+ }
170
+ map.delete(key);
171
+ map.set(key, value);
172
+ for (const [k, old] of map) {
173
+ if (map.size <= limit) {
174
+ break;
175
+ }
176
+ map.delete(k);
177
+ drop(old);
178
+ }
179
+ return value;
180
+ }
181
+
182
+ // Loads an image through the API (it needs the token header) and returns an object URL. Keyed by
183
+ // the image's hash too, so a newer CI run's image is never answered with an older one.
184
+ function image(kind, file, hash) {
185
+ return cached(
186
+ images,
187
+ `${state.target.id}/${state.project}/${kind}/${file}/${hash}`,
188
+ IMAGES_KEPT,
189
+ () => {
190
+ const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
191
+ return fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
126
192
  if (!res.ok) {
127
193
  throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
128
194
  }
129
195
  return URL.createObjectURL(await res.blob());
130
- }),
131
- );
132
- }
133
- return images.get(key);
196
+ });
197
+ },
198
+ (url) => url.then(URL.revokeObjectURL, () => {}),
199
+ );
134
200
  }
135
201
 
136
- function loaded(url) {
202
+ function loaded(url, what) {
137
203
  return new Promise((resolve, reject) => {
138
204
  const img = new Image();
139
205
  img.onload = () => resolve(img);
140
- img.onerror = reject;
206
+ img.onerror = () => reject(new Error(`${what}: the image could not be shown`));
141
207
  img.src = url;
142
208
  });
143
209
  }
@@ -148,6 +214,9 @@ const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
148
214
  const movedFrom = (item) => (item.from ? `moved from ${item.from}` : "");
149
215
  const short = (sha) => (sha ? sha.slice(0, 10) : "none");
150
216
  const decisionOf = (item) => state.data?.decisions[item.file] ?? null;
217
+ // The image a decision is about, as the server checks it: a decision on an image another run
218
+ // replaced since this page loaded is refused.
219
+ const imageHash = (item) => item.capture ?? item.baseline ?? null;
151
220
  const isLocal = () => state.target?.local === true;
152
221
  // Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
153
222
  const scale = () => state.data?.results.scale ?? 1;
@@ -182,18 +251,13 @@ function ordered(items) {
182
251
 
183
252
  function visibleItems() {
184
253
  const text = state.text.trim().toLowerCase();
185
- let items;
186
- if (state.filter === UNSEEDED) {
187
- items = state.data.items.filter((i) => i.status === UNSEEDED);
188
- } else {
189
- items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
190
- if (state.filter === "undecided") {
191
- items = items.filter((i) => !decisionOf(i));
192
- } else if (Object.hasOwn(DECISIONS, state.filter)) {
193
- items = items.filter((i) => decisionOf(i)?.decision === state.filter);
194
- } else if (state.filter !== "all") {
195
- items = items.filter((i) => i.status === state.filter);
196
- }
254
+ let items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
255
+ if (state.filter === "undecided") {
256
+ items = items.filter((i) => !decisionOf(i));
257
+ } else if (Object.hasOwn(DECISIONS, state.filter)) {
258
+ items = items.filter((i) => decisionOf(i)?.decision === state.filter);
259
+ } else if (state.filter !== "all") {
260
+ items = items.filter((i) => i.status === state.filter);
197
261
  }
198
262
  return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
199
263
  }
@@ -232,7 +296,10 @@ function loadOptions() {
232
296
 
233
297
  function saveOptions() {
234
298
  try {
235
- localStorage.setItem(OPTIONS_KEY, JSON.stringify({ showBox: state.showBox, blink: state.blink }));
299
+ localStorage.setItem(
300
+ OPTIONS_KEY,
301
+ JSON.stringify({ showBox: state.showBox, blink: state.blink, spotFlash: state.spotFlash }),
302
+ );
236
303
  } catch {
237
304
  // Not remembered; the choice still holds for this page.
238
305
  }
@@ -251,7 +318,7 @@ async function loadTargets() {
251
318
  try {
252
319
  const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
253
320
  state.targets = prs.targets;
254
- state.job = status.job;
321
+ track(status.job);
255
322
  say("");
256
323
  if (running()) {
257
324
  // A reload during a Finish, on any screen: follow the running one, never offer a second.
@@ -265,10 +332,11 @@ async function loadTargets() {
265
332
  }
266
333
 
267
334
  async function showTargets(notice = "") {
335
+ const seq = ++nav;
268
336
  stopFlash();
269
337
  state.screen = "targets";
270
338
  setCrumbs();
271
- if (!(await loadTargets())) {
339
+ if (!(await loadTargets()) || seq !== nav) {
272
340
  return;
273
341
  }
274
342
  remember();
@@ -384,7 +452,7 @@ function targetCard(t) {
384
452
  el(
385
453
  "td",
386
454
  {},
387
- p.reviewable > 0 || p.counts[UNSEEDED]
455
+ p.reviewable > 0
388
456
  ? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
389
457
  : null,
390
458
  ),
@@ -404,7 +472,7 @@ function targetCard(t) {
404
472
  type: "button",
405
473
  class: "primary",
406
474
  disabled: decided === 0 || running(),
407
- onclick: () => finishTarget(t),
475
+ onclick: (e) => finishTarget(t, e.currentTarget),
408
476
  },
409
477
  `Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
410
478
  ),
@@ -415,6 +483,7 @@ function targetCard(t) {
415
483
  // ---------------------------------------------------------------- screen: project grid
416
484
 
417
485
  async function openProject(target, project) {
486
+ const seq = ++nav;
418
487
  state.target = target;
419
488
  state.project = project;
420
489
  state.filter = "undecided";
@@ -429,6 +498,9 @@ async function openProject(target, project) {
429
498
  say(err.message, true);
430
499
  return;
431
500
  }
501
+ if (seq !== nav) {
502
+ return;
503
+ }
432
504
  // Nothing left to decide (or a local preview): show everything instead of an empty grid.
433
505
  if (visibleItems().length === 0) {
434
506
  state.filter = "all";
@@ -448,8 +520,8 @@ const thumbs = new IntersectionObserver((entries) => {
448
520
  for (const e of entries) {
449
521
  if (e.isIntersecting) {
450
522
  thumbs.unobserve(e.target);
451
- const { kind, file } = e.target.dataset;
452
- image(kind, file).then(
523
+ const { kind, file, hash } = e.target.dataset;
524
+ image(kind, file, hash).then(
453
525
  (url) => (e.target.src = url),
454
526
  (err) => (e.target.alt = err.message),
455
527
  );
@@ -470,7 +542,7 @@ function openItem(index) {
470
542
  function tile(item, number) {
471
543
  const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
472
544
  const img = kind
473
- ? el("img", { "data-kind": kind, "data-file": item.file, alt: itemName(item) })
545
+ ? el("img", { "data-kind": kind, "data-file": item.file, "data-hash": item[kind], alt: itemName(item) })
474
546
  : el("div", { class: "noimage" }, item.status);
475
547
  if (kind) {
476
548
  thumbs.observe(img);
@@ -547,11 +619,12 @@ function errorRow(item, number) {
547
619
  );
548
620
  }
549
621
 
550
- // How many items of `component` Accept would take without opening them.
551
- const undecidedIn = (component) =>
622
+ // The items of `component` Accept would take without opening them.
623
+ const undecided = (component) =>
552
624
  state.data.items.filter(
553
625
  (i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
554
- ).length;
626
+ );
627
+ const undecidedIn = (component) => undecided(component).length;
555
628
 
556
629
  // The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
557
630
  // decision not yet posted by Finish.
@@ -625,7 +698,9 @@ async function undo(files, where, one = false) {
625
698
 
626
699
  function showGrid() {
627
700
  stopFlash();
701
+ thumbs.disconnect();
628
702
  state.screen = "grid";
703
+ state.pending = "reject";
629
704
  setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
630
705
  const items = visibleItems();
631
706
  const counts = {};
@@ -851,20 +926,34 @@ function showStory() {
851
926
  }
852
927
  state.index = Math.max(0, Math.min(state.index, items.length - 1));
853
928
  const item = items[state.index];
929
+ if (item.file !== state.lastFile) {
930
+ // An Exclude (or reject) left waiting for a reason on another item is abandoned.
931
+ state.pending = "reject";
932
+ stageShown = null;
933
+ }
854
934
  state.lastFile = item.file;
855
935
  const d = decisionOf(item);
856
936
  if (d?.bulk && !isLocal()) {
857
- // Opening an item Accept all decided counts it as opened.
937
+ // Opening an item Accept all decided counts it as opened. `opened` never decides, so a stale
938
+ // grid cannot bring back an accept another tab undid or a Finish committed; the decisions
939
+ // are then read again, so such an item shows as undecided.
858
940
  delete d.bulk;
859
- api("/api/decide", { id: state.target.id, project: state.project, file: item.file, ...d }).catch((err) =>
860
- say(err.message, true),
861
- );
941
+ const { id } = state.target;
942
+ api("/api/decide", { id, project: state.project, file: item.file, opened: true, hash: imageHash(item) })
943
+ .then(reload)
944
+ .then(() => {
945
+ if (!decisionOf(item) && state.screen === "story" && state.lastFile === item.file) {
946
+ showStory();
947
+ say(`${itemName(item)}: its accept was undone or finished elsewhere; undecided now`);
948
+ }
949
+ })
950
+ .catch((err) => say(err.message, true));
862
951
  }
863
952
  const note = singleImageNote(item);
864
953
  const view = note ? "side" : state.view;
865
954
  const onlyExclude = item.status === "unstable" || item.status === "failed";
866
955
  const unseeded = item.status === UNSEEDED;
867
- const decidable = !isLocal() && !unseeded;
956
+ const decidable = !isLocal();
868
957
  setCrumbs(
869
958
  el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
870
959
  el("span", {}, itemName(item)),
@@ -905,11 +994,11 @@ function showStory() {
905
994
  const reason = el("input", {
906
995
  id: "reason",
907
996
  type: "text",
908
- placeholder: "Reason (needed to reject or exclude)",
997
+ placeholder: reasonHint(),
909
998
  value: d?.reason ?? "",
910
999
  maxlength: "2000",
911
1000
  onkeydown: (e) => {
912
- if (e.key === "Enter") {
1001
+ if (e.key === "Enter" && !e.repeat) {
913
1002
  decide(state.pending);
914
1003
  }
915
1004
  },
@@ -923,14 +1012,21 @@ function showStory() {
923
1012
  state.data.acceptable && !onlyExclude
924
1013
  ? el(
925
1014
  "button",
926
- { type: "button", class: "accept", onclick: () => decide("accept"), title: "A" },
1015
+ {
1016
+ type: "button",
1017
+ class: "accept",
1018
+ onclick: () => clicked("accept"),
1019
+ title: "A",
1020
+ // Until both images are shown: renderStage enables it.
1021
+ disabled: true,
1022
+ },
927
1023
  "Accept",
928
1024
  )
929
1025
  : null,
930
1026
  !onlyExclude
931
1027
  ? el(
932
1028
  "button",
933
- { type: "button", class: "reject", onclick: () => decide("reject"), title: "R" },
1029
+ { type: "button", class: "reject", onclick: () => clicked("reject"), title: "R" },
934
1030
  "Reject",
935
1031
  )
936
1032
  : null,
@@ -939,7 +1035,7 @@ function showStory() {
939
1035
  "button",
940
1036
  {
941
1037
  type: "button",
942
- onclick: () => decide("exclude"),
1038
+ onclick: () => clicked("exclude"),
943
1039
  title: "E: stop capturing every mode of this story",
944
1040
  },
945
1041
  "Exclude",
@@ -1025,6 +1121,20 @@ function showStory() {
1025
1121
  },
1026
1122
  "Blink",
1027
1123
  ),
1124
+ el(
1125
+ "button",
1126
+ {
1127
+ type: "button",
1128
+ "aria-pressed": String(state.spotFlash),
1129
+ disabled: view !== "spotlight",
1130
+ title:
1131
+ view === "spotlight"
1132
+ ? "F: flash the spotlighted baseline and new"
1133
+ : "Spotlight flash shows the spotlighted baseline and new one after the other",
1134
+ onclick: () => toggleOption("spotFlash"),
1135
+ },
1136
+ "Spotlight flash",
1137
+ ),
1028
1138
  note ? el("span", { id: "single-note", class: "meta" }, note) : null,
1029
1139
  el("span", { class: "spacer" }),
1030
1140
  ZOOMS.map(zoomButton),
@@ -1069,7 +1179,7 @@ function showStory() {
1069
1179
  "p",
1070
1180
  {},
1071
1181
  "No baseline yet, and this pull request does not change it: it looks as on master. " +
1072
- "Seed it from master's capture, or accept it on the pull request that changes it.",
1182
+ "Accepting it makes this image its first baseline.",
1073
1183
  )
1074
1184
  : null,
1075
1185
  decidable ? decisionButtons : null,
@@ -1080,37 +1190,35 @@ function showStory() {
1080
1190
  remember();
1081
1191
  }
1082
1192
 
1193
+ const reasonHint = () => `Reason (needed to reject or exclude); Enter ${state.pending}s`;
1194
+
1083
1195
  // The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
1084
- // boxes around each separate grown region. Computed once per item.
1196
+ // boxes around each separate grown region. Computed once per item and pair of images.
1085
1197
  async function diffOf(item) {
1086
- if (!diffs.has(item.file)) {
1087
- diffs.set(
1088
- item.file,
1089
- (async () => {
1090
- const [a, b] = await Promise.all([
1091
- loaded(await image("baseline", item.file)),
1092
- loaded(await image("capture", item.file)),
1093
- ]);
1094
- const w = Math.max(a.naturalWidth, b.naturalWidth);
1095
- const h = Math.max(a.naturalHeight, b.naturalHeight);
1096
- const pixels = (img) => {
1097
- const ctx = new OffscreenCanvas(w, h).getContext("2d");
1098
- ctx.drawImage(img, 0, 0);
1099
- return ctx.getImageData(0, 0, w, h).data;
1100
- };
1101
- const [pa, pb] = [pixels(a), pixels(b)];
1102
- const mask = new Uint8ClampedArray(w * h * 4);
1103
- pixelmatch(pa, pb, mask, w, h, {
1104
- threshold: item.threshold,
1105
- includeAA: item.includeAA,
1106
- diffMask: true,
1107
- });
1108
- const grown = grow(mask, w, h);
1109
- return { w, h, a: pa, b: pb, mask, grown, boxes: regions(grown, w, h) };
1110
- })(),
1111
- );
1112
- }
1113
- return diffs.get(item.file);
1198
+ const { file, baseline, capture, threshold, includeAA } = item;
1199
+ const key = `${state.target.id}/${state.project}/${file}/${baseline}/${capture}/${threshold}/${includeAA}`;
1200
+ return cached(diffs, key, DIFFS_KEPT, async () => {
1201
+ const [a, b] = await Promise.all([
1202
+ loaded(await image("baseline", item.file, item.baseline), `baseline of ${item.file}`),
1203
+ loaded(await image("capture", item.file, item.capture), `capture of ${item.file}`),
1204
+ ]);
1205
+ const w = Math.max(a.naturalWidth, b.naturalWidth);
1206
+ const h = Math.max(a.naturalHeight, b.naturalHeight);
1207
+ const pixels = (img) => {
1208
+ const ctx = new OffscreenCanvas(w, h).getContext("2d");
1209
+ ctx.drawImage(img, 0, 0);
1210
+ return ctx.getImageData(0, 0, w, h).data;
1211
+ };
1212
+ const [pa, pb] = [pixels(a), pixels(b)];
1213
+ const mask = new Uint8ClampedArray(w * h * 4);
1214
+ pixelmatch(pa, pb, mask, w, h, {
1215
+ threshold: item.threshold,
1216
+ includeAA: item.includeAA,
1217
+ diffMask: true,
1218
+ });
1219
+ const grown = grow(mask, w, h);
1220
+ return { w, h, a: pa, b: pb, mask, grown, boxes: regions(grown, w, h) };
1221
+ });
1114
1222
  }
1115
1223
 
1116
1224
  // A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
@@ -1212,6 +1320,7 @@ function fit(stage) {
1212
1320
  // Two panes, always: the baseline on the left and the new image (or the view's picture) on the
1213
1321
  // right. A missing image leaves its pane empty, the same size, so the other one never moves.
1214
1322
  async function renderStage(item, view, keep) {
1323
+ const seq = ++stageRender;
1215
1324
  const stage = document.getElementById("stage");
1216
1325
  const pane = (text, ...pics) =>
1217
1326
  el(
@@ -1225,7 +1334,7 @@ async function renderStage(item, view, keep) {
1225
1334
  ),
1226
1335
  );
1227
1336
  const imgOf = async (kind) => {
1228
- const img = await loaded(await image(kind, item.file));
1337
+ const img = await loaded(await image(kind, item.file, item[kind]), `${kind} of ${item.file}`);
1229
1338
  img.alt = `${kind} of ${itemName(item)}`;
1230
1339
  return img;
1231
1340
  };
@@ -1245,11 +1354,18 @@ async function renderStage(item, view, keep) {
1245
1354
  right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
1246
1355
  } else if (view === "side") {
1247
1356
  right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
1248
- } else if (view === "flash") {
1249
- // The two images themselves, one after the other in the same place: no overlay.
1250
- const [base, next] = [await imgOf("baseline"), await imgOf("capture")];
1357
+ } else if (view === "flash" || (view === "spotlight" && state.spotFlash)) {
1358
+ // The two images one after the other in the same place: themselves, or both spotlighted.
1359
+ const flash = view === "flash";
1360
+ const name = flash ? "Flash" : "Spotlight";
1361
+ const [base, next] = flash
1362
+ ? [await imgOf("baseline"), await imgOf("capture")]
1363
+ : [spotlight(diff, diff.a), spotlight(diff, diff.b)];
1364
+ if (seq !== stageRender) {
1365
+ return;
1366
+ }
1251
1367
  next.style.visibility = "hidden";
1252
- right = pane("Flash: baseline", base, next);
1368
+ right = pane(`${name}: baseline`, base, next);
1253
1369
  right.classList.add("flashing");
1254
1370
  const tag = right.querySelector(".label");
1255
1371
  let showingNew = false;
@@ -1257,14 +1373,23 @@ async function renderStage(item, view, keep) {
1257
1373
  showingNew = !showingNew;
1258
1374
  base.style.visibility = showingNew ? "hidden" : "visible";
1259
1375
  next.style.visibility = showingNew ? "visible" : "hidden";
1260
- tag.textContent = showingNew ? "Flash: new" : "Flash: baseline";
1376
+ tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
1261
1377
  }, FLASH_MS);
1262
1378
  } else if (view === "highlight") {
1263
1379
  right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
1264
1380
  } else {
1265
1381
  right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
1266
1382
  }
1383
+ if (seq !== stageRender) {
1384
+ // Another item (or view) was shown while this one loaded.
1385
+ return;
1386
+ }
1267
1387
  stage.replaceChildren(left, right);
1388
+ stageShown = item.file;
1389
+ const accept = document.querySelector(".actions button.accept");
1390
+ if (accept) {
1391
+ accept.disabled = false;
1392
+ }
1268
1393
  if (marked && state.blink) {
1269
1394
  // Both panes' overlays on and off together, at Flash's pace.
1270
1395
  const marks = [...stage.querySelectorAll(".diffmark")];
@@ -1307,7 +1432,9 @@ async function renderStage(item, view, keep) {
1307
1432
  }
1308
1433
  }
1309
1434
  } catch (err) {
1310
- stage.replaceChildren(el("p", { class: "error" }, err.message));
1435
+ if (seq === stageRender) {
1436
+ stage.replaceChildren(el("p", { class: "error" }, err.message || "the images could not be loaded"));
1437
+ }
1311
1438
  }
1312
1439
  }
1313
1440
 
@@ -1392,8 +1519,8 @@ function overlay(diff) {
1392
1519
  return canvas;
1393
1520
  }
1394
1521
 
1395
- // The new image with everything dimmed except the changed pixels grown by GROW pixels.
1396
- function spotlight(diff) {
1522
+ // An image (the new one unless given) with everything dimmed except the changed pixels grown by GROW pixels.
1523
+ function spotlight(diff, px = diff.b) {
1397
1524
  const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
1398
1525
  const ctx = canvas.getContext("2d");
1399
1526
  const out = ctx.createImageData(diff.w, diff.h);
@@ -1401,9 +1528,9 @@ function spotlight(diff) {
1401
1528
  for (let i = 0; i < diff.w * diff.h; i++) {
1402
1529
  const lit = diff.grown[i] === 1;
1403
1530
  for (let c = 0; c < 3; c++) {
1404
- out.data[i * 4 + c] = lit ? diff.b[i * 4 + c] : diff.b[i * 4 + c] * keep;
1531
+ out.data[i * 4 + c] = lit ? px[i * 4 + c] : px[i * 4 + c] * keep;
1405
1532
  }
1406
- out.data[i * 4 + 3] = lit ? diff.b[i * 4 + 3] : Math.max(diff.b[i * 4 + 3], SPOT_ALPHA);
1533
+ out.data[i * 4 + 3] = lit ? px[i * 4 + 3] : Math.max(px[i * 4 + 3], SPOT_ALPHA);
1407
1534
  }
1408
1535
  ctx.putImageData(out, 0, 0);
1409
1536
  return canvas;
@@ -1414,19 +1541,28 @@ async function acceptAll(component) {
1414
1541
  say(`${state.project} cannot be accepted here`, true);
1415
1542
  return;
1416
1543
  }
1417
- const n = undecidedIn(component);
1544
+ const items = undecided(component);
1545
+ const n = items.length;
1418
1546
  const where = component ? `the component ${component}` : state.project;
1419
1547
  if (n === 0) {
1420
1548
  say(`Nothing undecided to accept in ${where}.`);
1421
1549
  return;
1422
1550
  }
1423
- if (!confirm(`Accept ${n} undecided items of ${where} without opening them?`)) {
1551
+ // Accepting a removal deletes its baseline, so the question says how many it holds.
1552
+ const removals = items.filter((i) => i.status === "removed").length;
1553
+ const deletes =
1554
+ removals === 0
1555
+ ? ""
1556
+ : ` This includes ${removals} ${removals === 1 ? "removal" : "removals"}: accepting deletes ${removals === 1 ? "its baseline" : "their baselines"}.`;
1557
+ if (!(await ask(`Accept ${n} undecided items of ${where} without opening them?${deletes}`, "Accept"))) {
1424
1558
  return;
1425
1559
  }
1426
1560
  try {
1427
1561
  await api("/api/accept-all", {
1428
1562
  id: state.target.id,
1429
1563
  project: state.project,
1564
+ runId: state.data.target.runId,
1565
+ runAttempt: state.data.target.runAttempt,
1430
1566
  ...(component ? { component } : {}),
1431
1567
  });
1432
1568
  await reload();
@@ -1451,6 +1587,10 @@ async function decide(decision) {
1451
1587
  say("A local preview is only looked at: nothing is decided on it.", true);
1452
1588
  return;
1453
1589
  }
1590
+ if (deciding) {
1591
+ // A second click or key while the first decision is on its way.
1592
+ return;
1593
+ }
1454
1594
  const items = passItems();
1455
1595
  const item = items[state.index];
1456
1596
  const before = decisionOf(item);
@@ -1463,23 +1603,32 @@ async function decide(decision) {
1463
1603
  say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
1464
1604
  return;
1465
1605
  }
1606
+ if (decision === "accept" && stageShown !== item.file) {
1607
+ say(`${itemName(item)}: wait for both images before accepting.`, true);
1608
+ return;
1609
+ }
1466
1610
  const reasonBox = document.getElementById("reason");
1467
1611
  const reason = reasonBox?.value.trim() ?? "";
1468
1612
  if ((decision === "reject" || decision === "exclude") && reason === "") {
1469
1613
  state.pending = decision;
1470
- reasonBox?.focus();
1614
+ if (reasonBox) {
1615
+ reasonBox.placeholder = reasonHint();
1616
+ reasonBox.focus();
1617
+ }
1471
1618
  say(`A ${decision} needs a reason: type it, then press Enter.`);
1472
1619
  return;
1473
1620
  }
1474
1621
  if (
1475
1622
  decision === "exclude" &&
1476
- !confirm(
1623
+ !(await ask(
1477
1624
  `Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
1478
1625
  "until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
1479
- )
1626
+ "Exclude",
1627
+ ))
1480
1628
  ) {
1481
1629
  return;
1482
1630
  }
1631
+ deciding = true;
1483
1632
  try {
1484
1633
  await api("/api/decide", {
1485
1634
  id: state.target.id,
@@ -1487,10 +1636,13 @@ async function decide(decision) {
1487
1636
  file: item.file,
1488
1637
  decision,
1489
1638
  reason: reason === "" ? null : reason,
1639
+ hash: imageHash(item),
1490
1640
  });
1491
1641
  } catch (err) {
1492
1642
  say(err.message, true);
1493
1643
  return;
1644
+ } finally {
1645
+ deciding = false;
1494
1646
  }
1495
1647
  if (decision === null) {
1496
1648
  delete state.data.decisions[item.file];
@@ -1508,6 +1660,15 @@ async function decide(decision) {
1508
1660
  showStory();
1509
1661
  }
1510
1662
 
1663
+ // A decision button's click; a double click's second one is not a decision on the next item.
1664
+ function clicked(decision) {
1665
+ const now = performance.now();
1666
+ if (now - clickedAt >= DOUBLE_CLICK_MS) {
1667
+ clickedAt = now;
1668
+ decide(decision);
1669
+ }
1670
+ }
1671
+
1511
1672
  // ---------------------------------------------------------------- Finish
1512
1673
 
1513
1674
  function finishButton() {
@@ -1516,24 +1677,36 @@ function finishButton() {
1516
1677
  }
1517
1678
  return el(
1518
1679
  "button",
1519
- { type: "button", class: "primary", disabled: running(), onclick: () => finishTarget(state.target) },
1680
+ {
1681
+ type: "button",
1682
+ class: "primary",
1683
+ disabled: running(),
1684
+ onclick: (e) => finishTarget(state.target, e.currentTarget),
1685
+ },
1520
1686
  `Finish ${targetLabel()}`,
1521
1687
  );
1522
1688
  }
1523
1689
 
1524
- async function finishTarget(target) {
1690
+ // `button` is the Finish button pressed: off from the press until Finish is started or given up,
1691
+ // so the press shows at once, even while the server is slow to answer.
1692
+ async function finishTarget(target, button) {
1693
+ const label = target.pr === null ? "the master seed" : `#${target.pr}`;
1525
1694
  const what =
1526
1695
  target.pr === null ? "push a seed branch and open its pull request" : `commit and push to ${target.branch}`;
1696
+ const giveUp = (text, isError) => {
1697
+ button.disabled = running();
1698
+ say(text, isError);
1699
+ };
1700
+ button.disabled = true;
1701
+ say(`Checking ${label} before Finish...`);
1527
1702
  let fresh;
1528
1703
  try {
1529
1704
  fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
1530
1705
  } catch (err) {
1531
- say(err.message, true);
1706
+ giveUp(`Finish not started: ${err.message}`, true);
1532
1707
  return;
1533
1708
  }
1534
- const lines = [
1535
- `Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
1536
- ];
1709
+ const lines = [`Finish ${label}: ${what}, across every project?`];
1537
1710
  const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
1538
1711
  if (notOpened > 0) {
1539
1712
  lines.push(`${notOpened} accepted without being opened.`);
@@ -1551,7 +1724,9 @@ async function finishTarget(target) {
1551
1724
  );
1552
1725
  }
1553
1726
  lines.push("One commit status is posted when Finish completes.");
1554
- if (!confirm(lines.join("\n\n"))) {
1727
+ say(`Finish ${label}? Answer in the box.`);
1728
+ if (!(await ask(lines.join("\n\n"), `Finish ${label}`))) {
1729
+ giveUp("Finish cancelled: nothing was changed.");
1555
1730
  return;
1556
1731
  }
1557
1732
  // Finish runs on the server and can take minutes; the page only starts it and then asks how it
@@ -1565,7 +1740,7 @@ async function finishTarget(target) {
1565
1740
  .then((s) => s.job)
1566
1741
  .catch(() => null);
1567
1742
  if (!running() || state.job.target !== target.id) {
1568
- say("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
1743
+ giveUp("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
1569
1744
  app.prepend(el("pre", { class: "error" }, err.message));
1570
1745
  return;
1571
1746
  }
@@ -1589,7 +1764,7 @@ async function watchFinish() {
1589
1764
  say(finishing());
1590
1765
  await sleep(1000);
1591
1766
  try {
1592
- state.job = (await api("/api/finish-status")).job;
1767
+ track((await api("/api/finish-status")).job);
1593
1768
  } catch (err) {
1594
1769
  say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
1595
1770
  await sleep(2000);
@@ -1601,6 +1776,25 @@ async function watchFinish() {
1601
1776
  await showTargets();
1602
1777
  }
1603
1778
 
1779
+ // Takes the server's newest Finish. A server restarted during a Finish says it was interrupted;
1780
+ // one that could not save the Finish knows of none, so the page keeps the one it was following,
1781
+ // as interrupted, rather than showing the targets as if nothing had run.
1782
+ function track(job) {
1783
+ const last = state.job;
1784
+ if (!job && last?.running) {
1785
+ job = {
1786
+ ...last,
1787
+ running: false,
1788
+ step: null,
1789
+ interrupted: true,
1790
+ error:
1791
+ `the server restarted while this Finish was at "${last.step}" and kept no record of it: check ` +
1792
+ `whether ${last.branch ?? "the visual/seed-* branch"} on origin has its commit before pressing Finish again`,
1793
+ };
1794
+ }
1795
+ state.job = !job && last?.interrupted ? last : job;
1796
+ }
1797
+
1604
1798
  // What the newest Finish did, shown above the targets until the server restarts.
1605
1799
  function finishOutcome() {
1606
1800
  const job = state.job;
@@ -1608,6 +1802,18 @@ function finishOutcome() {
1608
1802
  return null;
1609
1803
  }
1610
1804
  const label = job.pr === null ? "the master seed" : `#${job.pr}`;
1805
+ if (job.interrupted) {
1806
+ return el(
1807
+ "section",
1808
+ { class: "card finish-outcome" },
1809
+ el(
1810
+ "p",
1811
+ { class: "error" },
1812
+ `Finish of ${label} was interrupted: the server restarted while it ran. Your decisions are kept.`,
1813
+ ),
1814
+ el("pre", { class: "error" }, job.error),
1815
+ );
1816
+ }
1611
1817
  if (job.error !== null) {
1612
1818
  return el(
1613
1819
  "section",
@@ -1642,7 +1848,7 @@ function finishOutcome() {
1642
1848
  // ---------------------------------------------------------------- the address
1643
1849
 
1644
1850
  // Every screen is in the address, after the session token, so a copied link opens it again:
1645
- // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off
1851
+ // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off&flash=off
1646
1852
  // Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
1647
1853
  function hashFor() {
1648
1854
  const p = new URLSearchParams({ token });
@@ -1660,6 +1866,7 @@ function hashFor() {
1660
1866
  p.set("zoom", String(state.zoom));
1661
1867
  p.set("box", state.showBox ? "on" : "off");
1662
1868
  p.set("blink", state.blink ? "on" : "off");
1869
+ p.set("flash", state.spotFlash ? "on" : "off");
1663
1870
  }
1664
1871
  return `#${p}`;
1665
1872
  }
@@ -1677,7 +1884,7 @@ function remember() {
1677
1884
  screen !== state.screen ||
1678
1885
  (screen !== "targets" &&
1679
1886
  (was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
1680
- const push = !routing && moved;
1887
+ const push = routes === 0 && moved;
1681
1888
  history[push ? "pushState" : "replaceState"](null, "", hash);
1682
1889
  }
1683
1890
 
@@ -1685,14 +1892,15 @@ function remember() {
1685
1892
  // from a new CI run) lands on the nearest screen that does, with a line saying so.
1686
1893
  async function route() {
1687
1894
  const p = new URLSearchParams(location.hash.slice(1));
1688
- routing = true;
1895
+ const seq = ++nav;
1896
+ routes++;
1689
1897
  try {
1690
1898
  const id = p.get("target");
1691
1899
  if (!id) {
1692
1900
  await showTargets();
1693
1901
  return;
1694
1902
  }
1695
- if (!(await loadTargets())) {
1903
+ if (!(await loadTargets()) || seq !== nav) {
1696
1904
  return;
1697
1905
  }
1698
1906
  const target = state.targets.find((t) => String(t.id) === id);
@@ -1704,12 +1912,18 @@ async function route() {
1704
1912
  }
1705
1913
  if (state.data === null || state.target?.id !== target.id || state.project !== project) {
1706
1914
  state.sequence = [];
1915
+ state.data = null;
1707
1916
  state.target = target;
1708
1917
  state.project = project;
1709
1918
  try {
1710
1919
  await reload();
1711
1920
  } catch (err) {
1712
- say(err.message, true);
1921
+ // A project whose download (or target) failed: its problem is on the targets screen.
1922
+ const problem = target.projects.find((x) => x.project === project).problem;
1923
+ await showTargets(`${project} of ${id} could not be opened: ${problem ?? err.message}`);
1924
+ return;
1925
+ }
1926
+ if (seq !== nav) {
1713
1927
  return;
1714
1928
  }
1715
1929
  }
@@ -1736,18 +1950,21 @@ async function route() {
1736
1950
  state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
1737
1951
  const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
1738
1952
  state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
1739
- // A link's box and blink apply to this page; the browser's remembered choice is unchanged.
1953
+ // A link's box, blink and flash apply to this page; the browser's remembered choice is unchanged.
1740
1954
  if (["on", "off"].includes(p.get("box"))) {
1741
1955
  state.showBox = p.get("box") === "on";
1742
1956
  }
1743
1957
  if (["on", "off"].includes(p.get("blink"))) {
1744
1958
  state.blink = p.get("blink") === "on";
1745
1959
  }
1960
+ if (["on", "off"].includes(p.get("flash"))) {
1961
+ state.spotFlash = p.get("flash") === "on";
1962
+ }
1746
1963
  state.box = 0;
1747
1964
  say("");
1748
1965
  showStory();
1749
1966
  } finally {
1750
- routing = false;
1967
+ routes--;
1751
1968
  }
1752
1969
  }
1753
1970
 
@@ -1772,7 +1989,9 @@ document.addEventListener("focusout", () => setTimeout(keepKeys));
1772
1989
  keepKeys();
1773
1990
 
1774
1991
  document.addEventListener("keydown", (e) => {
1775
- if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
1992
+ // An open question (ask) takes the keys: Escape cancels it.
1993
+ const asking = document.querySelector("dialog[open]") !== null;
1994
+ if (asking || !["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
1776
1995
  return;
1777
1996
  }
1778
1997
  const inInput = e.target instanceof HTMLInputElement;
@@ -1819,7 +2038,8 @@ document.addEventListener("keydown", (e) => {
1819
2038
  r: () => decide("reject"),
1820
2039
  e: () => decide("exclude"),
1821
2040
  u: () => decide(null),
1822
- f: () => toggleView("flash"),
2041
+ // In Spotlight, F flashes the spotlighted baseline and new instead of leaving it.
2042
+ f: () => (state.view === "spotlight" ? toggleOption("spotFlash") : toggleView("flash")),
1823
2043
  h: () => toggleView("highlight"),
1824
2044
  s: () => toggleView("spotlight"),
1825
2045
  b: () => toggleOption("showBox"),
@@ -1835,6 +2055,11 @@ document.addEventListener("keydown", (e) => {
1835
2055
  },
1836
2056
  };
1837
2057
  const action = keys[key];
2058
+ if (e.repeat && ["a", "r", "e", "u"].includes(key)) {
2059
+ // A held decision key decides once, never the items after it unseen.
2060
+ e.preventDefault();
2061
+ return;
2062
+ }
1838
2063
  if (action) {
1839
2064
  e.preventDefault();
1840
2065
  action();