@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 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>`. See
174
- [Seeding](#seeding-one-story-at-a-time).
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 and
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. The views, each shown in the right pane at the same scale and place:
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**, pixelmatch's changed pixels in red over the dimmed
286
- baseline; and **Spotlight**, the new image dimmed everywhere except around the changed pixels
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
@@ -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
- const CHROMIUM_ARGS = [
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.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. The capture job
9
- # repeats the "visual" job of the review workflow, except that it has no reference (every story
10
- # without a baseline is "new") and results.json names the captured commit.
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
- echo "projects=$(jq -c '(.baselines // "visual-baselines") as $b
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
- visual-review.config.json)" >> "$GITHUB_OUTPUT"
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
 
@@ -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;
@@ -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>
@@ -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
+ }
@@ -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();
@@ -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];