staysfixed 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  * run and still draw the screens that were photographed before it opened.
14
14
  */
15
15
 
16
+ import { pathToFileURL } from 'node:url';
16
17
  import { detail } from './log.js';
17
18
  import { messageOf } from './errors.js';
18
19
 
@@ -111,6 +112,33 @@ export function emitEvent(events, event) {
111
112
  events.emit(/** @type {RunEvent} */ (event));
112
113
  }
113
114
 
115
+ /**
116
+ * A file on disk, as an address a local page can load.
117
+ *
118
+ * The watch panel is itself a local `file://` page, which means it can open the real
119
+ * full-resolution PNGs this run just wrote instead of a shrunken copy pasted into the
120
+ * event. That is the difference between a picture you can zoom into and a picture you
121
+ * cannot read — and it costs nothing to send, because the address is a few dozen
122
+ * characters and the pixels never move.
123
+ *
124
+ * Hands back `undefined` rather than a broken address for anything that is not a real
125
+ * path: an <img> pointed at a file that is not there draws the browser's torn-page
126
+ * icon, which looks like the tool is broken. Nothing at all looks like nothing at all.
127
+ *
128
+ * @param {string|undefined|null} file An absolute path to a file that EXISTS. Callers
129
+ * pass the path they have just written, or one they have just read — this function
130
+ * does not touch the disk, so it cannot tell the difference itself.
131
+ * @returns {string|undefined}
132
+ */
133
+ export function fileUrl(file) {
134
+ if (typeof file !== 'string' || file === '') return undefined;
135
+ try {
136
+ return pathToFileURL(file).href;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
114
142
  /**
115
143
  * Where a run spent its time.
116
144
  *
@@ -53,7 +53,8 @@ const KNOWN_KEYS = new Set([...ACTION_ORDER, 'text', 'note']);
53
53
  * }} settings
54
54
  * @param {{fixturesDir: string, record?: boolean, timeoutMs?: number, thumbnail?: boolean}} ctx
55
55
  * `thumbnail` is asked for only while somebody is watching the run happen; it costs a
56
- * decode of the picture that was just taken, so it is off unless it is wanted.
56
+ * decode of the picture that was just taken (skipped when the masks already decoded it)
57
+ * plus about forty milliseconds of shrinking, so it is off unless it is wanted.
57
58
  * @returns {Promise<import('../types.js').CaptureReport & {
58
59
  * masks: import('../types.js').MaskRect[],
59
60
  * timings: {steps: number, prepare: number, settle: number},
@@ -134,7 +135,12 @@ export async function captureScreen(page, screen, settings, ctx) {
134
135
  spent.settle = since(startedSettle, clock());
135
136
 
136
137
  const rects = await resolveMasks(page, settings.masks ?? [], { deviceScaleFactor });
137
- const png = rects.length > 0 ? paintInto(held.png, rects) : held.png;
138
+ // Masks force us to decode the picture; hold on to those pixels. The preview a
139
+ // watcher is shown is made from the very same ones — the picture that gets
140
+ // compared, blackout boxes and all — so a masked screen decodes a retina
141
+ // screenshot once instead of twice, which is about eighty milliseconds a screen.
142
+ const painted = rects.length > 0 ? paintInto(held.png, rects) : null;
143
+ const png = painted ? painted.png : held.png;
138
144
  const size = pngSize(png);
139
145
 
140
146
  // The picture is taken; now put the app back if this screen asked us to.
@@ -164,7 +170,7 @@ export async function captureScreen(page, screen, settings, ctx) {
164
170
  spent,
165
171
  };
166
172
  if (ctx.thumbnail === true) {
167
- const small = await thumbnailOf(png);
173
+ const small = await thumbnailOf(painted ? painted.image : png);
168
174
  if (small) report.thumbnail = small;
169
175
  }
170
176
  return report;
@@ -190,14 +196,19 @@ function since(from, to) {
190
196
 
191
197
  /**
192
198
  * Paint the masks into a screenshot and re-encode it.
199
+ *
200
+ * Hands back the decoded picture as well as the bytes. Whoever wants a preview of this
201
+ * screen would otherwise decode the very same megapixels a second time, and on a retina
202
+ * screenshot that is the most expensive thing either of them does.
203
+ *
193
204
  * @param {Buffer} buffer
194
205
  * @param {import('../types.js').MaskRect[]} rects
195
- * @returns {Buffer}
206
+ * @returns {{png: Buffer, image: import('pngjs').PNG}}
196
207
  */
197
208
  function paintInto(buffer, rects) {
198
209
  const image = PNG.sync.read(buffer);
199
210
  paintMasks(image, rects);
200
- return PNG.sync.write(image);
211
+ return { png: PNG.sync.write(image), image };
201
212
  }
202
213
 
203
214
  /**
@@ -21,7 +21,7 @@ import { resetWindow } from '../drive/launch.js';
21
21
  import { gitInfo } from '../core/git.js';
22
22
  import { messageOf } from '../core/errors.js';
23
23
  import { detail } from '../core/log.js';
24
- import { emitEvent } from '../core/events.js';
24
+ import { emitEvent, fileUrl } from '../core/events.js';
25
25
 
26
26
  /**
27
27
  * A picture result plus the one extra fact the flake register needs: whether it
@@ -30,16 +30,26 @@ import { emitEvent } from '../core/events.js';
30
30
  */
31
31
 
32
32
  /**
33
- * The small pictures a watcher is shown, filled in as a screen is worked on.
33
+ * What a watcher is shown of one screen, filled in as it is worked on.
34
34
  *
35
- * They are kept apart from the result on purpose. A result is written to disk and
35
+ * Two kinds of thing, for two different moments. The `file://` addresses are the real
36
+ * full-resolution pictures this run wrote, and they are what a person actually looks
37
+ * at and zooms into; they cost nothing to fill in, so they are always filled in. The
38
+ * base64 previews are the instant stand-in for the fraction of a second before a file
39
+ * loads, they cost real milliseconds to make, and they are only made when a window is
40
+ * open to show them in.
41
+ *
42
+ * All of it is kept apart from the result on purpose. A result is written to disk and
36
43
  * read back by `approve` and `status`, and a base64 picture in there would bloat
37
44
  * every saved run for the sake of a panel that was only open for a minute.
38
45
  *
39
46
  * @typedef {object} Thumbs
40
- * @property {string} [shot] The picture just taken.
41
- * @property {string} [approved] The approved picture, when this screen changed.
42
- * @property {string} [diff] What moved, when this screen changed.
47
+ * @property {string} [shot] Instant preview of the picture just taken.
48
+ * @property {string} [approved] Instant preview of the approved picture, when this screen changed.
49
+ * @property {string} [diff] Instant preview of what moved, when this screen changed.
50
+ * @property {string} [shotFile] The picture just taken, on disk.
51
+ * @property {string} [approvedFile] The approved picture, on disk, when there is one.
52
+ * @property {string} [diffFile] The difference picture, on disk, when one was written.
43
53
  */
44
54
 
45
55
  /**
@@ -153,6 +163,12 @@ function emitDone(events, result, thumbs) {
153
163
  thumbnail: thumbs.shot,
154
164
  approvedThumb: thumbs.approved,
155
165
  diffThumb: thumbs.diff,
166
+ // The real pictures. Only ever set for a file that was written or read a moment
167
+ // ago, so anything that arrives here can be opened; a screen that was skipped, or
168
+ // one that could not be photographed at all, sends none of them.
169
+ shotFile: thumbs.shotFile,
170
+ approvedFile: thumbs.approvedFile,
171
+ diffFile: thumbs.diffFile,
156
172
  });
157
173
  }
158
174
 
@@ -194,6 +210,11 @@ async function runOneScreen(project, page, screen, ctx) {
194
210
  const approved = await readApproved(paths, screen.name);
195
211
  const platformNote = platformWarning(approved?.meta?.platform, ctx.here);
196
212
 
213
+ // We have just read it, so it is certainly there. Pointed out even for a screen that
214
+ // ends up matching: "show me what this is supposed to look like" is a fair question
215
+ // about a screen that passed, and answering it costs one string.
216
+ if (approved && ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
217
+
197
218
  /** @type {string[]} */
198
219
  let consoleErrors = [];
199
220
  /** @type {{width: number, height: number}|undefined} */
@@ -225,19 +246,28 @@ async function runOneScreen(project, page, screen, ctx) {
225
246
  consoleErrors = shot.consoleErrors;
226
247
  size = { width: shot.width, height: shot.height };
227
248
 
228
- // Said out loud the moment the shutter fires, before anything is compared,
229
- // so a person watching sees the picture appear while the run is still
230
- // deciding what it thinks of it.
231
- if (shot.thumbnail) {
232
- if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
233
- emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
234
- }
235
-
236
- await writeResult(paths, screen.name, shot.png, {
249
+ const shotFile = await writeResult(paths, screen.name, shot.png, {
237
250
  deviceScaleFactor: settings.viewport.deviceScaleFactor,
238
251
  describe: screen.describe,
239
252
  });
240
253
 
254
+ // Said out loud before anything is compared, so a person watching sees the
255
+ // picture appear while the run is still deciding what it thinks of it — but
256
+ // AFTER the PNG is on disk, not the instant the shutter fires. The panel opens
257
+ // the real file to show true pixels, and an <img> pointed at a file that does
258
+ // not exist yet draws a torn page and never retries. The preview travels on the
259
+ // same event and covers the moment the file takes to load.
260
+ if (ctx.thumbs) {
261
+ if (shot.thumbnail) ctx.thumbs.shot = shot.thumbnail;
262
+ ctx.thumbs.shotFile = fileUrl(shotFile);
263
+ }
264
+ emitEvent(ctx.events, {
265
+ type: 'screen:shot',
266
+ name: screen.name,
267
+ thumbnail: shot.thumbnail,
268
+ shotFile: fileUrl(shotFile),
269
+ });
270
+
241
271
  if (!approved) break;
242
272
 
243
273
  const stopCompare = ctx.timings?.mark('compare');
@@ -287,6 +317,8 @@ async function runOneScreen(project, page, screen, ctx) {
287
317
  describe: screen.describe,
288
318
  deviceScaleFactor: settings.viewport.deviceScaleFactor,
289
319
  });
320
+ // It exists now, because that call is what wrote it.
321
+ if (ctx.thumbs) ctx.thumbs.approvedFile = fileUrl(approvedPaths.png);
290
322
  return finish(ctx, {
291
323
  ...base,
292
324
  status: 'new',
@@ -340,11 +372,23 @@ async function runOneScreen(project, page, screen, ctx) {
340
372
 
341
373
  /** @type {string|undefined} */
342
374
  let diffPath;
343
- if (compare.diffPng) diffPath = await writeDiff(paths, screen.name, compare.diffPng);
375
+ if (compare.diffPng) {
376
+ diffPath = await writeDiff(paths, screen.name, compare.diffPng);
377
+ // Set only inside this branch: a screen that matched has no difference picture, and
378
+ // a stale one from a previous run is deleted before every run for exactly that
379
+ // reason. Pointing at one that is not there is how a panel starts lying.
380
+ if (ctx.thumbs) ctx.thumbs.diffFile = fileUrl(diffPath);
381
+ }
344
382
 
345
383
  // Only for a screen that actually moved, and only when somebody is watching:
346
384
  // this is the one moment a person wants the approved picture and the difference
347
385
  // side by side with the new one.
386
+ //
387
+ // Both are made at the same size as the new picture's preview, because a person
388
+ // comparing three pictures must not be shown one sharp one and two blurred ones.
389
+ // Each costs a decode plus a shrink — about an eighth of a second on a retina
390
+ // screenshot — so this stays behind both gates, and behind `changed`. A run where
391
+ // everything held pays none of it.
348
392
  if (ctx.thumbnail === true && ctx.thumbs) {
349
393
  ctx.thumbs.approved = (await thumbnailOf(approved.png)) ?? undefined;
350
394
  if (compare.diffPng) ctx.thumbs.diff = (await thumbnailOf(compare.diffPng)) ?? undefined;
@@ -17,11 +17,16 @@ import { platformTag } from '../drive/find.js';
17
17
  import { pngSize } from './capture.js';
18
18
 
19
19
  /**
20
- * How wide a thumbnail is, in real pixels. Wide enough to stay sharp in a panel a few
21
- * hundred pixels across on a retina screen, small enough that a run can put dozens of
22
- * them down a wire without anybody noticing.
20
+ * How wide a preview is, in real pixels.
21
+ *
22
+ * The watch panel is 460 CSS pixels across, and every screen a person reviews on is
23
+ * retina, so the panel is really 920 pixels wide. A 320-pixel preview stretched over
24
+ * that is the blur everybody complained about: you cannot read a label in it, and
25
+ * zooming in only makes the blur bigger. 900 is where a preview stops being a
26
+ * stand-in and starts being a picture — and it still weighs about 40KB, which a run
27
+ * can hand to a window a dozen times over without anybody noticing.
23
28
  */
24
- const THUMBNAIL_WIDTH = 320;
29
+ const PREVIEW_WIDTH = 900;
25
30
 
26
31
  /**
27
32
  * A small note written beside a result picture so `approve` knows things the PNG
@@ -31,39 +36,93 @@ const THUMBNAIL_WIDTH = 320;
31
36
  * @property {string} [describe]
32
37
  */
33
38
 
39
+ /**
40
+ * A picture already decoded into pixels: what `PNG.sync.read` hands back, and what the
41
+ * mask painter works on. Described by its shape rather than by pngjs's class, because
42
+ * pngjs does not hand back one of those.
43
+ * @typedef {{width: number, height: number, data: Uint8Array}} Pixels
44
+ */
45
+
34
46
  /**
35
47
  * A small copy of a picture, ready to drop straight into a page.
36
48
  *
37
- * This is for watching a run happen, so it is built from the finished picture rather than
38
- * asked of the app again: what a watcher sees is exactly what was compared, blackout
39
- * boxes and all, and never a second photograph taken a moment later that shows something
40
- * slightly different.
49
+ * This is the INSTANT preview, and only that. The watch panel loads the real PNG off
50
+ * disk the moment it exists, so this only has to hold the frame for the fraction of a
51
+ * second before that file arrives but it has to hold it honestly, at a size a person
52
+ * can actually read.
41
53
  *
42
- * Points are sampled rather than averaged. Averaging every pixel of a retina screenshot
43
- * costs a fifth of a second per screen and buys smoother edges on a picture two inches
44
- * wide; the point of the panel is to watch a run at full speed.
54
+ * It is built from the finished picture rather than asked of the app again: what a
55
+ * watcher sees is exactly what was compared, blackout boxes and all, and never a second
56
+ * photograph taken a moment later that shows something slightly different.
45
57
  *
46
- * @param {Buffer} png
58
+ * Points are averaged now, not sampled. Dropping nine pixels out of every ten is what
59
+ * made the old previews look broken rather than merely small — a one-pixel border or a
60
+ * line of text either survived or vanished depending on where it happened to land.
61
+ * Averaging every pixel that falls inside an output pixel costs about forty
62
+ * milliseconds on a retina screenshot, and it is the difference between a picture and a
63
+ * smear.
64
+ *
65
+ * @param {Buffer|Pixels} source
66
+ * The bytes of a PNG, or a picture already decoded. Hand over the decoded one when
67
+ * you have it — a retina screenshot costs about eighty milliseconds to decode, and
68
+ * decoding the same megapixels twice for one screen is the whole cost of this
69
+ * function paid for nothing.
47
70
  * @returns {Promise<string|null>} a data: address for an <img>, or null if it cannot be read
48
71
  */
49
- export async function thumbnailOf(png) {
72
+ export async function thumbnailOf(source) {
50
73
  try {
51
- const full = PNG.sync.read(png);
74
+ if (Buffer.isBuffer(source) && pngSize(source).width <= PREVIEW_WIDTH) {
75
+ // Already small enough to be its own preview. Saves decoding and re-encoding a
76
+ // picture only to hand back what we were given.
77
+ return `data:image/png;base64,${source.toString('base64')}`;
78
+ }
79
+ } catch {
80
+ // Not a PNG we can measure from its header. Fall through and let the decode below
81
+ // be the one that decides whether there is a picture here at all.
82
+ }
83
+
84
+ try {
85
+ // Told apart by what it IS, not by `instanceof PNG`: pngjs's sync reader hands back
86
+ // a plain object rather than an instance of its own class, so an instance check
87
+ // here quietly says "no" to every decoded picture ever passed in.
88
+ const full = Buffer.isBuffer(source) ? PNG.sync.read(source) : source;
52
89
  if (!(full.width > 0) || !(full.height > 0)) return null;
53
- const step = full.width > THUMBNAIL_WIDTH ? full.width / THUMBNAIL_WIDTH : 1;
54
- const width = Math.max(1, Math.round(full.width / step));
55
- const height = Math.max(1, Math.round(full.height / step));
90
+ const scale = full.width > PREVIEW_WIDTH ? full.width / PREVIEW_WIDTH : 1;
91
+ const width = Math.max(1, Math.round(full.width / scale));
92
+ const height = Math.max(1, Math.round(full.height / scale));
56
93
 
57
94
  const small = new PNG({ width, height });
95
+ const from = full.data;
96
+ const into = small.data;
97
+
58
98
  for (let y = 0; y < height; y += 1) {
59
- const sourceRow = Math.min(full.height - 1, Math.floor(y * step)) * full.width;
99
+ const top = Math.floor(y * scale);
100
+ // Always at least one row, even when the picture is barely bigger than the preview.
101
+ const bottom = Math.min(full.height, Math.max(top + 1, Math.floor((y + 1) * scale)));
60
102
  for (let x = 0; x < width; x += 1) {
61
- const from = (sourceRow + Math.min(full.width - 1, Math.floor(x * step))) * 4;
62
- const to = (y * width + x) * 4;
63
- small.data[to] = full.data[from];
64
- small.data[to + 1] = full.data[from + 1];
65
- small.data[to + 2] = full.data[from + 2];
66
- small.data[to + 3] = full.data[from + 3];
103
+ const left = Math.floor(x * scale);
104
+ const right = Math.min(full.width, Math.max(left + 1, Math.floor((x + 1) * scale)));
105
+ let r = 0;
106
+ let g = 0;
107
+ let b = 0;
108
+ let a = 0;
109
+ let n = 0;
110
+ for (let sy = top; sy < bottom; sy += 1) {
111
+ let i = (sy * full.width + left) * 4;
112
+ for (let sx = left; sx < right; sx += 1) {
113
+ r += from[i];
114
+ g += from[i + 1];
115
+ b += from[i + 2];
116
+ a += from[i + 3];
117
+ n += 1;
118
+ i += 4;
119
+ }
120
+ }
121
+ const at = (y * width + x) * 4;
122
+ into[at] = (r / n + 0.5) | 0;
123
+ into[at + 1] = (g / n + 0.5) | 0;
124
+ into[at + 2] = (b / n + 0.5) | 0;
125
+ into[at + 3] = (a / n + 0.5) | 0;
67
126
  }
68
127
  }
69
128
  return `data:image/png;base64,${PNG.sync.write(small).toString('base64')}`;
package/src/types.js CHANGED
@@ -495,7 +495,14 @@ export {};
495
495
  * @property {string} [message]
496
496
  * @property {string} [failedAt] The plain-language expectation that failed.
497
497
  * @property {string} [because] Why a guard exists.
498
- * @property {string} [thumbnail] A small JPEG as a data: URI, for watching.
498
+ * @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
499
+ * while the real file is still being written.
500
+ * @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
501
+ * The watch panel is itself a local page, so it can load the
502
+ * real PNG off disk and zoom into actual pixels — a scaled-up
503
+ * thumbnail is unreadable, which is the whole point of looking.
504
+ * @property {string} [approvedFile] file:// URL of the approved picture.
505
+ * @property {string} [diffFile] file:// URL of the difference image.
499
506
  * @property {string} [approvedThumb]
500
507
  * @property {string} [diffThumb]
501
508
  * @property {RunSummary} [summary] Only on 'run:done'.
package/src/walk/run.js CHANGED
@@ -16,7 +16,7 @@ import { settingsForScreen } from '../core/config.js';
16
16
  import { gitInfo } from '../core/git.js';
17
17
  import { StaysFixedError, messageOf } from '../core/errors.js';
18
18
  import { safeName } from '../core/paths.js';
19
- import { emitEvent } from '../core/events.js';
19
+ import { emitEvent, fileUrl } from '../core/events.js';
20
20
 
21
21
  /**
22
22
  * Progress handed to `opts.onStep`, once when a step starts and once when it is done.
@@ -87,7 +87,7 @@ export async function walkApp(project, app, opts = {}) {
87
87
  total: chosen.length,
88
88
  });
89
89
 
90
- /** @type {{shot?: string}} */
90
+ /** @type {{shot?: string, shotFile?: string}} */
91
91
  const thumbs = {};
92
92
  const step = await walkOneStep(page, screen, {
93
93
  index,
@@ -112,6 +112,9 @@ export async function walkApp(project, app, opts = {}) {
112
112
  durationMs: step.durationMs,
113
113
  message: stepMessage(step),
114
114
  thumbnail: thumbs.shot,
115
+ // The real photo of this step, at full resolution. A walk has nothing to compare
116
+ // against, so there is no approved picture and no difference to point at.
117
+ shotFile: thumbs.shotFile,
115
118
  });
116
119
 
117
120
  opts.onStep?.({
@@ -151,7 +154,7 @@ export async function walkApp(project, app, opts = {}) {
151
154
  * record: boolean,
152
155
  * events?: import('../types.js').RunEvents,
153
156
  * thumbnail?: boolean,
154
- * thumbs?: {shot?: string},
157
+ * thumbs?: {shot?: string, shotFile?: string},
155
158
  * timings?: ReturnType<typeof import('../core/events.js').makeTimings>,
156
159
  * }} ctx
157
160
  * @returns {Promise<import('../types.js').WalkStep>}
@@ -180,10 +183,7 @@ async function walkOneStep(page, screen, ctx) {
180
183
  await fsp.writeFile(target, shot.png);
181
184
  file = target;
182
185
  consoleErrors = shot.consoleErrors;
183
- if (shot.thumbnail) {
184
- if (ctx.thumbs) ctx.thumbs.shot = shot.thumbnail;
185
- emitEvent(ctx.events, { type: 'screen:shot', name: screen.name, thumbnail: shot.thumbnail });
186
- }
186
+ announceShot(ctx, screen.name, target, shot.thumbnail);
187
187
  } catch (cause) {
188
188
  error = messageOf(cause);
189
189
  consoleErrors = readConsole(page);
@@ -193,6 +193,10 @@ async function walkOneStep(page, screen, ctx) {
193
193
  try {
194
194
  await fsp.writeFile(target, await page.shoot());
195
195
  file = target;
196
+ // No preview for this one: the picture was taken by hand after the capture
197
+ // broke, so nothing shrank it. The panel loads the real file instead, which is
198
+ // the one a person needs to see anyway.
199
+ announceShot(ctx, screen.name, target, undefined);
196
200
  } catch {
197
201
  file = '';
198
202
  }
@@ -217,6 +221,29 @@ async function walkOneStep(page, screen, ctx) {
217
221
  return step;
218
222
  }
219
223
 
224
+ /**
225
+ * Tell anyone watching that this step has a picture now.
226
+ *
227
+ * Called only once the photo is ON DISK. The watch panel loads the real file so a
228
+ * person can zoom into true pixels, and an <img> pointed at a file that does not exist
229
+ * yet draws a torn page and never tries again — so the address is never announced early.
230
+ * The shrunken preview rides along on the same event and covers the moment the real
231
+ * file takes to load.
232
+ *
233
+ * @param {{events?: import('../types.js').RunEvents, thumbs?: {shot?: string, shotFile?: string}}} ctx
234
+ * @param {string} name
235
+ * @param {string} file Absolute path to the photo, already written.
236
+ * @param {string|undefined} thumbnail
237
+ * @returns {void}
238
+ */
239
+ function announceShot(ctx, name, file, thumbnail) {
240
+ if (ctx.thumbs) {
241
+ if (thumbnail) ctx.thumbs.shot = thumbnail;
242
+ ctx.thumbs.shotFile = fileUrl(file);
243
+ }
244
+ emitEvent(ctx.events, { type: 'screen:shot', name, thumbnail, shotFile: fileUrl(file) });
245
+ }
246
+
220
247
  /**
221
248
  * How many screens a walk is about to visit.
222
249
  *