@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.
- package/README.md +424 -148
- package/package.json +1 -1
- package/templates/visual-review.yml +4 -2
- package/trusted/gate.mjs +239 -41
- package/trusted/lib/accept.mjs +413 -94
- package/trusted/lib/approval.mjs +300 -0
- package/trusted/lib/github.mjs +96 -29
- package/trusted/lib/serve.mjs +850 -101
- package/trusted/lib/thumbs.mjs +149 -0
- package/trusted/page/index.html +20 -6
- package/trusted/page/passkey.js +94 -0
- package/trusted/page/review.css +915 -102
- package/trusted/page/review.js +3062 -1044
package/trusted/page/review.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
52
|
-
showBox: saved.showBox ?? true, // outline the changed
|
|
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"
|
|
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
|
-
|
|
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
|
|
74
|
-
let
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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,
|
|
258
|
-
items = items.filter((i) => decisionOf(i)?.decision ===
|
|
259
|
-
} else if (
|
|
260
|
-
items = items.filter((i) => i.status ===
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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({
|
|
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
|
-
//
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
801
|
+
state.ended = false;
|
|
802
|
+
app.classList.remove("story-screen");
|
|
803
|
+
setBar();
|
|
804
|
+
drawHeader();
|
|
342
805
|
remember();
|
|
343
806
|
say(notice);
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
|
389
|
-
const
|
|
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
|
-
:
|
|
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
|
-
`
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
:
|
|
1199
|
+
: busy
|
|
465
1200
|
? el("p", { class: "finish-running" }, `Finish is running: ${state.job.step}...`)
|
|
466
|
-
: el(
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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("
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
1384
|
+
adopt(
|
|
1385
|
+
await api(`/api/pr/${encodeURIComponent(state.target.id)}/${encodeURIComponent(state.project)}`),
|
|
1386
|
+
state.project,
|
|
1387
|
+
);
|
|
513
1388
|
}
|
|
514
1389
|
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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 `
|
|
533
|
-
// walk through.
|
|
534
|
-
function openItem(
|
|
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
|
-
|
|
537
|
-
state.
|
|
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
|
|
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", {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
"
|
|
553
|
-
{
|
|
554
|
-
|
|
555
|
-
"
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
el("span", { class:
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
),
|
|
568
|
-
|
|
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"
|
|
1537
|
+
el("span", { class: "what" }, text),
|
|
584
1538
|
d.posted
|
|
585
|
-
? el("span", { class: "meta" },
|
|
1539
|
+
? el("span", { class: "meta" }, postedText(d))
|
|
586
1540
|
: el(
|
|
587
1541
|
"button",
|
|
588
1542
|
{
|
|
589
1543
|
type: "button",
|
|
590
|
-
|
|
591
|
-
onclick: () => undo([item.file],
|
|
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
|
|
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(
|
|
606
|
-
el("span", { class: "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
|
|
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
|
-
|
|
1591
|
+
nearScreen.disconnect();
|
|
1592
|
+
thumbQueue.length = 0;
|
|
702
1593
|
state.screen = "grid";
|
|
703
|
-
state.
|
|
704
|
-
|
|
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
|
|
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 -> [
|
|
725
|
-
|
|
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
|
|
728
|
-
|
|
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
|
|
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 =
|
|
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}
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
"
|
|
851
|
-
{
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
{},
|
|
855
|
-
|
|
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", {},
|
|
1827
|
+
...(items.length === 0 ? [el("p", { class: "empty" }, empty)] : sections),
|
|
867
1828
|
);
|
|
868
|
-
|
|
869
|
-
state.armed = null;
|
|
1829
|
+
applyFind();
|
|
870
1830
|
remember();
|
|
871
1831
|
// Back from a story: show where it is in the grid.
|
|
872
|
-
const
|
|
873
|
-
|
|
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
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
if (
|
|
886
|
-
say(`
|
|
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
|
-
|
|
891
|
-
|
|
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(
|
|
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 "
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
955
|
-
const
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
|
2238
|
+
z === "fit" ? "Fit" : `${z}x`,
|
|
993
2239
|
);
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
"
|
|
1054
|
-
{
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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: "
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
2657
|
+
{ class: "error" },
|
|
2658
|
+
err.message,
|
|
2659
|
+
" ",
|
|
2660
|
+
el("button", { type: "button", onclick: () => showStory() }, "Retry"),
|
|
1092
2661
|
),
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
"
|
|
1173
|
-
{
|
|
1174
|
-
|
|
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
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
1557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
if (
|
|
1598
|
-
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
}
|
|
1606
|
-
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
!(
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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 (
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
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
|
-
|
|
1660
|
-
|
|
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
|
-
//
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
1680
|
-
{
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
async function finishTarget(
|
|
1693
|
-
|
|
1694
|
-
|
|
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
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 ?
|
|
1805
|
-
|
|
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
|
-
"
|
|
1811
|
-
{ class: "error" },
|
|
1812
|
-
|
|
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
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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
|
-
{
|
|
1824
|
-
|
|
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(
|
|
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.
|
|
1838
|
-
parts.push(
|
|
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
|
-
|
|
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("
|
|
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
|
-
//
|
|
1892
|
-
|
|
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
|
-
|
|
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 (
|
|
1909
|
-
const
|
|
1910
|
-
|
|
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 (
|
|
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;
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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 (
|
|
2016
|
-
|
|
2017
|
-
acceptAll(null);
|
|
4002
|
+
if (!state.shortcuts && /^[a-z/]$/.test(key)) {
|
|
4003
|
+
say(SHORTCUTS_OFF);
|
|
2018
4004
|
return;
|
|
2019
4005
|
}
|
|
2020
|
-
if (state.screen
|
|
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: () =>
|
|
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
|
|
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
|
-
|
|
2079
|
-
|
|
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
|
}
|