@graphty/visual-review 0.2.0 → 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.
@@ -51,6 +51,7 @@ const state = {
51
51
  box: 0, // which changed box "next changed box" is on
52
52
  showBox: saved.showBox ?? true, // outline the changed box (B)
53
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)
54
55
  held: null, // the view to return to when Space is released
55
56
  pending: "reject", // what Enter in the reason box does
56
57
  screen: "targets",
@@ -60,9 +61,21 @@ const state = {
60
61
  const running = () => state.job?.running === true;
61
62
  const VIEWS = ["side", "flash", "highlight", "spotlight"];
62
63
  const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
63
- let routing = false; // true while the page follows the address (a link opened, Back, Forward)
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.
64
68
  const images = new Map();
69
+ const IMAGES_KEPT = 50;
65
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
66
79
  let flashTimer = null;
67
80
  let factor = 1; // CSS pixels per image pixel of the pictures on the stage
68
81
  let shownBoxes = []; // the changed boxes of the item on the stage
@@ -103,6 +116,32 @@ function say(text, isError = false) {
103
116
  statusLine.className = isError ? "error" : "";
104
117
  }
105
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
+
106
145
  async function api(path, body) {
107
146
  const res = await fetch(path, {
108
147
  method: body ? "POST" : "GET",
@@ -116,29 +155,55 @@ async function api(path, body) {
116
155
  return json;
117
156
  }
118
157
 
119
- // Loads an image through the API (it needs the token header) and returns an object URL.
120
- function image(kind, file) {
121
- const key = `${state.target.id}/${state.project}/${kind}/${file}`;
122
- if (!images.has(key)) {
123
- const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
124
- images.set(
125
- key,
126
- 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) => {
127
192
  if (!res.ok) {
128
193
  throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
129
194
  }
130
195
  return URL.createObjectURL(await res.blob());
131
- }),
132
- );
133
- }
134
- return images.get(key);
196
+ });
197
+ },
198
+ (url) => url.then(URL.revokeObjectURL, () => {}),
199
+ );
135
200
  }
136
201
 
137
- function loaded(url) {
202
+ function loaded(url, what) {
138
203
  return new Promise((resolve, reject) => {
139
204
  const img = new Image();
140
205
  img.onload = () => resolve(img);
141
- img.onerror = reject;
206
+ img.onerror = () => reject(new Error(`${what}: the image could not be shown`));
142
207
  img.src = url;
143
208
  });
144
209
  }
@@ -149,6 +214,9 @@ const itemName = (item) => (item.mode ? `${item.id} (${item.mode})` : item.id);
149
214
  const movedFrom = (item) => (item.from ? `moved from ${item.from}` : "");
150
215
  const short = (sha) => (sha ? sha.slice(0, 10) : "none");
151
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;
152
220
  const isLocal = () => state.target?.local === true;
153
221
  // Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
154
222
  const scale = () => state.data?.results.scale ?? 1;
@@ -228,7 +296,10 @@ function loadOptions() {
228
296
 
229
297
  function saveOptions() {
230
298
  try {
231
- 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
+ );
232
303
  } catch {
233
304
  // Not remembered; the choice still holds for this page.
234
305
  }
@@ -247,7 +318,7 @@ async function loadTargets() {
247
318
  try {
248
319
  const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
249
320
  state.targets = prs.targets;
250
- state.job = status.job;
321
+ track(status.job);
251
322
  say("");
252
323
  if (running()) {
253
324
  // A reload during a Finish, on any screen: follow the running one, never offer a second.
@@ -261,10 +332,11 @@ async function loadTargets() {
261
332
  }
262
333
 
263
334
  async function showTargets(notice = "") {
335
+ const seq = ++nav;
264
336
  stopFlash();
265
337
  state.screen = "targets";
266
338
  setCrumbs();
267
- if (!(await loadTargets())) {
339
+ if (!(await loadTargets()) || seq !== nav) {
268
340
  return;
269
341
  }
270
342
  remember();
@@ -400,7 +472,7 @@ function targetCard(t) {
400
472
  type: "button",
401
473
  class: "primary",
402
474
  disabled: decided === 0 || running(),
403
- onclick: () => finishTarget(t),
475
+ onclick: (e) => finishTarget(t, e.currentTarget),
404
476
  },
405
477
  `Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
406
478
  ),
@@ -411,6 +483,7 @@ function targetCard(t) {
411
483
  // ---------------------------------------------------------------- screen: project grid
412
484
 
413
485
  async function openProject(target, project) {
486
+ const seq = ++nav;
414
487
  state.target = target;
415
488
  state.project = project;
416
489
  state.filter = "undecided";
@@ -425,6 +498,9 @@ async function openProject(target, project) {
425
498
  say(err.message, true);
426
499
  return;
427
500
  }
501
+ if (seq !== nav) {
502
+ return;
503
+ }
428
504
  // Nothing left to decide (or a local preview): show everything instead of an empty grid.
429
505
  if (visibleItems().length === 0) {
430
506
  state.filter = "all";
@@ -444,8 +520,8 @@ const thumbs = new IntersectionObserver((entries) => {
444
520
  for (const e of entries) {
445
521
  if (e.isIntersecting) {
446
522
  thumbs.unobserve(e.target);
447
- const { kind, file } = e.target.dataset;
448
- image(kind, file).then(
523
+ const { kind, file, hash } = e.target.dataset;
524
+ image(kind, file, hash).then(
449
525
  (url) => (e.target.src = url),
450
526
  (err) => (e.target.alt = err.message),
451
527
  );
@@ -466,7 +542,7 @@ function openItem(index) {
466
542
  function tile(item, number) {
467
543
  const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
468
544
  const img = kind
469
- ? 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) })
470
546
  : el("div", { class: "noimage" }, item.status);
471
547
  if (kind) {
472
548
  thumbs.observe(img);
@@ -543,11 +619,12 @@ function errorRow(item, number) {
543
619
  );
544
620
  }
545
621
 
546
- // How many items of `component` Accept would take without opening them.
547
- const undecidedIn = (component) =>
622
+ // The items of `component` Accept would take without opening them.
623
+ const undecided = (component) =>
548
624
  state.data.items.filter(
549
625
  (i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
550
- ).length;
626
+ );
627
+ const undecidedIn = (component) => undecided(component).length;
551
628
 
552
629
  // The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
553
630
  // decision not yet posted by Finish.
@@ -621,7 +698,9 @@ async function undo(files, where, one = false) {
621
698
 
622
699
  function showGrid() {
623
700
  stopFlash();
701
+ thumbs.disconnect();
624
702
  state.screen = "grid";
703
+ state.pending = "reject";
625
704
  setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
626
705
  const items = visibleItems();
627
706
  const counts = {};
@@ -847,14 +926,28 @@ function showStory() {
847
926
  }
848
927
  state.index = Math.max(0, Math.min(state.index, items.length - 1));
849
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
+ }
850
934
  state.lastFile = item.file;
851
935
  const d = decisionOf(item);
852
936
  if (d?.bulk && !isLocal()) {
853
- // 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.
854
940
  delete d.bulk;
855
- api("/api/decide", { id: state.target.id, project: state.project, file: item.file, ...d }).catch((err) =>
856
- say(err.message, true),
857
- );
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));
858
951
  }
859
952
  const note = singleImageNote(item);
860
953
  const view = note ? "side" : state.view;
@@ -901,11 +994,11 @@ function showStory() {
901
994
  const reason = el("input", {
902
995
  id: "reason",
903
996
  type: "text",
904
- placeholder: "Reason (needed to reject or exclude)",
997
+ placeholder: reasonHint(),
905
998
  value: d?.reason ?? "",
906
999
  maxlength: "2000",
907
1000
  onkeydown: (e) => {
908
- if (e.key === "Enter") {
1001
+ if (e.key === "Enter" && !e.repeat) {
909
1002
  decide(state.pending);
910
1003
  }
911
1004
  },
@@ -919,14 +1012,21 @@ function showStory() {
919
1012
  state.data.acceptable && !onlyExclude
920
1013
  ? el(
921
1014
  "button",
922
- { 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
+ },
923
1023
  "Accept",
924
1024
  )
925
1025
  : null,
926
1026
  !onlyExclude
927
1027
  ? el(
928
1028
  "button",
929
- { type: "button", class: "reject", onclick: () => decide("reject"), title: "R" },
1029
+ { type: "button", class: "reject", onclick: () => clicked("reject"), title: "R" },
930
1030
  "Reject",
931
1031
  )
932
1032
  : null,
@@ -935,7 +1035,7 @@ function showStory() {
935
1035
  "button",
936
1036
  {
937
1037
  type: "button",
938
- onclick: () => decide("exclude"),
1038
+ onclick: () => clicked("exclude"),
939
1039
  title: "E: stop capturing every mode of this story",
940
1040
  },
941
1041
  "Exclude",
@@ -1021,6 +1121,20 @@ function showStory() {
1021
1121
  },
1022
1122
  "Blink",
1023
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
+ ),
1024
1138
  note ? el("span", { id: "single-note", class: "meta" }, note) : null,
1025
1139
  el("span", { class: "spacer" }),
1026
1140
  ZOOMS.map(zoomButton),
@@ -1076,37 +1190,35 @@ function showStory() {
1076
1190
  remember();
1077
1191
  }
1078
1192
 
1193
+ const reasonHint = () => `Reason (needed to reject or exclude); Enter ${state.pending}s`;
1194
+
1079
1195
  // The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
1080
- // 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.
1081
1197
  async function diffOf(item) {
1082
- if (!diffs.has(item.file)) {
1083
- diffs.set(
1084
- item.file,
1085
- (async () => {
1086
- const [a, b] = await Promise.all([
1087
- loaded(await image("baseline", item.file)),
1088
- loaded(await image("capture", item.file)),
1089
- ]);
1090
- const w = Math.max(a.naturalWidth, b.naturalWidth);
1091
- const h = Math.max(a.naturalHeight, b.naturalHeight);
1092
- const pixels = (img) => {
1093
- const ctx = new OffscreenCanvas(w, h).getContext("2d");
1094
- ctx.drawImage(img, 0, 0);
1095
- return ctx.getImageData(0, 0, w, h).data;
1096
- };
1097
- const [pa, pb] = [pixels(a), pixels(b)];
1098
- const mask = new Uint8ClampedArray(w * h * 4);
1099
- pixelmatch(pa, pb, mask, w, h, {
1100
- threshold: item.threshold,
1101
- includeAA: item.includeAA,
1102
- diffMask: true,
1103
- });
1104
- const grown = grow(mask, w, h);
1105
- return { w, h, a: pa, b: pb, mask, grown, boxes: regions(grown, w, h) };
1106
- })(),
1107
- );
1108
- }
1109
- 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
+ });
1110
1222
  }
1111
1223
 
1112
1224
  // A square dilation by GROW pixels, as two passes (rows, then columns) of a sliding window.
@@ -1208,6 +1320,7 @@ function fit(stage) {
1208
1320
  // Two panes, always: the baseline on the left and the new image (or the view's picture) on the
1209
1321
  // right. A missing image leaves its pane empty, the same size, so the other one never moves.
1210
1322
  async function renderStage(item, view, keep) {
1323
+ const seq = ++stageRender;
1211
1324
  const stage = document.getElementById("stage");
1212
1325
  const pane = (text, ...pics) =>
1213
1326
  el(
@@ -1221,7 +1334,7 @@ async function renderStage(item, view, keep) {
1221
1334
  ),
1222
1335
  );
1223
1336
  const imgOf = async (kind) => {
1224
- 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}`);
1225
1338
  img.alt = `${kind} of ${itemName(item)}`;
1226
1339
  return img;
1227
1340
  };
@@ -1241,11 +1354,18 @@ async function renderStage(item, view, keep) {
1241
1354
  right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
1242
1355
  } else if (view === "side") {
1243
1356
  right = pane(item.baseline ? "New" : "New (no baseline)", await imgOf("capture"));
1244
- } else if (view === "flash") {
1245
- // The two images themselves, one after the other in the same place: no overlay.
1246
- 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
+ }
1247
1367
  next.style.visibility = "hidden";
1248
- right = pane("Flash: baseline", base, next);
1368
+ right = pane(`${name}: baseline`, base, next);
1249
1369
  right.classList.add("flashing");
1250
1370
  const tag = right.querySelector(".label");
1251
1371
  let showingNew = false;
@@ -1253,14 +1373,23 @@ async function renderStage(item, view, keep) {
1253
1373
  showingNew = !showingNew;
1254
1374
  base.style.visibility = showingNew ? "hidden" : "visible";
1255
1375
  next.style.visibility = showingNew ? "visible" : "hidden";
1256
- tag.textContent = showingNew ? "Flash: new" : "Flash: baseline";
1376
+ tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
1257
1377
  }, FLASH_MS);
1258
1378
  } else if (view === "highlight") {
1259
1379
  right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
1260
1380
  } else {
1261
1381
  right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
1262
1382
  }
1383
+ if (seq !== stageRender) {
1384
+ // Another item (or view) was shown while this one loaded.
1385
+ return;
1386
+ }
1263
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
+ }
1264
1393
  if (marked && state.blink) {
1265
1394
  // Both panes' overlays on and off together, at Flash's pace.
1266
1395
  const marks = [...stage.querySelectorAll(".diffmark")];
@@ -1303,7 +1432,9 @@ async function renderStage(item, view, keep) {
1303
1432
  }
1304
1433
  }
1305
1434
  } catch (err) {
1306
- 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
+ }
1307
1438
  }
1308
1439
  }
1309
1440
 
@@ -1388,8 +1519,8 @@ function overlay(diff) {
1388
1519
  return canvas;
1389
1520
  }
1390
1521
 
1391
- // The new image with everything dimmed except the changed pixels grown by GROW pixels.
1392
- 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) {
1393
1524
  const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
1394
1525
  const ctx = canvas.getContext("2d");
1395
1526
  const out = ctx.createImageData(diff.w, diff.h);
@@ -1397,9 +1528,9 @@ function spotlight(diff) {
1397
1528
  for (let i = 0; i < diff.w * diff.h; i++) {
1398
1529
  const lit = diff.grown[i] === 1;
1399
1530
  for (let c = 0; c < 3; c++) {
1400
- 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;
1401
1532
  }
1402
- 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);
1403
1534
  }
1404
1535
  ctx.putImageData(out, 0, 0);
1405
1536
  return canvas;
@@ -1410,19 +1541,28 @@ async function acceptAll(component) {
1410
1541
  say(`${state.project} cannot be accepted here`, true);
1411
1542
  return;
1412
1543
  }
1413
- const n = undecidedIn(component);
1544
+ const items = undecided(component);
1545
+ const n = items.length;
1414
1546
  const where = component ? `the component ${component}` : state.project;
1415
1547
  if (n === 0) {
1416
1548
  say(`Nothing undecided to accept in ${where}.`);
1417
1549
  return;
1418
1550
  }
1419
- 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"))) {
1420
1558
  return;
1421
1559
  }
1422
1560
  try {
1423
1561
  await api("/api/accept-all", {
1424
1562
  id: state.target.id,
1425
1563
  project: state.project,
1564
+ runId: state.data.target.runId,
1565
+ runAttempt: state.data.target.runAttempt,
1426
1566
  ...(component ? { component } : {}),
1427
1567
  });
1428
1568
  await reload();
@@ -1447,6 +1587,10 @@ async function decide(decision) {
1447
1587
  say("A local preview is only looked at: nothing is decided on it.", true);
1448
1588
  return;
1449
1589
  }
1590
+ if (deciding) {
1591
+ // A second click or key while the first decision is on its way.
1592
+ return;
1593
+ }
1450
1594
  const items = passItems();
1451
1595
  const item = items[state.index];
1452
1596
  const before = decisionOf(item);
@@ -1459,23 +1603,32 @@ async function decide(decision) {
1459
1603
  say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
1460
1604
  return;
1461
1605
  }
1606
+ if (decision === "accept" && stageShown !== item.file) {
1607
+ say(`${itemName(item)}: wait for both images before accepting.`, true);
1608
+ return;
1609
+ }
1462
1610
  const reasonBox = document.getElementById("reason");
1463
1611
  const reason = reasonBox?.value.trim() ?? "";
1464
1612
  if ((decision === "reject" || decision === "exclude") && reason === "") {
1465
1613
  state.pending = decision;
1466
- reasonBox?.focus();
1614
+ if (reasonBox) {
1615
+ reasonBox.placeholder = reasonHint();
1616
+ reasonBox.focus();
1617
+ }
1467
1618
  say(`A ${decision} needs a reason: type it, then press Enter.`);
1468
1619
  return;
1469
1620
  }
1470
1621
  if (
1471
1622
  decision === "exclude" &&
1472
- !confirm(
1623
+ !(await ask(
1473
1624
  `Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
1474
1625
  "until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
1475
- )
1626
+ "Exclude",
1627
+ ))
1476
1628
  ) {
1477
1629
  return;
1478
1630
  }
1631
+ deciding = true;
1479
1632
  try {
1480
1633
  await api("/api/decide", {
1481
1634
  id: state.target.id,
@@ -1483,10 +1636,13 @@ async function decide(decision) {
1483
1636
  file: item.file,
1484
1637
  decision,
1485
1638
  reason: reason === "" ? null : reason,
1639
+ hash: imageHash(item),
1486
1640
  });
1487
1641
  } catch (err) {
1488
1642
  say(err.message, true);
1489
1643
  return;
1644
+ } finally {
1645
+ deciding = false;
1490
1646
  }
1491
1647
  if (decision === null) {
1492
1648
  delete state.data.decisions[item.file];
@@ -1504,6 +1660,15 @@ async function decide(decision) {
1504
1660
  showStory();
1505
1661
  }
1506
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
+
1507
1672
  // ---------------------------------------------------------------- Finish
1508
1673
 
1509
1674
  function finishButton() {
@@ -1512,24 +1677,36 @@ function finishButton() {
1512
1677
  }
1513
1678
  return el(
1514
1679
  "button",
1515
- { 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
+ },
1516
1686
  `Finish ${targetLabel()}`,
1517
1687
  );
1518
1688
  }
1519
1689
 
1520
- 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}`;
1521
1694
  const what =
1522
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...`);
1523
1702
  let fresh;
1524
1703
  try {
1525
1704
  fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
1526
1705
  } catch (err) {
1527
- say(err.message, true);
1706
+ giveUp(`Finish not started: ${err.message}`, true);
1528
1707
  return;
1529
1708
  }
1530
- const lines = [
1531
- `Finish ${target.pr === null ? "the master seed" : `#${target.pr}`}: ${what}, across every project?`,
1532
- ];
1709
+ const lines = [`Finish ${label}: ${what}, across every project?`];
1533
1710
  const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
1534
1711
  if (notOpened > 0) {
1535
1712
  lines.push(`${notOpened} accepted without being opened.`);
@@ -1547,7 +1724,9 @@ async function finishTarget(target) {
1547
1724
  );
1548
1725
  }
1549
1726
  lines.push("One commit status is posted when Finish completes.");
1550
- 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.");
1551
1730
  return;
1552
1731
  }
1553
1732
  // Finish runs on the server and can take minutes; the page only starts it and then asks how it
@@ -1561,7 +1740,7 @@ async function finishTarget(target) {
1561
1740
  .then((s) => s.job)
1562
1741
  .catch(() => null);
1563
1742
  if (!running() || state.job.target !== target.id) {
1564
- 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);
1565
1744
  app.prepend(el("pre", { class: "error" }, err.message));
1566
1745
  return;
1567
1746
  }
@@ -1585,7 +1764,7 @@ async function watchFinish() {
1585
1764
  say(finishing());
1586
1765
  await sleep(1000);
1587
1766
  try {
1588
- state.job = (await api("/api/finish-status")).job;
1767
+ track((await api("/api/finish-status")).job);
1589
1768
  } catch (err) {
1590
1769
  say(`Lost contact with the server (${err.message}); Finish goes on there. Retrying...`, true);
1591
1770
  await sleep(2000);
@@ -1597,6 +1776,25 @@ async function watchFinish() {
1597
1776
  await showTargets();
1598
1777
  }
1599
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
+
1600
1798
  // What the newest Finish did, shown above the targets until the server restarts.
1601
1799
  function finishOutcome() {
1602
1800
  const job = state.job;
@@ -1604,6 +1802,18 @@ function finishOutcome() {
1604
1802
  return null;
1605
1803
  }
1606
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
+ }
1607
1817
  if (job.error !== null) {
1608
1818
  return el(
1609
1819
  "section",
@@ -1638,7 +1848,7 @@ function finishOutcome() {
1638
1848
  // ---------------------------------------------------------------- the address
1639
1849
 
1640
1850
  // Every screen is in the address, after the session token, so a copied link opens it again:
1641
- // #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
1642
1852
  // Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
1643
1853
  function hashFor() {
1644
1854
  const p = new URLSearchParams({ token });
@@ -1656,6 +1866,7 @@ function hashFor() {
1656
1866
  p.set("zoom", String(state.zoom));
1657
1867
  p.set("box", state.showBox ? "on" : "off");
1658
1868
  p.set("blink", state.blink ? "on" : "off");
1869
+ p.set("flash", state.spotFlash ? "on" : "off");
1659
1870
  }
1660
1871
  return `#${p}`;
1661
1872
  }
@@ -1673,7 +1884,7 @@ function remember() {
1673
1884
  screen !== state.screen ||
1674
1885
  (screen !== "targets" &&
1675
1886
  (was.get("target") !== String(state.target.id) || was.get("project") !== state.project));
1676
- const push = !routing && moved;
1887
+ const push = routes === 0 && moved;
1677
1888
  history[push ? "pushState" : "replaceState"](null, "", hash);
1678
1889
  }
1679
1890
 
@@ -1681,14 +1892,15 @@ function remember() {
1681
1892
  // from a new CI run) lands on the nearest screen that does, with a line saying so.
1682
1893
  async function route() {
1683
1894
  const p = new URLSearchParams(location.hash.slice(1));
1684
- routing = true;
1895
+ const seq = ++nav;
1896
+ routes++;
1685
1897
  try {
1686
1898
  const id = p.get("target");
1687
1899
  if (!id) {
1688
1900
  await showTargets();
1689
1901
  return;
1690
1902
  }
1691
- if (!(await loadTargets())) {
1903
+ if (!(await loadTargets()) || seq !== nav) {
1692
1904
  return;
1693
1905
  }
1694
1906
  const target = state.targets.find((t) => String(t.id) === id);
@@ -1700,12 +1912,18 @@ async function route() {
1700
1912
  }
1701
1913
  if (state.data === null || state.target?.id !== target.id || state.project !== project) {
1702
1914
  state.sequence = [];
1915
+ state.data = null;
1703
1916
  state.target = target;
1704
1917
  state.project = project;
1705
1918
  try {
1706
1919
  await reload();
1707
1920
  } catch (err) {
1708
- 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) {
1709
1927
  return;
1710
1928
  }
1711
1929
  }
@@ -1732,18 +1950,21 @@ async function route() {
1732
1950
  state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
1733
1951
  const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
1734
1952
  state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
1735
- // 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.
1736
1954
  if (["on", "off"].includes(p.get("box"))) {
1737
1955
  state.showBox = p.get("box") === "on";
1738
1956
  }
1739
1957
  if (["on", "off"].includes(p.get("blink"))) {
1740
1958
  state.blink = p.get("blink") === "on";
1741
1959
  }
1960
+ if (["on", "off"].includes(p.get("flash"))) {
1961
+ state.spotFlash = p.get("flash") === "on";
1962
+ }
1742
1963
  state.box = 0;
1743
1964
  say("");
1744
1965
  showStory();
1745
1966
  } finally {
1746
- routing = false;
1967
+ routes--;
1747
1968
  }
1748
1969
  }
1749
1970
 
@@ -1768,7 +1989,9 @@ document.addEventListener("focusout", () => setTimeout(keepKeys));
1768
1989
  keepKeys();
1769
1990
 
1770
1991
  document.addEventListener("keydown", (e) => {
1771
- 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) {
1772
1995
  return;
1773
1996
  }
1774
1997
  const inInput = e.target instanceof HTMLInputElement;
@@ -1815,7 +2038,8 @@ document.addEventListener("keydown", (e) => {
1815
2038
  r: () => decide("reject"),
1816
2039
  e: () => decide("exclude"),
1817
2040
  u: () => decide(null),
1818
- 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")),
1819
2043
  h: () => toggleView("highlight"),
1820
2044
  s: () => toggleView("spotlight"),
1821
2045
  b: () => toggleOption("showBox"),
@@ -1831,6 +2055,11 @@ document.addEventListener("keydown", (e) => {
1831
2055
  },
1832
2056
  };
1833
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
+ }
1834
2063
  if (action) {
1835
2064
  e.preventDefault();
1836
2065
  action();