@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 +12 -6
- package/package.json +1 -1
- package/trusted/lib/github.mjs +43 -6
- package/trusted/page/review.css +6 -0
- package/trusted/page/review.js +109 -20
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
|
|
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.
|
|
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**,
|
|
286
|
-
|
|
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.
|
|
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",
|
package/trusted/lib/github.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/trusted/page/review.css
CHANGED
|
@@ -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;
|
package/trusted/page/review.js
CHANGED
|
@@ -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(
|
|
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("
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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];
|