@graphty/visual-review 0.1.1 → 0.1.3
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 +24 -8
- package/capture/capture.mjs +8 -1
- package/package.json +1 -1
- package/templates/visual-seed.yml +32 -12
- package/trusted/lib/github.mjs +43 -6
- package/trusted/page/index.html +1 -1
- package/trusted/page/review.css +10 -0
- package/trusted/page/review.js +121 -20
package/README.md
CHANGED
|
@@ -170,8 +170,10 @@ copy the `visual` job and the gate's steps into it, keep those names, and set `w
|
|
|
170
170
|
file.
|
|
171
171
|
|
|
172
172
|
**`visual-seed.yml`** is started by hand to capture an older commit with the default branch's
|
|
173
|
-
tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`.
|
|
174
|
-
|
|
173
|
+
tool: `gh workflow run visual-seed.yml --ref main -f ref=<sha>`. It captures every project seeded
|
|
174
|
+
from the default branch; add `-f projects="web charts"` to capture only those. Each project is
|
|
175
|
+
built and captured on its own, so one whose Storybook does not build at that commit fails alone.
|
|
176
|
+
See [Seeding](#seeding-one-story-at-a-time).
|
|
175
177
|
|
|
176
178
|
Both need nothing but the default `GITHUB_TOKEN`: the capture job reads Actions artifacts
|
|
177
179
|
(`actions: read`); nothing in CI writes to the repository.
|
|
@@ -214,9 +216,11 @@ token is kept in the work directory, so the URL stays valid across restarts; del
|
|
|
214
216
|
### Links to a screen
|
|
215
217
|
|
|
216
218
|
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`.
|
|
219
|
+
request (or master) and project with the grid's filter and text; or one story with its view,
|
|
220
|
+
zoom, changed box and blink, for example
|
|
221
|
+
`#token=...&target=123&project=web&filter=undecided&item=button--primary.dark.png&view=flash&zoom=2&box=on&blink=off`.
|
|
222
|
+
A link's `box` and `blink` apply to the page it opens; the choice this browser remembers for B
|
|
223
|
+
and L is left as it was.
|
|
220
224
|
Opening that address, in another tab or on another device, opens the same screen. **Copy link**
|
|
221
225
|
at the top right copies it. The link carries your session token, so it works on your iPad the way
|
|
222
226
|
the printed URL does; keep it to yourself as you would that URL. All of it sits after `#`, which a
|
|
@@ -279,11 +283,13 @@ starts the same server from your own shell.
|
|
|
279
283
|
other to the same place, and **Fit to screen** returns to the whole image. (On an iPad,
|
|
280
284
|
pinching zooms the whole page; use the zoom buttons to zoom the images.) **Next changed box**
|
|
281
285
|
(N) scrolls both panes until the next region of changed pixels is in view and outlines it;
|
|
282
|
-
"box i of k" counts them.
|
|
286
|
+
"box i of k" counts them. **Box** (B) turns that outline on and off; the page remembers the
|
|
287
|
+
choice in this browser. The views, each shown in the right pane at the same scale and place:
|
|
283
288
|
**Side by side**; **Flash**, which shows baseline and new one after the other in the same
|
|
284
289
|
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
|
-
|
|
290
|
+
scroll it was opened at; **Highlight**, the changed pixels in solid red laid over both images
|
|
291
|
+
themselves, in both panes, where **Blink** (L) flashes the red pixels on and off at Flash's
|
|
292
|
+
pace (remembered in this browser); and **Spotlight**, the new image dimmed everywhere except around the changed pixels
|
|
287
293
|
(each grown by 10 image pixels), which finds a one-pixel change. Flash, Highlight and
|
|
288
294
|
Spotlight need two images; on a new or removed story they are off and the page says why
|
|
289
295
|
("New story, no baseline", "Only one image: this story was removed"). Badges here:
|
|
@@ -323,6 +329,8 @@ for them. Seed them from the default branch (below), or accept them on the pull
|
|
|
323
329
|
| S | Spotlight the changes; S again returns to side by side |
|
|
324
330
|
| Z | Next zoom: fit to screen, real size, 2x, 4x, 8x, then fit again |
|
|
325
331
|
| N | Next changed box |
|
|
332
|
+
| B | Outline the changed box, or stop outlining it |
|
|
333
|
+
| L | In Highlight: blink the red changed pixels, or hold them on |
|
|
326
334
|
| Space (hold) | Flash while held |
|
|
327
335
|
| Shift+A | Accept every undecided item of this project without opening it (asks first) |
|
|
328
336
|
| Escape | Back to the grid from a story, wherever the focus is (the reason box included) |
|
|
@@ -456,6 +464,14 @@ the pull request.
|
|
|
456
464
|
wider. It is never cropped to the content, so a small component sits in the full canvas and
|
|
457
465
|
every capture of a project has the same size unless its story overflows. results.json records the scale as `scale`, and each review record
|
|
458
466
|
copies it into its `subject`.
|
|
467
|
+
- **Why captures rasterize on the CPU.** Chromium runs with `--disable-gpu-rasterization`, so the
|
|
468
|
+
page's text and shapes are drawn by the CPU; WebGL still runs on SwiftShader. Drawn through
|
|
469
|
+
SwiftShader, a glyph that sat on a sub-pixel boundary landed on either side of it from one
|
|
470
|
+
render to the next (a quarter-pixel shift of one letter, in 1 to 7 of 48 renders of the same
|
|
471
|
+
story), so stories with nothing moving read `unstable`, a different few on each run. With the
|
|
472
|
+
switch, 48 of 48 renders matched. The repository owner chose this on 2026-09-30, knowing it
|
|
473
|
+
changes how text is drawn in every story of every project: a baseline captured before it can
|
|
474
|
+
read `changed` once, and is accepted again.
|
|
459
475
|
- **From GitHub Actions to the page.** Each `visual` job uploads `results.json` and the PNGs to
|
|
460
476
|
review as an artifact `visual-<project>-<attempt>`, kept 30 days. The server lists open pull
|
|
461
477
|
requests with `gh`, finds each one's newest run of the capturing workflow, and downloads those
|
package/capture/capture.mjs
CHANGED
|
@@ -54,10 +54,17 @@ const VIEWPORT = { width: 1200, height: 900 };
|
|
|
54
54
|
/** Device pixels per CSS pixel, as Chromatic captures; recorded in results.json as `scale`. */
|
|
55
55
|
const SCALE = 2;
|
|
56
56
|
const RENDER_TIMEOUT = 30_000;
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Chromium's switches for every capture. `--disable-gpu-rasterization` draws the page's text and
|
|
59
|
+
* shapes on the CPU: rasterized through SwiftShader, a glyph that sits on a sub-pixel boundary
|
|
60
|
+
* landed on either side of it from one render to the next, so a story with nothing moving read
|
|
61
|
+
* `unstable` (the README, "Why captures rasterize on the CPU"). WebGL still runs on SwiftShader.
|
|
62
|
+
*/
|
|
63
|
+
export const CHROMIUM_ARGS = [
|
|
58
64
|
"--use-gl=angle",
|
|
59
65
|
"--use-angle=swiftshader",
|
|
60
66
|
"--enable-unsafe-swiftshader",
|
|
67
|
+
"--disable-gpu-rasterization",
|
|
61
68
|
"--force-color-profile=srgb",
|
|
62
69
|
"--disable-lcd-text",
|
|
63
70
|
"--font-render-hinting=none",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphty/visual-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
# baselines can be seeded from it: `visual-review serve --master-run <this run's id>` lists the run
|
|
6
6
|
# as the default branch, and Finish opens the seed pull request. Dispatch it from __BRANCH__:
|
|
7
7
|
# gh workflow run visual-seed.yml --ref __BRANCH__ -f ref=<sha>
|
|
8
|
-
# Only the projects whose "seedFromDefaultBranch" is not false are captured
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# Only the projects whose "seedFromDefaultBranch" is not false are captured, or, with
|
|
9
|
+
# `-f projects="a b"`, only those of them named there. Each project is built and captured on its
|
|
10
|
+
# own: one whose Storybook fails to build at that commit fails its own jobs and no other project's.
|
|
11
|
+
# The capture job repeats the "visual" job of the review workflow, except that it has no reference
|
|
12
|
+
# (every story without a baseline is "new") and results.json names the captured commit.
|
|
11
13
|
|
|
12
14
|
name: Visual seed capture
|
|
13
15
|
|
|
@@ -18,6 +20,11 @@ on:
|
|
|
18
20
|
description: "The commit (or branch or tag) whose Storybooks are captured"
|
|
19
21
|
required: true
|
|
20
22
|
type: string
|
|
23
|
+
projects:
|
|
24
|
+
description: "The projects to capture, separated by spaces or commas (default: every one seeded from the default branch)"
|
|
25
|
+
required: false
|
|
26
|
+
type: string
|
|
27
|
+
default: ""
|
|
21
28
|
|
|
22
29
|
permissions:
|
|
23
30
|
contents: read
|
|
@@ -34,13 +41,23 @@ jobs:
|
|
|
34
41
|
sparse-checkout: visual-review.config.json
|
|
35
42
|
sparse-checkout-cone-mode: false
|
|
36
43
|
|
|
44
|
+
# A name that is not a project seeded from the default branch stops the run here, rather
|
|
45
|
+
# than seeding fewer projects than were asked for.
|
|
37
46
|
- name: List the projects
|
|
38
47
|
id: plan
|
|
48
|
+
env:
|
|
49
|
+
WANT: ${{ inputs.projects }}
|
|
39
50
|
run: |
|
|
40
|
-
|
|
51
|
+
projects=$(jq -c --arg want "$WANT" '(.baselines // "visual-baselines") as $b
|
|
52
|
+
| [$want | splits("[ ,]+") | select(. != "")] as $w
|
|
41
53
|
| [.projects | to_entries[] | select(.value.seedFromDefaultBranch != false)
|
|
42
|
-
| {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}]
|
|
43
|
-
|
|
54
|
+
| {project: .key, storybook: .value.storybook, build: .value.build, baselines: $b}] as $all
|
|
55
|
+
| ($w - [$all[].project]) as $unknown
|
|
56
|
+
| if ($unknown | length) > 0
|
|
57
|
+
then error("not a project seeded from the default branch: \($unknown | join(", "))")
|
|
58
|
+
else [$all[] | select(($w | length) == 0 or (.project | IN($w[])))] end' \
|
|
59
|
+
visual-review.config.json)
|
|
60
|
+
echo "projects=$projects" >> "$GITHUB_OUTPUT"
|
|
44
61
|
|
|
45
62
|
storybook:
|
|
46
63
|
name: Build ${{ matrix.project }} at ${{ inputs.ref }}
|
|
@@ -71,9 +88,12 @@ jobs:
|
|
|
71
88
|
name: storybook-${{ matrix.project }}
|
|
72
89
|
path: ${{ matrix.storybook }}/
|
|
73
90
|
|
|
91
|
+
# Runs even when another project's build failed. A project whose own build failed has no
|
|
92
|
+
# Storybook artifact, so its job stops at the download, before installing anything.
|
|
74
93
|
visual:
|
|
75
94
|
name: visual (${{ matrix.project }})
|
|
76
95
|
needs: [plan, storybook]
|
|
96
|
+
if: ${{ !cancelled() && needs.plan.result == 'success' }}
|
|
77
97
|
runs-on: ubuntu-24.04
|
|
78
98
|
timeout-minutes: 45
|
|
79
99
|
strategy:
|
|
@@ -84,6 +104,12 @@ jobs:
|
|
|
84
104
|
# The default branch's checkout: the capture tool, the config and the baselines come from here.
|
|
85
105
|
- uses: actions/checkout@v4
|
|
86
106
|
|
|
107
|
+
- name: Download Storybook build
|
|
108
|
+
uses: actions/download-artifact@v4
|
|
109
|
+
with:
|
|
110
|
+
name: storybook-${{ matrix.project }}
|
|
111
|
+
path: ${{ matrix.storybook }}/
|
|
112
|
+
|
|
87
113
|
- name: Restore this project's baseline images
|
|
88
114
|
uses: actions/cache@v4
|
|
89
115
|
with:
|
|
@@ -99,12 +125,6 @@ jobs:
|
|
|
99
125
|
- name: Install Chromium
|
|
100
126
|
run: __CLI__ install-browser
|
|
101
127
|
|
|
102
|
-
- name: Download Storybook build
|
|
103
|
-
uses: actions/download-artifact@v4
|
|
104
|
-
with:
|
|
105
|
-
name: storybook-${{ matrix.project }}
|
|
106
|
-
path: ${{ matrix.storybook }}/
|
|
107
|
-
|
|
108
128
|
- name: Capture
|
|
109
129
|
run: __CLI__ capture --project ${{ matrix.project }} --out "$RUNNER_TEMP/visual"
|
|
110
130
|
|
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/index.html
CHANGED
|
@@ -14,6 +14,6 @@
|
|
|
14
14
|
<span id="status" role="status"></span>
|
|
15
15
|
<button type="button" id="copy-link" title="Copy a link to this screen">Copy link</button>
|
|
16
16
|
</header>
|
|
17
|
-
<main id="app"><p>Loading...</p></main>
|
|
17
|
+
<main id="app" tabindex="-1"><p>Loading...</p></main>
|
|
18
18
|
</body>
|
|
19
19
|
</html>
|
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;
|
|
@@ -501,3 +507,7 @@ td {
|
|
|
501
507
|
word-break: break-all;
|
|
502
508
|
color: var(--accent);
|
|
503
509
|
}
|
|
510
|
+
|
|
511
|
+
#app:focus {
|
|
512
|
+
outline: none;
|
|
513
|
+
}
|
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();
|
|
@@ -1676,6 +1759,18 @@ function toggleView(view) {
|
|
|
1676
1759
|
showStory();
|
|
1677
1760
|
}
|
|
1678
1761
|
|
|
1762
|
+
// Safari on an iPad sends a hardware keyboard's keys only to a focused element, and tapping an
|
|
1763
|
+
// image or a button focuses nothing, so the shortcuts never arrived. The page itself holds focus
|
|
1764
|
+
// whenever nothing else does.
|
|
1765
|
+
function keepKeys() {
|
|
1766
|
+
if (document.activeElement === null || document.activeElement === document.body) {
|
|
1767
|
+
app.focus({ preventScroll: true });
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
document.addEventListener("pointerup", () => setTimeout(keepKeys));
|
|
1771
|
+
document.addEventListener("focusout", () => setTimeout(keepKeys));
|
|
1772
|
+
keepKeys();
|
|
1773
|
+
|
|
1679
1774
|
document.addEventListener("keydown", (e) => {
|
|
1680
1775
|
if (!["story", "grid"].includes(state.screen) || e.ctrlKey || e.metaKey || e.altKey) {
|
|
1681
1776
|
return;
|
|
@@ -1727,6 +1822,12 @@ document.addEventListener("keydown", (e) => {
|
|
|
1727
1822
|
f: () => toggleView("flash"),
|
|
1728
1823
|
h: () => toggleView("highlight"),
|
|
1729
1824
|
s: () => toggleView("spotlight"),
|
|
1825
|
+
b: () => toggleOption("showBox"),
|
|
1826
|
+
l: () => {
|
|
1827
|
+
if (state.view === "highlight") {
|
|
1828
|
+
toggleOption("blink");
|
|
1829
|
+
}
|
|
1830
|
+
},
|
|
1730
1831
|
n: nextBox,
|
|
1731
1832
|
z: () => {
|
|
1732
1833
|
state.zoom = ZOOMS[(ZOOMS.indexOf(state.zoom) + 1) % ZOOMS.length];
|