@graphty/visual-review 0.1.1 → 0.1.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 CHANGED
@@ -214,9 +214,11 @@ token is kept in the work directory, so the URL stays valid across restarts; del
214
214
  ### Links to a screen
215
215
 
216
216
  The address always names the screen you are on, after the token: the targets list; a pull
217
- request (or master) and project with the grid's filter and text; or one story with its view and
218
- zoom, for example
219
- `#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2`.
217
+ request (or master) and project with the grid's filter and text; or one story with its view,
218
+ zoom, changed box and blink, for example
219
+ `#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off`.
220
+ A link's `box` and `blink` apply to the page it opens; the choice this browser remembers for B
221
+ and L is left as it was.
220
222
  Opening that address, in another tab or on another device, opens the same screen. **Copy link**
221
223
  at the top right copies it. The link carries your session token, so it works on your iPad the way
222
224
  the printed URL does; keep it to yourself as you would that URL. All of it sits after `#`, which a
@@ -279,11 +281,13 @@ starts the same server from your own shell.
279
281
  other to the same place, and **Fit to screen** returns to the whole image. (On an iPad,
280
282
  pinching zooms the whole page; use the zoom buttons to zoom the images.) **Next changed box**
281
283
  (N) scrolls both panes until the next region of changed pixels is in view and outlines it;
282
- "box i of k" counts them. The views, each shown in the right pane at the same scale and place:
284
+ "box i of k" counts them. **Box** (B) turns that outline on and off; the page remembers the
285
+ choice in this browser. The views, each shown in the right pane at the same scale and place:
283
286
  **Side by side**; **Flash**, which shows baseline and new one after the other in the same
284
287
  place, about 1.5 times a second (the images themselves, not an overlay), keeping the zoom and
285
- scroll it was opened at; **Highlight**, pixelmatch's changed pixels in red over the dimmed
286
- baseline; and **Spotlight**, the new image dimmed everywhere except around the changed pixels
288
+ scroll it was opened at; **Highlight**, the changed pixels in solid red laid over both images
289
+ themselves, in both panes, where **Blink** (L) flashes the red pixels on and off at Flash's
290
+ pace (remembered in this browser); and **Spotlight**, the new image dimmed everywhere except around the changed pixels
287
291
  (each grown by 10 image pixels), which finds a one-pixel change. Flash, Highlight and
288
292
  Spotlight need two images; on a new or removed story they are off and the page says why
289
293
  ("New story, no baseline", "Only one image: this story was removed"). Badges here:
@@ -323,6 +327,8 @@ for them. Seed them from the default branch (below), or accept them on the pull
323
327
  | S | Spotlight the changes; S again returns to side by side |
324
328
  | Z | Next zoom: fit to screen, real size, 2x, 4x, 8x, then fit again |
325
329
  | N | Next changed box |
330
+ | B | Outline the changed box, or stop outlining it |
331
+ | L | In Highlight: blink the red changed pixels, or hold them on |
326
332
  | Space (hold) | Flash while held |
327
333
  | Shift+A | Accept every undecided item of this project without opening it (asks first) |
328
334
  | Escape | Back to the grid from a story, wherever the focus is (the reason box included) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphty/visual-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Self-hosted visual review of Storybook stories: capture in GitHub Actions, keep the baselines in git (Git LFS), accept or reject each change in a local page, and gate pull requests",
5
5
  "author": "Adam Powers <apowers@ato.ms>",
6
6
  "type": "module",
@@ -10,8 +10,8 @@
10
10
  */
11
11
 
12
12
  import { execFile } from "node:child_process";
13
- import { existsSync, readFileSync } from "node:fs";
14
- import { join } from "node:path";
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
15
 
16
16
  import { validateResults } from "./results.mjs";
17
17
 
@@ -114,9 +114,48 @@ export async function visualJobs(gh, run, attempt, projects) {
114
114
  );
115
115
  }
116
116
 
117
+ // Downloads in flight, by target directory: concurrent refreshes of one run await the same one.
118
+ const downloading = new Map();
119
+
120
+ /**
121
+ * Downloads one artifact into `dir`, unless it is already there. It is extracted into a sibling
122
+ * temporary directory and renamed into place only once its results.json is there, so `dir` either
123
+ * does not exist or holds a whole artifact; a failed or interrupted download leaves nothing behind.
124
+ * An artifact without results.json is discarded, and its readers report the capture as failed.
125
+ * @param {Function} gh the gh runner
126
+ * @param {number} runId the run
127
+ * @param {string} name the artifact
128
+ * @param {string} dir where it goes
129
+ * @returns {Promise<void>} settles when `dir` is complete, or the artifact had no results.json
130
+ */
131
+ function download(gh, runId, name, dir) {
132
+ if (existsSync(join(dir, "results.json"))) {
133
+ return Promise.resolve();
134
+ }
135
+ if (!downloading.has(dir)) {
136
+ const done = (async () => {
137
+ // A directory without results.json is left over from before downloads were atomic.
138
+ rmSync(dir, { recursive: true, force: true });
139
+ mkdirSync(dirname(dir), { recursive: true });
140
+ const part = mkdtempSync(`${dir}.part-`);
141
+ try {
142
+ await gh(["run", "download", String(runId), "-n", name, "-D", part]);
143
+ if (existsSync(join(part, "results.json"))) {
144
+ renameSync(part, dir);
145
+ }
146
+ } finally {
147
+ rmSync(part, { recursive: true, force: true });
148
+ }
149
+ })().finally(() => downloading.delete(dir));
150
+ downloading.set(dir, done);
151
+ }
152
+ return downloading.get(dir);
153
+ }
154
+
117
155
  /**
118
156
  * Downloads each project's capture artifact from the highest attempt that uploaded one, into
119
- * `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again.
157
+ * `<tmp>/<run>-<attempt>/<project>/`. An artifact already downloaded is not fetched again, and
158
+ * concurrent calls for the same one share a single download.
120
159
  * @param {Function} gh the gh runner
121
160
  * @param {{ id: number }} run the run
122
161
  * @param {string[]} projects project ids
@@ -139,9 +178,7 @@ export async function downloadCaptures(gh, run, projects, tmp) {
139
178
  continue;
140
179
  }
141
180
  const dir = join(tmp, `${run.id}-${newest.attempt}`, project);
142
- if (!existsSync(join(dir, "results.json"))) {
143
- await gh(["run", "download", String(run.id), "-n", newest.name, "-D", dir]);
144
- }
181
+ await download(gh, run.id, newest.name, dir);
145
182
  out[project] = { dir, attempt: newest.attempt };
146
183
  }
147
184
  return out;
@@ -445,6 +445,12 @@ td {
445
445
  image-rendering: pixelated;
446
446
  }
447
447
 
448
+ /* Highlight's changed pixels, over the image: no checkerboard behind them. */
449
+ .sheet > canvas.diffmark {
450
+ background: none;
451
+ pointer-events: none;
452
+ }
453
+
448
454
  .boxmark {
449
455
  position: absolute;
450
456
  box-sizing: border-box;
@@ -29,6 +29,9 @@ const SPOT_ALPHA = 190; // the spotlight's dimming, out of 255, as Chromatic's f
29
29
  const RE_REVIEW = "re-review: your earlier accept was replaced by master's baseline";
30
30
  // The grid's decision filters, and how a decision reads on a tile.
31
31
  const DECISIONS = { accept: "Accepted", reject: "Rejected", exclude: "Excluded" };
32
+ // The reviewer's own display choices (the changed box, blinking the overlay), kept in this browser.
33
+ const OPTIONS_KEY = "visual-review:options";
34
+ const saved = loadOptions();
32
35
 
33
36
  const state = {
34
37
  targets: [],
@@ -45,6 +48,8 @@ const state = {
45
48
  view: "side", // side | flash | highlight | spotlight
46
49
  zoom: "fit",
47
50
  box: 0, // which changed box "next changed box" is on
51
+ showBox: saved.showBox ?? true, // outline the changed box (B)
52
+ blink: saved.blink ?? false, // blink the changed pixels Highlight lays over the images (L)
48
53
  held: null, // the view to return to when Space is released
49
54
  pending: "reject", // what Enter in the reason box does
50
55
  screen: "targets",
@@ -215,6 +220,30 @@ function stopFlash() {
215
220
  flashTimer = null;
216
221
  }
217
222
 
223
+ // Local storage can be missing or refuse (a private window, blocked site data): the page then uses
224
+ // the defaults and forgets the choices, and nothing else changes.
225
+ function loadOptions() {
226
+ try {
227
+ return JSON.parse(localStorage.getItem(OPTIONS_KEY) ?? "{}") ?? {};
228
+ } catch {
229
+ return {};
230
+ }
231
+ }
232
+
233
+ function saveOptions() {
234
+ try {
235
+ localStorage.setItem(OPTIONS_KEY, JSON.stringify({ showBox: state.showBox, blink: state.blink }));
236
+ } catch {
237
+ // Not remembered; the choice still holds for this page.
238
+ }
239
+ }
240
+
241
+ function toggleOption(key) {
242
+ state[key] = !state[key];
243
+ saveOptions();
244
+ showStory();
245
+ }
246
+
218
247
  // ---------------------------------------------------------------- screen: targets
219
248
 
220
249
  async function loadTargets() {
@@ -972,6 +1001,30 @@ function showStory() {
972
1001
  viewButton("flash", "Flash", "F, or hold Space"),
973
1002
  viewButton("highlight", "Highlight", "H"),
974
1003
  viewButton("spotlight", "Spotlight", "S"),
1004
+ el(
1005
+ "button",
1006
+ {
1007
+ type: "button",
1008
+ "aria-pressed": String(state.showBox),
1009
+ title: "B: outline the changed box",
1010
+ onclick: () => toggleOption("showBox"),
1011
+ },
1012
+ "Box",
1013
+ ),
1014
+ el(
1015
+ "button",
1016
+ {
1017
+ type: "button",
1018
+ "aria-pressed": String(state.blink),
1019
+ disabled: view !== "highlight",
1020
+ title:
1021
+ view === "highlight"
1022
+ ? "L: blink the changed pixels"
1023
+ : "Blink flashes the changed pixels that Highlight lays over the images",
1024
+ onclick: () => toggleOption("blink"),
1025
+ },
1026
+ "Blink",
1027
+ ),
975
1028
  note ? el("span", { id: "single-note", class: "meta" }, note) : null,
976
1029
  el("span", { class: "spacer" }),
977
1030
  ZOOMS.map(zoomButton),
@@ -1053,7 +1106,7 @@ async function diffOf(item) {
1053
1106
  diffMask: true,
1054
1107
  });
1055
1108
  const grown = grow(mask, w, h);
1056
- return { w, h, a: pa, b: pb, grown, boxes: regions(grown, w, h) };
1109
+ return { w, h, a: pa, b: pb, mask, grown, boxes: regions(grown, w, h) };
1057
1110
  })(),
1058
1111
  );
1059
1112
  }
@@ -1178,8 +1231,14 @@ async function renderStage(item, view, keep) {
1178
1231
  };
1179
1232
  try {
1180
1233
  const diff = item.baseline && item.capture ? await diffOf(item) : null;
1234
+ const marked = view === "highlight" && diff !== null;
1235
+ const baseName = item.from ? `Baseline of ${item.from}` : "Baseline";
1181
1236
  const left = item.baseline
1182
- ? pane(item.from ? `Baseline of ${item.from}` : "Baseline", await imgOf("baseline"))
1237
+ ? pane(
1238
+ marked ? `${baseName}, changed pixels in red` : baseName,
1239
+ await imgOf("baseline"),
1240
+ ...(marked ? [overlay(diff)] : []),
1241
+ )
1183
1242
  : pane("No baseline");
1184
1243
  let right;
1185
1244
  if (!item.capture) {
@@ -1201,11 +1260,22 @@ async function renderStage(item, view, keep) {
1201
1260
  tag.textContent = showingNew ? "Flash: new" : "Flash: baseline";
1202
1261
  }, FLASH_MS);
1203
1262
  } else if (view === "highlight") {
1204
- right = pane("Changed pixels in red over the dimmed baseline", highlight(item, diff));
1263
+ right = pane("New, changed pixels in red", await imgOf("capture"), overlay(diff));
1205
1264
  } else {
1206
1265
  right = pane("Spotlight: the new image, dimmed except around each change", spotlight(diff));
1207
1266
  }
1208
1267
  stage.replaceChildren(left, right);
1268
+ if (marked && state.blink) {
1269
+ // Both panes' overlays on and off together, at Flash's pace.
1270
+ const marks = [...stage.querySelectorAll(".diffmark")];
1271
+ let on = true;
1272
+ flashTimer = setInterval(() => {
1273
+ on = !on;
1274
+ for (const m of marks) {
1275
+ m.style.visibility = on ? "visible" : "hidden";
1276
+ }
1277
+ }, FLASH_MS);
1278
+ }
1209
1279
  const frames = [...stage.querySelectorAll(".frame")];
1210
1280
  // Zoomed, scrolling one pane scrolls the other to the same place.
1211
1281
  for (const f of frames) {
@@ -1262,14 +1332,16 @@ function showBox(stage, boxes, jump) {
1262
1332
  const [left, top] = [Math.max(0, x * factor - 2), Math.max(0, y * factor - 2)];
1263
1333
  const [right, bottom] = [Math.min(sw, (x + w) * factor + 2), Math.min(sh, (y + h) * factor + 2)];
1264
1334
  sheet.querySelector(".boxmark")?.remove();
1265
- const mark = el("div", { class: "boxmark" });
1266
- Object.assign(mark.style, {
1267
- left: `${left}px`,
1268
- top: `${top}px`,
1269
- width: `${right - left}px`,
1270
- height: `${bottom - top}px`,
1271
- });
1272
- sheet.append(mark);
1335
+ if (state.showBox) {
1336
+ const mark = el("div", { class: "boxmark" });
1337
+ Object.assign(mark.style, {
1338
+ left: `${left}px`,
1339
+ top: `${top}px`,
1340
+ width: `${right - left}px`,
1341
+ height: `${bottom - top}px`,
1342
+ });
1343
+ sheet.append(mark);
1344
+ }
1273
1345
  if (!jump) {
1274
1346
  frame.scrollLeft = 0;
1275
1347
  frame.scrollTop = 0;
@@ -1304,16 +1376,18 @@ async function nextBox() {
1304
1376
  showBox(document.getElementById("stage"), boxes, true);
1305
1377
  }
1306
1378
 
1307
- // pixelmatch's own picture: the changed pixels in red over the dimmed baseline.
1308
- function highlight(item, diff) {
1309
- const canvas = el("canvas", { width: String(diff.w), height: String(diff.h) });
1379
+ // The changed pixels alone, in solid red, transparent everywhere else: laid over an image in the
1380
+ // same grid cell, so it lines up with the image at every zoom.
1381
+ function overlay(diff) {
1382
+ const canvas = el("canvas", { width: String(diff.w), height: String(diff.h), class: "diffmark" });
1310
1383
  const ctx = canvas.getContext("2d");
1311
1384
  const out = ctx.createImageData(diff.w, diff.h);
1312
- pixelmatch(diff.a, diff.b, out.data, diff.w, diff.h, {
1313
- threshold: item.threshold,
1314
- includeAA: item.includeAA,
1315
- alpha: 0.2,
1316
- });
1385
+ for (let i = 0; i < diff.w * diff.h; i++) {
1386
+ if (diff.mask[i * 4 + 3] !== 0) {
1387
+ out.data[i * 4] = 255;
1388
+ out.data[i * 4 + 3] = 255;
1389
+ }
1390
+ }
1317
1391
  ctx.putImageData(out, 0, 0);
1318
1392
  return canvas;
1319
1393
  }
@@ -1568,7 +1642,7 @@ function finishOutcome() {
1568
1642
  // ---------------------------------------------------------------- the address
1569
1643
 
1570
1644
  // Every screen is in the address, after the session token, so a copied link opens it again:
1571
- // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit
1645
+ // #token=...&target=123&project=p&filter=undecided&q=text&item=file.png&view=side&zoom=fit&box=on&blink=off
1572
1646
  // Only the fragment holds it: a browser never sends a fragment to a server or in a Referer.
1573
1647
  function hashFor() {
1574
1648
  const p = new URLSearchParams({ token });
@@ -1584,6 +1658,8 @@ function hashFor() {
1584
1658
  p.set("item", current().file);
1585
1659
  p.set("view", state.held ?? state.view);
1586
1660
  p.set("zoom", String(state.zoom));
1661
+ p.set("box", state.showBox ? "on" : "off");
1662
+ p.set("blink", state.blink ? "on" : "off");
1587
1663
  }
1588
1664
  return `#${p}`;
1589
1665
  }
@@ -1660,6 +1736,13 @@ async function route() {
1660
1736
  state.view = VIEWS.includes(p.get("view")) ? p.get("view") : "side";
1661
1737
  const zoom = p.get("zoom") === "fit" ? "fit" : Number(p.get("zoom"));
1662
1738
  state.zoom = ZOOMS.includes(zoom) ? zoom : "fit";
1739
+ // A link's box and blink apply to this page; the browser's remembered choice is unchanged.
1740
+ if (["on", "off"].includes(p.get("box"))) {
1741
+ state.showBox = p.get("box") === "on";
1742
+ }
1743
+ if (["on", "off"].includes(p.get("blink"))) {
1744
+ state.blink = p.get("blink") === "on";
1745
+ }
1663
1746
  state.box = 0;
1664
1747
  say("");
1665
1748
  showStory();
@@ -1727,6 +1810,12 @@ document.addEventListener("keydown", (e) => {
1727
1810
  f: () => toggleView("flash"),
1728
1811
  h: () => toggleView("highlight"),
1729
1812
  s: () => toggleView("spotlight"),
1813
+ b: () => toggleOption("showBox"),
1814
+ l: () => {
1815
+ if (state.view === "highlight") {
1816
+ toggleOption("blink");
1817
+ }
1818
+ },
1730
1819
  n: nextBox,
1731
1820
  z: () => {
1732
1821
  state.zoom = ZOOMS[(ZOOMS.indexOf(state.zoom) + 1) % ZOOMS.length];