@graphty/visual-review 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,27 @@
2
2
  * The review page. Plain JavaScript on purpose: this page is where the owner approves images, so
3
3
  * it must stay small enough to read. Every text that came from a pull request (titles, reasons,
4
4
  * console lines) is set with textContent, never parsed as HTML.
5
+ *
6
+ * Three screens share one frame: the header (with the target and project pickers and Finish),
7
+ * the screen's own bar (the grid bar, or the story's decision bar), and the status row, the one
8
+ * place the page writes messages. Under them, main shows the screen. The story screen never
9
+ * scrolls: only its two panes do, so its buttons stay where the reader's hand already is.
5
10
  */
6
11
 
12
+ import { approve, prepareRegistration, registerPasskey } from "/passkey.js";
7
13
  import pixelmatch from "/pixelmatch.mjs";
8
14
 
9
15
  const token = new URLSearchParams(location.hash.slice(1)).get("token") ?? "";
10
16
  const app = document.getElementById("app");
11
- const crumbs = document.getElementById("crumbs");
17
+ const bar = document.getElementById("bar");
18
+ const statusRow = document.getElementById("status-row");
12
19
  const statusLine = document.getElementById("status");
20
+ const alertLine = document.getElementById("alert");
21
+ const elapsedLine = document.getElementById("elapsed");
22
+ const statusMore = document.getElementById("status-more");
23
+ const pickTarget = document.getElementById("pick-target");
24
+ const pickProject = document.getElementById("pick-project");
25
+ const finishSlot = document.getElementById("finish-slot");
13
26
 
14
27
  const REVIEWABLE = ["changed", "moved", "new", "unseeded", "removed", "unstable", "failed"];
15
28
  const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
@@ -18,6 +31,16 @@ const ACCEPTABLE = ["changed", "moved", "new", "unseeded", "removed"];
18
31
  const UNSEEDED = "unseeded";
19
32
  const NO_BASELINE = "no baseline yet";
20
33
  const statusLabel = (status) => (status === UNSEEDED ? NO_BASELINE : status);
34
+ // The grid's More filters, in the order they are listed, with how each is named.
35
+ const STATUS_FILTERS = {
36
+ changed: "Changed",
37
+ moved: "Moved",
38
+ new: "New",
39
+ unseeded: "No baseline yet",
40
+ removed: "Removed",
41
+ unstable: "Unstable",
42
+ failed: "Failed captures",
43
+ };
21
44
  // Errors first (their own list), then what changed, then moved, new, unstable and removed stories.
22
45
  const RANK = { failed: 0, changed: 1, moved: 2, new: 3, unstable: 4, removed: 5, unseeded: 6 };
23
46
  const FLASH_MS = 333; // one image each third of a second: about 1.5 full cycles a second
@@ -27,65 +50,84 @@ const ZOOMS = ["fit", 1, 2, 4, 8];
27
50
  const CRISP_FROM = 4; // from this zoom on, pixels are drawn as hard squares
28
51
  const GROW = 10; // image pixels the spotlight and the changed boxes grow each changed pixel by
29
52
  const SPOT_ALPHA = 190; // the spotlight's dimming, out of 255, as Chromatic's focus mask
30
- const RE_REVIEW = "re-review: your earlier accept was replaced by master's baseline";
53
+ // A decision this soon after its item's images appeared is the second tap of a double tap, not a
54
+ // decision on an image the reader saw.
55
+ const GUARD_MS = 250;
56
+ const SLOW_MS = 300; // a wait longer than this says what it waits for
31
57
  // The grid's decision filters, and how a decision reads on a tile.
32
58
  const DECISIONS = { accept: "Accepted", reject: "Rejected", exclude: "Excluded" };
33
- // The reviewer's own display choices (the changed box, blinking the overlay), kept in this browser.
59
+ const DONE = { accept: "accepted", reject: "rejected", exclude: "excluded" };
60
+ // The reviewer's own display choices (the changed-area outline, blinking the overlay, Spotlight
61
+ // flash, whether single-letter keys work, the failed-captures list), kept in this browser.
34
62
  const OPTIONS_KEY = "visual-review:options";
35
63
  const saved = loadOptions();
64
+ const reduceMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
36
65
 
37
66
  const state = {
38
- targets: [],
67
+ list: null, // GET /api/prs?cached=1: the targets, when they were read, and any refresh running
39
68
  target: null, // summary of the pull request (or master) being reviewed
40
69
  project: null,
41
70
  data: null, // GET /api/pr/:id/:project
42
71
  filter: "undecided", // the grid opens on what still needs a decision
43
- text: "", // the grid's text filter
44
- index: 0, // the item shown, in `sequence`
72
+ text: "", // the grid's Find story text
45
73
  // The files of this pass through the stories, frozen when a story is opened from the grid:
46
- // deciding one never drops it, so Previous goes back to it and its Undo.
74
+ // deciding one never drops it, so Previous goes back to it and its Undo. `pass` names the
75
+ // filter it was taken from, which the address carries.
76
+ pass: "undecided",
47
77
  sequence: [],
78
+ index: 0, // the item shown, in `sequence`
79
+ ended: false, // the end card is shown instead of the panes
48
80
  lastFile: null, // the item last opened, highlighted when the grid comes back
49
81
  view: "side", // side | flash | highlight | spotlight
50
82
  zoom: "fit",
51
- box: 0, // which changed box "next changed box" is on
52
- showBox: saved.showBox ?? true, // outline the changed box (B)
83
+ box: 0, // which changed area Next change is on
84
+ showBox: saved.showBox ?? true, // outline the changed area (B)
53
85
  blink: saved.blink ?? false, // blink the changed pixels Highlight lays over the images (L)
54
86
  spotFlash: saved.spotFlash ?? false, // Spotlight flashes baseline and new (F in Spotlight)
87
+ shortcuts: saved.shortcuts ?? true, // single-letter keys on
88
+ // Flash, Blink and Spotlight flash run; a page load or a deep link opens them stopped.
89
+ motion: true,
55
90
  held: null, // the view to return to when Space is released
56
- pending: "reject", // what Enter in the reason box does
91
+ pending: null, // "reject" or "exclude" waiting for its reason in the note box
57
92
  screen: "targets",
58
93
  job: null, // the newest Finish, from GET /api/finish-status
59
- armed: null, // the bulk Undo button pressed once: its second press undoes
94
+ plan: null, // what the running Finish was started to do, for its step list
60
95
  };
61
96
  const running = () => state.job?.running === true;
62
97
  const VIEWS = ["side", "flash", "highlight", "spotlight"];
63
98
  const FILTERS = ["undecided", "all", ...REVIEWABLE, ...Object.keys(DECISIONS)];
64
99
  let routes = 0; // how many routes are running: the page follows the address (a link, Back, Forward)
65
100
  let nav = 0; // bumped by each screen change that waits on the server: a superseded one stops there
101
+ // Text typed in the note box, kept with the item it was typed on until its decision is saved.
102
+ const drafts = new Map();
103
+ // The last messages, in full, for the key overlay.
104
+ const messages = [];
66
105
  // The least recently used last-opened images and diffs are dropped past these, so a long session
67
106
  // stays small: an object URL holds its image, and a diff about 13 bytes per image pixel.
68
107
  const images = new Map();
69
108
  const IMAGES_KEPT = 50;
109
+ const thumbs = new Map();
110
+ const THUMBS_KEPT = 400;
70
111
  const diffs = new Map();
71
112
  const DIFFS_KEPT = 4;
72
113
  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
114
+ let lastStageAt = 0; // when the last renderStage began
115
+ let lastStageFile = null; // and for which item
116
+ let lastShown = null; // the last item whose images were shown (never reset by a move)
117
+ const SKIM_MS = 120;
118
+ let stageShown = null; // the file whose images are on the stage: Accept waits for it
119
+ let appearedAt = 0; // when the item on screen appeared, or its images did
120
+ let saving = null; // the decision on its way to the server
79
121
  let flashTimer = null;
80
122
  let factor = 1; // CSS pixels per image pixel of the pictures on the stage
81
- let shownBoxes = []; // the changed boxes of the item on the stage
123
+ let shownBoxes = []; // the changed areas of the item on the stage
82
124
  let stageKey = null; // "file|zoom" of what is on the stage, to keep its scroll across view changes
83
125
  // At fit, the stage refits when the window (or an iPad's orientation) changes its size.
84
126
  const refit = new ResizeObserver(() => {
85
127
  const stage = document.getElementById("stage");
86
128
  if (state.screen === "story" && state.zoom === "fit" && stage?.querySelector(".sheet")) {
87
129
  fit(stage);
88
- showBox(stage, shownBoxes, false);
130
+ showBox(stage, shownBoxes, false, false);
89
131
  }
90
132
  });
91
133
 
@@ -100,40 +142,310 @@ function el(tag, attrs = {}, ...children) {
100
142
  node.setAttribute(key, value === true ? "" : value);
101
143
  }
102
144
  }
103
- node.append(...children.flat().filter((c) => c !== null && c !== undefined && c !== false));
145
+ node.append(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
104
146
  return node;
105
147
  }
106
148
 
149
+ const kbd = (key) => el("kbd", { "aria-hidden": "true" }, key);
150
+ const spinner = () => el("span", { class: "spinner", "aria-hidden": "true" });
151
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
152
+
107
153
  // Like replaceChildren, but a null child (a section that does not apply) is left out.
108
154
  function render(...children) {
109
155
  app.replaceChildren(...children.filter((c) => c !== null && c !== undefined && c !== false));
110
156
  }
111
157
 
112
- // Clearing the status line during a Finish shows the Finish's step instead, so a screen that
113
- // opens while it runs (a reload, Visual review, a link) never blanks its progress.
158
+ function setBar(node = null) {
159
+ bar.replaceChildren(...(node ? [node] : []));
160
+ }
161
+
162
+ // The status row. Clearing it during a Finish shows the Finish's step instead, so a screen that
163
+ // opens while it runs (a reload, Visual review, a link) never blanks its progress. Errors go to
164
+ // the row's alert, which a screen reader reads at once.
165
+ // The same message again is not rewritten, so a screen reader does not read it again.
114
166
  function say(text, isError = false) {
115
- statusLine.textContent = text === "" && running() ? finishing() : text;
116
- statusLine.className = isError ? "error" : "";
167
+ const shown = text === "" && running() ? finishing() : text;
168
+ elapsedLine.textContent = "";
169
+ if (!isError && shown !== "" && statusLine.textContent === shown && alertLine.textContent === "") {
170
+ return;
171
+ }
172
+ statusRow.classList.remove("busy", "open");
173
+ statusLine.replaceChildren(isError ? "" : shown);
174
+ alertLine.textContent = isError ? shown : "";
175
+ // A message longer than the row shows More, which opens the row to its full height.
176
+ requestAnimationFrame(() => {
177
+ const cut = [statusLine, alertLine].some((n) => n.scrollHeight > n.clientHeight + 1);
178
+ statusMore.hidden = !cut;
179
+ statusMore.textContent = "More";
180
+ });
181
+ if (shown && messages.at(-1) !== shown) {
182
+ messages.push(shown);
183
+ messages.splice(0, messages.length - 20);
184
+ }
185
+ }
186
+
187
+ // A message with a spinner, for a wait under way.
188
+ function sayBusy(text, ...extra) {
189
+ if (statusRow.classList.contains("busy") && statusLine.textContent === text && extra.length === 0) {
190
+ return;
191
+ }
192
+ statusLine.textContent = "";
193
+ say(text);
194
+ statusRow.classList.add("busy");
195
+ statusLine.prepend(spinner());
196
+ statusLine.append(...extra);
197
+ }
198
+
199
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
200
+
201
+ // ---------------------------------------------------------------- the wait box
202
+
203
+ // A wait that blocks what the reader can do (the first list, a project still downloading or still
204
+ // opening, Finish) is said in a box in the middle of the screen, over everything: what it waits
205
+ // for, its progress in counts, the time spent, and Cancel or Retry where they make sense. A
206
+ // failure turns the box into the error, with Retry. A wait in the background stays in the status
207
+ // row instead. The box appears only once a wait has taken SLOW_MS, so a quick one never flashes.
208
+ let blocking = null; // the wait the box shows, or will show once it has taken SLOW_MS
209
+ const waitPart = (name) => document.getElementById(`wait-${name}`);
210
+
211
+ // The box, made when a wait first shows it and removed when it ends, so a page with no wait has
212
+ // none. Its lines are always there, empty or not, so nothing in it moves as they change.
213
+ function waitBox() {
214
+ let box = document.getElementById("wait");
215
+ if (box) {
216
+ return box;
217
+ }
218
+ const button = (name, label, onclick, cls = null) =>
219
+ el("button", { type: "button", id: `wait-${name}`, class: cls, onclick }, label);
220
+ box = el(
221
+ "dialog",
222
+ { id: "wait", class: "wait", tabindex: "-1", "aria-labelledby": "wait-title", "aria-describedby": "wait-text" },
223
+ el("h2", { id: "wait-title" }),
224
+ el("p", { id: "wait-text", role: "status" }),
225
+ el("div", { id: "wait-body" }),
226
+ el("progress", { id: "wait-bar" }),
227
+ el("p", { id: "wait-detail", "aria-hidden": "true" }),
228
+ el("div", { id: "wait-notes" }, el("p", { id: "wait-net" }), el("p", { id: "wait-error", role: "alert" })),
229
+ el(
230
+ "p",
231
+ { class: "actions" },
232
+ el("span", { id: "wait-elapsed", "aria-hidden": "true" }),
233
+ // Cancel (Close once the wait failed) and Retry.
234
+ button("cancel", "Cancel", () => {
235
+ const w = blocking;
236
+ if (w && (w.cancel || w.failed)) {
237
+ w.end();
238
+ (w.failed ? w.closed : w.cancel)?.();
239
+ }
240
+ }),
241
+ button(
242
+ "retry",
243
+ "Retry",
244
+ () => {
245
+ const w = blocking;
246
+ if (w?.failed && w.retry) {
247
+ w.end();
248
+ w.retry();
249
+ }
250
+ },
251
+ "primary",
252
+ ),
253
+ ),
254
+ );
255
+ // Escape cancels only what Cancel would; it never just hides a wait.
256
+ box.addEventListener("cancel", (e) => {
257
+ e.preventDefault();
258
+ waitPart("cancel").click();
259
+ });
260
+ document.body.append(box);
261
+ return box;
262
+ }
263
+ const waitOpen = () => document.getElementById("wait")?.open === true;
264
+
265
+ const duration = (s) => (s >= 60 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${s} s`);
266
+ const megabytes = (b) => `${b < 1e7 ? (b / 1e6).toFixed(1) : Math.round(b / 1e6)} MB`;
267
+
268
+ // A gh call the server is waiting to retry after a network failure, said plainly.
269
+ function networkLine(n, now) {
270
+ if (!n) {
271
+ return "";
272
+ }
273
+ const s = Math.max(0, Math.ceil((n.until - now) / 1000));
274
+ return `GitHub did not answer (${n.error}). Trying again in ${s} s, try ${n.attempt} of ${n.of}.`;
275
+ }
276
+
277
+ // Starts a blocking wait. `cancel` (when given) is what Cancel and Escape do after the box closes.
278
+ // The returned wait has `update(fields)` (title, text, detail, done and total for the bar, net,
279
+ // body, since: when it began, by performance.now()), `fail(message, retry, closed)` and `end()`.
280
+ function block(title, { cancel = null, delay = SLOW_MS } = {}) {
281
+ blocking?.end();
282
+ const w = {
283
+ title,
284
+ text: "",
285
+ detail: "",
286
+ done: null,
287
+ total: null,
288
+ net: "",
289
+ body: null,
290
+ error: "",
291
+ since: performance.now(),
292
+ cancel,
293
+ retry: null,
294
+ closed: null,
295
+ failed: false,
296
+ ended: false,
297
+ };
298
+ const draw = () => {
299
+ if (w.ended || !waitOpen()) {
300
+ return;
301
+ }
302
+ waitBox().classList.toggle("failed", w.failed);
303
+ waitPart("title").textContent = w.title;
304
+ // Rewritten only when it changes, so a screen reader reads it once.
305
+ if (waitPart("text").textContent !== w.text) {
306
+ waitPart("text").textContent = w.text;
307
+ }
308
+ if (waitPart("body").firstChild !== w.body) {
309
+ waitPart("body").replaceChildren(...(w.body ? [w.body] : []));
310
+ }
311
+ const bar = waitPart("bar");
312
+ if (w.total > 0) {
313
+ bar.max = w.total;
314
+ bar.value = Math.min(w.done ?? 0, w.total);
315
+ } else {
316
+ bar.removeAttribute("value"); // moving, with no count to show yet
317
+ }
318
+ waitPart("detail").textContent = w.detail;
319
+ waitPart("net").textContent = w.failed ? "" : w.net;
320
+ waitPart("error").textContent = w.error;
321
+ const cancelButton = waitPart("cancel");
322
+ cancelButton.textContent = w.failed ? "Close" : "Cancel";
323
+ cancelButton.classList.toggle("unused", !w.failed && !w.cancel);
324
+ waitPart("retry").classList.toggle("unused", !(w.failed && w.retry));
325
+ };
326
+ const tick = () => {
327
+ const s = Math.max(0, Math.floor((performance.now() - w.since) / 1000));
328
+ waitPart("elapsed").textContent = w.failed ? `Stopped after ${duration(s)}` : duration(s);
329
+ };
330
+ const show = () => {
331
+ if (!w.ended && !waitOpen()) {
332
+ const box = waitBox();
333
+ box.showModal();
334
+ // On the box, not a button: a key pressed as it appears must not press Cancel.
335
+ box.focus();
336
+ }
337
+ draw();
338
+ tick();
339
+ };
340
+ let timer = setTimeout(function again() {
341
+ show();
342
+ timer = setTimeout(again, 1000);
343
+ }, delay);
344
+ w.update = (fields) => {
345
+ Object.assign(w, fields);
346
+ draw();
347
+ if (waitOpen()) {
348
+ tick();
349
+ }
350
+ };
351
+ w.fail = (message, retry = null, closed = null) => {
352
+ clearTimeout(timer);
353
+ Object.assign(w, { failed: true, error: message, retry, closed, detail: "" });
354
+ messages.push(message);
355
+ show();
356
+ };
357
+ w.end = () => {
358
+ if (w.ended) {
359
+ return;
360
+ }
361
+ clearTimeout(timer);
362
+ w.ended = true;
363
+ if (blocking === w) {
364
+ blocking = null;
365
+ document.getElementById("wait")?.remove();
366
+ }
367
+ };
368
+ blocking = w;
369
+ return w;
370
+ }
371
+
372
+ // The status row's line for work in the background (a refresh, downloads): written only over an
373
+ // empty row or its own earlier line, never over another message, and not kept in the recent
374
+ // messages. `extra` changes every second, outside the live region.
375
+ let backgroundSaid = "";
376
+ function sayBackground(text, extra = "") {
377
+ const mine =
378
+ alertLine.textContent === "" && (statusLine.textContent === "" || statusLine.textContent === backgroundSaid);
379
+ if (!mine || running()) {
380
+ return;
381
+ }
382
+ if (text === "") {
383
+ if (backgroundSaid !== "") {
384
+ backgroundSaid = "";
385
+ statusLine.textContent = "";
386
+ elapsedLine.textContent = "";
387
+ statusRow.classList.remove("busy");
388
+ }
389
+ return;
390
+ }
391
+ if (statusLine.textContent !== text) {
392
+ statusLine.replaceChildren(spinner(), text);
393
+ statusRow.classList.add("busy");
394
+ }
395
+ backgroundSaid = text;
396
+ elapsedLine.textContent = extra;
117
397
  }
118
398
 
119
399
  // Asks in the page, never with confirm(): once a browser stops a page's dialogs ("prevent this
120
400
  // page from creating additional dialogs"), confirm() returns false without showing anything, and
121
401
  // 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);
402
+ // `onYes` runs inside the click on `yes` itself, for what a browser allows only there.
403
+ // `unavailable` makes `yes` say why instead (inside the dialog, which a screen reader hears), and
404
+ // `why` names the element of `message` explaining it; `focusYes` focuses `yes` (a retry).
405
+ function ask(message, yes, { onYes = () => {}, label = null, unavailable = null, why = null, focusYes = false } = {}) {
406
+ // Focus on the box, not a button: the Enter that asked (in the note box) must not answer it.
407
+ const text = el("div", { class: "ask-text", id: "ask-text" }, message);
408
+ const dialog = el("dialog", {
409
+ class: "ask",
410
+ tabindex: "-1",
411
+ "aria-labelledby": label ?? "ask-text",
412
+ "aria-describedby": "ask-text",
413
+ });
414
+ const alert = el("p", { class: "error", role: "alert", id: "ask-alert" });
415
+ const answer = (value) => () => {
416
+ if (value === "yes" && unavailable) {
417
+ alert.textContent = unavailable;
418
+ return;
419
+ }
420
+ if (value === "yes") {
421
+ onYes();
422
+ }
423
+ dialog.close(value);
424
+ };
126
425
  dialog.append(
426
+ text,
427
+ alert,
127
428
  el(
128
429
  "p",
129
430
  { class: "actions" },
130
431
  el("button", { type: "button", onclick: answer("no") }, "Cancel"),
131
- el("button", { type: "button", class: "primary", onclick: answer("yes") }, yes),
432
+ el(
433
+ "button",
434
+ {
435
+ type: "button",
436
+ class: "primary",
437
+ id: "ask-yes",
438
+ "aria-disabled": unavailable ? "true" : null,
439
+ "aria-describedby": unavailable && why ? why : null,
440
+ onclick: answer("yes"),
441
+ },
442
+ yes,
443
+ ),
132
444
  ),
133
445
  );
134
446
  document.body.append(dialog);
135
447
  dialog.showModal();
136
- dialog.focus();
448
+ (focusYes ? dialog.querySelector("#ask-yes") : dialog).focus();
137
449
  return new Promise((resolve) =>
138
450
  dialog.addEventListener("close", () => {
139
451
  dialog.remove();
@@ -150,7 +462,9 @@ async function api(path, body) {
150
462
  });
151
463
  const json = await res.json().catch(() => ({}));
152
464
  if (!res.ok) {
153
- throw new Error(json.error ?? `${res.status} ${res.statusText}`);
465
+ const err = new Error(json.error ?? `${res.status} ${res.statusText}`);
466
+ err.status = res.status;
467
+ throw err;
154
468
  }
155
469
  return json;
156
470
  }
@@ -180,14 +494,15 @@ function cached(map, key, limit, make, drop = () => {}) {
180
494
  }
181
495
 
182
496
  // 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) {
497
+ // the image's hash too, so a newer CI run's image is never answered with an older one. `route`
498
+ // is "img" for the image itself, "thumb" for the grid's small copy.
499
+ function image(kind, file, hash, route = "img") {
185
500
  return cached(
186
- images,
501
+ route === "img" ? images : thumbs,
187
502
  `${state.target.id}/${state.project}/${kind}/${file}/${hash}`,
188
- IMAGES_KEPT,
503
+ route === "img" ? IMAGES_KEPT : THUMBS_KEPT,
189
504
  () => {
190
- const path = ["img", state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
505
+ const path = [route, state.target.id, state.project, kind, file].map(encodeURIComponent).join("/");
191
506
  return fetch(`/api/${path}`, { headers: { "x-review-token": token } }).then(async (res) => {
192
507
  if (!res.ok) {
193
508
  throw new Error(`${file}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
@@ -220,9 +535,15 @@ const imageHash = (item) => item.capture ?? item.baseline ?? null;
220
535
  const isLocal = () => state.target?.local === true;
221
536
  // Device pixels per CSS pixel of this capture: 2 today, 1 for captures made before it was recorded.
222
537
  const scale = () => state.data?.results.scale ?? 1;
538
+ // The default branch's name, as the server reports it.
539
+ const branchName = (t = state.target) => t?.defaultBranch ?? state.list?.defaultBranch ?? "master";
540
+ const labelOf = (t) => (t.local ? "Local preview" : t.pr === null ? `${branchName(t)} seed` : `#${t.pr}`);
541
+ const undecidedOf = (t) => t.projects.reduce((n, p) => n + p.undecided, 0);
542
+ const finishedOf = (t) => t.projects.reduce((n, p) => n + (p.finished ?? 0), 0);
543
+ const draftKey = (item) => `${state.target.id}/${state.project}/${item.file}`;
223
544
 
224
545
  // Errors first, then components by their most urgent item, stories the same way within one, and
225
- // the modes of a story together. Tile numbers and the story's "N of M" both follow this order.
546
+ // the modes of a story together. Tile numbers follow this order over every item ("All").
226
547
  function ordered(items) {
227
548
  const best = new Map();
228
549
  const keep = (key, item) => best.set(key, Math.min(best.get(key) ?? 9, RANK[item.status] ?? 9));
@@ -249,17 +570,36 @@ function ordered(items) {
249
570
  });
250
571
  }
251
572
 
252
- function visibleItems() {
253
- const text = state.text.trim().toLowerCase();
254
- let items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
255
- if (state.filter === "undecided") {
573
+ const reviewable = () => state.data.items.filter((i) => REVIEWABLE.includes(i.status));
574
+
575
+ // What Find story narrows by: a number is the item's number, not part of an id.
576
+ const findText = (text) => (/^\s*\d+\s*$/.test(text) ? "" : text.trim().toLowerCase());
577
+
578
+ function itemsFor(filter, text = "") {
579
+ const find = findText(text);
580
+ let items = reviewable();
581
+ if (filter === "undecided") {
256
582
  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);
583
+ } else if (Object.hasOwn(DECISIONS, filter)) {
584
+ items = items.filter((i) => decisionOf(i)?.decision === filter);
585
+ } else if (filter !== "all") {
586
+ items = items.filter((i) => i.status === filter);
587
+ }
588
+ return ordered(find ? items.filter((i) => i.file.includes(find)) : items);
589
+ }
590
+
591
+ const visibleItems = () => itemsFor(state.filter, state.text);
592
+
593
+ // An item's number is its place in the whole project ("All"), so it never changes as items are
594
+ // decided or filtered.
595
+ const numbering = new WeakMap();
596
+ function numberOf(item) {
597
+ let numbers = numbering.get(state.data);
598
+ if (!numbers) {
599
+ numbers = new Map(ordered(reviewable()).map((i, n) => [i.file, n + 1]));
600
+ numbering.set(state.data, numbers);
261
601
  }
262
- return ordered(text ? items.filter((i) => i.file.includes(text)) : items);
602
+ return numbers.get(item.file);
263
603
  }
264
604
 
265
605
  // The items of the frozen pass, in its order; an item gone from a reloaded project is left out.
@@ -270,13 +610,23 @@ function passItems() {
270
610
 
271
611
  const current = () => passItems()[state.index];
272
612
 
273
- function progress() {
274
- const items = state.data.items.filter((i) => REVIEWABLE.includes(i.status));
275
- return `${items.filter(decisionOf).length} / ${items.length} reviewed`;
613
+ // The pass is kept in this tab's session storage under its address, so a reload (or Safari
614
+ // restoring a tab it discarded) reopens the same pass at the same place.
615
+ const passKey = () => `visual-review:pass:${state.target.id}/${state.project}/${state.pass}`;
616
+ function savePass() {
617
+ try {
618
+ sessionStorage.setItem(passKey(), JSON.stringify(state.sequence));
619
+ } catch {
620
+ // Not kept: a reload rebuilds the pass from its filter.
621
+ }
276
622
  }
277
-
278
- function setCrumbs(...parts) {
279
- crumbs.replaceChildren(...parts.flatMap((p, i) => (i === 0 ? [p] : [" / ", p])));
623
+ function loadPass() {
624
+ try {
625
+ const files = JSON.parse(sessionStorage.getItem(passKey()) ?? "null");
626
+ return Array.isArray(files) ? files : null;
627
+ } catch {
628
+ return null;
629
+ }
280
630
  }
281
631
 
282
632
  function stopFlash() {
@@ -294,11 +644,18 @@ function loadOptions() {
294
644
  }
295
645
  }
296
646
 
297
- function saveOptions() {
647
+ function saveOptions(extra = {}) {
298
648
  try {
299
649
  localStorage.setItem(
300
650
  OPTIONS_KEY,
301
- JSON.stringify({ showBox: state.showBox, blink: state.blink, spotFlash: state.spotFlash }),
651
+ JSON.stringify({
652
+ ...loadOptions(),
653
+ showBox: state.showBox,
654
+ blink: state.blink,
655
+ spotFlash: state.spotFlash,
656
+ shortcuts: state.shortcuts,
657
+ ...extra,
658
+ }),
302
659
  );
303
660
  } catch {
304
661
  // Not remembered; the choice still holds for this page.
@@ -307,46 +664,420 @@ function saveOptions() {
307
664
 
308
665
  function toggleOption(key) {
309
666
  state[key] = !state[key];
667
+ state.motion = true;
310
668
  saveOptions();
311
669
  showStory();
312
670
  }
313
671
 
314
- // ---------------------------------------------------------------- screen: targets
672
+ // Takes a project's data from the server as the one on screen.
673
+ function adopt(data, project) {
674
+ state.data = data;
675
+ state.target = data.target;
676
+ state.project = project;
677
+ const listed = state.list?.targets?.findIndex((t) => t.id === data.target.id) ?? -1;
678
+ if (listed >= 0) {
679
+ state.list.targets[listed] = data.target;
680
+ }
681
+ }
315
682
 
316
- async function loadTargets() {
317
- say("Loading pull requests and captures...");
318
- try {
319
- const [prs, status] = await Promise.all([api("/api/prs"), api("/api/finish-status")]);
320
- state.targets = prs.targets;
321
- track(status.job);
322
- say("");
323
- if (running()) {
324
- // A reload during a Finish, on any screen: follow the running one, never offer a second.
325
- watchFinish();
326
- }
327
- return true;
328
- } catch (err) {
329
- say(err.message, true);
330
- return false;
683
+ // A decision changed the target's counts: the header's Finish and the pickers follow.
684
+ function counted(item, before, after, unpublished) {
685
+ const p = state.target.projects.find((x) => x.project === state.project);
686
+ if (p && Boolean(before) !== Boolean(after)) {
687
+ p.decided += after ? 1 : -1;
688
+ p.undecided += after ? -1 : 1;
689
+ }
690
+ if (typeof unpublished === "number") {
691
+ state.target.unpublished = unpublished;
692
+ }
693
+ drawHeader();
694
+ }
695
+
696
+ // ---------------------------------------------------------------- header
697
+
698
+ // The target and project pickers (on the grid and story screens) and Finish.
699
+ function drawHeader() {
700
+ const onTargets = state.screen === "targets" || state.target === null;
701
+ pickTarget.parentElement.hidden = onTargets;
702
+ pickProject.parentElement.hidden = onTargets;
703
+ finishSlot.replaceChildren();
704
+ if (onTargets) {
705
+ return;
706
+ }
707
+ const targets = state.list?.targets ?? [state.target];
708
+ const listed = targets.some((t) => t.id === state.target.id) ? targets : [state.target, ...targets];
709
+ pickTarget.replaceChildren(
710
+ ...listed.map((t) =>
711
+ el(
712
+ "option",
713
+ { value: t.id, selected: t.id === state.target.id },
714
+ t.local ? labelOf(t) : `${labelOf(t)} (${undecidedOf(t)})`,
715
+ ),
716
+ ),
717
+ );
718
+ pickProject.replaceChildren(
719
+ ...state.target.projects
720
+ .filter((p) => p.reviewable > 0 || p.downloading || p.project === state.project)
721
+ .map((p) =>
722
+ el(
723
+ "option",
724
+ {
725
+ value: p.project,
726
+ selected: p.project === state.project,
727
+ disabled: p.reviewable === 0 && !p.downloading,
728
+ },
729
+ `${p.project} (${p.undecided})`,
730
+ ),
731
+ ),
732
+ );
733
+ if (!isLocal()) {
734
+ finishSlot.append(...finishControl(state.target));
331
735
  }
332
736
  }
333
737
 
334
- async function showTargets(notice = "") {
738
+ // Finish names its target and how many decisions it would publish. At 0 it is unavailable, and
739
+ // says why beside it. Whether a passkey must approve it is the sheet's to say: the server decides
740
+ // that when the sheet opens, and rejects alone never wait for a passkey to be registered. The
741
+ // reason's tail is only for wide windows and screen readers, so a narrow header keeps Finish whole.
742
+ function finishControl(t) {
743
+ const count = t.unpublished ?? 0;
744
+ const can = count > 0 && !running();
745
+ const why = count === 0 ? ["Nothing new", " to finish"] : null;
746
+ return [
747
+ el(
748
+ "button",
749
+ {
750
+ type: "button",
751
+ class: "primary finish",
752
+ "aria-disabled": String(!can),
753
+ "aria-describedby": why ? `finish-why-${t.id}` : null,
754
+ onclick: (e) => {
755
+ if (can) {
756
+ finishTarget(t.id, e.currentTarget);
757
+ } else {
758
+ say(running() ? "A Finish is running: wait for it to end." : "Nothing new to finish.");
759
+ }
760
+ },
761
+ },
762
+ `Finish ${labelOf(t)} (${count})`,
763
+ ),
764
+ why
765
+ ? el(
766
+ "span",
767
+ { class: "meta finish-why", id: `finish-why-${t.id}` },
768
+ why[0],
769
+ el("span", { class: "wide-only" }, why[1]),
770
+ )
771
+ : null,
772
+ ].filter(Boolean);
773
+ }
774
+
775
+ statusMore.addEventListener("click", () => {
776
+ const open = statusRow.classList.toggle("open");
777
+ statusMore.textContent = open ? "Less" : "More";
778
+ });
779
+ pickTarget.addEventListener("change", () => openTarget(pickTarget.value, false));
780
+ pickProject.addEventListener("change", () => openProject(state.target.id, pickProject.value));
781
+
782
+ // ---------------------------------------------------------------- screen: targets
783
+
784
+ // A refresh's step as the page says it: "Finding CI runs: 3 of 5".
785
+ function stepText(r) {
786
+ const name = r.step.charAt(0).toUpperCase() + r.step.slice(1);
787
+ return r.total !== null && r.done !== null ? `${name}: ${r.done} of ${r.total}` : name;
788
+ }
789
+
790
+ const secondsSince = (from, now) => Math.max(0, Math.round((now - from) / 1000));
791
+
792
+ // Every target, from the server's cached list: drawn at once, and refreshed in the background
793
+ // when the list is over a minute old or Refresh is pressed. Until there is a list at all (the
794
+ // server's first load), the wait box says what the server is doing; after that a refresh and the
795
+ // downloads go on in the status row. The screen keeps asking while it is shown, so it never looks
796
+ // idle while the server works: that goes on after this returns, until another screen opens.
797
+ function showTargets(notice = "", refresh = false) {
335
798
  const seq = ++nav;
336
799
  stopFlash();
337
800
  state.screen = "targets";
338
- setCrumbs();
339
- if (!(await loadTargets()) || seq !== nav) {
340
- return;
341
- }
801
+ state.ended = false;
802
+ app.classList.remove("story-screen");
803
+ setBar();
804
+ drawHeader();
342
805
  remember();
343
806
  say(notice);
344
- if (state.targets.length === 0) {
345
- render(finishOutcome(), el("p", {}, "No open pull request has a CI run, and no master run was given."));
807
+ drawTargets();
808
+ followTargets(seq, refresh ? "refresh" : "cached");
809
+ }
810
+
811
+ async function followTargets(seq, ask) {
812
+ let box = null; // the wait box, while there is no list to show
813
+ const first = () =>
814
+ box && !box.ended && !box.failed ? box : (box = block("Loading the pull requests", { delay: 0 }));
815
+ const retry = () => showTargets("", true);
816
+ for (;;) {
817
+ let list;
818
+ try {
819
+ list = await api(`/api/prs?${ask}=1`);
820
+ } catch (err) {
821
+ if (seq === nav) {
822
+ const message = `The review server did not answer: ${err.message}`;
823
+ if (state.list?.targets) {
824
+ say(message, true);
825
+ } else {
826
+ first().fail(message, retry);
827
+ }
828
+ }
829
+ return;
830
+ }
831
+ if (seq !== nav) {
832
+ box?.end();
833
+ return;
834
+ }
835
+ const before = JSON.stringify(state.list?.targets ?? null);
836
+ state.list = list;
837
+ const stale = list.targets && list.updatedAt !== null && list.now - list.updatedAt > 60000;
838
+ if (ask === "cached" && stale && !list.refreshing) {
839
+ ask = "refresh";
840
+ continue;
841
+ }
842
+ if (JSON.stringify(list.targets ?? null) === before) {
843
+ drawListLine();
844
+ } else {
845
+ drawTargets();
846
+ }
847
+ const net = networkLine(list.network, list.now);
848
+ if (!list.targets) {
849
+ const r = list.refreshing;
850
+ if (!r && list.error) {
851
+ first().fail(`Could not read the pull requests from GitHub: ${list.error}`, retry);
852
+ return;
853
+ }
854
+ first().update({
855
+ text: "Reading the open pull requests and their CI runs from GitHub.",
856
+ detail: r ? stepText(r) : "Starting",
857
+ done: r?.done ?? null,
858
+ total: r?.total ?? null,
859
+ net,
860
+ since: r ? performance.now() - (list.now - r.startedAt) : performance.now(),
861
+ });
862
+ } else {
863
+ box?.end();
864
+ backgroundLine(list, net);
865
+ }
866
+ ask = "cached";
867
+ const busy = list.refreshing || !list.targets || list.targets.some((t) => t.downloading);
868
+ await sleep(busy ? 700 : 5000);
869
+ if (seq !== nav) {
870
+ box?.end();
871
+ return;
872
+ }
873
+ }
874
+ }
875
+
876
+ // What the server does in the background, in the status row: a refresh, with its step and time
877
+ // beside it, or the captures still downloading.
878
+ function backgroundLine(list, net) {
879
+ const r = list.refreshing;
880
+ const downloading = list.targets.filter((t) => t.downloading);
881
+ if (r) {
882
+ const s = secondsSince(r.startedAt, list.now);
883
+ sayBackground(
884
+ net ? `Checking GitHub for new CI runs. ${net}` : "Checking GitHub for new CI runs",
885
+ `${stepText(r)}, ${s} s`,
886
+ );
887
+ } else if (downloading.length > 0) {
888
+ sayBackground(`Downloading captures in the background: ${downloading.map(labelOf).join(", ")}`, net);
889
+ } else {
890
+ sayBackground("");
891
+ }
892
+ }
893
+
894
+ // The line above the cards: how old the list is, and Refresh.
895
+ function listLine() {
896
+ const list = state.list;
897
+ return el(
898
+ "p",
899
+ { class: "listline", id: "listline" },
900
+ list?.updatedAt ? `Updated ${secondsSince(list.updatedAt, list.now)} s ago` : "",
901
+ el("button", { type: "button", onclick: () => showTargets("", true) }, "Refresh"),
902
+ );
903
+ }
904
+
905
+ function drawListLine() {
906
+ document.getElementById("listline")?.replaceWith(listLine());
907
+ }
908
+
909
+ function drawTargets() {
910
+ if (state.screen !== "targets") {
911
+ return;
912
+ }
913
+ const targets = state.list?.targets;
914
+ if (!targets) {
915
+ // The first load after the server starts: one placeholder card under the server's step.
916
+ render(listLine(), el("section", { class: "card placeholder", "aria-hidden": "true" }));
917
+ return;
918
+ }
919
+ const finishable = targets.find((t) => !t.local);
920
+ const focused = document.activeElement?.id;
921
+ render(
922
+ finishOutcome(),
923
+ finishable ? signerBlock(finishable) : null,
924
+ finishable ? passkeyLine(finishable.passkey) : null,
925
+ listLine(),
926
+ targets.length === 0
927
+ ? el("p", {}, "No open pull requests. To seed baselines, start the server with --master-run <run id>.")
928
+ : null,
929
+ ...targets.map(targetCard),
930
+ );
931
+ if (running()) {
932
+ drawFinishPanel();
933
+ }
934
+ // A redraw (the list refreshing under the Finish result) keeps focus where it was.
935
+ if (focused && document.getElementById(focused) && focused !== "app") {
936
+ document.getElementById(focused).focus({ preventScroll: true });
937
+ }
938
+ }
939
+
940
+ // ---------------------------------------------------------------- the passkey
941
+
942
+ const hasKeys = (pk) => (pk?.keys ?? []).length > 0;
943
+
944
+ // Whether Finish needs the passkey and has one: the targets screen's line, and Register passkey.
945
+ // Until a passkey is on the default branch the gate does not check approvals, and the line says so.
946
+ function passkeyLine(pk) {
947
+ if (!pk) {
948
+ return null;
949
+ }
950
+ const register = (name) =>
951
+ el("button", { type: "button", id: "register-passkey", onclick: registerOwnPasskey }, name);
952
+ const problem = pk.problem ? el("span", { class: "error" }, ` ${pk.problem}.`) : null;
953
+ const main = pk.keys.filter((k) => !k.pending);
954
+ if (main.length > 0) {
955
+ const names = main.map((k) => k.label).join("; ");
956
+ return el(
957
+ "p",
958
+ { class: "meta passkey-line" },
959
+ `Finish is approved with your passkey (${names}), and the CI gate refuses accepts without it. `,
960
+ register("Register another device"),
961
+ problem,
962
+ );
963
+ }
964
+ if (hasKeys(pk)) {
965
+ return el(
966
+ "p",
967
+ { class: "warning passkey-line" },
968
+ `Passkey waiting for ${pk.waiting !== null ? `#${pk.waiting}` : "its pull request"} to merge: Finish ` +
969
+ "asks for it already, but the CI gate checks approvals only once it is merged.",
970
+ problem,
971
+ );
972
+ }
973
+ return el(
974
+ "p",
975
+ { class: "warning passkey-line" },
976
+ "No passkey registered: accepts are not yet protected. Finish commits them without your approval, " +
977
+ `and the CI gate does not check who accepted them until a passkey is on ${branchName()}. `,
978
+ register("Register passkey"),
979
+ problem,
980
+ );
981
+ }
982
+
983
+ // What a passkey is called in visual-review/passkeys.json and on every device: the kind of device
984
+ // that made it and the day, never "this device" (the file is read on every device).
985
+ function passkeyName(platform) {
986
+ const day = new Date().toISOString().slice(0, 10);
987
+ if (!platform) {
988
+ return `security key, ${day}`;
989
+ }
990
+ const ua = navigator.userAgent;
991
+ const device =
992
+ /iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1)
993
+ ? "iPad"
994
+ : /iPhone/.test(ua)
995
+ ? "iPhone"
996
+ : /Android/.test(ua)
997
+ ? "Android"
998
+ : /Mac OS X|Macintosh/.test(ua)
999
+ ? "Mac"
1000
+ : /Windows/.test(ua)
1001
+ ? "Windows"
1002
+ : /Linux|CrOS/.test(ua)
1003
+ ? "Linux"
1004
+ : "device";
1005
+ return `${device} passkey, ${day}`;
1006
+ }
1007
+
1008
+ // The browser's refusal of a passkey, said with its likely cause: a page opened from another
1009
+ // address than the one the passkey was made on.
1010
+ function passkeyFailure(err, pk) {
1011
+ const hosts = [...new Set((pk?.keys ?? []).map((k) => k.rpId))];
1012
+ if (hosts.length > 0 && !hosts.includes(location.hostname)) {
1013
+ return (
1014
+ `Your passkey belongs to ${hosts.join(" or ")}, and this page is open at ${location.hostname}: ` +
1015
+ `open it from https://${hosts[0]} instead. Nothing was changed.`
1016
+ );
1017
+ }
1018
+ return `Passkey failed (${err?.message ?? err}): nothing was changed.`;
1019
+ }
1020
+
1021
+ // Register passkey: the server's challenge is fetched first, so the sheet's button can start Face
1022
+ // ID (or a security key) inside the tap, as iPad Safari requires. The server checks the new
1023
+ // passkey and opens the pull request that adds it to the default branch's passkeys file.
1024
+ async function registerOwnPasskey(e) {
1025
+ const button = e.currentTarget;
1026
+ let prepared;
1027
+ try {
1028
+ prepared = await prepareRegistration(api);
1029
+ } catch (err) {
1030
+ say(`Passkey not created: ${err.message}`, true);
1031
+ return;
1032
+ }
1033
+ let making = null;
1034
+ const go = await ask(
1035
+ [
1036
+ el("h2", { id: "passkey-title" }, `Register a passkey for ${prepared.rpId}`),
1037
+ el(
1038
+ "p",
1039
+ {},
1040
+ "Face ID (or a security key) makes the passkey; then a pull request adds it to " +
1041
+ `visual-review/passkeys.json on ${branchName()}. Merge it only if it names the credential ` +
1042
+ "id this page shows next.",
1043
+ ),
1044
+ ],
1045
+ "Create the passkey",
1046
+ {
1047
+ label: "passkey-title",
1048
+ onYes: () =>
1049
+ (making = registerPasskey(api, prepared, (made) =>
1050
+ passkeyName(made.authenticatorAttachment === "platform"),
1051
+ )),
1052
+ },
1053
+ );
1054
+ if (!go) {
1055
+ say("Passkey not created: nothing was changed.");
1056
+ button.focus();
346
1057
  return;
347
1058
  }
348
- const finishable = state.targets.find((t) => !t.local);
349
- render(finishOutcome(), finishable ? signerBlock(finishable) : null, ...state.targets.map(targetCard));
1059
+ sayBusy("Creating the passkey and opening its pull request...");
1060
+ try {
1061
+ const r = await making;
1062
+ showTargets(
1063
+ `Passkey registered (${r.entry.label}), credential id ${r.entry.id}. Opened ${r.pullRequest}: check it ` +
1064
+ (r.gated
1065
+ ? "names this id. A passkey is already on the default branch, so the gate fails that pull request: it takes an administrator's merge."
1066
+ : "names this id, then merge it to make the CI gate require your approval."),
1067
+ true,
1068
+ );
1069
+ } catch (err) {
1070
+ say(
1071
+ err?.name === "NotAllowedError"
1072
+ ? "Passkey not created: nothing was changed."
1073
+ : err?.name === "SecurityError"
1074
+ ? `Passkey not created (${err.message}): a passkey needs this page opened by its host name ` +
1075
+ "over https, not by an IP address."
1076
+ : `Passkey not created: ${err.message}`,
1077
+ true,
1078
+ );
1079
+ button.focus?.();
1080
+ }
350
1081
  }
351
1082
 
352
1083
  // The key Finish signs with comes from the server's environment, which is an agent's when an
@@ -368,48 +1099,67 @@ function signerLine(s) {
368
1099
  return `Finish commits are signed with ${s.format} key ${key}${from}${who}.${env}`;
369
1100
  }
370
1101
 
1102
+ // One line; a card with the command to sign as yourself when someone else started the server.
371
1103
  function signerBlock(t) {
1104
+ if (!t.startCommand) {
1105
+ return el("p", { class: "meta signer" }, signerLine(t.signer));
1106
+ }
372
1107
  return el(
373
1108
  "section",
374
1109
  { class: "card signer" },
375
1110
  el("p", {}, signerLine(t.signer)),
376
- t.startCommand
377
- ? el(
378
- "p",
379
- {},
380
- "To sign as yourself, stop this server and start it from your own shell, in your own terminal:",
381
- )
382
- : null,
383
- t.startCommand ? el("pre", { class: "console" }, t.startCommand) : null,
1111
+ el("p", {}, "To sign as yourself, stop this server and start it from your own shell, in your own terminal:"),
1112
+ el("pre", { class: "console" }, t.startCommand),
384
1113
  );
385
1114
  }
386
1115
 
1116
+ const link = (href, text) => el("a", { href, target: "_blank", rel: "noreferrer" }, text);
1117
+
387
1118
  function targetCard(t) {
388
- const decided = t.projects.reduce((n, p) => n + p.decided, 0);
389
- const heading = t.local ? "Local preview " : t.pr === null ? "master (seed) " : `#${t.pr} `;
1119
+ const heading = t.local ? "Local preview " : t.pr === null ? `${branchName(t)} seed` : `#${t.pr} `;
1120
+ const busy = running() && state.job.target === t.id;
1121
+ const shown = t.projects.filter((p) => p.reviewable > 0 || p.problem || p.downloading);
1122
+ const unchanged = t.projects.filter((p) => !shown.includes(p));
1123
+ const problem = t.projects.find((p) => p.problem)?.problem;
390
1124
  return el(
391
1125
  "section",
392
- { class: "card" },
393
- el(
394
- "h2",
395
- {},
396
- heading,
397
- t.url
398
- ? el("a", { href: t.url, target: "_blank", rel: "noreferrer" }, t.title)
399
- : t.pr === null
400
- ? null
401
- : t.title,
402
- ),
1126
+ { class: "card", "data-target": t.id },
1127
+ el("h2", {}, heading, t.url ? link(t.url, t.title) : t.pr === null ? null : t.title),
403
1128
  t.local
404
1129
  ? el("p", { class: "meta" }, t.title)
405
- : el(
1130
+ : t.runId
1131
+ ? el(
1132
+ "p",
1133
+ { class: "meta" },
1134
+ t.commit || t.headSha
1135
+ ? `Captured ${short(t.pr === null ? t.commit : t.headSha)}${t.branch ? ` on ${t.branch}` : ""}, `
1136
+ : "",
1137
+ t.runUrl ? link(t.runUrl, `CI run ${t.runId}`) : `CI run ${t.runId}`,
1138
+ ` (attempt ${t.runAttempt})`,
1139
+ )
1140
+ : el("p", { class: "meta" }, problem ?? "No CI run"),
1141
+ ...(t.warnings ?? []).map((w) =>
1142
+ el(
1143
+ "p",
1144
+ { class: "warning" },
1145
+ w.replace(/; reload the page to retry$/, ""),
1146
+ /reload the page to retry$/.test(w)
1147
+ ? el("button", { type: "button", onclick: () => showTargets("", true) }, "Retry")
1148
+ : null,
1149
+ ),
1150
+ ),
1151
+ t.downloading ? el("p", { class: "meta" }, spinner(), downloadText(t)) : null,
1152
+ !t.local && t.unpublished > 0
1153
+ ? el("p", { class: "meta" }, `${plural(t.unpublished, "decision")} not yet finished`)
1154
+ : null,
1155
+ finishedOf(t) > 0
1156
+ ? el(
406
1157
  "p",
407
1158
  { class: "meta" },
408
- `CI run ${t.runId ?? "none"}, attempt ${t.runAttempt ?? "-"}; captured ${short(t.pr === null ? t.commit : t.headSha)}`,
409
- t.branch ? ` on ${t.branch}` : "",
410
- " ",
411
- t.runUrl ? el("a", { href: t.runUrl, target: "_blank", rel: "noreferrer" }, "run") : null,
412
- ),
1159
+ `Finished: ${plural(finishedOf(t), "decision")} already on the branch, waiting for the next CI ` +
1160
+ "run, which no longer shows them.",
1161
+ )
1162
+ : null,
413
1163
  el(
414
1164
  "p",
415
1165
  { class: "badges" },
@@ -422,83 +1172,205 @@ function targetCard(t) {
422
1172
  )
423
1173
  : null,
424
1174
  ),
425
- el(
426
- "table",
427
- {},
428
- el("tr", {}, el("th", {}, "Project"), el("th", {}, "Found"), el("th", {}, "Reviewed"), el("th", {})),
429
- t.projects.map((p) =>
430
- el(
431
- "tr",
432
- {},
433
- el(
434
- "td",
435
- {},
436
- p.project,
437
- p.acceptable || t.local ? null : el("span", { class: "badge" }, "not seeded from master"),
438
- ),
439
- el(
440
- "td",
441
- {},
442
- p.problem ? el("span", { class: "badge warn" }, p.problem) : null,
443
- p.problem && p.logUrl
444
- ? el("a", { href: p.logUrl, target: "_blank", rel: "noreferrer" }, " job log")
445
- : null,
446
- " ",
447
- Object.entries(p.counts)
448
- .map(([s, n]) => `${n} ${statusLabel(s)}`)
449
- .join(", "),
450
- ),
451
- el("td", {}, t.local ? "-" : `${p.decided} / ${p.reviewable}`),
452
- el(
453
- "td",
454
- {},
455
- p.reviewable > 0
456
- ? el("button", { type: "button", onclick: () => openProject(t, p.project) }, "Review")
457
- : null,
458
- ),
459
- ),
460
- ),
461
- ),
1175
+ shown.length > 0
1176
+ ? el(
1177
+ "table",
1178
+ {},
1179
+ el(
1180
+ "tr",
1181
+ {},
1182
+ el("th", {}, "Project"),
1183
+ el("th", {}, "Results"),
1184
+ el("th", {}, "Decided"),
1185
+ el("th", {}, el("span", { class: "sr-only" }, "Action")),
1186
+ ),
1187
+ shown.map((p) => projectRow(t, p)),
1188
+ )
1189
+ : null,
1190
+ unchanged.length > 0
1191
+ ? el(
1192
+ "p",
1193
+ { class: "meta" },
1194
+ `${plural(unchanged.length, "project")} unchanged: ${unchanged.map((p) => p.project).join(", ")}`,
1195
+ )
1196
+ : null,
462
1197
  t.local
463
1198
  ? null
464
- : running() && state.job.target === t.id
1199
+ : busy
465
1200
  ? el("p", { class: "finish-running" }, `Finish is running: ${state.job.step}...`)
466
- : el(
467
- "p",
468
- {},
469
- el(
470
- "button",
471
- {
472
- type: "button",
473
- class: "primary",
474
- disabled: decided === 0 || running(),
475
- onclick: (e) => finishTarget(t, e.currentTarget),
476
- },
477
- `Finish ${t.pr === null ? "seed" : `#${t.pr}`} (${decided} decisions)`,
478
- ),
479
- ),
1201
+ : el("p", { class: "finish-line" }, ...finishControl(t)),
1202
+ );
1203
+ }
1204
+
1205
+ // "Downloading: 1 of 3 artifacts, 41 MB of 120 MB, 12 s": what has landed so far, and the time
1206
+ // since it began.
1207
+ function downloadText(t) {
1208
+ const d = t.download;
1209
+ if (!d) {
1210
+ return "Downloading the captures...";
1211
+ }
1212
+ const bytes = d.bytes ? `, ${megabytes(d.bytesDone)} of ${megabytes(d.bytes)}` : "";
1213
+ const s = d.startedAt && state.list?.now ? `, ${secondsSince(d.startedAt, state.list.now)} s` : "";
1214
+ return `Downloading: ${d.done} of ${plural(d.total, "artifact")}${bytes}${s}`;
1215
+ }
1216
+
1217
+ function projectRow(t, p) {
1218
+ const results = Object.entries(p.counts)
1219
+ .filter(([s]) => REVIEWABLE.includes(s))
1220
+ .map(([s, n]) => `${n} ${statusLabel(s)}`)
1221
+ .join(", ");
1222
+ const retry = p.problem && /reload/.test(p.problem);
1223
+ return el(
1224
+ "tr",
1225
+ {},
1226
+ el(
1227
+ "td",
1228
+ {},
1229
+ p.project,
1230
+ p.acceptable || t.local || p.downloading || p.problem
1231
+ ? null
1232
+ : el("span", { class: "badge" }, "Reject only here: accept on a pull request"),
1233
+ ),
1234
+ el(
1235
+ "td",
1236
+ {},
1237
+ p.problem
1238
+ ? el(
1239
+ "span",
1240
+ { class: "badge warn" },
1241
+ p.problem.replace(/[;:] reload (the page )?(to retry|in a moment|when it finishes)$/, ""),
1242
+ )
1243
+ : null,
1244
+ p.problem && p.logUrl ? [" -- ", link(p.logUrl, "Job log")] : null,
1245
+ retry ? [" ", el("button", { type: "button", onclick: () => showTargets("", true) }, "Retry")] : null,
1246
+ p.problem ? " " : null,
1247
+ results,
1248
+ ),
1249
+ el("td", {}, t.local || p.downloading ? "-" : `${p.decided} of ${p.reviewable}`),
1250
+ el(
1251
+ "td",
1252
+ {},
1253
+ p.downloading
1254
+ ? el(
1255
+ "button",
1256
+ { type: "button", onclick: () => openProject(t.id, p.project) },
1257
+ spinner(),
1258
+ "Downloading...",
1259
+ )
1260
+ : p.reviewable > 0
1261
+ ? el("button", { type: "button", onclick: () => openProject(t.id, p.project) }, "Review")
1262
+ : null,
1263
+ ),
480
1264
  );
481
1265
  }
482
1266
 
1267
+ // The next target after `id` in the list's order that still has undecided items.
1268
+ function nextTarget(id) {
1269
+ const list = (state.list?.targets ?? []).filter((t) => !t.local);
1270
+ const at = list.findIndex((t) => t.id === id);
1271
+ return [...list.slice(at + 1), ...list.slice(0, Math.max(at, 0))].find((t) => t.id !== id && undecidedOf(t) > 0);
1272
+ }
1273
+
1274
+ // A target from the pickers or an offer: its first project with something undecided (its grid,
1275
+ // or with `story`, its first undecided item).
1276
+ async function openTarget(id, story) {
1277
+ const t = state.list?.targets?.find((x) => x.id === id);
1278
+ const p =
1279
+ t?.projects.find((x) => x.undecided > 0 && !x.downloading) ??
1280
+ t?.projects.find((x) => x.reviewable > 0) ??
1281
+ t?.projects.find((x) => x.downloading);
1282
+ if (!p) {
1283
+ showTargets(t ? `${labelOf(t)} has nothing to review yet.` : "");
1284
+ return;
1285
+ }
1286
+ await openProject(id, p.project, story);
1287
+ }
1288
+
483
1289
  // ---------------------------------------------------------------- screen: project grid
484
1290
 
485
- async function openProject(target, project) {
1291
+ // A project's items, in the wait box for as long as that takes. A project whose captures are still
1292
+ // downloading is asked for again until they land: the server downloads it ahead of the others, and
1293
+ // the box counts the artifacts and bytes. Resolves null when the wait was cancelled or another
1294
+ // screen took over; { data } once loaded; { error, box } when it failed, the box still open for the
1295
+ // caller to say so.
1296
+ async function loadProject(targetId, project, seq) {
1297
+ const box = block(`Opening ${project}`, {
1298
+ cancel: () => {
1299
+ nav++;
1300
+ if (state.screen === "targets") {
1301
+ showTargets();
1302
+ }
1303
+ },
1304
+ });
1305
+ box.update({ text: `Reading the ${project} captures from the server.` });
1306
+ for (;;) {
1307
+ let data;
1308
+ try {
1309
+ data = await api(`/api/pr/${encodeURIComponent(targetId)}/${encodeURIComponent(project)}`);
1310
+ } catch (error) {
1311
+ if (seq !== nav) {
1312
+ box.end();
1313
+ return null;
1314
+ }
1315
+ return { error, box };
1316
+ }
1317
+ if (seq !== nav) {
1318
+ box.end();
1319
+ return null;
1320
+ }
1321
+ if (!data.downloading) {
1322
+ box.end();
1323
+ return { data };
1324
+ }
1325
+ const t = data.target;
1326
+ const d = t.download;
1327
+ const size = t.projects.find((x) => x.project === project)?.bytes;
1328
+ box.update({
1329
+ title: `Downloading ${project} captures for ${labelOf(t)}`,
1330
+ text:
1331
+ `CI run ${t.runId}'s captures are not on this computer yet. ${project}` +
1332
+ `${size ? ` (${megabytes(size)})` : ""} downloads first; the rest go on in the background.`,
1333
+ detail: d
1334
+ ? `${d.done} of ${plural(d.total, "artifact")}${d.bytes ? `, ${megabytes(d.bytesDone)} of ${megabytes(d.bytes)}` : ""}`
1335
+ : "",
1336
+ done: d?.done ?? null,
1337
+ total: d?.total ?? null,
1338
+ net: networkLine(data.network, data.now),
1339
+ });
1340
+ await sleep(700);
1341
+ if (seq !== nav) {
1342
+ box.end();
1343
+ return null;
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ // Opens a project's grid, or with `story` its first undecided item (Next project).
1349
+ async function openProject(targetId, project, story = false) {
486
1350
  const seq = ++nav;
487
- state.target = target;
488
- state.project = project;
1351
+ stopFlash();
1352
+ const got = await loadProject(targetId, project, seq);
1353
+ if (!got) {
1354
+ return;
1355
+ }
1356
+ if (got.error) {
1357
+ const message = `${project} could not be opened: ${got.error.message}`;
1358
+ got.box.fail(
1359
+ message,
1360
+ () => openProject(targetId, project, story),
1361
+ () => say(message, true),
1362
+ );
1363
+ return;
1364
+ }
1365
+ const { data } = got;
1366
+ adopt(data, project);
489
1367
  state.filter = "undecided";
490
1368
  state.text = "";
491
1369
  state.lastFile = null;
492
1370
  state.sequence = [];
493
- say("Loading...");
494
- try {
495
- await reload();
496
- say("");
497
- } catch (err) {
498
- say(err.message, true);
499
- return;
500
- }
501
- if (seq !== nav) {
1371
+ say("");
1372
+ if (story && itemsFor("undecided").length > 0) {
1373
+ startPass("undecided");
502
1374
  return;
503
1375
  }
504
1376
  // Nothing left to decide (or a local preview): show everything instead of an empty grid.
@@ -509,66 +1381,148 @@ async function openProject(target, project) {
509
1381
  }
510
1382
 
511
1383
  async function reload() {
512
- state.data = await api(`/api/pr/${encodeURIComponent(state.target.id)}/${encodeURIComponent(state.project)}`);
1384
+ adopt(
1385
+ await api(`/api/pr/${encodeURIComponent(state.target.id)}/${encodeURIComponent(state.project)}`),
1386
+ state.project,
1387
+ );
513
1388
  }
514
1389
 
515
- function targetLabel() {
516
- return state.target.local ? "local preview" : state.target.pr === null ? "master" : `#${state.target.pr}`;
517
- }
1390
+ // Thumbnails load only near the screen, at most six at a time, so a fast scroll through a large
1391
+ // project fetches what is shown, not every tile it passed.
1392
+ const thumbQueue = [];
1393
+ let thumbsLoading = 0;
1394
+ const nearScreen = new IntersectionObserver(
1395
+ (entries) => {
1396
+ for (const e of entries) {
1397
+ if (e.isIntersecting) {
1398
+ nearScreen.unobserve(e.target);
1399
+ thumbQueue.push(e.target.querySelector("img[data-hash]"));
1400
+ }
1401
+ }
1402
+ pumpThumbs();
1403
+ },
1404
+ { rootMargin: "300px" },
1405
+ );
518
1406
 
519
- const thumbs = new IntersectionObserver((entries) => {
520
- for (const e of entries) {
521
- if (e.isIntersecting) {
522
- thumbs.unobserve(e.target);
523
- const { kind, file, hash } = e.target.dataset;
524
- image(kind, file, hash).then(
525
- (url) => (e.target.src = url),
526
- (err) => (e.target.alt = err.message),
527
- );
1407
+ function pumpThumbs() {
1408
+ while (thumbsLoading < 6 && thumbQueue.length > 0) {
1409
+ const img = thumbQueue.shift();
1410
+ if (!img.isConnected) {
1411
+ continue;
528
1412
  }
1413
+ thumbsLoading++;
1414
+ const { kind, file, hash } = img.dataset;
1415
+ const wait = img.previousElementSibling;
1416
+ image(kind, file, hash, "thumb")
1417
+ .then(
1418
+ (url) => {
1419
+ img.src = url;
1420
+ img.hidden = false;
1421
+ wait?.remove();
1422
+ delete img.closest(".tile").dataset.failed;
1423
+ },
1424
+ () => {
1425
+ if (wait) {
1426
+ wait.textContent = "Failed -- tap to retry";
1427
+ }
1428
+ img.closest(".tile").dataset.failed = "";
1429
+ },
1430
+ )
1431
+ .finally(() => {
1432
+ thumbsLoading--;
1433
+ pumpThumbs();
1434
+ });
529
1435
  }
530
- });
1436
+ }
531
1437
 
532
- // Opens item `index` of the grid, and freezes what the grid shows as the pass Next and Previous
533
- // walk through.
534
- function openItem(index) {
1438
+ // Opens the item `file` of the grid, and freezes what the grid shows as the pass Next and
1439
+ // Previous walk through.
1440
+ function openItem(file) {
535
1441
  say("");
536
- state.sequence = visibleItems().map((i) => i.file);
537
- state.index = index;
1442
+ const shown = visibleItems();
1443
+ const item = state.data.items.find((i) => i.file === file);
1444
+ state.pass = state.filter;
1445
+ state.sequence = (shown.includes(item) ? shown : ordered([...shown, item])).map((i) => i.file);
1446
+ state.index = state.sequence.indexOf(file);
1447
+ enterStory();
1448
+ }
1449
+
1450
+ // A pass over every item a filter selects, from its first: "Review 152 undecided".
1451
+ function startPass(filter) {
1452
+ const items = itemsFor(filter);
1453
+ if (items.length === 0) {
1454
+ say("Everything is decided.");
1455
+ return;
1456
+ }
1457
+ state.pass = filter;
1458
+ state.sequence = items.map((i) => i.file);
1459
+ state.index = 0;
1460
+ enterStory();
1461
+ }
1462
+
1463
+ function enterStory() {
1464
+ savePass();
1465
+ state.ended = false;
538
1466
  state.box = 0;
1467
+ if (reduceMotion()) {
1468
+ state.motion = false;
1469
+ }
539
1470
  showStory();
540
1471
  }
541
1472
 
542
- function tile(item, number) {
1473
+ function tile(item) {
1474
+ const number = numberOf(item);
543
1475
  const kind = item.capture ? "capture" : item.baseline ? "baseline" : null;
544
1476
  const img = kind
545
- ? el("img", { "data-kind": kind, "data-file": item.file, "data-hash": item[kind], alt: itemName(item) })
546
- : el("div", { class: "noimage" }, item.status);
547
- if (kind) {
548
- thumbs.observe(img);
549
- }
1477
+ ? el("img", {
1478
+ "data-kind": kind,
1479
+ "data-file": item.file,
1480
+ "data-hash": item[kind],
1481
+ alt: itemName(item),
1482
+ hidden: true,
1483
+ })
1484
+ : null;
550
1485
  const d = decisionOf(item);
551
- return el(
552
- "div",
553
- { class: "tile-box" },
554
- el(
555
- "button",
556
- {
557
- type: "button",
558
- class: `tile ${d ? `decided ${d.decision}` : ""} ${item.file === state.lastFile ? "current" : ""}`,
559
- "data-file": item.file,
560
- onclick: () => openItem(number - 1),
1486
+ const button = el(
1487
+ "button",
1488
+ {
1489
+ type: "button",
1490
+ class: `tile ${d ? `decided ${d.decision}` : ""} ${item.file === state.lastFile ? "current" : ""}`,
1491
+ "data-file": item.file,
1492
+ onclick: (e) => {
1493
+ // A failed thumbnail is fetched again; otherwise the tile opens its item.
1494
+ if (img && e.currentTarget.dataset.failed !== undefined) {
1495
+ thumbQueue.push(img);
1496
+ img.previousElementSibling.textContent = "Loading...";
1497
+ pumpThumbs();
1498
+ return;
1499
+ }
1500
+ openItem(item.file);
561
1501
  },
562
- img,
563
- el("span", { class: "name" }, el("span", { class: "number" }, `${number}`), " ", item.mode ?? ""),
564
- el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
565
- item.from ? el("span", { class: "moved-from", title: movedFrom(item) }, movedFrom(item)) : null,
566
- item.reReview ? el("span", { class: "badge warn", title: RE_REVIEW }, "re-review") : null,
567
- ),
568
- decisionLine(item),
1502
+ },
1503
+ img
1504
+ ? el("span", { class: "thumb-wait", "aria-hidden": "true" }, "Loading...")
1505
+ : el("div", { class: "noimage" }, item.status),
1506
+ img,
1507
+ el("span", { class: "name" }, el("span", { class: "number" }, `${number}`), " ", item.mode ?? ""),
1508
+ el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
1509
+ item.from ? el("span", { class: "moved-from" }, movedFrom(item)) : null,
1510
+ item.reReview ? el("span", { class: "badge warn" }, "re-review") : null,
569
1511
  );
1512
+ if (img) {
1513
+ // The tile, not its image: an image not shown yet has no box to come near the screen.
1514
+ nearScreen.observe(button);
1515
+ }
1516
+ return el("div", { class: "tile-box", "data-file": item.file }, button, decisionLine(item));
570
1517
  }
571
1518
 
1519
+ // A decision an earlier Finish published: a reject stays; an accept or exclusion is on the branch
1520
+ // until the next CI run, which no longer shows it.
1521
+ const postedText = (d) =>
1522
+ d.decision === "reject"
1523
+ ? "Posted by an earlier Finish: it stays."
1524
+ : "Finished: it is on the branch, and the next CI run no longer shows it.";
1525
+
572
1526
  // An item's decision, with its reason, and an Undo that clears it without opening the story. A
573
1527
  // reject an earlier Finish already posted stays: it is shown, never undone from the grid.
574
1528
  function decisionLine(item) {
@@ -580,15 +1534,15 @@ function decisionLine(item) {
580
1534
  return el(
581
1535
  "div",
582
1536
  { class: `decision ${d.decision}`, "data-file": item.file },
583
- el("span", { class: "what", title: text }, text),
1537
+ el("span", { class: "what" }, text),
584
1538
  d.posted
585
- ? el("span", { class: "meta" }, "Posted by Finish: stays")
1539
+ ? el("span", { class: "meta" }, postedText(d))
586
1540
  : el(
587
1541
  "button",
588
1542
  {
589
1543
  type: "button",
590
- title: `Undo the ${d.decision} of ${itemName(item)}`,
591
- onclick: () => undo([item.file], itemName(item), true),
1544
+ "aria-label": `Undo the ${d.decision} of ${itemName(item)}`,
1545
+ onclick: () => undo([item.file], `#${numberOf(item)}`, true),
592
1546
  },
593
1547
  "Undo",
594
1548
  ),
@@ -596,14 +1550,14 @@ function decisionLine(item) {
596
1550
  }
597
1551
 
598
1552
  // A failed capture: its reason, and the console and stack output, in the errors list.
599
- function errorRow(item, number) {
1553
+ function errorRow(item) {
600
1554
  return el(
601
1555
  "li",
602
1556
  { class: item.file === state.lastFile ? "current" : null, "data-file": item.file },
603
1557
  el(
604
1558
  "button",
605
- { type: "button", class: "link", onclick: () => openItem(number - 1) },
606
- el("span", { class: "number" }, `${number}`),
1559
+ { type: "button", class: "link", onclick: () => openItem(item.file) },
1560
+ el("span", { class: "number" }, `${numberOf(item)}`),
607
1561
  ` ${itemName(item)}`,
608
1562
  ),
609
1563
  decisionLine(item),
@@ -612,7 +1566,7 @@ function errorRow(item, number) {
612
1566
  ? el(
613
1567
  "details",
614
1568
  {},
615
- el("summary", {}, `console and stack (${item.console.length} lines)`),
1569
+ el("summary", {}, `console and stack (${plural(item.console.length, "line")})`),
616
1570
  el("pre", { class: "console" }, item.console.join("\n")),
617
1571
  )
618
1572
  : null,
@@ -624,7 +1578,6 @@ const undecided = (component) =>
624
1578
  state.data.items.filter(
625
1579
  (i) => ACCEPTABLE.includes(i.status) && !decisionOf(i) && (!component || componentOf(i.id) === component),
626
1580
  );
627
- const undecidedIn = (component) => undecided(component).length;
628
1581
 
629
1582
  // The files whose decisions a bulk Undo in `component` (or the whole project) clears: every
630
1583
  // decision not yet posted by Finish.
@@ -633,78 +1586,23 @@ const undoableIn = (component) =>
633
1586
  .filter((i) => decisionOf(i) && !decisionOf(i).posted && (!component || componentOf(i.id) === component))
634
1587
  .map((i) => i.file);
635
1588
 
636
- // A bulk Undo asks by a second press: the first arms it, and any other redraw of the grid disarms it.
637
- function undoButton(component, label) {
638
- const files = undoableIn(component);
639
- if (isLocal() || files.length === 0) {
640
- return null;
641
- }
642
- const key = component ?? "";
643
- const where = component ? `the component ${component}` : state.project;
644
- const armed = state.armed === key;
645
- return el(
646
- "button",
647
- {
648
- type: "button",
649
- class: `undo-all ${armed ? "reject" : ""}`,
650
- "data-armed": String(armed),
651
- onclick: () => {
652
- if (armed) {
653
- undo(files, where);
654
- return;
655
- }
656
- state.armed = key;
657
- showGrid();
658
- say(`Press Confirm to undo the decisions of ${where}, or Escape to cancel.`);
659
- },
660
- },
661
- armed ? `Confirm: undo ${files.length} ${files.length === 1 ? "decision" : "decisions"}` : label(files.length),
662
- );
663
- }
664
-
665
- // Clears decisions one by one through the same request as the story screen's U.
666
- // ponytail: one request per item; a bulk endpoint if a project ever holds thousands of decisions.
667
- async function undo(files, where, one = false) {
668
- state.armed = null;
669
- let done = 0;
670
- try {
671
- for (const file of files) {
672
- await api("/api/decide", {
673
- id: state.target.id,
674
- project: state.project,
675
- file,
676
- decision: null,
677
- reason: null,
678
- });
679
- done++;
680
- }
681
- } catch (err) {
682
- say(`Undid ${done} of ${files.length}, then: ${err.message}`, true);
683
- }
684
- try {
685
- await reload();
686
- } catch (err) {
687
- say(err.message, true);
688
- }
689
- if (done === files.length) {
690
- say(
691
- one
692
- ? `${where}: undone, undecided again`
693
- : `Undid ${done} ${done === 1 ? "decision" : "decisions"} of ${where}.`,
694
- );
695
- }
696
- showGrid();
697
- }
698
-
699
1589
  function showGrid() {
700
1590
  stopFlash();
701
- thumbs.disconnect();
1591
+ nearScreen.disconnect();
1592
+ thumbQueue.length = 0;
702
1593
  state.screen = "grid";
703
- state.pending = "reject";
704
- setCrumbs(el("span", {}, targetLabel()), el("span", {}, state.project));
1594
+ state.ended = false;
1595
+ state.pending = null;
1596
+ app.classList.remove("story-screen");
1597
+ drawHeader();
1598
+ const all = reviewable();
705
1599
  const items = visibleItems();
1600
+ const decided = all.filter(decisionOf).length;
1601
+ const open = all.length - decided;
1602
+ const acceptable = state.data.acceptable && !isLocal();
1603
+ const takeable = undecided(null).length;
706
1604
  const counts = {};
707
- for (const i of state.data.items) {
1605
+ for (const i of all) {
708
1606
  counts[i.status] = (counts[i.status] ?? 0) + 1;
709
1607
  }
710
1608
  const filterButton = (f, label) =>
@@ -720,12 +1618,130 @@ function showGrid() {
720
1618
  },
721
1619
  label,
722
1620
  );
1621
+ const more = el(
1622
+ "select",
1623
+ {
1624
+ id: "more-filters",
1625
+ "aria-label": "More filters",
1626
+ onchange: (e) => {
1627
+ state.filter = e.target.value || "undecided";
1628
+ showGrid();
1629
+ },
1630
+ },
1631
+ el("option", { value: "" }, "More filters"),
1632
+ Object.entries(STATUS_FILTERS)
1633
+ .filter(([s]) => counts[s])
1634
+ .map(([s, name]) => el("option", { value: s, selected: state.filter === s }, `${name} (${counts[s]})`)),
1635
+ isLocal()
1636
+ ? null
1637
+ : Object.entries(DECISIONS).map(([k, name]) =>
1638
+ el(
1639
+ "option",
1640
+ { value: k, selected: state.filter === k },
1641
+ `${name} (${all.filter((i) => decisionOf(i)?.decision === k).length})`,
1642
+ ),
1643
+ ),
1644
+ );
1645
+ let findTimer = null;
1646
+ const find = el("input", {
1647
+ id: "find",
1648
+ type: "search",
1649
+ placeholder: "Id or number; Enter opens",
1650
+ value: state.text,
1651
+ autocomplete: "off",
1652
+ oninput: (e) => {
1653
+ clearTimeout(findTimer);
1654
+ findTimer = setTimeout(() => {
1655
+ state.text = e.target.value;
1656
+ applyFind();
1657
+ remember();
1658
+ }, 200);
1659
+ },
1660
+ onkeydown: (e) => {
1661
+ if (e.key === "Enter") {
1662
+ clearTimeout(findTimer);
1663
+ state.text = findText(e.target.value) === "" ? "" : e.target.value;
1664
+ goTo(e.target.value);
1665
+ }
1666
+ },
1667
+ });
1668
+ const undoAll = undoableIn(null);
1669
+ setBar(
1670
+ el(
1671
+ "div",
1672
+ { class: "gridbar" },
1673
+ el(
1674
+ "div",
1675
+ { class: "row" },
1676
+ el("strong", { id: "progress" }, `${decided} of ${all.length} decided`),
1677
+ el(
1678
+ "button",
1679
+ {
1680
+ type: "button",
1681
+ class: "primary",
1682
+ id: "review-undecided",
1683
+ "aria-disabled": String(open === 0),
1684
+ onclick: () => startPass("undecided"),
1685
+ },
1686
+ `Review ${open} undecided`,
1687
+ ),
1688
+ el(
1689
+ "span",
1690
+ { role: "group", "aria-label": "Show" },
1691
+ filterButton("undecided", `Needs a decision (${open})`),
1692
+ filterButton("all", `All (${all.length})`),
1693
+ more,
1694
+ ),
1695
+ ),
1696
+ el(
1697
+ "div",
1698
+ { class: "row" },
1699
+ el("label", { for: "find" }, "Find story", find),
1700
+ acceptable
1701
+ ? el(
1702
+ "button",
1703
+ {
1704
+ type: "button",
1705
+ class: "accept",
1706
+ id: "accept-all",
1707
+ "aria-keyshortcuts": "Shift+A",
1708
+ "aria-disabled": String(takeable === 0),
1709
+ onclick: () => acceptAll(null),
1710
+ },
1711
+ `Accept all undecided (${takeable})`,
1712
+ )
1713
+ : null,
1714
+ el(
1715
+ "details",
1716
+ { class: "menu" },
1717
+ el("summary", {}, "More"),
1718
+ el(
1719
+ "div",
1720
+ {},
1721
+ isLocal()
1722
+ ? null
1723
+ : el(
1724
+ "button",
1725
+ {
1726
+ type: "button",
1727
+ class: "undo-all",
1728
+ "aria-disabled": String(undoAll.length === 0),
1729
+ onclick: () => bulkUndo(null),
1730
+ },
1731
+ "Undo all decisions...",
1732
+ ),
1733
+ el("button", { type: "button", onclick: copyLink }, "Copy link to this grid"),
1734
+ ),
1735
+ ),
1736
+ ),
1737
+ ),
1738
+ );
723
1739
  const errors = [];
724
- const groups = new Map(); // component -> story id -> [[item, number]]
725
- items.forEach((item, i) => {
1740
+ const groups = new Map(); // component -> story id -> [tile]
1741
+ for (const item of items) {
726
1742
  if (item.status === "failed") {
727
- errors.push(errorRow(item, i + 1));
728
- return;
1743
+ errors.push(errorRow(item));
1744
+ continue;
729
1745
  }
730
1746
  const c = componentOf(item.id);
731
1747
  if (!groups.has(c)) {
@@ -735,11 +1751,11 @@ function showGrid() {
735
1751
  if (!stories.has(item.id)) {
736
1752
  stories.set(item.id, []);
737
1753
  }
738
- stories.get(item.id).push(tile(item, i + 1));
739
- });
740
- const acceptable = state.data.acceptable && !isLocal();
1754
+ stories.get(item.id).push(tile(item));
1755
+ }
741
1756
  const sections = [...groups].map(([component, stories]) => {
742
- const n = undecidedIn(component);
1757
+ const n = undecided(component).length;
1758
+ const k = undoableIn(component).length;
743
1759
  return el(
744
1760
  "section",
745
1761
  { class: "component", "data-component": component },
@@ -751,10 +1767,16 @@ function showGrid() {
751
1767
  ? el(
752
1768
  "button",
753
1769
  { type: "button", class: "accept", onclick: () => acceptAll(component) },
754
- `Accept ${n} undecided`,
1770
+ `Accept ${n}`,
1771
+ )
1772
+ : null,
1773
+ !isLocal() && k > 0
1774
+ ? el(
1775
+ "button",
1776
+ { type: "button", class: "undo-all", onclick: () => bulkUndo(component) },
1777
+ `Undo ${k}`,
755
1778
  )
756
1779
  : null,
757
- undoButton(component, (k) => `Undo ${k} ${k === 1 ? "decision" : "decisions"}`),
758
1780
  ),
759
1781
  el(
760
1782
  "div",
@@ -762,7 +1784,7 @@ function showGrid() {
762
1784
  [...stories].map(([id, tiles]) =>
763
1785
  el(
764
1786
  "div",
765
- { class: "story" },
1787
+ { class: "story", "data-story": id },
766
1788
  el("div", { class: "story-name" }, id.slice(component.length + 2) || id),
767
1789
  el("div", { class: "modes" }, tiles),
768
1790
  ),
@@ -770,65 +1792,13 @@ function showGrid() {
770
1792
  ),
771
1793
  );
772
1794
  });
773
- const filterBox = el("input", {
774
- id: "filter",
775
- type: "search",
776
- placeholder: "Filter by story id",
777
- value: state.text,
778
- oninput: (e) => {
779
- state.text = e.target.value;
780
- const at = e.target.selectionStart;
781
- showGrid();
782
- const box = document.getElementById("filter");
783
- box.focus();
784
- box.setSelectionRange(at, at);
785
- },
786
- });
787
- const goto = el("input", {
788
- id: "goto",
789
- type: "text",
790
- placeholder: "Go to: number or story id",
791
- onkeydown: (e) => {
792
- if (e.key === "Enter") {
793
- goTo(e.target.value);
794
- }
795
- },
796
- });
1795
+ const empty =
1796
+ state.filter === "undecided"
1797
+ ? isLocal()
1798
+ ? "Nothing here."
1799
+ : `Everything is decided. Finish ${labelOf(state.target)} when ready.`
1800
+ : "No stories match this filter.";
797
1801
  render(
798
- el(
799
- "div",
800
- { class: "toolbar" },
801
- el("strong", { id: "progress" }, progress()),
802
- filterButton(
803
- "undecided",
804
- `Needs a decision (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && !decisionOf(i)).length})`,
805
- ),
806
- filterButton("all", `All (${state.data.items.filter((i) => REVIEWABLE.includes(i.status)).length})`),
807
- ["changed", "moved", "new", "unstable", "removed", "failed"]
808
- .filter((s) => counts[s])
809
- .map((s) => filterButton(s, `${s} (${counts[s]})`)),
810
- counts[UNSEEDED] ? filterButton(UNSEEDED, `${NO_BASELINE} (${counts[UNSEEDED]})`) : null,
811
- isLocal()
812
- ? null
813
- : Object.entries(DECISIONS).map(([k, label]) =>
814
- filterButton(
815
- k,
816
- `${label} (${state.data.items.filter((i) => REVIEWABLE.includes(i.status) && decisionOf(i)?.decision === k).length})`,
817
- ),
818
- ),
819
- el("span", { class: "spacer" }),
820
- filterBox,
821
- goto,
822
- acceptable
823
- ? el(
824
- "button",
825
- { type: "button", class: "accept", onclick: () => acceptAll(null), title: "Shift+A" },
826
- "Accept all",
827
- )
828
- : null,
829
- undoButton(null, () => "Undo all decisions"),
830
- finishButton(),
831
- ),
832
1802
  isLocal()
833
1803
  ? el(
834
1804
  "p",
@@ -836,24 +1806,15 @@ function showGrid() {
836
1806
  "Local preview: look only. Nothing can be decided here; only a CI capture of a pushed commit can.",
837
1807
  )
838
1808
  : null,
839
- items.length > 0
840
- ? el(
841
- "p",
842
- { id: "shown", class: "meta" },
843
- `Showing ${items.length}: ${errors.length} ${errors.length === 1 ? "error" : "errors"} ` +
844
- `(listed first, never accepted) and ${items.length - errors.length} ` +
845
- `${items.length - errors.length === 1 ? "image" : "images"} to compare.`,
846
- )
847
- : null,
848
1809
  errors.length > 0
849
1810
  ? el(
850
- "section",
851
- { class: "errors" },
852
- el(
853
- "h3",
854
- {},
855
- `Errors: ${errors.length} ${errors.length === 1 ? "story" : "stories"} failed to capture`,
856
- ),
1811
+ "details",
1812
+ {
1813
+ class: "errors",
1814
+ open: loadOptions().showFailed === true,
1815
+ ontoggle: (e) => saveOptions({ showFailed: e.target.open }),
1816
+ },
1817
+ el("summary", {}, `${plural(errors.length, "failed capture")}: only Exclude applies`),
857
1818
  el(
858
1819
  "p",
859
1820
  { class: "meta" },
@@ -863,41 +1824,57 @@ function showGrid() {
863
1824
  el("ol", { class: "error-list" }, errors),
864
1825
  )
865
1826
  : null,
866
- ...(items.length === 0 ? [el("p", {}, "Nothing here.")] : sections),
1827
+ ...(items.length === 0 ? [el("p", { class: "empty" }, empty)] : sections),
867
1828
  );
868
- // An armed bulk Undo lasts until the next redraw: this render showed it, the next one does not.
869
- state.armed = null;
1829
+ applyFind();
870
1830
  remember();
871
1831
  // Back from a story: show where it is in the grid.
872
- const current = app.querySelector(".current");
873
- current?.scrollIntoView({ block: "center" });
1832
+ const here = app.querySelector(".current");
1833
+ here?.scrollIntoView({ block: "center" });
1834
+ }
1835
+
1836
+ // Find story narrows the grid by hiding tiles (not rebuilding them), so typing stays quick.
1837
+ function applyFind() {
1838
+ const text = findText(state.text);
1839
+ for (const node of app.querySelectorAll(".tile-box[data-file], .error-list li[data-file]")) {
1840
+ node.hidden = text !== "" && !node.dataset.file.includes(text);
1841
+ }
1842
+ for (const story of app.querySelectorAll(".story")) {
1843
+ story.hidden = story.querySelector(".tile-box:not([hidden])") === null;
1844
+ }
1845
+ for (const c of app.querySelectorAll(".component")) {
1846
+ c.hidden = c.querySelector(".story:not([hidden])") === null;
1847
+ }
874
1848
  }
875
1849
 
876
- // A number opens that item; anything else opens the first item whose id contains it.
1850
+ // A number opens the item with that number; anything else opens the first item whose id
1851
+ // contains it.
877
1852
  function goTo(value) {
878
1853
  const v = value.trim().toLowerCase();
879
1854
  if (v === "") {
880
1855
  return;
881
1856
  }
882
- let items = visibleItems();
883
- let at = /^\d+$/.test(v) ? Number(v) - 1 : items.findIndex((i) => i.file.includes(v));
884
- if (at < 0 || at >= items.length) {
885
- if (/^\d+$/.test(v)) {
886
- say(`There is no item ${v}: the list has ${items.length}.`, true);
1857
+ const all = ordered(reviewable());
1858
+ if (/^\d+$/.test(v)) {
1859
+ const n = Number(v);
1860
+ if (n < 1 || n > all.length) {
1861
+ say(`No item ${v}: items are numbered 1 to ${all.length} in All.`, true);
887
1862
  return;
888
1863
  }
1864
+ openItem(all[n - 1].file);
1865
+ return;
1866
+ }
1867
+ let hit = visibleItems().find((i) => i.file.includes(v));
1868
+ if (!hit) {
889
1869
  // Not in this filter: look in everything reviewable.
890
- state.filter = "all";
891
- state.text = "";
892
- items = visibleItems();
893
- at = items.findIndex((i) => i.file.includes(v));
894
- if (at < 0) {
1870
+ hit = all.find((i) => i.file.includes(v));
1871
+ if (!hit) {
895
1872
  say(`No story id contains "${value.trim()}".`, true);
896
- showGrid();
897
1873
  return;
898
1874
  }
1875
+ state.filter = "all";
899
1876
  }
900
- openItem(at);
1877
+ openItem(hit.file);
901
1878
  }
902
1879
 
903
1880
  // ---------------------------------------------------------------- screen: story
@@ -913,23 +1890,101 @@ function singleImageNote(item) {
913
1890
  if (item.status === "failed") {
914
1891
  return "No image: the capture failed.";
915
1892
  }
916
- return "New story, no baseline: there is only the new image.";
1893
+ return "Only one image: there is no baseline to compare it with.";
1894
+ }
1895
+
1896
+ const UNSTABLE =
1897
+ "Unstable: two captures of the same commit differed, so it cannot be accepted or rejected. Fix the story, or exclude it.";
1898
+ const FAILED = "Failed: the story did not render. Fix it, re-run the visual job, or exclude it.";
1899
+ const notSeeded = () => `Accept on a pull request: ${state.project} is not seeded from ${branchName()}.`;
1900
+ const onlyExclude = (item) => item.status === "unstable" || item.status === "failed";
1901
+
1902
+ // Which decision buttons apply to `item` now; one that does not is shown unavailable, never removed.
1903
+ function available(item, d) {
1904
+ const open = !isLocal() && !state.ended;
1905
+ const acceptable = state.data.acceptable;
1906
+ return {
1907
+ accept: open && acceptable && !onlyExclude(item) && !d,
1908
+ reject: open && !onlyExclude(item) && !d,
1909
+ exclude: open && acceptable && !d,
1910
+ undo: open && Boolean(d) && !d.posted,
1911
+ };
1912
+ }
1913
+
1914
+ // The item line's second line: why a button is unavailable or what it will do, the item's status
1915
+ // note, or what changed.
1916
+ function explanation(item, d) {
1917
+ if (isLocal()) {
1918
+ return "Local preview: look only. Only a CI capture of a pushed commit can be decided.";
1919
+ }
1920
+ if (state.ended) {
1921
+ return "End of this pass: choose what is next.";
1922
+ }
1923
+ if (state.pending === "exclude") {
1924
+ return "Exclude stops capturing every mode of this story.";
1925
+ }
1926
+ if (d?.posted) {
1927
+ return postedText(d);
1928
+ }
1929
+ if (item.status === "unstable") {
1930
+ return UNSTABLE;
1931
+ }
1932
+ if (item.status === "failed") {
1933
+ return FAILED;
1934
+ }
1935
+ if (!d && !state.data.acceptable) {
1936
+ return notSeeded();
1937
+ }
1938
+ if (item.status === "removed") {
1939
+ return "Removed from the Storybook: Accept deletes its baseline.";
1940
+ }
1941
+ if (item.status === UNSEEDED) {
1942
+ return (
1943
+ `No baseline yet, and this pull request does not change it: it looks as on ${branchName()}. ` +
1944
+ "Accepting it makes this image its first baseline."
1945
+ );
1946
+ }
1947
+ if (item.status === "new") {
1948
+ return "New story: no baseline yet.";
1949
+ }
1950
+ const parts = [];
1951
+ if (item.from) {
1952
+ parts.push(`Moved from ${item.from}: Accept moves its baseline to this id. `);
1953
+ }
1954
+ if (item.changedPixels !== null && item.changedPixels !== undefined) {
1955
+ parts.push(`${plural(item.changedPixels, "pixel")} changed`);
1956
+ if (item.bbox) {
1957
+ const [x, y, w, h] = item.bbox;
1958
+ parts.push(`, in a ${w} x ${h} area at (${x}, ${y})`);
1959
+ }
1960
+ parts.push(". ");
1961
+ }
1962
+ parts.push(`Threshold ${item.threshold}.`);
1963
+ return parts.join("");
917
1964
  }
918
1965
 
919
- function showStory() {
1966
+ // The id of the decision-bar button that has focus, to give it back after a redraw.
1967
+ function barFocus() {
1968
+ const a = document.activeElement;
1969
+ return a && a.tagName === "BUTTON" && bar.contains(a) && a.id ? a.id : null;
1970
+ }
1971
+
1972
+ function showStory({ focusNote = false } = {}) {
920
1973
  stopFlash();
921
- state.screen = "story";
922
1974
  const items = passItems();
923
1975
  if (items.length === 0) {
924
1976
  showGrid();
925
1977
  return;
926
1978
  }
1979
+ const focusId = barFocus();
1980
+ state.screen = "story";
927
1981
  state.index = Math.max(0, Math.min(state.index, items.length - 1));
928
1982
  const item = items[state.index];
929
1983
  if (item.file !== state.lastFile) {
930
1984
  // An Exclude (or reject) left waiting for a reason on another item is abandoned.
931
- state.pending = "reject";
1985
+ state.pending = null;
932
1986
  stageShown = null;
1987
+ appearedAt = performance.now();
933
1988
  }
934
1989
  state.lastFile = item.file;
935
1990
  const d = decisionOf(item);
@@ -938,44 +1993,236 @@ function showStory() {
938
1993
  // grid cannot bring back an accept another tab undid or a Finish committed; the decisions
939
1994
  // are then read again, so such an item shows as undecided.
940
1995
  delete d.bulk;
1996
+ d.wasBulk = true;
941
1997
  const { id } = state.target;
942
1998
  api("/api/decide", { id, project: state.project, file: item.file, opened: true, hash: imageHash(item) })
943
1999
  .then(reload)
944
2000
  .then(() => {
945
2001
  if (!decisionOf(item) && state.screen === "story" && state.lastFile === item.file) {
946
2002
  showStory();
947
- say(`${itemName(item)}: its accept was undone or finished elsewhere; undecided now`);
2003
+ say(`#${numberOf(item)}: its accept was undone or finished elsewhere; undecided now`);
948
2004
  }
949
2005
  })
950
2006
  .catch((err) => say(err.message, true));
951
2007
  }
2008
+ app.classList.add("story-screen");
2009
+ drawHeader();
2010
+ setBar(decisionBar(item, items, d));
952
2011
  const note = singleImageNote(item);
953
2012
  const view = note ? "side" : state.view;
954
- const onlyExclude = item.status === "unstable" || item.status === "failed";
955
- const unseeded = item.status === UNSEEDED;
956
- const decidable = !isLocal();
957
- setCrumbs(
958
- el("button", { type: "button", class: "link", onclick: showGrid }, `${targetLabel()} / ${state.project}`),
959
- el("span", {}, itemName(item)),
2013
+ // Keep the panes' scroll when only the view changes (F, H, S, Space) on the same item and zoom.
2014
+ const oldFrame = document.querySelector("#stage .sheet")?.parentElement;
2015
+ const keep =
2016
+ !state.ended && oldFrame && stageKey === `${item.file}|${state.zoom}`
2017
+ ? [oldFrame.scrollLeft, oldFrame.scrollTop]
2018
+ : null;
2019
+ render(
2020
+ el(
2021
+ "div",
2022
+ { class: "story-view" },
2023
+ itemLine(item, d),
2024
+ viewBar(item, view, note),
2025
+ state.ended
2026
+ ? endCard()
2027
+ : el("div", { id: "stage", class: `stage ${view} zoom-${state.zoom}`, "data-file": item.file }),
2028
+ ),
960
2029
  );
2030
+ if (!state.ended) {
2031
+ renderStage(item, view, keep);
2032
+ }
2033
+ // Focus goes back where it was: on the same bar button if one had it, never into the note box
2034
+ // unless a reject or exclude is waiting for its reason.
2035
+ if (focusNote) {
2036
+ document.getElementById("note").focus();
2037
+ } else if (focusId && document.getElementById(focusId)) {
2038
+ document.getElementById(focusId).focus({ preventScroll: true });
2039
+ } else if (!state.ended) {
2040
+ app.focus({ preventScroll: true });
2041
+ }
2042
+ remember();
2043
+ }
2044
+
2045
+ // The decision bar: Grid, Previous, the count, Next, then Accept, Reject, Exclude, Undo and the
2046
+ // note box, each always present in the same place.
2047
+ function decisionBar(item, items, d) {
2048
+ const can = available(item, d);
2049
+ const left = items.filter((i) => !decisionOf(i)).length;
2050
+ const waitingImages = can.accept && stageShown !== item.file;
2051
+ const button = (id, name, key, ok, extra = {}, ...prefix) =>
2052
+ el(
2053
+ "button",
2054
+ {
2055
+ type: "button",
2056
+ id,
2057
+ "aria-disabled": String(!ok),
2058
+ "aria-describedby": ok ? null : state.ended ? "end-heading" : "explain",
2059
+ "aria-keyshortcuts": key,
2060
+ ...extra,
2061
+ },
2062
+ ...prefix,
2063
+ name,
2064
+ kbd(key),
2065
+ );
2066
+ const draft = drafts.get(draftKey(item)) ?? "";
2067
+ return el(
2068
+ "div",
2069
+ { class: "decisionbar", role: "toolbar", "aria-label": "Decide" },
2070
+ el(
2071
+ "button",
2072
+ { type: "button", id: "to-grid", "aria-keyshortcuts": "Escape", onclick: () => toGrid() },
2073
+ "Grid",
2074
+ kbd("Esc"),
2075
+ ),
2076
+ el(
2077
+ "button",
2078
+ { type: "button", id: "prev", "aria-keyshortcuts": "K", onclick: () => move(-1) },
2079
+ "Prev",
2080
+ kbd("K"),
2081
+ ),
2082
+ el(
2083
+ "span",
2084
+ { id: "position" },
2085
+ state.ended ? `End of this pass -- ${left} left` : `${state.index + 1} of ${items.length} -- ${left} left`,
2086
+ ),
2087
+ el(
2088
+ "button",
2089
+ { type: "button", id: "next", "aria-keyshortcuts": "J", onclick: () => move(1) },
2090
+ "Next",
2091
+ kbd("J"),
2092
+ ),
2093
+ button(
2094
+ "accept",
2095
+ "Accept",
2096
+ "A",
2097
+ can.accept,
2098
+ {
2099
+ class: `accept ${waitingImages ? "loading" : ""}`,
2100
+ "aria-pressed": String(d?.decision === "accept"),
2101
+ onclick: () => decide("accept"),
2102
+ },
2103
+ waitingImages ? spinner() : null,
2104
+ ),
2105
+ button("reject", "Reject", "R", can.reject, {
2106
+ class: `reject ${state.pending === "reject" ? "waiting" : ""}`,
2107
+ "aria-pressed": String(d?.decision === "reject"),
2108
+ onclick: () => decide("reject"),
2109
+ }),
2110
+ button("exclude", "Exclude", "E", can.exclude, {
2111
+ class: state.pending === "exclude" ? "waiting" : null,
2112
+ "aria-pressed": String(d?.decision === "exclude"),
2113
+ onclick: () => decide("exclude"),
2114
+ }),
2115
+ button("undo", "Undo", "U", can.undo, { onclick: () => decide(null) }),
2116
+ el(
2117
+ "span",
2118
+ { class: "notebox" },
2119
+ el("label", { for: "note" }, "Note"),
2120
+ el("input", {
2121
+ id: "note",
2122
+ type: "text",
2123
+ maxlength: "2000",
2124
+ autocomplete: "off",
2125
+ readonly: Boolean(d) || isLocal() || state.ended,
2126
+ placeholder: d
2127
+ ? ""
2128
+ : state.pending
2129
+ ? `Reason to ${state.pending}, then Enter`
2130
+ : "Needed to Reject or Exclude",
2131
+ value: d ? (d.reason ?? "") : draft,
2132
+ oninput: (e) => drafts.set(draftKey(item), e.target.value),
2133
+ onkeydown: (e) => {
2134
+ if (e.key === "Enter" && !e.repeat) {
2135
+ e.preventDefault();
2136
+ if (state.pending) {
2137
+ decide(state.pending);
2138
+ } else {
2139
+ e.target.blur();
2140
+ }
2141
+ }
2142
+ },
2143
+ }),
2144
+ ),
2145
+ );
2146
+ }
2147
+
2148
+ // Item number, name and badges; then one explanation. Not a live region: the status row speaks.
2149
+ function itemLine(item, d) {
961
2150
  const sizeChanged =
962
2151
  item.size &&
963
2152
  item.baselineSize &&
964
2153
  (item.size[0] !== item.baselineSize[0] || item.size[1] !== item.baselineSize[1]);
2154
+ return el(
2155
+ "div",
2156
+ { class: "itemline", onclick: (e) => e.currentTarget.classList.toggle("open") },
2157
+ el(
2158
+ "h2",
2159
+ {},
2160
+ el("span", { class: "number" }, `#${numberOf(item)}`),
2161
+ ` ${itemName(item)} `,
2162
+ el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
2163
+ item.from ? el("span", { class: "badge moved" }, movedFrom(item)) : null,
2164
+ d
2165
+ ? el(
2166
+ "span",
2167
+ { class: `badge ${d.decision}` },
2168
+ `${DECISIONS[d.decision]}${d.reason ? `: ${d.reason}` : ""}`,
2169
+ )
2170
+ : null,
2171
+ sizeChanged
2172
+ ? el(
2173
+ "span",
2174
+ { class: "badge warn" },
2175
+ `size changed ${item.baselineSize.join(" x ")} -> ${item.size.join(" x ")}`,
2176
+ )
2177
+ : null,
2178
+ item.flaky ? el("span", { class: "badge" }, "flaky") : null,
2179
+ item.reReview
2180
+ ? el(
2181
+ "span",
2182
+ { class: "badge warn" },
2183
+ `re-review: your earlier accept was replaced by ${branchName()}'s baseline`,
2184
+ )
2185
+ : null,
2186
+ ),
2187
+ el("p", { id: "explain" }, explanation(item, d)),
2188
+ );
2189
+ }
2190
+
2191
+ function viewBar(item, view, note) {
2192
+ const two = Boolean(item.baseline && item.capture);
965
2193
  const viewButton = (v, label, key) =>
966
2194
  el(
967
2195
  "button",
968
2196
  {
969
2197
  type: "button",
970
2198
  "aria-pressed": String(view === v),
971
- disabled: Boolean(note) && v !== "side",
972
- title: note && v !== "side" ? note : key,
2199
+ "aria-disabled": String(Boolean(note) && v !== "side"),
2200
+ "aria-keyshortcuts": key,
973
2201
  onclick: () => {
2202
+ if (note && v !== "side") {
2203
+ say(note);
2204
+ return;
2205
+ }
974
2206
  state.view = v;
2207
+ state.motion = true;
975
2208
  showStory();
976
2209
  },
977
2210
  },
978
2211
  label,
2212
+ key ? kbd(key) : null,
2213
+ );
2214
+ const option = (key, label, letter) =>
2215
+ el(
2216
+ "button",
2217
+ {
2218
+ type: "button",
2219
+ id: `opt-${key}`,
2220
+ "aria-pressed": String(state[key]),
2221
+ "aria-keyshortcuts": letter,
2222
+ onclick: () => toggleOption(key),
2223
+ },
2224
+ label,
2225
+ kbd(letter),
979
2226
  );
980
2227
  const zoomButton = (z) =>
981
2228
  el(
@@ -983,214 +2230,538 @@ function showStory() {
983
2230
  {
984
2231
  type: "button",
985
2232
  "aria-pressed": String(state.zoom === z),
986
- title: "Z cycles the zoom",
987
2233
  onclick: () => {
988
2234
  state.zoom = z;
989
2235
  showStory();
990
2236
  },
991
2237
  },
992
- z === "fit" ? "Fit to screen" : z === 1 ? "Real size (1x)" : `${z}x`,
2238
+ z === "fit" ? "Fit" : `${z}x`,
993
2239
  );
994
- const reason = el("input", {
995
- id: "reason",
996
- type: "text",
997
- placeholder: reasonHint(),
998
- value: d?.reason ?? "",
999
- maxlength: "2000",
1000
- onkeydown: (e) => {
1001
- if (e.key === "Enter" && !e.repeat) {
1002
- decide(state.pending);
1003
- }
1004
- },
1005
- });
1006
- const decisionButtons = d
1007
- ? [
1008
- el("span", { class: "meta" }, `Decided: ${d.decision}. To change it, undo it first.`),
1009
- el("button", { type: "button", onclick: () => decide(null), title: "U" }, "Undo"),
1010
- ]
1011
- : [
1012
- state.data.acceptable && !onlyExclude
1013
- ? el(
1014
- "button",
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
- },
1023
- "Accept",
1024
- )
1025
- : null,
1026
- !onlyExclude
1027
- ? el(
1028
- "button",
1029
- { type: "button", class: "reject", onclick: () => clicked("reject"), title: "R" },
1030
- "Reject",
1031
- )
1032
- : null,
1033
- state.data.acceptable
1034
- ? el(
1035
- "button",
1036
- {
1037
- type: "button",
1038
- onclick: () => clicked("exclude"),
1039
- title: "E: stop capturing every mode of this story",
1040
- },
1041
- "Exclude",
1042
- )
1043
- : null,
1044
- reason,
1045
- ];
1046
- // Keep the panes' scroll when only the view changes (F, H, S, Space) on the same item and zoom.
1047
- const oldFrame = document.querySelector("#stage .sheet")?.parentElement;
1048
- const keep =
1049
- oldFrame && stageKey === `${item.file}|${state.zoom}` ? [oldFrame.scrollLeft, oldFrame.scrollTop] : null;
1050
- // One screen: the controls, then the two panes taking all the height left, then the decisions.
1051
- render(
2240
+ const details = [
2241
+ `Threshold ${item.threshold}; anti-aliased pixels are ${item.includeAA ? "counted" : "not counted"}.`,
2242
+ `Captured at ${scale()} image ${scale() === 1 ? "pixel" : "pixels"} per CSS pixel.`,
2243
+ item.size ? `Size ${item.size.join(" x ")} image pixels.` : null,
2244
+ ].filter(Boolean);
2245
+ return el(
2246
+ "div",
2247
+ { class: "viewbar", role: "toolbar", "aria-label": "View" },
1052
2248
  el(
1053
- "div",
1054
- { class: "story-view" },
2249
+ "span",
2250
+ { role: "group", "aria-label": "View" },
2251
+ viewButton("side", "Side by side", null),
2252
+ viewButton("flash", "Flash", "F"),
2253
+ viewButton("highlight", "Highlight", "H"),
2254
+ viewButton("spotlight", "Spotlight", "S"),
2255
+ ),
2256
+ // One slot of fixed width for the view's own option, so no view moves or wraps the bar.
2257
+ el(
2258
+ "span",
2259
+ { class: "view-option" },
2260
+ view === "highlight" ? option("blink", "Blink", "L") : null,
2261
+ view === "spotlight" ? option("spotFlash", "Spotlight flash", "F") : null,
2262
+ ),
2263
+ el("span", { class: "row-break", "aria-hidden": "true" }),
2264
+ option("showBox", "Outline", "B"),
2265
+ el(
2266
+ "button",
2267
+ {
2268
+ type: "button",
2269
+ id: "next-box",
2270
+ "aria-disabled": String(!two),
2271
+ "aria-keyshortcuts": "N",
2272
+ onclick: nextBox,
2273
+ },
2274
+ "Next change",
2275
+ kbd("N"),
2276
+ ),
2277
+ el("span", { id: "box-count", class: "meta" }),
2278
+ el("span", { role: "group", "aria-label": "Zoom" }, ZOOMS.map(zoomButton)),
2279
+ kbd("Z"),
2280
+ el(
2281
+ "details",
2282
+ { class: "details-pop" },
2283
+ el("summary", {}, "Details"),
1055
2284
  el(
1056
2285
  "div",
1057
- { class: "toolbar" },
1058
- el("button", { type: "button", onclick: () => move(-1), title: "K" }, "Previous"),
1059
- el("strong", { id: "position" }, `${state.index + 1} of ${items.length}`),
1060
- el("button", { type: "button", onclick: () => move(1), title: "J" }, "Next"),
1061
- el("strong", { id: "progress" }, progress()),
1062
- el("span", { class: "spacer" }),
1063
- el("button", { type: "button", onclick: showGrid, title: "Escape" }, "Back to the grid"),
1064
- finishButton(),
1065
- ),
1066
- el(
1067
- "h2",
1068
2286
  {},
1069
- itemName(item),
1070
- " ",
1071
- el("span", { class: `badge ${item.status}` }, statusLabel(item.status)),
1072
- item.from ? el("span", { class: "badge moved" }, movedFrom(item)) : null,
1073
- d
1074
- ? el("span", { class: `badge ${d.decision}` }, `${d.decision}${d.reason ? `: ${d.reason}` : ""}`)
1075
- : null,
1076
- sizeChanged
1077
- ? el(
1078
- "span",
1079
- { class: "badge warn" },
1080
- `size changed ${item.baselineSize.join("x")} -> ${item.size.join("x")} image pixels`,
1081
- )
2287
+ details.map((t) => el("p", {}, t)),
2288
+ item.console.length > 0 && item.status !== "failed"
2289
+ ? el("pre", { class: "console" }, item.console.join("\n"))
1082
2290
  : null,
1083
- item.flaky ? el("span", { class: "badge" }, "flaky") : null,
1084
- item.reReview ? el("span", { class: "badge warn" }, RE_REVIEW) : null,
1085
2291
  ),
2292
+ ),
2293
+ );
2294
+ }
2295
+
2296
+ // Sizes every picture on the stage at one scale, so equal-size images line up pixel for pixel in
2297
+ // their panes. At "fit" the largest of them fits its pane both ways (never above real size, one CSS
2298
+ // pixel per CSS pixel the story was drawn at); zoomed, it is real size times the zoom and the
2299
+ // panes scroll. Each pane's sheet is as large as the largest picture, so both scroll alike.
2300
+ function fit(stage) {
2301
+ const pics = [...stage.querySelectorAll(".sheet > img, .sheet > canvas")];
2302
+ if (pics.length === 0) {
2303
+ return;
2304
+ }
2305
+ const natural = (p) => [p.naturalWidth ?? p.width, p.naturalHeight ?? p.height];
2306
+ let [w, h] = [1, 1];
2307
+ for (const p of pics) {
2308
+ const [x, y] = natural(p);
2309
+ [w, h] = [Math.max(w, x), Math.max(h, y)];
2310
+ }
2311
+ const frame = stage.querySelector(".frame");
2312
+ factor =
2313
+ state.zoom === "fit"
2314
+ ? Math.min(1 / scale(), frame.clientWidth / w, frame.clientHeight / h)
2315
+ : state.zoom / scale();
2316
+ for (const p of pics) {
2317
+ const [x, y] = natural(p);
2318
+ p.style.width = `${x * factor}px`;
2319
+ p.style.height = `${y * factor}px`;
2320
+ p.classList.toggle("crisp", factor * scale() >= CRISP_FROM);
2321
+ }
2322
+ for (const sheet of stage.querySelectorAll(".sheet")) {
2323
+ sheet.style.width = `${w * factor}px`;
2324
+ sheet.style.height = `${h * factor}px`;
2325
+ }
2326
+ }
2327
+
2328
+ const pane = (text, content, empty = false) =>
2329
+ el(
2330
+ "figure",
2331
+ {},
2332
+ el("div", { class: "label" }, text),
2333
+ el("div", { class: empty ? "frame empty" : "frame" }, content),
2334
+ );
2335
+ const paneWait = (what) => el("p", { class: "wait" }, spinner(), `Loading ${what}...`);
2336
+ const paneError = (message) =>
2337
+ el(
2338
+ "p",
2339
+ { class: "failed error" },
2340
+ message || "the images could not be loaded",
2341
+ " ",
2342
+ el("button", { type: "button", onclick: () => showStory() }, "Retry"),
2343
+ );
2344
+
2345
+ // Two panes, always: the baseline on the left and the new image (or the view's picture) on the
2346
+ // right, both drawn at once with what they are waiting for, so nothing moves when the images
2347
+ // arrive. A missing image leaves its pane empty, the same size, so the other one never moves.
2348
+ async function renderStage(item, view, keep) {
2349
+ const seq = ++stageRender;
2350
+ const stage = document.getElementById("stage");
2351
+ const baseName = item.from ? `Baseline of ${item.from}` : "Baseline";
2352
+ const imgOf = async (kind) => {
2353
+ const img = await loaded(
2354
+ await image(kind, item.file, item[kind]),
2355
+ `${kind === "capture" ? "new image" : "baseline"} of ${item.file}`,
2356
+ );
2357
+ img.alt = `${kind === "capture" ? "new image" : "baseline"} of ${itemName(item)}`;
2358
+ return img;
2359
+ };
2360
+ const emptyRight = () =>
2361
+ item.status === "failed"
2362
+ ? pane(
2363
+ "No capture: it failed",
2364
+ item.console.length > 0
2365
+ ? el("pre", { class: "console" }, `${item.reason ?? ""}\n\n${item.console.join("\n")}`)
2366
+ : el("p", { class: "failed" }, item.reason ?? "capture failed"),
2367
+ true,
2368
+ )
2369
+ : pane("No capture", null, true);
2370
+ const left = item.baseline ? pane(baseName, paneWait("baseline")) : pane("No baseline", null, true);
2371
+ const right = item.capture ? pane("New", paneWait("new image")) : emptyRight();
2372
+ if (!keep) {
2373
+ stage.replaceChildren(left, right);
2374
+ }
2375
+ // Skimming (J held): the item before was left before its images even showed, so this one waits
2376
+ // SKIM_MS before fetching; passed in that time, it fetches nothing, and the item stopped on is
2377
+ // not queued behind every item skipped.
2378
+ const now = performance.now();
2379
+ const skimming = now - lastStageAt < SKIM_MS && lastShown !== lastStageFile;
2380
+ lastStageAt = now;
2381
+ lastStageFile = item.file;
2382
+ if (skimming) {
2383
+ await sleep(SKIM_MS);
2384
+ if (seq !== stageRender) {
2385
+ return;
2386
+ }
2387
+ }
2388
+ let diff = null;
2389
+ try {
2390
+ if (view === "side") {
2391
+ // Each image as soon as it arrives; the changed areas fill in afterwards.
2392
+ const fill = async (kind, figure) => {
2393
+ const frame = figure.querySelector(".frame");
2394
+ try {
2395
+ const img = await imgOf(kind);
2396
+ if (seq === stageRender) {
2397
+ frame.replaceChildren(el("div", { class: "sheet" }, img));
2398
+ fit(stage);
2399
+ }
2400
+ } catch (err) {
2401
+ if (seq === stageRender) {
2402
+ frame.replaceChildren(paneError(err.message));
2403
+ }
2404
+ throw err;
2405
+ }
2406
+ };
2407
+ await Promise.all([item.baseline && fill("baseline", left), item.capture && fill("capture", right)]);
2408
+ if (seq !== stageRender) {
2409
+ return;
2410
+ }
2411
+ if (keep) {
2412
+ stage.replaceChildren(left, right);
2413
+ }
2414
+ } else {
2415
+ diff = await diffOf(item);
2416
+ const marked = view === "highlight";
2417
+ const leftPics = [await imgOf("baseline"), ...(marked ? [overlay(diff)] : [])];
2418
+ let rightLabel;
2419
+ let rightPics;
2420
+ const flashing = view === "flash" || (view === "spotlight" && state.spotFlash);
2421
+ if (flashing) {
2422
+ // The two images one after the other in the same place: themselves, or both spotlighted.
2423
+ const flash = view === "flash";
2424
+ rightPics = flash
2425
+ ? [await imgOf("baseline"), await imgOf("capture")]
2426
+ : [spotlight(diff, diff.a), spotlight(diff, diff.b)];
2427
+ rightLabel = `${flash ? "Flash" : "Spotlight"}: baseline${state.motion ? "" : " (stopped: press F)"}`;
2428
+ } else if (marked) {
2429
+ rightPics = [await imgOf("capture"), overlay(diff)];
2430
+ rightLabel = "New, changed pixels in red";
2431
+ } else {
2432
+ rightPics = [spotlight(diff)];
2433
+ rightLabel = "Spotlight: the new image, dimmed except around each change";
2434
+ }
2435
+ if (seq !== stageRender) {
2436
+ return;
2437
+ }
2438
+ const l = pane(
2439
+ marked ? `${baseName}, changed pixels in red` : baseName,
2440
+ el("div", { class: "sheet" }, leftPics),
2441
+ );
2442
+ const r = pane(rightLabel, el("div", { class: "sheet" }, rightPics));
2443
+ stage.replaceChildren(l, r);
2444
+ if (flashing) {
2445
+ const [base, next] = rightPics;
2446
+ next.style.visibility = "hidden";
2447
+ r.classList.add("flashing");
2448
+ if (state.motion) {
2449
+ const name = view === "flash" ? "Flash" : "Spotlight";
2450
+ const tag = r.querySelector(".label");
2451
+ let showingNew = false;
2452
+ flashTimer = setInterval(() => {
2453
+ showingNew = !showingNew;
2454
+ base.style.visibility = showingNew ? "hidden" : "visible";
2455
+ next.style.visibility = showingNew ? "visible" : "hidden";
2456
+ tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
2457
+ }, FLASH_MS);
2458
+ }
2459
+ }
2460
+ if (marked && state.blink && state.motion) {
2461
+ // Both panes' overlays on and off together, at Flash's pace.
2462
+ const marks = [...stage.querySelectorAll(".diffmark")];
2463
+ let on = true;
2464
+ flashTimer = setInterval(() => {
2465
+ on = !on;
2466
+ for (const m of marks) {
2467
+ m.style.visibility = on ? "visible" : "hidden";
2468
+ }
2469
+ }, FLASH_MS);
2470
+ }
2471
+ }
2472
+ } catch (err) {
2473
+ if (seq === stageRender) {
2474
+ if (view !== "side") {
2475
+ stage.replaceChildren(pane(baseName, paneError(err.message)), pane("New", paneError(err.message)));
2476
+ } else if (keep) {
2477
+ stage.replaceChildren(left, right);
2478
+ }
2479
+ }
2480
+ return;
2481
+ }
2482
+ shown(stage, item);
2483
+ const frames = [...stage.querySelectorAll(".frame")];
2484
+ // Zoomed, scrolling one pane scrolls the other to the same place.
2485
+ for (const f of frames) {
2486
+ f.addEventListener("scroll", () => {
2487
+ for (const o of frames) {
2488
+ if (o !== f && (o.scrollLeft !== f.scrollLeft || o.scrollTop !== f.scrollTop)) {
2489
+ o.scrollLeft = f.scrollLeft;
2490
+ o.scrollTop = f.scrollTop;
2491
+ }
2492
+ }
2493
+ });
2494
+ }
2495
+ fit(stage);
2496
+ refit.disconnect();
2497
+ refit.observe(stage);
2498
+ stageKey = `${item.file}|${state.zoom}`;
2499
+ if (keep) {
2500
+ for (const f of frames) {
2501
+ [f.scrollLeft, f.scrollTop] = keep;
2502
+ }
2503
+ }
2504
+ if (item.baseline && item.capture) {
2505
+ try {
2506
+ diff ??= await diffOf(item);
2507
+ } catch {
2508
+ return;
2509
+ }
2510
+ if (seq !== stageRender) {
2511
+ return;
2512
+ }
2513
+ shownBoxes = diff.boxes;
2514
+ const count = document.getElementById("box-count");
2515
+ count.textContent =
2516
+ diff.boxes.length === 0
2517
+ ? "No changed area at this threshold"
2518
+ : `${Math.min(state.box, diff.boxes.length - 1) + 1} of ${diff.boxes.length}`;
2519
+ showBox(stage, diff.boxes, false, !keep);
2520
+ } else {
2521
+ shownBoxes = [];
2522
+ }
2523
+ }
2524
+
2525
+ // The item's images are on the screen: Accept applies, the next two items load in the
2526
+ // background, and a quarter second later the stage says it is ready (a decision before then is
2527
+ // taken for the second tap of a double tap).
2528
+ function shown(stage, item) {
2529
+ const first = stageShown !== item.file;
2530
+ stageShown = item.file;
2531
+ lastShown = item.file;
2532
+ const accept = document.getElementById("accept");
2533
+ if (accept?.classList.contains("loading")) {
2534
+ accept.classList.remove("loading");
2535
+ accept.querySelector(".spinner")?.remove();
2536
+ }
2537
+ if (first) {
2538
+ appearedAt = performance.now();
2539
+ for (const next of passItems().slice(state.index + 1, state.index + 3)) {
2540
+ for (const kind of ["baseline", "capture"]) {
2541
+ if (next[kind]) {
2542
+ image(kind, next.file, next[kind]).catch(() => {});
2543
+ }
2544
+ }
2545
+ }
2546
+ }
2547
+ setTimeout(
2548
+ () => {
2549
+ if (stage.isConnected && stageShown === item.file) {
2550
+ stage.dataset.ready = "";
2551
+ }
2552
+ },
2553
+ Math.max(0, GUARD_MS - (performance.now() - appearedAt)),
2554
+ );
2555
+ }
2556
+
2557
+ // Outlines the current changed area in every pane, and scrolls it into view. A pane opens at the
2558
+ // top left of its image (`reset`); on opening (`jump` false) it scrolls only when the area is
2559
+ // smaller than the pane and out of sight. Next change (`jump` true) always brings the area into
2560
+ // view, its top left first when it is larger than the pane.
2561
+ function showBox(stage, boxes, jump, reset = true) {
2562
+ if (boxes.length === 0) {
2563
+ return;
2564
+ }
2565
+ state.box = Math.min(state.box, boxes.length - 1);
2566
+ const [x, y, w, h] = boxes[state.box];
2567
+ const PAD = 16;
2568
+ for (const frame of stage.querySelectorAll(".frame")) {
2569
+ const sheet = frame.querySelector(".sheet");
2570
+ if (!sheet) {
2571
+ continue;
2572
+ }
2573
+ const [sw, sh] = [sheet.offsetWidth, sheet.offsetHeight];
2574
+ // The outline is drawn inside the image, so an area at an edge keeps all four sides.
2575
+ const [left, top] = [Math.max(0, x * factor - 2), Math.max(0, y * factor - 2)];
2576
+ const [right, bottom] = [Math.min(sw, (x + w) * factor + 2), Math.min(sh, (y + h) * factor + 2)];
2577
+ sheet.querySelector(".boxmark")?.remove();
2578
+ if (state.showBox) {
2579
+ const mark = el("div", { class: "boxmark" });
2580
+ Object.assign(mark.style, {
2581
+ left: `${left}px`,
2582
+ top: `${top}px`,
2583
+ width: `${right - left}px`,
2584
+ height: `${bottom - top}px`,
2585
+ });
2586
+ sheet.append(mark);
2587
+ }
2588
+ if (!jump && !reset) {
2589
+ continue;
2590
+ }
2591
+ if (!jump) {
2592
+ frame.scrollLeft = 0;
2593
+ frame.scrollTop = 0;
2594
+ }
2595
+ const fits = right - left <= frame.clientWidth && bottom - top <= frame.clientHeight;
2596
+ if (!jump && !fits) {
2597
+ continue;
2598
+ }
2599
+ const reveal = (start, end, scroll, view) => {
2600
+ if (end - start > view || start < scroll) {
2601
+ return Math.max(0, start - PAD);
2602
+ }
2603
+ return end > scroll + view ? end - view + PAD : scroll;
2604
+ };
2605
+ const [ox, oy] = [sheet.offsetLeft, sheet.offsetTop];
2606
+ frame.scrollLeft = reveal(ox + left, ox + right, frame.scrollLeft, frame.clientWidth);
2607
+ frame.scrollTop = reveal(oy + top, oy + bottom, frame.scrollTop, frame.clientHeight);
2608
+ }
2609
+ }
2610
+
2611
+ async function nextBox() {
2612
+ const item = current();
2613
+ if (!item?.baseline || !item?.capture || state.ended) {
2614
+ say("Next change needs two images.");
2615
+ return;
2616
+ }
2617
+ const { boxes } = await diffOf(item);
2618
+ if (boxes.length === 0) {
2619
+ say("No changed area at this threshold.");
2620
+ return;
2621
+ }
2622
+ state.box = (state.box + 1) % boxes.length;
2623
+ document.getElementById("box-count").textContent = `${state.box + 1} of ${boxes.length}`;
2624
+ showBox(document.getElementById("stage"), boxes, true);
2625
+ }
2626
+
2627
+ // ---------------------------------------------------------------- the end of a pass
2628
+
2629
+ // The end card replaces the panes; the address stays on the last item, so K and Back return to
2630
+ // it. It asks the server for the target's current counts (no GitHub call) and offers what is next.
2631
+ function endCard() {
2632
+ const card = el(
2633
+ "section",
2634
+ { class: "endcard", id: "endcard", "aria-labelledby": "end-heading" },
2635
+ el("h2", { id: "end-heading", tabindex: "-1" }, spinner(), `End of ${state.project}...`),
2636
+ );
2637
+ fillEnd(card, nav);
2638
+ return card;
2639
+ }
2640
+
2641
+ async function fillEnd(card, seq) {
2642
+ let t;
2643
+ try {
2644
+ [t] = await Promise.all([
2645
+ api(`/api/target/${encodeURIComponent(state.target.id)}`),
2646
+ api("/api/prs?cached=1").then((list) => {
2647
+ if (list.targets) {
2648
+ state.list = list;
2649
+ }
2650
+ }),
2651
+ ]);
2652
+ } catch (err) {
2653
+ card.replaceChildren(
2654
+ el("h2", { id: "end-heading" }, `End of ${state.project}.`),
1086
2655
  el(
1087
2656
  "p",
1088
- { class: "meta" },
1089
- item.changedPixels !== null ? `${item.changedPixels} changed image pixels` : "",
1090
- item.bbox ? ` in [${item.bbox.join(", ")}]` : "",
1091
- ` at threshold ${item.threshold}; captured at ${scale()} image pixels per CSS pixel`,
2657
+ { class: "error" },
2658
+ err.message,
2659
+ " ",
2660
+ el("button", { type: "button", onclick: () => showStory() }, "Retry"),
1092
2661
  ),
1093
- el(
1094
- "div",
1095
- { class: "toolbar" },
1096
- viewButton("side", "Side by side", ""),
1097
- viewButton("flash", "Flash", "F, or hold Space"),
1098
- viewButton("highlight", "Highlight", "H"),
1099
- viewButton("spotlight", "Spotlight", "S"),
1100
- el(
1101
- "button",
1102
- {
1103
- type: "button",
1104
- "aria-pressed": String(state.showBox),
1105
- title: "B: outline the changed box",
1106
- onclick: () => toggleOption("showBox"),
1107
- },
1108
- "Box",
1109
- ),
1110
- el(
1111
- "button",
1112
- {
1113
- type: "button",
1114
- "aria-pressed": String(state.blink),
1115
- disabled: view !== "highlight",
1116
- title:
1117
- view === "highlight"
1118
- ? "L: blink the changed pixels"
1119
- : "Blink flashes the changed pixels that Highlight lays over the images",
1120
- onclick: () => toggleOption("blink"),
1121
- },
1122
- "Blink",
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
- ),
1138
- note ? el("span", { id: "single-note", class: "meta" }, note) : null,
1139
- el("span", { class: "spacer" }),
1140
- ZOOMS.map(zoomButton),
1141
- el(
1142
- "button",
1143
- {
1144
- type: "button",
1145
- id: "next-box",
1146
- onclick: nextBox,
1147
- title: "N",
1148
- disabled: !item.baseline || !item.capture,
1149
- },
1150
- "Next changed box",
1151
- ),
1152
- el("span", { id: "box-count", class: "meta" }),
2662
+ );
2663
+ return;
2664
+ }
2665
+ if (seq !== nav || !card.isConnected) {
2666
+ return;
2667
+ }
2668
+ state.target = { ...state.target, ...t };
2669
+ drawHeader();
2670
+ const here = t.projects.find((p) => p.project === state.project);
2671
+ const at = t.projects.indexOf(here);
2672
+ const others = [...t.projects.slice(at + 1), ...t.projects.slice(0, at)];
2673
+ const next = others.find((p) => p.undecided > 0 && !p.downloading && !p.problem);
2674
+ const waitingFor = others.filter((p) => p.downloading);
2675
+ const everything = t.projects.every((p) => p.undecided === 0 && !p.downloading);
2676
+ const offer = (label, onclick, cls = null) =>
2677
+ el("button", { type: "button", class: cls, onclick, "aria-describedby": "end-heading" }, label);
2678
+ const offers = [];
2679
+ const finish = isLocal()
2680
+ ? null
2681
+ : el(
2682
+ "button",
2683
+ {
2684
+ type: "button",
2685
+ class: next ? null : "primary",
2686
+ "aria-describedby": "end-heading",
2687
+ onclick: (e) => finishTarget(t.id, e.currentTarget),
2688
+ },
2689
+ `Finish ${labelOf(t)} (${t.unpublished})`,
2690
+ );
2691
+ // The next project comes first (focused, so Enter takes it): moving on is the usual step. The
2692
+ // items of this project left undecided (a skim with J) come next, or first when no project is.
2693
+ if (next) {
2694
+ offers.push(
2695
+ offer(
2696
+ `Next project: ${next.project} (${next.undecided} undecided)`,
2697
+ () => openProject(t.id, next.project, true),
2698
+ "primary",
1153
2699
  ),
1154
- el("div", { id: "stage", class: `stage ${view} zoom-${state.zoom}` }),
1155
- item.console.length > 0
1156
- ? el(
1157
- "details",
1158
- {
1159
- class: `console-block ${item.status === "failed" ? "errors" : ""}`,
1160
- open: item.status === "failed",
1161
- },
1162
- el(
1163
- "summary",
1164
- {},
1165
- item.status === "failed" ? `Error: ${item.reason ?? "capture failed"}` : "Console",
1166
- ` (${item.console.length} lines)`,
1167
- ),
1168
- el("pre", { class: "console" }, item.console.join("\n")),
1169
- )
1170
- : null,
2700
+ );
2701
+ }
2702
+ if (here.undecided > 0) {
2703
+ const left = reviewable().filter((i) => !decisionOf(i));
2704
+ const excludeOnly = left.length > 0 && left.every(onlyExclude);
2705
+ offers.push(
2706
+ offer(
2707
+ `Review the ${here.undecided} undecided${excludeOnly ? " (Exclude only)" : ""}`,
2708
+ () => startPass("undecided"),
2709
+ next ? null : "primary",
2710
+ ),
2711
+ );
2712
+ }
2713
+ const finishFirst = !next && here.undecided === 0;
2714
+ if (finishFirst && finish && t.unpublished > 0) {
2715
+ offers.unshift(finish);
2716
+ }
2717
+ offers.push(offer("Back to the grid", () => toGrid()));
2718
+ if (!finishFirst && finish && t.unpublished > 0) {
2719
+ finish.classList.remove("primary");
2720
+ offers.push(finish);
2721
+ }
2722
+ const after = !next ? nextTarget(t.id) : null;
2723
+ if (after) {
2724
+ offers.push(
2725
+ offer(`Next: ${labelOf(after)} (${undecidedOf(after)} undecided)`, () => openTarget(after.id, true)),
2726
+ );
2727
+ }
2728
+ card.replaceChildren(
2729
+ ...[
1171
2730
  el(
1172
- "div",
1173
- { class: "actions" },
1174
- isLocal()
1175
- ? el("p", {}, "Local preview: look only. Only a CI capture of a pushed commit can be decided.")
1176
- : null,
1177
- unseeded
1178
- ? el(
1179
- "p",
1180
- {},
1181
- "No baseline yet, and this pull request does not change it: it looks as on master. " +
1182
- "Accepting it makes this image its first baseline.",
1183
- )
1184
- : null,
1185
- decidable ? decisionButtons : null,
2731
+ "h2",
2732
+ { id: "end-heading", tabindex: "-1" },
2733
+ `End of ${state.project}: ${here.decided} of ${here.reviewable} decided, ${here.undecided} undecided.`,
1186
2734
  ),
1187
- ),
2735
+ everything && !isLocal() ? el("p", {}, `Every project of ${labelOf(t)} is decided.`) : null,
2736
+ el("div", { class: "offers" }, offers),
2737
+ waitingFor.length > 0
2738
+ ? el(
2739
+ "ul",
2740
+ { class: "meta" },
2741
+ waitingFor.map((p) =>
2742
+ el(
2743
+ "li",
2744
+ {},
2745
+ t.download
2746
+ ? `${p.project}: downloading (${t.download.done} of ${plural(t.download.total, "artifact")} done)`
2747
+ : `${p.project}: downloading`,
2748
+ ),
2749
+ ),
2750
+ )
2751
+ : null,
2752
+ ].filter(Boolean),
1188
2753
  );
1189
- renderStage(item, view, keep);
1190
- remember();
2754
+ offers[0].focus();
2755
+ }
2756
+
2757
+ function showEnd(message) {
2758
+ state.ended = true;
2759
+ state.pending = null;
2760
+ showStory();
2761
+ say(message);
1191
2762
  }
1192
2763
 
1193
- const reasonHint = () => `Reason (needed to reject or exclude); Enter ${state.pending}s`;
2764
+ // ---------------------------------------------------------------- the changed pixels
1194
2765
 
1195
2766
  // The changed pixels of an item, padded top-left to the larger size, grown by GROW pixels, and the
1196
2767
  // boxes around each separate grown region. Computed once per item and pair of images.
@@ -1288,221 +2859,6 @@ function regions(grown, w, h) {
1288
2859
  return boxes.sort((p, q) => q[2] * q[3] - p[2] * p[3]);
1289
2860
  }
1290
2861
 
1291
- // Sizes every picture on the stage at one scale, so equal-size images line up pixel for pixel in
1292
- // their panes. At "fit" the largest of them fits its pane both ways (never above real size, one CSS
1293
- // pixel per CSS pixel the story was drawn at); zoomed, it is real size times the zoom and the
1294
- // panes scroll. Each pane's sheet is as large as the largest picture, so both scroll alike.
1295
- function fit(stage) {
1296
- const pics = [...stage.querySelectorAll(".sheet > img, .sheet > canvas")];
1297
- const natural = (p) => [p.naturalWidth ?? p.width, p.naturalHeight ?? p.height];
1298
- let [w, h] = [1, 1];
1299
- for (const p of pics) {
1300
- const [x, y] = natural(p);
1301
- [w, h] = [Math.max(w, x), Math.max(h, y)];
1302
- }
1303
- const frame = stage.querySelector(".frame");
1304
- factor =
1305
- state.zoom === "fit"
1306
- ? Math.min(1 / scale(), frame.clientWidth / w, frame.clientHeight / h)
1307
- : state.zoom / scale();
1308
- for (const p of pics) {
1309
- const [x, y] = natural(p);
1310
- p.style.width = `${x * factor}px`;
1311
- p.style.height = `${y * factor}px`;
1312
- p.classList.toggle("crisp", factor * scale() >= CRISP_FROM);
1313
- }
1314
- for (const sheet of stage.querySelectorAll(".sheet")) {
1315
- sheet.style.width = `${w * factor}px`;
1316
- sheet.style.height = `${h * factor}px`;
1317
- }
1318
- }
1319
-
1320
- // Two panes, always: the baseline on the left and the new image (or the view's picture) on the
1321
- // right. A missing image leaves its pane empty, the same size, so the other one never moves.
1322
- async function renderStage(item, view, keep) {
1323
- const seq = ++stageRender;
1324
- const stage = document.getElementById("stage");
1325
- const pane = (text, ...pics) =>
1326
- el(
1327
- "figure",
1328
- {},
1329
- el("div", { class: "label", title: text }, text),
1330
- el(
1331
- "div",
1332
- { class: pics.length ? "frame" : "frame empty" },
1333
- pics.length ? el("div", { class: "sheet" }, pics) : null,
1334
- ),
1335
- );
1336
- const imgOf = async (kind) => {
1337
- const img = await loaded(await image(kind, item.file, item[kind]), `${kind} of ${item.file}`);
1338
- img.alt = `${kind} of ${itemName(item)}`;
1339
- return img;
1340
- };
1341
- try {
1342
- const diff = item.baseline && item.capture ? await diffOf(item) : null;
1343
- const marked = view === "highlight" && diff !== null;
1344
- const baseName = item.from ? `Baseline of ${item.from}` : "Baseline";
1345
- const left = item.baseline
1346
- ? pane(
1347
- marked ? `${baseName}, changed pixels in red` : baseName,
1348
- await imgOf("baseline"),
1349
- ...(marked ? [overlay(diff)] : []),
1350
- )
1351
- : pane("No baseline");
1352
- let right;
1353
- if (!item.capture) {
1354
- right = pane(item.status === "failed" ? "No capture: it failed" : "No capture");
1355
- } else if (view === "side") {
1356
- right = pane(item.baseline ? "New" : "New (no 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
- }
1367
- next.style.visibility = "hidden";
1368
- right = pane(`${name}: baseline`, base, next);
1369
- right.classList.add("flashing");
1370
- const tag = right.querySelector(".label");
1371
- let showingNew = false;
1372
- flashTimer = setInterval(() => {
1373
- showingNew = !showingNew;
1374
- base.style.visibility = showingNew ? "hidden" : "visible";
1375
- next.style.visibility = showingNew ? "visible" : "hidden";
1376
- tag.textContent = showingNew ? `${name}: new` : `${name}: baseline`;
1377
- }, FLASH_MS);
1378
- } else if (view === "highlight") {
1379
- right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
1380
- } else {
1381
- right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
1382
- }
1383
- if (seq !== stageRender) {
1384
- // Another item (or view) was shown while this one loaded.
1385
- return;
1386
- }
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
- }
1393
- if (marked && state.blink) {
1394
- // Both panes' overlays on and off together, at Flash's pace.
1395
- const marks = [...stage.querySelectorAll(".diffmark")];
1396
- let on = true;
1397
- flashTimer = setInterval(() => {
1398
- on = !on;
1399
- for (const m of marks) {
1400
- m.style.visibility = on ? "visible" : "hidden";
1401
- }
1402
- }, FLASH_MS);
1403
- }
1404
- const frames = [...stage.querySelectorAll(".frame")];
1405
- // Zoomed, scrolling one pane scrolls the other to the same place.
1406
- for (const f of frames) {
1407
- f.addEventListener("scroll", () => {
1408
- for (const o of frames) {
1409
- if (o !== f && (o.scrollLeft !== f.scrollLeft || o.scrollTop !== f.scrollTop)) {
1410
- o.scrollLeft = f.scrollLeft;
1411
- o.scrollTop = f.scrollTop;
1412
- }
1413
- }
1414
- });
1415
- }
1416
- fit(stage);
1417
- refit.disconnect();
1418
- refit.observe(stage);
1419
- shownBoxes = diff ? diff.boxes : [];
1420
- stageKey = `${item.file}|${state.zoom}`;
1421
- const count = document.getElementById("box-count");
1422
- if (diff) {
1423
- count.textContent =
1424
- diff.boxes.length === 0
1425
- ? "no changed box at this threshold"
1426
- : `box ${Math.min(state.box, diff.boxes.length - 1) + 1} of ${diff.boxes.length}`;
1427
- showBox(stage, diff.boxes, false);
1428
- }
1429
- if (keep) {
1430
- for (const f of frames) {
1431
- [f.scrollLeft, f.scrollTop] = keep;
1432
- }
1433
- }
1434
- } catch (err) {
1435
- if (seq === stageRender) {
1436
- stage.replaceChildren(el("p", { class: "error" }, err.message || "the images could not be loaded"));
1437
- }
1438
- }
1439
- }
1440
-
1441
- // Outlines the current changed box in every pane, and scrolls it into view. A pane opens at the
1442
- // top left of its image; on opening (`jump` false) it scrolls only when the box is smaller than
1443
- // the pane and out of sight. Next changed box (`jump` true) always brings the box into view, its
1444
- // top left first when it is larger than the pane.
1445
- function showBox(stage, boxes, jump) {
1446
- if (boxes.length === 0) {
1447
- return;
1448
- }
1449
- state.box = Math.min(state.box, boxes.length - 1);
1450
- const [x, y, w, h] = boxes[state.box];
1451
- const PAD = 16;
1452
- for (const frame of stage.querySelectorAll(".frame")) {
1453
- const sheet = frame.querySelector(".sheet");
1454
- if (!sheet) {
1455
- continue;
1456
- }
1457
- const [sw, sh] = [sheet.offsetWidth, sheet.offsetHeight];
1458
- // The outline is drawn inside the image, so a box at an edge keeps all four sides.
1459
- const [left, top] = [Math.max(0, x * factor - 2), Math.max(0, y * factor - 2)];
1460
- const [right, bottom] = [Math.min(sw, (x + w) * factor + 2), Math.min(sh, (y + h) * factor + 2)];
1461
- sheet.querySelector(".boxmark")?.remove();
1462
- if (state.showBox) {
1463
- const mark = el("div", { class: "boxmark" });
1464
- Object.assign(mark.style, {
1465
- left: `${left}px`,
1466
- top: `${top}px`,
1467
- width: `${right - left}px`,
1468
- height: `${bottom - top}px`,
1469
- });
1470
- sheet.append(mark);
1471
- }
1472
- if (!jump) {
1473
- frame.scrollLeft = 0;
1474
- frame.scrollTop = 0;
1475
- }
1476
- const fits = right - left <= frame.clientWidth && bottom - top <= frame.clientHeight;
1477
- if (!jump && !fits) {
1478
- continue;
1479
- }
1480
- const reveal = (start, end, scroll, view) => {
1481
- if (end - start > view || start < scroll) {
1482
- return Math.max(0, start - PAD);
1483
- }
1484
- return end > scroll + view ? end - view + PAD : scroll;
1485
- };
1486
- const [ox, oy] = [sheet.offsetLeft, sheet.offsetTop];
1487
- frame.scrollLeft = reveal(ox + left, ox + right, frame.scrollLeft, frame.clientWidth);
1488
- frame.scrollTop = reveal(oy + top, oy + bottom, frame.scrollTop, frame.clientHeight);
1489
- }
1490
- }
1491
-
1492
- async function nextBox() {
1493
- const item = current();
1494
- if (!item?.baseline || !item?.capture) {
1495
- return;
1496
- }
1497
- const { boxes } = await diffOf(item);
1498
- if (boxes.length === 0) {
1499
- return;
1500
- }
1501
- state.box = (state.box + 1) % boxes.length;
1502
- document.getElementById("box-count").textContent = `box ${state.box + 1} of ${boxes.length}`;
1503
- showBox(document.getElementById("stage"), boxes, true);
1504
- }
1505
-
1506
2862
  // The changed pixels alone, in solid red, transparent everywhere else: laid over an image in the
1507
2863
  // same grid cell, so it lines up with the image at every zoom.
1508
2864
  function overlay(diff) {
@@ -1532,18 +2888,265 @@ function spotlight(diff, px = diff.b) {
1532
2888
  }
1533
2889
  out.data[i * 4 + 3] = lit ? px[i * 4 + 3] : Math.max(px[i * 4 + 3], SPOT_ALPHA);
1534
2890
  }
1535
- ctx.putImageData(out, 0, 0);
1536
- return canvas;
2891
+ ctx.putImageData(out, 0, 0);
2892
+ return canvas;
2893
+ }
2894
+
2895
+ // ---------------------------------------------------------------- moving and deciding
2896
+
2897
+ function toGrid() {
2898
+ say("");
2899
+ showGrid();
2900
+ // Focus on the tile of the item just left, so Tab goes on from there, not from the top.
2901
+ app.querySelector(".tile.current")?.focus({ preventScroll: true });
2902
+ }
2903
+
2904
+ // Next and Previous walk the pass. Next on its last item shows the end card (it never wraps);
2905
+ // Previous on the end card comes back to the last item.
2906
+ function move(step) {
2907
+ if (state.ended) {
2908
+ if (step < 0) {
2909
+ state.ended = false;
2910
+ say("");
2911
+ showStory();
2912
+ } else {
2913
+ say("End of this pass: choose what is next.");
2914
+ }
2915
+ return;
2916
+ }
2917
+ const count = passItems().length;
2918
+ const at = state.index + step;
2919
+ if (at >= count) {
2920
+ showEnd("");
2921
+ return;
2922
+ }
2923
+ if (at < 0) {
2924
+ say("This is the first item of the pass.");
2925
+ return;
2926
+ }
2927
+ state.index = at;
2928
+ state.box = 0;
2929
+ if (reduceMotion()) {
2930
+ state.motion = false;
2931
+ }
2932
+ showStory();
2933
+ const item = current();
2934
+ say(`Now #${numberOf(item)}, ${at + 1} of ${count}: ${itemName(item)}, ${statusLabel(item.status)}.`);
2935
+ }
2936
+
2937
+ // Accept, Reject, Exclude and (with null) Undo of the item on screen. Every press either decides
2938
+ // or says in the status row why it did not.
2939
+ async function decide(decision) {
2940
+ if (state.ended) {
2941
+ say("End of this pass: choose what is next.");
2942
+ return;
2943
+ }
2944
+ const items = passItems();
2945
+ const item = items[state.index];
2946
+ if (!item) {
2947
+ return;
2948
+ }
2949
+ const n = `#${numberOf(item)}`;
2950
+ if (isLocal()) {
2951
+ say("Local preview: look only. Nothing is decided on it.");
2952
+ return;
2953
+ }
2954
+ if (saving) {
2955
+ say("Still saving the last decision.");
2956
+ return;
2957
+ }
2958
+ const before = decisionOf(item);
2959
+ const draft = (drafts.get(draftKey(item)) ?? "").trim();
2960
+ if (decision === null) {
2961
+ if (!before) {
2962
+ say("Nothing to undo.");
2963
+ return;
2964
+ }
2965
+ if (before.posted) {
2966
+ say(postedText(before));
2967
+ return;
2968
+ }
2969
+ } else {
2970
+ // No press reverses a decision: changing one is an explicit Undo first.
2971
+ if (before?.posted) {
2972
+ say(postedText(before));
2973
+ return;
2974
+ }
2975
+ if (before) {
2976
+ say(
2977
+ decision === "accept" && before.decision === "accept" && before.wasBulk && draft !== ""
2978
+ ? "Accepted without opening. Undo it to add a note."
2979
+ : `Already ${DONE[before.decision]}. Undo it to change it.`,
2980
+ );
2981
+ return;
2982
+ }
2983
+ if (onlyExclude(item) && decision !== "exclude") {
2984
+ say(item.status === "unstable" ? UNSTABLE : FAILED);
2985
+ return;
2986
+ }
2987
+ if (!state.data.acceptable && decision !== "reject") {
2988
+ say(notSeeded());
2989
+ return;
2990
+ }
2991
+ if (decision === "accept" && stageShown !== item.file) {
2992
+ say("Loading images: Accept waits for them.");
2993
+ return;
2994
+ }
2995
+ if (performance.now() - appearedAt < GUARD_MS) {
2996
+ say("Ignored: this image appeared less than a quarter second ago.");
2997
+ return;
2998
+ }
2999
+ if ((decision === "reject" || decision === "exclude") && draft === "") {
3000
+ state.pending = decision;
3001
+ showStory({ focusNote: true });
3002
+ say(`Type the reason, then press Enter to ${decision}.`);
3003
+ return;
3004
+ }
3005
+ if (
3006
+ decision === "exclude" &&
3007
+ !(await ask(
3008
+ `Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
3009
+ "until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
3010
+ "Exclude",
3011
+ ))
3012
+ ) {
3013
+ say("Exclude cancelled.");
3014
+ return;
3015
+ }
3016
+ }
3017
+ const reason = decision === null || draft === "" ? null : draft;
3018
+ const slow = setTimeout(() => sayBusy("Saving the last decision..."), SLOW_MS);
3019
+ let answer;
3020
+ saving = api("/api/decide", {
3021
+ id: state.target.id,
3022
+ project: state.project,
3023
+ file: item.file,
3024
+ decision,
3025
+ reason,
3026
+ hash: imageHash(item),
3027
+ });
3028
+ try {
3029
+ answer = await saving;
3030
+ } catch (err) {
3031
+ say(
3032
+ decision === null
3033
+ ? `Not saved: ${err.message}. ${n} is still ${DONE[before.decision]}.`
3034
+ : `Not saved: ${err.message}. ${n} is still undecided.`,
3035
+ true,
3036
+ );
3037
+ return;
3038
+ } finally {
3039
+ clearTimeout(slow);
3040
+ saving = null;
3041
+ }
3042
+ if (decision === null) {
3043
+ delete state.data.decisions[item.file];
3044
+ // The note comes back as the item's draft, so an Undo to fix a typo does not lose it.
3045
+ if (before.reason) {
3046
+ drafts.set(draftKey(item), before.reason);
3047
+ }
3048
+ } else {
3049
+ state.data.decisions[item.file] = { decision, reason };
3050
+ drafts.delete(draftKey(item));
3051
+ }
3052
+ state.pending = null;
3053
+ counted(item, before, decision, answer.unpublished);
3054
+ if (decision === null) {
3055
+ // Undo stays on the item, undecided again.
3056
+ showStory();
3057
+ say(`Undid ${n}: undecided again.`);
3058
+ return;
3059
+ }
3060
+ // The pass is frozen, so a decided item stays in it: a decision moves on to the next one, and
3061
+ // Previous comes back to it. After the last one, the end card.
3062
+ const did = `${DECISIONS[decision]} ${n}.`;
3063
+ if (state.index >= items.length - 1) {
3064
+ showEnd(`${did} End of this pass.`);
3065
+ return;
3066
+ }
3067
+ state.index++;
3068
+ state.box = 0;
3069
+ if (reduceMotion()) {
3070
+ state.motion = false;
3071
+ }
3072
+ showStory();
3073
+ const next = current();
3074
+ // One message per item, for the eye and for a screen reader.
3075
+ say(
3076
+ `${did} Now #${numberOf(next)}, ${state.index + 1} of ${items.length}: ${itemName(next)}, ${statusLabel(next.status)}.`,
3077
+ );
3078
+ }
3079
+
3080
+ // Clears decisions one by one through the same request as the story screen's U, with a progress
3081
+ // bar and Stop when there are many.
3082
+ // ponytail: one request per item; a bulk endpoint if a project ever holds thousands of decisions.
3083
+ async function undo(files, where, one = false) {
3084
+ let done = 0;
3085
+ let stopped = false;
3086
+ const stop = el("button", { type: "button", onclick: () => (stopped = true) }, "Stop");
3087
+ const meter = el("progress", { max: String(files.length), value: "0" });
3088
+ try {
3089
+ for (const file of files) {
3090
+ if (stopped) {
3091
+ break;
3092
+ }
3093
+ if (!one) {
3094
+ sayBusy(`Undoing ${done + 1} of ${files.length}...`, " ", meter, " ", stop);
3095
+ }
3096
+ await api("/api/decide", {
3097
+ id: state.target.id,
3098
+ project: state.project,
3099
+ file,
3100
+ decision: null,
3101
+ reason: null,
3102
+ });
3103
+ done++;
3104
+ meter.value = done;
3105
+ }
3106
+ } catch (err) {
3107
+ say(`Undid ${done} of ${files.length}, then: ${err.message}`, true);
3108
+ }
3109
+ try {
3110
+ await reload();
3111
+ } catch (err) {
3112
+ say(err.message, true);
3113
+ }
3114
+ showGrid();
3115
+ if (done === files.length || stopped) {
3116
+ say(
3117
+ one
3118
+ ? `Undid ${where}: undecided again.`
3119
+ : stopped
3120
+ ? `Stopped: undid ${done} of ${plural(files.length, "decision")} of ${where}.`
3121
+ : `Undid ${plural(done, "decision")} of ${where}.`,
3122
+ );
3123
+ }
3124
+ }
3125
+
3126
+ // A component's Undo N, or More > Undo all decisions: asks first, naming the count.
3127
+ async function bulkUndo(component) {
3128
+ const files = undoableIn(component);
3129
+ if (files.length === 0) {
3130
+ say("Nothing to undo.");
3131
+ return;
3132
+ }
3133
+ const k = files.length;
3134
+ const question = component
3135
+ ? `Undo the ${plural(k, "decision")} of ${component}?`
3136
+ : `Undo all ${plural(k, "decision")} of ${state.project}?`;
3137
+ if (await ask(question, `Undo ${k}`)) {
3138
+ await undo(files, component ? `the component ${component}` : state.project);
3139
+ }
1537
3140
  }
1538
3141
 
1539
3142
  async function acceptAll(component) {
1540
3143
  if (!state.data.acceptable || isLocal()) {
1541
- say(`${state.project} cannot be accepted here`, true);
3144
+ say(isLocal() ? "Local preview: look only. Nothing is decided on it." : notSeeded());
1542
3145
  return;
1543
3146
  }
1544
3147
  const items = undecided(component);
1545
3148
  const n = items.length;
1546
- const where = component ? `the component ${component}` : state.project;
3149
+ const where = component ?? state.project;
1547
3150
  if (n === 0) {
1548
3151
  say(`Nothing undecided to accept in ${where}.`);
1549
3152
  return;
@@ -1553,12 +3156,23 @@ async function acceptAll(component) {
1553
3156
  const deletes =
1554
3157
  removals === 0
1555
3158
  ? ""
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"))) {
3159
+ : ` This includes ${plural(removals, "removal")}: accepting deletes ${removals === 1 ? "its baseline" : "their baselines"}.`;
3160
+ const stuck = state.data.items.filter(
3161
+ (i) => onlyExclude(i) && !decisionOf(i) && (!component || componentOf(i.id) === component),
3162
+ );
3163
+ const left =
3164
+ stuck.length === 0
3165
+ ? ""
3166
+ : ` ${stuck.length} more (${[...new Set(stuck.map((i) => i.status))].sort().join(", ")}) can only be excluded and stay undecided.`;
3167
+ const question = component
3168
+ ? `Accept the ${n} undecided ${n === 1 ? "item" : "items"} of ${component} without opening them?`
3169
+ : `Accept ${n} undecided ${n === 1 ? "item" : "items"} of ${where} without opening them?`;
3170
+ if (!(await ask(`${question}${deletes}${left}`, `Accept ${n}`))) {
1558
3171
  return;
1559
3172
  }
3173
+ let answer;
1560
3174
  try {
1561
- await api("/api/accept-all", {
3175
+ answer = await api("/api/accept-all", {
1562
3176
  id: state.target.id,
1563
3177
  project: state.project,
1564
3178
  runId: state.data.target.runId,
@@ -1570,198 +3184,259 @@ async function acceptAll(component) {
1570
3184
  say(err.message, true);
1571
3185
  return;
1572
3186
  }
1573
- say(`Accepted ${n} items in ${where}.`);
1574
3187
  (state.screen === "story" ? showStory : showGrid)();
3188
+ say(`Accepted ${plural(answer.accepted, "item")} in ${where}.`);
1575
3189
  }
1576
3190
 
1577
- function move(step) {
1578
- say("");
1579
- const count = passItems().length;
1580
- state.index = (state.index + step + count) % count;
1581
- state.box = 0;
1582
- showStory();
1583
- }
3191
+ // ---------------------------------------------------------------- Finish
1584
3192
 
1585
- async function decide(decision) {
1586
- if (isLocal()) {
1587
- say("A local preview is only looked at: nothing is decided on it.", true);
1588
- return;
1589
- }
1590
- if (deciding) {
1591
- // A second click or key while the first decision is on its way.
1592
- return;
1593
- }
1594
- const items = passItems();
1595
- const item = items[state.index];
1596
- const before = decisionOf(item);
1597
- if (decision === null && !before) {
1598
- return;
1599
- }
1600
- // No key reverses a decision: changing one is an explicit Undo first.
1601
- if (decision !== null && before) {
1602
- const done = { accept: "accepted", reject: "rejected", exclude: "excluded" }[before.decision];
1603
- say(`${itemName(item)} is already ${done}. Press U (Undo) first to change it.`, true);
1604
- return;
1605
- }
1606
- if (decision === "accept" && stageShown !== item.file) {
1607
- say(`${itemName(item)}: wait for both images before accepting.`, true);
1608
- return;
1609
- }
1610
- const reasonBox = document.getElementById("reason");
1611
- const reason = reasonBox?.value.trim() ?? "";
1612
- if ((decision === "reject" || decision === "exclude") && reason === "") {
1613
- state.pending = decision;
1614
- if (reasonBox) {
1615
- reasonBox.placeholder = reasonHint();
1616
- reasonBox.focus();
1617
- }
1618
- say(`A ${decision} needs a reason: type it, then press Enter.`);
1619
- return;
3193
+ const finishing = () =>
3194
+ `Finishing ${state.job.pr === null ? `${branchName()} seed` : `#${state.job.pr}`}: ${state.job.step}...`;
3195
+
3196
+ // What Finish's sheet says it will do, from the server's preview: every line is the server's count.
3197
+ function sheet(t, f, prepared, notice) {
3198
+ const label = labelOf(t);
3199
+ const seed = t.pr === null;
3200
+ const lines = [];
3201
+ const kinds = [
3202
+ f.accepts > 0 ? plural(f.accepts, "accept") : null,
3203
+ f.excludes > 0 ? plural(f.excludes, "exclusion") : null,
3204
+ ].filter(Boolean);
3205
+ if (kinds.length === 0) {
3206
+ lines.push(f.rejects > 0 ? "Nothing is committed: only rejects." : "Nothing is committed.");
3207
+ } else if (seed) {
3208
+ const day = new Date().toISOString().slice(0, 10);
3209
+ lines.push(
3210
+ `Push visual/seed-${day} with ${kinds.join(" and ")} and open its pull request` +
3211
+ `${f.acceptNotes > 0 ? `, with ${plural(f.acceptNotes, "accept note")} in its description` : ""}.`,
3212
+ );
3213
+ } else {
3214
+ lines.push(`Commit ${kinds.join(" and ")} to ${t.branch}.`);
1620
3215
  }
1621
- if (
1622
- decision === "exclude" &&
1623
- !(await ask(
1624
- `Exclude ${item.id}? Every mode of this story stops being captured, on every pull request, ` +
1625
- "until its settings file is removed. To clear a one-off failure, re-run the visual job instead.",
1626
- "Exclude",
1627
- ))
1628
- ) {
1629
- return;
3216
+ const posts = [
3217
+ f.rejects > 0 ? plural(f.rejects, "reject") : null,
3218
+ !seed && f.acceptNotes > 0 ? plural(f.acceptNotes, "accept note") : null,
3219
+ ].filter(Boolean);
3220
+ if (seed && f.rejects > 0) {
3221
+ lines.push(`Open one issue for ${plural(f.rejects, "reject")}.`);
3222
+ } else if (posts.length > 0) {
3223
+ lines.push(`Post ${posts.join(" and ")} as a comment on ${label}.`);
1630
3224
  }
1631
- deciding = true;
1632
- try {
1633
- await api("/api/decide", {
1634
- id: state.target.id,
1635
- project: state.project,
1636
- file: item.file,
1637
- decision,
1638
- reason: reason === "" ? null : reason,
1639
- hash: imageHash(item),
1640
- });
1641
- } catch (err) {
1642
- say(err.message, true);
1643
- return;
1644
- } finally {
1645
- deciding = false;
3225
+ const why =
3226
+ f.status.state === "failure"
3227
+ ? ` (${f.rejects} rejected)`
3228
+ : f.status.state === "pending"
3229
+ ? ` (${[
3230
+ f.undecided.length > 0 ? `${f.undecided.reduce((k, p) => k + p.undecided, 0)} undecided` : null,
3231
+ f.unloaded.length > 0 ? `not loaded: ${f.unloaded.join(", ")}` : null,
3232
+ ]
3233
+ .filter(Boolean)
3234
+ .join("; ")})`
3235
+ : "";
3236
+ lines.push(`Then set the commit status 'Visual review' to ${f.status.state}${why}.`);
3237
+ if (f.notOpened > 0) {
3238
+ lines.push(`Accepted without opening: ${f.notOpened}.`);
1646
3239
  }
1647
- if (decision === null) {
1648
- delete state.data.decisions[item.file];
1649
- } else {
1650
- state.data.decisions[item.file] = { decision, reason: reason === "" ? null : reason };
3240
+ if (f.undecided.length > 0) {
3241
+ lines.push(
3242
+ `Still undecided, left for a later round: ${f.undecided.map((p) => `${p.project} ${p.undecided}`).join(", ")}.`,
3243
+ );
1651
3244
  }
1652
- state.pending = "reject";
1653
- say(`${itemName(item)}: ${decision ?? "undone, undecided again"}`);
1654
- // The pass is frozen, so a decided item stays in it: a decision moves on to the next one, and
1655
- // Previous comes back to it. Undo stays on the item.
1656
- if (decision !== null) {
1657
- state.index = Math.min(state.index + 1, items.length - 1);
3245
+ if (f.unloaded.length > 0) {
3246
+ lines.push(`Not loaded, so not reviewed: ${f.unloaded.join(", ")}.`);
1658
3247
  }
1659
- state.box = 0;
1660
- showStory();
3248
+ return [
3249
+ el("h2", { id: "sheet-title" }, `Finish ${label}, every project:`),
3250
+ notice ? el("p", { class: "error" }, notice) : null,
3251
+ ...lines.map((l) => el("p", {}, l)),
3252
+ f.notes.length > 0
3253
+ ? [
3254
+ el("p", {}, "Notes to publish:"),
3255
+ el(
3256
+ "ul",
3257
+ { class: "notes" },
3258
+ f.notes.map((x) => el("li", {}, `${DECISIONS[x.decision]} ${x.project}/${x.file}: ${x.note}`)),
3259
+ ),
3260
+ ]
3261
+ : null,
3262
+ el("p", {}, signerLine(t.signer)),
3263
+ t.startCommand
3264
+ ? el(
3265
+ "p",
3266
+ {},
3267
+ `To sign as yourself instead, cancel and start the server from your own shell:\n${t.startCommand}`,
3268
+ )
3269
+ : null,
3270
+ passkeySentence(f, prepared),
3271
+ ].filter(Boolean);
1661
3272
  }
1662
3273
 
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);
3274
+ // What the passkey does for this Finish, from POST /api/finish-prepare: once a passkey is known it
3275
+ // approves the whole record (rejects too); before that, accepts are not yet protected, and the
3276
+ // sheet says so. Rejects alone never wait for a passkey to be registered.
3277
+ function passkeySentence(f, prepared) {
3278
+ if (prepared.error) {
3279
+ return el("p", { class: "error", id: "sheet-passkey" }, `Finish cannot be approved now: ${prepared.error}`);
1669
3280
  }
1670
- }
1671
-
1672
- // ---------------------------------------------------------------- Finish
1673
-
1674
- function finishButton() {
1675
- if (isLocal()) {
3281
+ if (prepared.required) {
3282
+ return el("p", { class: "passkey" }, "Your passkey confirms this Finish (Face ID or a security key).");
3283
+ }
3284
+ if (f.accepts + f.excludes === 0) {
1676
3285
  return null;
1677
3286
  }
1678
3287
  return el(
1679
- "button",
1680
- {
1681
- type: "button",
1682
- class: "primary",
1683
- disabled: running(),
1684
- onclick: (e) => finishTarget(state.target, e.currentTarget),
1685
- },
1686
- `Finish ${targetLabel()}`,
3288
+ "p",
3289
+ { class: "warning" },
3290
+ "Not yet protected: no passkey is registered, so this Finish is not approved by you and the CI gate " +
3291
+ "does not check who accepted. Register a passkey on the targets screen and merge its pull request " +
3292
+ "to turn that on.",
1687
3293
  );
1688
3294
  }
1689
3295
 
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}`;
1694
- const what =
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...`);
1702
- let fresh;
1703
- try {
1704
- fresh = await api(`/api/target/${encodeURIComponent(target.id)}`);
1705
- } catch (err) {
1706
- giveUp(`Finish not started: ${err.message}`, true);
3296
+ // Finish publishes every decision of one target. It asks the server what it would do, states it
3297
+ // in a sheet, and starts on the server; then the targets screen follows it step by step.
3298
+ async function finishTarget(id, button) {
3299
+ if (running()) {
3300
+ say("A Finish is running: wait for it to end.");
1707
3301
  return;
1708
3302
  }
1709
- const lines = [`Finish ${label}: ${what}, across every project?`];
1710
- const notOpened = fresh.projects.reduce((n, p) => n + p.notOpened, 0);
1711
- if (notOpened > 0) {
1712
- lines.push(`${notOpened} accepted without being opened.`);
1713
- }
1714
- const left = fresh.projects.filter((p) => p.undecided > 0);
1715
- if (left.length > 0) {
1716
- lines.push(
1717
- `Warning: still undecided, left for a later round: ${left.map((p) => `${p.project}: ${p.undecided} undecided`).join(", ")}.`,
1718
- );
3303
+ const reset = () => {
3304
+ button.classList.remove("busy");
3305
+ button.querySelector(".spinner")?.remove();
3306
+ button.setAttribute("aria-disabled", String(running()));
3307
+ };
3308
+ button.classList.add("busy");
3309
+ button.prepend(spinner());
3310
+ if (saving) {
3311
+ sayBusy("Saving the last decision...");
3312
+ await saving.catch(() => {});
1719
3313
  }
1720
- lines.push(signerLine(fresh.signer));
1721
- if (fresh.startCommand) {
1722
- lines.push(
1723
- `To sign as yourself instead, cancel and start the server from your own shell:\n${fresh.startCommand}`,
3314
+ let notice = "";
3315
+ for (;;) {
3316
+ let t;
3317
+ let prepared;
3318
+ try {
3319
+ // The passkey's challenge is fetched before the sheet shows its button, so the button
3320
+ // can start Face ID inside the tap. A refusal (a passkey for another host) is shown in
3321
+ // the sheet, not thrown.
3322
+ [t, prepared] = await Promise.all([
3323
+ api(`/api/target/${encodeURIComponent(id)}?finish=1`),
3324
+ api("/api/finish-prepare", { id }).catch((err) => {
3325
+ if (err.status === 409) {
3326
+ return { error: err.message };
3327
+ }
3328
+ throw err;
3329
+ }),
3330
+ ]);
3331
+ } catch (err) {
3332
+ reset();
3333
+ say(`Finish not started: ${err.message}`, true);
3334
+ return;
3335
+ }
3336
+ const f = t.finish;
3337
+ const onlyRejects = f.accepts + f.excludes === 0 && f.acceptNotes === 0 && f.rejects > 0;
3338
+ const commits = f.accepts + f.excludes > 0;
3339
+ // When the server cannot prepare an approval the final button is unavailable, and the sheet
3340
+ // says why.
3341
+ let approval = null;
3342
+ const go = await ask(
3343
+ sheet(t, f, prepared, notice),
3344
+ prepared.required
3345
+ ? `Sign and finish ${labelOf(t)}`
3346
+ : onlyRejects
3347
+ ? `Post ${plural(f.rejects, "reject")} to ${labelOf(t)}`
3348
+ : `Finish ${labelOf(t)}`,
3349
+ {
3350
+ label: "sheet-title",
3351
+ unavailable: prepared.error ? "Finish cannot be approved now: see the sheet." : null,
3352
+ why: "sheet-passkey",
3353
+ focusYes: notice !== "",
3354
+ onYes: () => (approval = prepared.required ? approve(prepared) : null),
3355
+ },
1724
3356
  );
1725
- }
1726
- lines.push("One commit status is posted when Finish completes.");
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.");
1730
- return;
1731
- }
1732
- // Finish runs on the server and can take minutes; the page only starts it and then asks how it
1733
- // is going, so a dropped connection or a reload loses nothing.
1734
- say("Starting Finish...");
1735
- try {
1736
- state.job = (await api("/api/finish", { id: target.id })).job;
1737
- } catch (err) {
1738
- // The request may have been lost after the server started it: ask before calling it failed.
1739
- state.job = await api("/api/finish-status")
1740
- .then((s) => s.job)
1741
- .catch(() => null);
1742
- if (!running() || state.job.target !== target.id) {
1743
- giveUp("Finish failed; your decisions are kept. Fix the cause and press Finish again.", true);
1744
- app.prepend(el("pre", { class: "error" }, err.message));
3357
+ if (!go) {
3358
+ reset();
3359
+ say("Finish cancelled: nothing was changed.");
1745
3360
  return;
1746
3361
  }
3362
+ if (approval) {
3363
+ sayBusy("Waiting for your passkey...");
3364
+ try {
3365
+ approval = await approval;
3366
+ } catch (err) {
3367
+ notice =
3368
+ err?.name === "NotAllowedError" || err?.name === "AbortError"
3369
+ ? "Passkey cancelled: nothing was changed."
3370
+ : passkeyFailure(err, t.passkey);
3371
+ say(notice);
3372
+ continue;
3373
+ }
3374
+ }
3375
+ // Finish runs on the server and can take minutes; the page only starts it and then asks how
3376
+ // it is going, so a dropped connection or a reload loses nothing.
3377
+ sayBusy("Starting Finish...");
3378
+ // A seed's accept notes go into its pull request's description: only rejects post anything.
3379
+ const posts = t.pr === null ? f.rejects > 0 : f.rejects + f.acceptNotes > 0;
3380
+ state.plan = { seed: t.pr === null, commits, posts, signed: Boolean(approval) };
3381
+ try {
3382
+ state.job = (
3383
+ await api("/api/finish", {
3384
+ id,
3385
+ digest: f.digest,
3386
+ ...(approval ? { challenge: prepared.challenge, approval } : {}),
3387
+ })
3388
+ ).job;
3389
+ break;
3390
+ } catch (err) {
3391
+ if (err.status === 409 && /Decisions changed/.test(err.message)) {
3392
+ notice = err.message;
3393
+ say(notice);
3394
+ continue;
3395
+ }
3396
+ if (
3397
+ (err.status === 400 || err.status === 403 || err.status === 409) &&
3398
+ /passkey|approval/.test(err.message)
3399
+ ) {
3400
+ notice = `${err.message}. Nothing was changed.`;
3401
+ say(notice);
3402
+ continue;
3403
+ }
3404
+ // The request may have been lost after the server started it: ask before calling it failed.
3405
+ state.job = await api("/api/finish-status")
3406
+ .then((s) => s.job)
3407
+ .catch(() => null);
3408
+ if (!running() || state.job.target !== id) {
3409
+ reset();
3410
+ say(`Finish of ${labelOf(t)} failed: ${err.message}`, true);
3411
+ return;
3412
+ }
3413
+ break;
3414
+ }
1747
3415
  }
3416
+ state.finishSeen = performance.now();
3417
+ showTargets();
1748
3418
  await watchFinish();
1749
3419
  }
1750
3420
 
1751
- const finishing = () =>
1752
- `Finishing ${state.job.pr === null ? "the master seed" : `#${state.job.pr}`}: ${state.job.step}...`;
1753
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1754
3421
  let watching = false;
1755
3422
 
1756
- // Follows the running Finish to its end, then shows the targets with its outcome.
3423
+ // Follows the running Finish to its end, then shows its result above the targets.
1757
3424
  async function watchFinish() {
1758
3425
  if (watching) {
1759
3426
  return;
1760
3427
  }
1761
3428
  watching = true;
3429
+ state.finishSeen ??= performance.now();
1762
3430
  try {
3431
+ let spoken = "";
1763
3432
  while (running()) {
1764
- say(finishing());
3433
+ // Each step is read out once, when it starts: its count ("37 of 214 done") is only shown.
3434
+ const step = finishing().replace(/ \(\d+ of \d+ done\)/, "");
3435
+ if (step !== spoken) {
3436
+ say(step);
3437
+ spoken = step;
3438
+ }
3439
+ drawFinishPanel();
1765
3440
  await sleep(1000);
1766
3441
  try {
1767
3442
  track((await api("/api/finish-status")).job);
@@ -1773,7 +3448,105 @@ async function watchFinish() {
1773
3448
  } finally {
1774
3449
  watching = false;
1775
3450
  }
1776
- await showTargets();
3451
+ state.finishSeen = null;
3452
+ say("");
3453
+ // The box closes on a Finish that worked: its result is the card on the targets screen. One
3454
+ // that failed keeps the box, as the error; the card below it says what was pushed.
3455
+ const job = state.job;
3456
+ if (job && (job.error !== null || job.interrupted) && finishBox && !finishBox.ended) {
3457
+ const label = job.pr === null ? `${branchName()} seed` : `#${job.pr}`;
3458
+ finishBox.update({ title: `Finish of ${label} failed`, body: null, detail: "" });
3459
+ finishBox.fail(job.error ?? "it was interrupted", null);
3460
+ } else {
3461
+ finishBox?.end();
3462
+ }
3463
+ finishBox = null;
3464
+ if (state.screen === "targets") {
3465
+ showTargets("", true);
3466
+ document.getElementById("outcome-heading")?.focus();
3467
+ } else {
3468
+ drawHeader();
3469
+ }
3470
+ }
3471
+
3472
+ // The steps a Finish goes through, as the server names them when each starts.
3473
+ const FINISH_STEPS = [
3474
+ ["passkey", "Confirming with your passkey", /^confirming/],
3475
+ ["checking", "Checking", /^(starting|checking)/],
3476
+ ["writing", "Writing the files", /^writing/],
3477
+ ["committing", "Committing", /^committing/],
3478
+ ["lfs", "Uploading images to LFS", /^uploading images to LFS/],
3479
+ ["pushing", "Pushing", /^pushing/],
3480
+ ["pull", "Opening the pull request", /^opening the pull request/],
3481
+ ["comment", "Posting the comment", /^(posting the comment|opening the issue)/],
3482
+ ["status", "Posting the status", /^posting the status/],
3483
+ ];
3484
+
3485
+ let finishBox = null; // the wait box following the running Finish
3486
+
3487
+ // The running Finish's steps, each marked done, in progress or waiting, with the count of the
3488
+ // current one and the time spent: in the wait box, over whatever screen is open. Finish runs on
3489
+ // the server, so the box has no Cancel: closing or reloading the page does not stop it.
3490
+ function drawFinishPanel() {
3491
+ if (!running()) {
3492
+ return;
3493
+ }
3494
+ const plan = state.plan;
3495
+ const steps = FINISH_STEPS.filter(([key]) => {
3496
+ if (!plan) {
3497
+ return key !== "passkey";
3498
+ }
3499
+ if (["writing", "committing", "lfs", "pushing"].includes(key)) {
3500
+ return plan.commits;
3501
+ }
3502
+ if (key === "pull") {
3503
+ return plan.seed && plan.commits;
3504
+ }
3505
+ if (key === "comment") {
3506
+ return plan.posts;
3507
+ }
3508
+ if (key === "passkey") {
3509
+ return plan.signed;
3510
+ }
3511
+ return true;
3512
+ });
3513
+ const step = state.job.step ?? "";
3514
+ const now = steps.findIndex(([, , match]) => match.test(step));
3515
+ const count = /\((\d+) of (\d+) done\)/.exec(step);
3516
+ const label = state.job.pr === null ? `${branchName()} seed` : `#${state.job.pr}`;
3517
+ const panel = el(
3518
+ "section",
3519
+ { class: "finish-panel", id: "finish-panel" },
3520
+ el(
3521
+ "ol",
3522
+ {},
3523
+ steps.map(([key, name], i) =>
3524
+ el(
3525
+ "li",
3526
+ { class: i < now ? "done" : i === now ? "now" : "waiting" },
3527
+ `${key === "comment" && plan?.seed ? "Opening the issue" : name}: ${i < now ? "done" : i === now ? "in progress" : "waiting"}`,
3528
+ i === now && count
3529
+ ? [
3530
+ ` (${count[1]} of ${count[2]})`,
3531
+ el("progress", { max: count[2], value: count[1], "aria-hidden": "true" }),
3532
+ ]
3533
+ : null,
3534
+ ),
3535
+ ),
3536
+ ),
3537
+ );
3538
+ if (!finishBox || finishBox.ended) {
3539
+ finishBox = block(`Finishing ${label}`, { delay: 0 });
3540
+ }
3541
+ finishBox.update({
3542
+ title: `Finishing ${label}`,
3543
+ text: "Publishing your decisions. Closing or reloading this page does not stop it.",
3544
+ body: panel,
3545
+ detail: `${Math.max(now, 0)} of ${plural(steps.length, "step")} done`,
3546
+ done: Math.max(now, 0),
3547
+ total: steps.length,
3548
+ since: state.finishSeen ?? performance.now(),
3549
+ });
1777
3550
  }
1778
3551
 
1779
3552
  // Takes the server's newest Finish. A server restarted during a Finish says it was interrupted;
@@ -1795,60 +3568,129 @@ function track(job) {
1795
3568
  state.job = !job && last?.interrupted ? last : job;
1796
3569
  }
1797
3570
 
1798
- // What the newest Finish did, shown above the targets until the server restarts.
3571
+ // The commit link, from the pull request's address (github.com/o/r/pull/1 -> github.com/o/r).
3572
+ const repoUrl = (url) => (url ? url.replace(/\/pull\/\d+$/, "") : null);
3573
+
3574
+ // What the newest Finish did, shown above the targets until it is dismissed or the server restarts.
1799
3575
  function finishOutcome() {
1800
3576
  const job = state.job;
1801
- if (!job || job.running) {
3577
+ if (!job || job.running || state.dismissed === job.id) {
1802
3578
  return null;
1803
3579
  }
1804
- const label = job.pr === null ? "the master seed" : `#${job.pr}`;
1805
- if (job.interrupted) {
3580
+ const label = job.pr === null ? `${branchName()} seed` : `#${job.pr}`;
3581
+ const t = state.list?.targets?.find((x) => x.id === job.target);
3582
+ const dismiss = el(
3583
+ "button",
3584
+ {
3585
+ type: "button",
3586
+ onclick: () => {
3587
+ state.dismissed = job.id;
3588
+ drawTargets();
3589
+ },
3590
+ },
3591
+ "Dismiss",
3592
+ );
3593
+ const warnings = (job.warnings ?? []).map((w) => el("p", { class: "warning" }, w));
3594
+ if (job.interrupted || job.error !== null) {
1806
3595
  return el(
1807
3596
  "section",
1808
3597
  { class: "card finish-outcome" },
1809
3598
  el(
1810
- "p",
1811
- { class: "error" },
1812
- `Finish of ${label} was interrupted: the server restarted while it ran. Your decisions are kept.`,
3599
+ "h2",
3600
+ { id: "outcome-heading", tabindex: "-1", class: "error" },
3601
+ job.interrupted
3602
+ ? `Finish of ${label} was interrupted.`
3603
+ : `Finish of ${label} failed. Decisions not yet published are kept; anything pushed is listed below.`,
1813
3604
  ),
1814
3605
  el("pre", { class: "error" }, job.error),
3606
+ warnings,
3607
+ el("p", { class: "offers" }, dismiss),
1815
3608
  );
1816
3609
  }
1817
- if (job.error !== null) {
1818
- return el(
1819
- "section",
1820
- { class: "card finish-outcome" },
3610
+ const out = job.result;
3611
+ const repo = repoUrl(t?.url ?? out.pullRequest);
3612
+ const parts = [];
3613
+ const notes = out.acceptNotes ?? 0;
3614
+ parts.push(
3615
+ out.commit
3616
+ ? el(
3617
+ "p",
3618
+ {},
3619
+ "Committed ",
3620
+ repo ? link(`${repo}/commit/${out.commit}`, short(out.commit)) : short(out.commit),
3621
+ ` to ${out.branch}.`,
3622
+ )
3623
+ : el("p", {}, "No commit: nothing was accepted or excluded."),
3624
+ );
3625
+ if (out.pullRequest) {
3626
+ parts.push(
1821
3627
  el(
1822
3628
  "p",
1823
- { class: "error" },
1824
- `Finish of ${label} failed; your decisions are kept. Fix the cause and press Finish again.`,
3629
+ {},
3630
+ "Opened pull request ",
3631
+ link(out.pullRequest, `#${out.pullRequest.split("/").pop()}`),
3632
+ job.pr === null && notes > 0 ? `, with ${plural(notes, "accept note")} in its description.` : ".",
1825
3633
  ),
1826
- el("pre", { class: "error" }, job.error),
1827
3634
  );
1828
3635
  }
1829
- const out = job.result;
1830
- const parts = [out.commit ? `Committed ${short(out.commit)} to ${out.branch}.` : "No commit (nothing accepted)."];
1831
- if (out.rejects > 0) {
1832
- parts.push(`${out.rejects} rejects posted.`);
1833
- }
1834
3636
  if (out.issue) {
1835
- parts.push(`Issue: ${out.issue}`);
3637
+ parts.push(
3638
+ el(
3639
+ "p",
3640
+ {},
3641
+ `Filed ${plural(out.rejects, "reject")} as issue `,
3642
+ link(out.issue, `#${out.issue.split("/").pop()}`),
3643
+ ".",
3644
+ ),
3645
+ );
3646
+ } else if (out.rejects > 0 || (job.pr !== null && notes > 0 && !out.commentError)) {
3647
+ const posted = [
3648
+ out.rejects > 0 ? plural(out.rejects, "reject") : null,
3649
+ notes > 0 ? plural(notes, "accept note") : null,
3650
+ ]
3651
+ .filter(Boolean)
3652
+ .join(" and ");
3653
+ parts.push(el("p", {}, `Posted ${posted} as a comment.`));
1836
3654
  }
1837
- if (out.pullRequest) {
1838
- parts.push(`Pull request: ${out.pullRequest}`);
3655
+ if (out.commentError) {
3656
+ parts.push(
3657
+ el("p", { class: "error" }, `The comment with the accept notes was not posted: ${out.commentError}`),
3658
+ );
1839
3659
  }
1840
- parts.push(out.statusError ? `The commit status failed: ${out.statusError}` : `Status: ${out.status}.`);
3660
+ const counts = (out.status ?? "").replace(/^Reviewed: /, "").replace(" left undecided", " undecided");
3661
+ parts.push(
3662
+ out.statusError
3663
+ ? el("p", { class: "error" }, `The commit status failed: ${out.statusError}`)
3664
+ : el("p", {}, `Commit status: ${out.state ?? "set"} -- ${counts}.`),
3665
+ );
3666
+ const next = nextTarget(job.target);
3667
+ const offers = [
3668
+ next
3669
+ ? el(
3670
+ "button",
3671
+ { type: "button", class: "primary", onclick: () => openTarget(next.id, true) },
3672
+ `Next: ${labelOf(next)} (${undecidedOf(next)} undecided)`,
3673
+ )
3674
+ : null,
3675
+ t && undecidedOf(t) > 0
3676
+ ? el("button", { type: "button", onclick: () => openTarget(t.id, false) }, `Back to ${label}`)
3677
+ : null,
3678
+ dismiss,
3679
+ ];
1841
3680
  return el(
1842
3681
  "section",
1843
3682
  { class: "card finish-outcome" },
1844
- el("p", { class: out.statusError ? "error" : "" }, `Finish of ${label} done. `, parts.join(" ")),
3683
+ el("h2", { id: "outcome-heading", tabindex: "-1" }, `Finished ${label}.`),
3684
+ parts,
3685
+ warnings,
3686
+ el("p", { class: "offers" }, offers),
1845
3687
  );
1846
3688
  }
1847
3689
 
1848
3690
  // ---------------------------------------------------------------- the address
1849
3691
 
1850
3692
  // Every screen is in the address, after the session token, so a copied link opens it again:
1851
- // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off&flash=off
3693
+ // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&pass=undecided&view=side&zoom=fit&box=on&blink=off&flash=off
1852
3694
  // Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
1853
3695
  function hashFor() {
1854
3696
  const p = new URLSearchParams({ token });
@@ -1862,6 +3704,7 @@ function hashFor() {
1862
3704
  }
1863
3705
  if (state.screen === "story") {
1864
3706
  p.set("item", current().file);
3707
+ p.set("pass", state.pass);
1865
3708
  p.set("view", state.held ?? state.view);
1866
3709
  p.set("zoom", String(state.zoom));
1867
3710
  p.set("box", state.showBox ? "on" : "off");
@@ -1888,8 +3731,21 @@ function remember() {
1888
3731
  history[push ? "pushState" : "replaceState"](null, "", hash);
1889
3732
  }
1890
3733
 
1891
- // Shows the screen the address names; what no longer exists (a closed pull request, a story gone
1892
- // from a new CI run) lands on the nearest screen that does, with a line saying so.
3734
+ // Reads the newest Finish once; a running one is followed on whatever screen opens.
3735
+ async function checkFinish() {
3736
+ try {
3737
+ track((await api("/api/finish-status")).job);
3738
+ } catch {
3739
+ return;
3740
+ }
3741
+ if (running()) {
3742
+ watchFinish();
3743
+ }
3744
+ }
3745
+
3746
+ // Shows the screen the address names, from what the server already holds (no GitHub call); what
3747
+ // no longer exists (a closed pull request, a story gone from a new CI run) lands on the nearest
3748
+ // screen that does, with a line saying so.
1893
3749
  async function route() {
1894
3750
  const p = new URLSearchParams(location.hash.slice(1));
1895
3751
  const seq = ++nav;
@@ -1897,37 +3753,38 @@ async function route() {
1897
3753
  try {
1898
3754
  const id = p.get("target");
1899
3755
  if (!id) {
1900
- await showTargets();
1901
- return;
1902
- }
1903
- if (!(await loadTargets()) || seq !== nav) {
3756
+ showTargets();
1904
3757
  return;
1905
3758
  }
1906
- const target = state.targets.find((t) => String(t.id) === id);
1907
3759
  const project = p.get("project");
1908
- if (!target || !target.projects.some((x) => x.project === project)) {
1909
- const what = target ? `${project} is not a project of ${id}` : `${id} is no longer listed`;
1910
- await showTargets(`${what}: showing every target.`);
1911
- return;
1912
- }
1913
- if (state.data === null || state.target?.id !== target.id || state.project !== project) {
1914
- state.sequence = [];
1915
- state.data = null;
1916
- state.target = target;
1917
- state.project = project;
1918
- try {
1919
- await reload();
1920
- } catch (err) {
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}`);
3760
+ if (state.data === null || state.target?.id !== id || state.project !== project) {
3761
+ const got = await loadProject(id, project, seq);
3762
+ if (!got) {
1924
3763
  return;
1925
3764
  }
1926
- if (seq !== nav) {
3765
+ if (got.error) {
3766
+ const err = got.error;
3767
+ if (err.status === 404) {
3768
+ // Gone (a closed pull request, a project not in this run): the nearest screen.
3769
+ got.box.end();
3770
+ const what = /not listed/.test(err.message)
3771
+ ? `${id} is no longer listed`
3772
+ : `${project} of ${id} could not be opened: ${err.message}`;
3773
+ showTargets(`${what}: showing every target.`);
3774
+ } else {
3775
+ got.box.fail(
3776
+ `${project} of ${id} could not be opened: ${err.message}`,
3777
+ () => route(),
3778
+ () => showTargets(),
3779
+ );
3780
+ }
1927
3781
  return;
1928
3782
  }
3783
+ const { data } = got;
3784
+ adopt(data, project);
3785
+ state.sequence = [];
3786
+ listForPickers();
1929
3787
  }
1930
- state.target = target;
1931
3788
  state.filter = FILTERS.includes(p.get("filter")) ? p.get("filter") : "undecided";
1932
3789
  state.text = p.get("q") ?? "";
1933
3790
  const file = p.get("item");
@@ -1941,12 +3798,22 @@ async function route() {
1941
3798
  say(`${file} is not in this CI run any more: showing the grid.`);
1942
3799
  return;
1943
3800
  }
1944
- // Back into the pass it came from keeps that pass; otherwise the grid's items, with this one.
1945
- if (!state.sequence.includes(file)) {
1946
- const shown = visibleItems();
1947
- state.sequence = (shown.includes(item) ? shown : ordered([...shown, item])).map((i) => i.file);
3801
+ // Back into the pass it came from keeps that pass; a reload finds it in this tab's session
3802
+ // storage; otherwise it is rebuilt from its filter, with this item in it.
3803
+ const pass = FILTERS.includes(p.get("pass")) ? p.get("pass") : state.filter;
3804
+ if (state.pass !== pass || !state.sequence.includes(file)) {
3805
+ state.pass = pass;
3806
+ const kept = loadPass();
3807
+ if (kept?.includes(file)) {
3808
+ state.sequence = kept;
3809
+ } else {
3810
+ const shown = itemsFor(pass);
3811
+ state.sequence = (shown.includes(item) ? shown : ordered([...shown, item])).map((i) => i.file);
3812
+ savePass();
3813
+ }
1948
3814
  }
1949
3815
  state.index = state.sequence.indexOf(file);
3816
+ state.ended = false;
1950
3817
  state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
1951
3818
  const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
1952
3819
  state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
@@ -1960,6 +3827,8 @@ async function route() {
1960
3827
  if (["on", "off"].includes(p.get("flash"))) {
1961
3828
  state.spotFlash = p.get("flash") === "on";
1962
3829
  }
3830
+ // Flash, Blink and Spotlight flash open stopped from a link: a press starts them.
3831
+ state.motion = false;
1963
3832
  state.box = 0;
1964
3833
  say("");
1965
3834
  showStory();
@@ -1968,14 +3837,119 @@ async function route() {
1968
3837
  }
1969
3838
  }
1970
3839
 
3840
+ // A link straight into a project has only its own target: the list for the header's Target picker
3841
+ // is read from the server's cache (no GitHub call), asking again while its first load runs.
3842
+ async function listForPickers() {
3843
+ for (let tries = 0; tries < 60 && !state.list?.targets; tries++) {
3844
+ try {
3845
+ const list = await api("/api/prs?cached=1");
3846
+ if (list.targets) {
3847
+ if (!state.list?.targets) {
3848
+ state.list = list;
3849
+ }
3850
+ drawHeader();
3851
+ return;
3852
+ }
3853
+ } catch {
3854
+ return;
3855
+ }
3856
+ await sleep(1000);
3857
+ }
3858
+ }
3859
+
1971
3860
  // ---------------------------------------------------------------- keys and start
1972
3861
 
1973
- // F, H and S switch to that view, or back to side by side when it is already shown.
3862
+ // F, H and S switch to that view, or back to side by side when it is already shown; a view opened
3863
+ // stopped from a link starts on its first press.
1974
3864
  function toggleView(view) {
1975
- state.view = state.view === view ? "side" : view;
3865
+ const note = current() && singleImageNote(current());
3866
+ if (note) {
3867
+ say(note);
3868
+ return;
3869
+ }
3870
+ if (state.view === view && !state.motion && view === "flash") {
3871
+ state.motion = true;
3872
+ } else {
3873
+ state.view = state.view === view ? "side" : view;
3874
+ state.motion = true;
3875
+ }
1976
3876
  showStory();
1977
3877
  }
1978
3878
 
3879
+ // Said when the switch turns them off, and again at each letter typed while they are off.
3880
+ const SHORTCUTS_OFF = "Single-key shortcuts are off: letters do nothing until you turn them on again in Keys (?).";
3881
+
3882
+ const KEYS = [
3883
+ ["J / K", "Next / previous item of the pass; J on the last item shows what is next"],
3884
+ ["A", "Accept, once the images are shown"],
3885
+ ["(type), Esc, A", "Accept with a note: type it, leave the note box, accept"],
3886
+ ["R", "Reject; with an empty note, type the reason, then Enter"],
3887
+ ["E", "Exclude; with an empty note, type the reason, then Enter, then confirm"],
3888
+ ["U", "Undo the item's decision; you stay on the item"],
3889
+ ["Enter (note box)", "Send the Reject or Exclude waiting for its reason"],
3890
+ ["F", "Flash, or back to side by side; in Spotlight, Spotlight flash on or off"],
3891
+ ["Space (hold)", "Flash while held"],
3892
+ ["H", "Highlight the changed pixels in red, or back to side by side"],
3893
+ ["L", "In Highlight: Blink on or off"],
3894
+ ["S", "Spotlight, or back to side by side (on an iPad held upright, the way to see a change large)"],
3895
+ ["B", "Outline the changed area, or not"],
3896
+ ["N", "Next change"],
3897
+ ["Z", "Next zoom: Fit, 1x, 2x, 4x, 8x, then Fit again (from Fit, 2x is two presses, or one tap on 2x)"],
3898
+ ["Shift+A", "Grid: accept every undecided item (asks first)"],
3899
+ ["/", "Grid: Find story"],
3900
+ ["?", "Show or hide this list"],
3901
+ ["Esc", "Story: back to the grid; in the note box, first leaves the box (its text stays)"],
3902
+ ["Enter (end card)", "Take the first offer: the next project, the undecided items left here, or Finish"],
3903
+ ];
3904
+
3905
+ // The key overlay: every key, the last messages in full, and the single-key shortcuts switch.
3906
+ function toggleKeys() {
3907
+ const open = document.querySelector("dialog.keys");
3908
+ if (open) {
3909
+ open.close();
3910
+ return;
3911
+ }
3912
+ const toggle = el(
3913
+ "button",
3914
+ {
3915
+ type: "button",
3916
+ "aria-pressed": String(state.shortcuts),
3917
+ onclick: () => {
3918
+ state.shortcuts = !state.shortcuts;
3919
+ saveOptions();
3920
+ toggle.setAttribute("aria-pressed", String(state.shortcuts));
3921
+ toggle.textContent = `Single-key shortcuts: ${state.shortcuts ? "on" : "off"}`;
3922
+ say(state.shortcuts ? "Single-key shortcuts are on." : SHORTCUTS_OFF);
3923
+ },
3924
+ },
3925
+ `Single-key shortcuts: ${state.shortcuts ? "on" : "off"}`,
3926
+ );
3927
+ // Focus goes to the dialog box, never the switch: a Space or Enter typed as the overlay opens
3928
+ // must not turn every shortcut off unseen.
3929
+ const dialog = el(
3930
+ "dialog",
3931
+ { class: "keys", "aria-labelledby": "keys-title", tabindex: "-1" },
3932
+ el("h2", { id: "keys-title" }, "Keys"),
3933
+ el("p", {}, toggle),
3934
+ el(
3935
+ "table",
3936
+ {},
3937
+ KEYS.map(([k, what]) => el("tr", {}, el("td", {}, k), el("td", {}, what))),
3938
+ ),
3939
+ el("h3", {}, "Recent messages"),
3940
+ el(
3941
+ "ol",
3942
+ { class: "recent", tabindex: "0" },
3943
+ messages.length === 0 ? el("li", {}, "None yet.") : [...messages].reverse().map((m) => el("li", {}, m)),
3944
+ ),
3945
+ el("p", { class: "actions" }, el("button", { type: "button", onclick: () => dialog.close() }, "Close")),
3946
+ );
3947
+ dialog.addEventListener("close", () => dialog.remove());
3948
+ document.body.append(dialog);
3949
+ dialog.showModal();
3950
+ dialog.focus();
3951
+ }
3952
+
1979
3953
  // Safari on an iPad sends a hardware keyboard's keys only to a focused element, and tapping an
1980
3954
  // image or a button focuses nothing, so the shortcuts never arrived. The page itself holds focus
1981
3955
  // whenever nothing else does.
@@ -1989,22 +3963,35 @@ document.addEventListener("focusout", () => setTimeout(keepKeys));
1989
3963
  keepKeys();
1990
3964
 
1991
3965
  document.addEventListener("keydown", (e) => {
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) {
3966
+ if (e.ctrlKey || e.metaKey || e.altKey) {
3967
+ return;
3968
+ }
3969
+ const inInput = ["INPUT", "SELECT", "TEXTAREA"].includes(e.target.tagName);
3970
+ const open = document.querySelector("dialog[open]");
3971
+ if (e.key === "?" && !inInput && (!open || open.classList.contains("keys"))) {
3972
+ e.preventDefault();
3973
+ toggleKeys();
3974
+ return;
3975
+ }
3976
+ // An open question (ask), the Finish sheet or the overlay takes the keys: Escape cancels it.
3977
+ if (open || !["story", "grid"].includes(state.screen)) {
1995
3978
  return;
1996
3979
  }
1997
- const inInput = e.target instanceof HTMLInputElement;
1998
3980
  if (e.key === "Escape") {
1999
- // From a story, wherever focus is (the reason box included), back to the grid.
2000
3981
  e.preventDefault();
2001
3982
  if (inInput) {
3983
+ // Leaves the box; its text stays with its item. A reject waiting for its reason is dropped.
2002
3984
  e.target.blur();
3985
+ if (state.pending) {
3986
+ state.pending = null;
3987
+ showStory();
3988
+ say("");
3989
+ }
3990
+ return;
2003
3991
  }
2004
- if (state.screen === "story" || app.querySelector('[data-armed="true"]')) {
2005
- // From a story back to the grid; on the grid, it disarms a bulk Undo pressed once.
2006
- say("");
2007
- showGrid();
3992
+ app.querySelector("details.menu[open]")?.removeAttribute("open");
3993
+ if (state.screen === "story") {
3994
+ toGrid();
2008
3995
  }
2009
3996
  return;
2010
3997
  }
@@ -2012,21 +3999,32 @@ document.addEventListener("keydown", (e) => {
2012
3999
  return;
2013
4000
  }
2014
4001
  const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
2015
- if (e.shiftKey && key === "a") {
2016
- e.preventDefault();
2017
- acceptAll(null);
4002
+ if (!state.shortcuts && /^[a-z/]$/.test(key)) {
4003
+ say(SHORTCUTS_OFF);
2018
4004
  return;
2019
4005
  }
2020
- if (state.screen !== "story") {
4006
+ if (state.screen === "grid") {
4007
+ if (e.shiftKey && key === "a") {
4008
+ e.preventDefault();
4009
+ acceptAll(null);
4010
+ } else if (key === "/") {
4011
+ e.preventDefault();
4012
+ document.getElementById("find")?.focus();
4013
+ }
2021
4014
  return;
2022
4015
  }
2023
4016
  if (key === " ") {
4017
+ // A focused button takes Space as a press.
4018
+ if (e.target.tagName === "BUTTON") {
4019
+ return;
4020
+ }
2024
4021
  // Held: flash until released, then back to the view it came from.
2025
4022
  e.preventDefault();
2026
4023
  const item = current();
2027
- if (!e.repeat && state.held === null && item?.baseline && item?.capture) {
4024
+ if (!e.repeat && state.held === null && item?.baseline && item?.capture && !state.ended) {
2028
4025
  state.held = state.view;
2029
4026
  state.view = "flash";
4027
+ state.motion = true;
2030
4028
  showStory();
2031
4029
  }
2032
4030
  return;
@@ -2039,12 +4037,22 @@ document.addEventListener("keydown", (e) => {
2039
4037
  e: () => decide("exclude"),
2040
4038
  u: () => decide(null),
2041
4039
  // In Spotlight, F flashes the spotlighted baseline and new instead of leaving it.
2042
- f: () => (state.view === "spotlight" ? toggleOption("spotFlash") : toggleView("flash")),
4040
+ f: () =>
4041
+ state.view === "spotlight"
4042
+ ? state.spotFlash && !state.motion
4043
+ ? ((state.motion = true), showStory())
4044
+ : toggleOption("spotFlash")
4045
+ : toggleView("flash"),
2043
4046
  h: () => toggleView("highlight"),
2044
4047
  s: () => toggleView("spotlight"),
2045
4048
  b: () => toggleOption("showBox"),
2046
4049
  l: () => {
2047
- if (state.view === "highlight") {
4050
+ if (state.view !== "highlight") {
4051
+ say("Blink works in Highlight (H).");
4052
+ } else if (state.blink && !state.motion) {
4053
+ state.motion = true;
4054
+ showStory();
4055
+ } else {
2048
4056
  toggleOption("blink");
2049
4057
  }
2050
4058
  },
@@ -2054,6 +4062,10 @@ document.addEventListener("keydown", (e) => {
2054
4062
  showStory();
2055
4063
  },
2056
4064
  };
4065
+ if (e.shiftKey && key === "a") {
4066
+ say("Accept all is on the grid: press Escape, then Shift+A.");
4067
+ return;
4068
+ }
2057
4069
  const action = keys[key];
2058
4070
  if (e.repeat && ["a", "r", "e", "u"].includes(key)) {
2059
4071
  // A held decision key decides once, never the items after it unseen.
@@ -2075,8 +4087,9 @@ document.addEventListener("keyup", (e) => {
2075
4087
  }
2076
4088
  }
2077
4089
  });
2078
- document.getElementById("home").addEventListener("click", () => showTargets());
2079
- document.getElementById("copy-link").addEventListener("click", async () => {
4090
+
4091
+ async function copyLink() {
4092
+ document.querySelector("details.menu[open]")?.removeAttribute("open");
2080
4093
  try {
2081
4094
  await navigator.clipboard.writeText(location.href);
2082
4095
  say("Link copied. It carries your session token: it opens this screen on any device.");
@@ -2084,11 +4097,16 @@ document.getElementById("copy-link").addEventListener("click", async () => {
2084
4097
  // No clipboard (a page served over plain http to another device): the browser's own box.
2085
4098
  prompt("Copy this link:", location.href);
2086
4099
  }
2087
- });
4100
+ }
4101
+
4102
+ document.getElementById("home").addEventListener("click", () => showTargets());
4103
+ document.getElementById("copy-link").addEventListener("click", copyLink);
4104
+ document.getElementById("keys-button").addEventListener("click", toggleKeys);
2088
4105
  window.addEventListener("popstate", () => route());
2089
4106
 
2090
4107
  if (token === "") {
2091
4108
  render(el("p", { class: "error" }, "No session token: open the URL that visual-review serve printed."));
2092
4109
  } else {
4110
+ checkFinish();
2093
4111
  route();
2094
4112
  }