tapline-host 1.4.0 → 1.5.0

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.
Files changed (49) hide show
  1. package/dist/audit.d.ts +2 -0
  2. package/dist/audit.js +1 -1
  3. package/dist/audit.js.map +1 -1
  4. package/dist/cli.js +4 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/format.d.ts +4 -0
  7. package/dist/format.js +61 -20
  8. package/dist/format.js.map +1 -1
  9. package/dist/images.d.ts +27 -0
  10. package/dist/images.js +50 -0
  11. package/dist/images.js.map +1 -1
  12. package/dist/journey.d.ts +259 -0
  13. package/dist/journey.js +336 -0
  14. package/dist/journey.js.map +1 -0
  15. package/dist/journeyexport.d.ts +58 -0
  16. package/dist/journeyexport.js +318 -0
  17. package/dist/journeyexport.js.map +1 -0
  18. package/dist/journeygif.d.ts +62 -0
  19. package/dist/journeygif.js +192 -0
  20. package/dist/journeygif.js.map +1 -0
  21. package/dist/journeyreport.d.ts +202 -0
  22. package/dist/journeyreport.js +664 -0
  23. package/dist/journeyreport.js.map +1 -0
  24. package/dist/mcp.d.ts +1 -0
  25. package/dist/mcp.js +27 -1
  26. package/dist/mcp.js.map +1 -1
  27. package/dist/media.d.ts +86 -0
  28. package/dist/media.js +244 -0
  29. package/dist/media.js.map +1 -0
  30. package/dist/protocol.d.ts +4 -0
  31. package/dist/protocol.js +2 -0
  32. package/dist/protocol.js.map +1 -1
  33. package/dist/run.js +1 -1
  34. package/dist/run.js.map +1 -1
  35. package/dist/server.d.ts +2 -0
  36. package/dist/server.js +8 -0
  37. package/dist/server.js.map +1 -1
  38. package/dist/tool-text-data.d.ts +3 -0
  39. package/dist/tool-text-data.js +3 -0
  40. package/dist/tool-text-data.js.map +1 -1
  41. package/dist/tools.d.ts +213 -0
  42. package/dist/tools.js +1075 -28
  43. package/dist/tools.js.map +1 -1
  44. package/dist/uxcheck.d.ts +102 -0
  45. package/dist/uxcheck.js +128 -0
  46. package/dist/uxcheck.js.map +1 -0
  47. package/package.json +2 -1
  48. package/skills/tapline/SKILL.md +7 -0
  49. package/skills/tapline/references/personas.md +45 -0
@@ -0,0 +1,664 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { jpegOf, markBox, markRing, readPixels } from "./images.js";
5
+ import { GIF_MAX_BYTES, encodeJourneyGif } from "./journeygif.js";
6
+ import { formatDuration, personaWords, summaryLine, summaryNotes } from "./journey.js";
7
+ /**
8
+ * The journey's visual report (AGENT-TOOLS 1.12 §17.5): the frames kept after each counted step, the findings, and
9
+ * the folder written at `stop`: `report.md`, `report.html`, `frames/`, `findings/`, `journey.gif`, `key-1.jpg`,
10
+ * `key-2.jpg`. Pictures stay in files; only the two key pictures reach the agent.
11
+ */
12
+ export const FRAME_WIDTH = 360;
13
+ /** The report lists this many findings at most (the worst first) and says how many more there were; it never blocks `stop`. */
14
+ export const MAX_FINDINGS = 100;
15
+ /** `ux_check` findings kept (each with a picture) while the journey runs. */
16
+ const MAX_SEEN = 300;
17
+ const FRAME_QUALITY = 0.7;
18
+ /** The screen's elements as one hash: texts, values, positions and the route, not refs. */
19
+ export function screenSignature(elements, route, alerts) {
20
+ const hash = createHash("sha1");
21
+ hash.update(JSON.stringify([route, alerts, elements.map((e) => [e.role ?? "", e.text ?? "", e.label ?? "", typeof e.value === "string" || typeof e.value === "number" || typeof e.value === "boolean" ? e.value : "", e.frame ? [Math.round(e.frame.x), Math.round(e.frame.y), Math.round(e.frame.width), Math.round(e.frame.height)] : null])]));
22
+ return hash.digest("hex");
23
+ }
24
+ export class JourneyRecorder {
25
+ folder;
26
+ source;
27
+ clock;
28
+ frames = [];
29
+ seen = [];
30
+ /** Skipped frames: the list hadn't changed and nothing animated. */
31
+ skipped = 0;
32
+ /** Frames not taken because the journey's app was not connected and in front, by where it was. */
33
+ away = new Map();
34
+ actions = new Map();
35
+ queue = Promise.resolve();
36
+ lastSignature;
37
+ uxCount = 0;
38
+ /** Times the app's photo picker was skipped (`media` with `picker`) during the journey. */
39
+ pickerSkips = 0;
40
+ pickerPictures = 0;
41
+ constructor(folder, source, clock) {
42
+ this.folder = folder;
43
+ this.source = source;
44
+ this.clock = clock;
45
+ mkdirSync(join(folder, "frames"), { recursive: true });
46
+ }
47
+ get name() {
48
+ return this.folder.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "journey";
49
+ }
50
+ /** Runs `work` after the ones before it: the pictures come in the order of the steps. */
51
+ later(work) {
52
+ const next = this.queue.then(work, work);
53
+ this.queue = next.catch(() => undefined);
54
+ return next.catch(() => undefined);
55
+ }
56
+ /**
57
+ * Keeps a small picture of the screen after a step. Skipped when the element list is what it was and nothing
58
+ * animates (AGENTS.md: don't look twice). Never fails the step.
59
+ */
60
+ frame(step, screen, touch, animating) {
61
+ return this.later(async () => {
62
+ const where = this.source.where();
63
+ if (where !== "front") {
64
+ this.away.set(where, (this.away.get(where) ?? 0) + 1);
65
+ return;
66
+ }
67
+ const peek = await this.source.peek();
68
+ // A touch is kept even when the screen did not change: the report shows where it landed.
69
+ if (peek !== undefined && peek.signature === this.lastSignature && !animating && touch === undefined) {
70
+ this.skipped++;
71
+ return;
72
+ }
73
+ const shot = await this.source.shot(FRAME_WIDTH, FRAME_QUALITY);
74
+ if (shot === undefined)
75
+ return;
76
+ const n = this.frames.length + 1;
77
+ const file = `frames/${String(n).padStart(3, "0")}.jpg`;
78
+ writeFileSync(join(this.folder, file), Buffer.from(shot.data, "base64"));
79
+ const size = peek?.screen;
80
+ const share = (x, w, extent) => [x / extent, w / extent];
81
+ const record = { n, step, file, at: this.clock(), screen, signature: peek?.signature ?? "" };
82
+ if (touch !== undefined && size !== undefined && size.w > 0 && size.h > 0) {
83
+ if (touch.point !== undefined)
84
+ record.tap = { x: touch.point.x / size.w, y: touch.point.y / size.h };
85
+ if (touch.frame !== undefined) {
86
+ const [x, w] = share(touch.frame.x, touch.frame.width, size.w);
87
+ const [y, h] = share(touch.frame.y, touch.frame.height, size.h);
88
+ record.box = { x, y, w, h };
89
+ record.tap ??= { x: x + w / 2, y: y + h / 2 };
90
+ }
91
+ }
92
+ this.frames.push(record);
93
+ this.lastSignature = peek?.signature;
94
+ });
95
+ }
96
+ /** The action labels of the screens seen before `current`, for `ux_check`'s naming check. */
97
+ earlier(current) {
98
+ const out = [];
99
+ for (const [screen, list] of this.actions)
100
+ if (screen !== current)
101
+ out.push(...list);
102
+ return out;
103
+ }
104
+ /** A `ux_check` was made: keep its actions, and a picture per finding with the element boxed in red. */
105
+ uxSeen(screen, title, actions, findings) {
106
+ if (this.source.where() !== "front")
107
+ return Promise.resolve();
108
+ if (screen !== undefined)
109
+ this.actions.set(screen, actions);
110
+ const room = Math.max(0, MAX_SEEN - this.seen.length);
111
+ const fresh = findings
112
+ .filter((f) => !this.seen.some((s) => s.screen === screen && s.kind === f.kind && s.problem === f.problem && s.ref === f.ref))
113
+ .slice(0, room);
114
+ const mine = fresh.map((f) => {
115
+ const { frame: _frame, ...summary } = f;
116
+ return { ...summary, screen, title };
117
+ });
118
+ this.seen.push(...mine);
119
+ if (fresh.length === 0)
120
+ return Promise.resolve();
121
+ const k = ++this.uxCount;
122
+ return this.later(async () => {
123
+ const [peek, shot] = await Promise.all([this.source.peek(), this.source.shot(FRAME_WIDTH, FRAME_QUALITY)]);
124
+ if (shot === undefined)
125
+ return;
126
+ const pixels = readPixels(shot);
127
+ mkdirSync(join(this.folder, "findings"), { recursive: true });
128
+ for (const [i, f] of fresh.entries()) {
129
+ const target = mine[i];
130
+ const size = peek?.screen;
131
+ if (target === undefined)
132
+ continue;
133
+ const marked = f.frame !== undefined && size !== undefined && size.w > 0 && size.h > 0
134
+ ? markBox(pixels, { x: f.frame.x / size.w, y: f.frame.y / size.h, w: f.frame.width / size.w, h: f.frame.height / size.h })
135
+ : pixels;
136
+ const file = `findings/ux-${k}-${i + 1}.jpg`;
137
+ writeFileSync(join(this.folder, file), Buffer.from(jpegOf(marked).data, "base64"));
138
+ target.shot = file;
139
+ }
140
+ });
141
+ }
142
+ /** Everything asked for is on disk. */
143
+ done() {
144
+ return this.queue;
145
+ }
146
+ }
147
+ /**
148
+ * Simple rules, written in the report's footer. A `ux_check` kind not named here is low.
149
+ */
150
+ export const SEVERITY_RULES = [
151
+ "High: the goal wasn't reached; the app crashed or lost its connection; a stress moment wasn't survived; a screen leaves the user with no next step (an error or empty state with nothing to tap, or no way back).",
152
+ "Medium: something blocked or misled the user (a touch a user couldn't make, an error, a wrong turn, more taps than a house rule allows, no clear main action, an icon-only button with no name, cut-off text, too many choices).",
153
+ "Low: polish (contrast, small tap areas, thumb reach, the same action named differently, hesitation).",
154
+ ];
155
+ const UX_SEVERITY = {
156
+ dead_end: "high",
157
+ way_back: "high",
158
+ main_action: "medium",
159
+ icon_only: "medium",
160
+ cut_off_text: "medium",
161
+ choices: "medium",
162
+ contrast: "low",
163
+ target_size: "low",
164
+ thumb_reach: "low",
165
+ naming: "low",
166
+ };
167
+ const ORDER = { high: 0, medium: 1, low: 2 };
168
+ function stepWords(step) {
169
+ const target = step.target !== undefined ? ` ${step.target}` : "";
170
+ return `${step.tool}${target}`;
171
+ }
172
+ /** The findings of a finished journey, worst first. `steps` pictures are filled in by `markSteps`. */
173
+ export function buildFindings(state, seen, maxTaps) {
174
+ const out = [];
175
+ const lastScreen = state.currentScreen;
176
+ if (state.reached === false) {
177
+ out.push({
178
+ severity: "high",
179
+ kind: "goal_not_reached",
180
+ title: `The goal wasn't reached${state.why !== undefined ? `: ${state.why}` : "."}`,
181
+ screen: lastScreen,
182
+ basis: "journey result",
183
+ });
184
+ }
185
+ for (const stress of state.stress) {
186
+ if (stress.survived === false) {
187
+ out.push({ severity: "high", kind: "stress", title: stress.said.replace(/^Stress: /, ""), screen: state.steps[stress.afterStep - 1]?.screen ?? lastScreen, basis: "stress moment" });
188
+ }
189
+ }
190
+ const once = new Set();
191
+ for (const step of state.steps) {
192
+ for (const flag of step.flags ?? []) {
193
+ const key = `${flag}|${step.tool}|${step.target ?? ""}|${step.screen ?? ""}`;
194
+ if (once.has(key))
195
+ continue;
196
+ once.add(key);
197
+ const at = `${stepWords(step)}: ${step.said}`;
198
+ if (flag === "crash")
199
+ out.push({ severity: "high", kind: "crash", title: `The app crashed or lost its connection at ${at}`, screen: step.screen, basis: `measured, step ${step.n}` });
200
+ else if (flag === "error")
201
+ out.push({ severity: "medium", kind: "error", title: `An error at ${at}`, screen: step.screen, basis: `measured, step ${step.n}` });
202
+ else if (flag === "dead_end")
203
+ out.push({ severity: "medium", kind: "dead_end_touch", title: `A touch a user couldn't make at ${at}`, screen: step.screen, basis: `measured, step ${step.n}` });
204
+ else
205
+ out.push({ severity: "medium", kind: "wrong_turn", title: `A wrong turn: this screen was left by going back with nothing done on it (${at})`, screen: step.screen, basis: `measured, step ${step.n}` });
206
+ }
207
+ }
208
+ if (maxTaps !== undefined && state.reached === true && state.tapsToGoal !== null && state.tapsToGoal > maxTaps) {
209
+ out.push({
210
+ severity: "medium",
211
+ kind: "max_taps_to_goal",
212
+ title: `The goal took ${state.tapsToGoal} taps; the house rule allows ${maxTaps}.`,
213
+ screen: lastScreen,
214
+ basis: `house rule: max_taps_to_goal ${maxTaps}`,
215
+ });
216
+ }
217
+ for (const f of seen) {
218
+ const shot = f.shot;
219
+ out.push({
220
+ severity: UX_SEVERITY[f.kind] ?? "low",
221
+ kind: f.kind,
222
+ title: f.problem,
223
+ screen: f.screen,
224
+ basis: f.basis,
225
+ ...(shot !== undefined ? { shot } : {}),
226
+ });
227
+ }
228
+ if (state.measures.hesitations >= 2) {
229
+ out.push({ severity: "low", kind: "hesitation", title: `${state.measures.hesitations} hesitations: reads in a row with no action, a sign the screen didn't say what to do.`, screen: lastScreen, basis: "measured" });
230
+ }
231
+ return out.map((f, i) => ({ f, i })).sort((a, b) => ORDER[a.f.severity] - ORDER[b.f.severity] || a.i - b.i).map(({ f }) => f);
232
+ }
233
+ export function countFindings(findings) {
234
+ const counts = { high: 0, medium: 0, low: 0 };
235
+ for (const f of findings)
236
+ counts[f.severity]++;
237
+ return counts;
238
+ }
239
+ /** `Findings: 2 high, 3 medium, 1 low.` or `Findings: none.` */
240
+ export function findingsLine(counts) {
241
+ const parts = ["high", "medium", "low"].filter((s) => counts[s] > 0).map((s) => `${counts[s]} ${s}`);
242
+ return `Findings: ${parts.length > 0 ? parts.join(", ") : "none"}.`;
243
+ }
244
+ // ---- the folder ---------------------------------------------------------------
245
+ /** `2026-11-12-1530-log-breakfast-by-scanning-its-barcode`, in the computer's local time. */
246
+ export function reportFolderName(goal, at) {
247
+ const d = new Date(at);
248
+ const two = (n) => String(n).padStart(2, "0");
249
+ const stamp = `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`;
250
+ const slug = goal
251
+ .toLowerCase()
252
+ .normalize("NFKD")
253
+ .replace(/[^a-z0-9]+/g, "-")
254
+ .replace(/^-+|-+$/g, "")
255
+ .slice(0, 40)
256
+ .replace(/-+$/g, "");
257
+ return `${stamp}-${slug === "" ? "journey" : slug}`;
258
+ }
259
+ /** A folder name that doesn't exist yet in `root` (`-2`, `-3` when the same goal ran in the same minute). */
260
+ /** How many journey reports a host keeps in its journeys folder (1.5 review, finding 6). */
261
+ export const JOURNEYS_KEPT = 20;
262
+ /**
263
+ * Keeps the newest `keep` report folders in `root` (by when they were last changed) and removes the rest. Only folders
264
+ * that hold a report or frames (what a journey writes) are counted or removed; anything else in `root` is left alone.
265
+ */
266
+ export function pruneJourneys(root, keep) {
267
+ let removed = 0;
268
+ try {
269
+ const reports = readdirSync(root, { withFileTypes: true })
270
+ .filter((entry) => entry.isDirectory())
271
+ .map((entry) => join(root, entry.name))
272
+ .filter((dir) => existsSync(join(dir, "report.md")) || existsSync(join(dir, "frames")))
273
+ .map((dir) => ({ dir, at: statSync(dir).mtimeMs }))
274
+ .sort((a, b) => b.at - a.at);
275
+ for (const old of reports.slice(Math.max(0, keep))) {
276
+ try {
277
+ rmSync(old.dir, { recursive: true, force: true });
278
+ removed++;
279
+ }
280
+ catch {
281
+ // in use: left for next time
282
+ }
283
+ }
284
+ }
285
+ catch {
286
+ // no folder yet, or it can't be read: nothing to prune
287
+ }
288
+ return removed;
289
+ }
290
+ export function freshFolder(root, goal, at) {
291
+ const base = reportFolderName(goal, at);
292
+ let name = base;
293
+ for (let n = 2; existsSync(join(root, name)); n++)
294
+ name = `${base}-${n}`;
295
+ return join(root, name);
296
+ }
297
+ function writeJpegFile(path, data) {
298
+ writeFileSync(path, Buffer.from(data, "base64"));
299
+ }
300
+ function readShot(folder, file) {
301
+ try {
302
+ return { mime: "image/jpeg", data: readFileSync(join(folder, file)).toString("base64") };
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ }
308
+ /** The frame a step's finding shows: the one made after that step, else the nearest before it. */
309
+ function frameForStep(frames, step) {
310
+ let found;
311
+ for (const f of frames)
312
+ if (f.step <= step)
313
+ found = f;
314
+ return found ?? frames[0];
315
+ }
316
+ /** Pictures for the findings that came from steps: the frame, with a red box at the touched element where there is one. */
317
+ function markSteps(folder, state, findings, frames) {
318
+ mkdirSync(join(folder, "findings"), { recursive: true });
319
+ findings.forEach((finding, i) => {
320
+ if (finding.shot !== undefined)
321
+ return;
322
+ const stepNumber = /step (\d+)$/.exec(finding.basis)?.[1];
323
+ const step = stepNumber !== undefined ? Number(stepNumber) : finding.kind === "goal_not_reached" || finding.kind === "max_taps_to_goal" ? state.steps.length : undefined;
324
+ const stressStep = finding.kind === "stress" ? state.stress.find((s) => finding.title.startsWith(s.said.replace(/^Stress: /, "")))?.afterStep : undefined;
325
+ const at = step ?? stressStep;
326
+ if (at === undefined)
327
+ return;
328
+ const frame = frameForStep(frames, at);
329
+ if (frame === undefined)
330
+ return;
331
+ const shot = readShot(folder, frame.file);
332
+ if (shot === undefined)
333
+ return;
334
+ const file = `findings/${String(i + 1).padStart(2, "0")}-${finding.kind}.jpg`;
335
+ let pixels = readPixels(shot);
336
+ if (frame.step === at && frame.box !== undefined)
337
+ pixels = markBox(pixels, frame.box);
338
+ else if (frame.step === at && frame.tap !== undefined)
339
+ pixels = markBox(pixels, { x: frame.tap.x - 0.06, y: frame.tap.y - 0.03, w: 0.12, h: 0.06 });
340
+ writeJpegFile(join(folder, file), jpegOf(pixels).data);
341
+ finding.shot = file;
342
+ });
343
+ }
344
+ /** At most this many pictures are decoded for the GIF (each is about 1 MB as pixels): 1.5 review, finding 6. */
345
+ export const GIF_MAX_SOURCE_FRAMES = 150;
346
+ /**
347
+ * The frames the GIF is made of, at most `max`: every tap and every first picture of a screen stays (they tell the
348
+ * story); the other, still pictures are thinned evenly. Chosen before anything is decoded.
349
+ */
350
+ export function framesForGif(frames, max = GIF_MAX_SOURCE_FRAMES) {
351
+ if (frames.length <= max)
352
+ return [...frames];
353
+ const keep = new Set();
354
+ frames.forEach((frame, i) => {
355
+ const before = frames[i - 1];
356
+ if (i === 0 || i === frames.length - 1 || frame.tap !== undefined || before === undefined || before.screen !== frame.screen)
357
+ keep.add(i);
358
+ });
359
+ const rest = frames.map((_, i) => i).filter((i) => !keep.has(i));
360
+ const room = Math.max(0, max - keep.size);
361
+ if (room > 0 && rest.length > 0) {
362
+ const every = rest.length / room;
363
+ for (let k = 0; k < room; k++) {
364
+ const pick = rest[Math.floor(k * every)];
365
+ if (pick !== undefined)
366
+ keep.add(pick);
367
+ }
368
+ }
369
+ const chosen = [...keep].sort((a, b) => a - b).map((i) => frames[i]).filter((f) => f !== undefined);
370
+ // Taps and screen changes alone can be over the limit in a very long journey: then those are thinned evenly too.
371
+ if (chosen.length <= max)
372
+ return chosen;
373
+ // The first and the last stay (the start and the result); the ones between are sampled.
374
+ const middle = chosen.slice(1, -1);
375
+ const room2 = Math.max(0, max - 2);
376
+ const step = middle.length / Math.max(1, room2);
377
+ const sampled = Array.from({ length: Math.min(room2, middle.length) }, (_, k) => middle[Math.floor(k * step)]);
378
+ return [chosen[0], ...sampled, chosen[chosen.length - 1]].filter((f) => f !== undefined);
379
+ }
380
+ function buildGif(folder, all, maxBytes) {
381
+ const frames = framesForGif(all);
382
+ const gifFrames = [];
383
+ for (const frame of frames) {
384
+ const shot = readShot(folder, frame.file);
385
+ if (shot === undefined)
386
+ continue;
387
+ gifFrames.push({ pixels: readPixels(shot), at: frame.at, ...(frame.tap !== undefined ? { tap: frame.tap } : {}) });
388
+ }
389
+ if (gifFrames.length === 0)
390
+ return null;
391
+ const gif = encodeJourneyGif(gifFrames, { maxBytes });
392
+ writeFileSync(join(folder, "journey.gif"), gif.bytes);
393
+ return { frames: gif.frames, bytes: gif.bytes.length, dropped: gif.dropped + (all.length - frames.length) };
394
+ }
395
+ // ---- words --------------------------------------------------------------------
396
+ function escapeHtml(value) {
397
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
398
+ }
399
+ /** Text the app controls (a label, a route), made safe to sit in report.md: no link, image or tag can be written by it. */
400
+ function escapeMarkdown(value) {
401
+ return value.replace(/\s*[\r\n]+\s*/g, " ").replace(/[\\`[\]<>!]/g, (c) => `\\${c}`);
402
+ }
403
+ function screenName(state, n) {
404
+ if (n === undefined)
405
+ return "no screen";
406
+ const screen = state.screens[n - 1];
407
+ if (screen === undefined)
408
+ return `screen ${n}`;
409
+ return `screen ${n}${screen.route.length > 0 ? ` (${screen.route.join(" › ")})` : ""}`;
410
+ }
411
+ function conditionWords(state) {
412
+ const parts = Object.entries(state.conditions).map(([k, v]) => `${k.replace("_", " ")} ${String(v)}`);
413
+ return parts.length > 0 ? parts.join(", ") : "none";
414
+ }
415
+ function resultWords(state) {
416
+ const base = state.reached === true ? "Reached" : "Not reached";
417
+ return state.why !== undefined ? `${base}: ${state.why}` : base;
418
+ }
419
+ function numbersTable(state) {
420
+ const m = state.measures;
421
+ const elapsed = (state.endedAt ?? state.startedAt) - state.startedAt;
422
+ return [
423
+ ["Taps", String(m.taps)],
424
+ ["Taps to the goal", state.tapsToGoal === null ? "not reached" : String(state.tapsToGoal)],
425
+ ["Typing", String(m.typing)],
426
+ ["Scrolls", String(m.scrolls)],
427
+ ["Screens entered", String(m.screensEntered)],
428
+ ["Time", `${formatDuration(elapsed)} (${formatDuration(m.waitMs)} waiting)`],
429
+ ["Wrong turns", String(m.wrongTurns)],
430
+ ["Dead ends", String(m.deadEnds)],
431
+ ["Errors hit", String(m.errors)],
432
+ ["Hesitations", String(m.hesitations)],
433
+ ];
434
+ }
435
+ function usedLines(state, rec) {
436
+ const lines = [];
437
+ if (state.shortcuts.length > 0)
438
+ lines.push(`Shortcuts used (a person doesn't have these): ${state.shortcuts.map((s) => `${s.tool} ${JSON.stringify(s.what)}`).join(", ")}.`);
439
+ if (rec.pickerSkips > 0)
440
+ lines.push(`Photo picker skipped ${rec.pickerSkips === 1 ? "once" : `${rec.pickerSkips} times`} (${rec.pickerPictures} picture${rec.pickerPictures === 1 ? "" : "s"} handed over without the OS picker): picker skipped.`);
441
+ if (state.eyesSwitchedAt !== undefined)
442
+ lines.push("Eyes: agent. The agent turned a user's eyes off during the journey (it saw ids, labels and everything in the tree and could jump), so the numbers after that are not a user's.");
443
+ return lines;
444
+ }
445
+ /**
446
+ * The report's own folder, in whatever slash style a step's text carries it, replaced by `<report>`: an export
447
+ * must not publish this computer's user name and folders.
448
+ */
449
+ export function scrubFolder(text, folder) {
450
+ const base = folder.replace(/[\\/]+$/, "");
451
+ if (base === "")
452
+ return text;
453
+ const slashes = base.replace(/\\/g, "/");
454
+ const forms = [base, slashes, slashes.replace(/\//g, "\\"), slashes.replace(/\//g, "\\\\")];
455
+ // The folder also as it sits after HTML or Markdown escaping, so it goes from report.html and report.md alike.
456
+ const escaped = forms.flatMap((f) => [escapeHtml(f), escapeMarkdown(f)]);
457
+ const unique = [...new Set([...forms, ...escaped])].sort((a, b) => b.length - a.length);
458
+ const pattern = new RegExp(unique.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gi");
459
+ return text.replace(pattern, "<report>");
460
+ }
461
+ function pathScreens(ctx) {
462
+ return ctx.state.screens.map((screen) => ({
463
+ n: screen.n,
464
+ title: screenName(ctx.state, screen.n),
465
+ ...(ctx.rec.frames.find((f) => f.screen === screen.n)?.file !== undefined ? { picture: ctx.rec.frames.find((f) => f.screen === screen.n)?.file } : {}),
466
+ steps: ctx.state.steps.filter((s) => s.screen === screen.n),
467
+ }));
468
+ }
469
+ /** One step as a line; `esc` makes the app's words safe for the format (Markdown escaping, none for HTML that escapes later). */
470
+ function stepLine(step, esc = (v) => v) {
471
+ return `${step.n}. ${esc(stepWords(step))}${step.ok ? "" : ` (failed${step.code !== undefined ? `: ${esc(step.code)}` : ""})`} — ${esc(step.said)}`;
472
+ }
473
+ function rulesLines(ctx) {
474
+ const out = [];
475
+ if (ctx.maxTaps !== undefined)
476
+ out.push(`max_taps_to_goal: ${ctx.maxTaps}`);
477
+ for (const rule of ctx.textRules)
478
+ out.push(`${rule} (judged by the agent)`);
479
+ return out;
480
+ }
481
+ export function renderMarkdown(ctx) {
482
+ const { state } = ctx;
483
+ const lines = [`# Journey report: ${state.goal}`, "", summaryLine(state), findingsLine(ctx.counts), ""];
484
+ lines.push(`- Goal: ${state.goal}`, `- Persona: ${personaWords(state)}`, `- Conditions: ${conditionWords(state)}`, `- Result: ${resultWords(state)}`, "");
485
+ if (ctx.gif !== null)
486
+ lines.push("![The journey](journey.gif)", "");
487
+ lines.push("## Numbers", "", "| Measure | Value |", "| --- | --- |", ...numbersTable(state).map(([k, v]) => `| ${k} | ${v} |`), "");
488
+ lines.push("## The path", "");
489
+ const screens = pathScreens(ctx);
490
+ if (screens.length === 0)
491
+ lines.push("No screen was listed during the journey.", "");
492
+ for (const s of screens) {
493
+ lines.push(`### ${escapeMarkdown(s.title)}`, "");
494
+ if (s.picture !== undefined)
495
+ lines.push(`![${escapeMarkdown(s.title)}](${s.picture})`, "");
496
+ else
497
+ lines.push("(no picture was kept for this screen)", "");
498
+ for (const step of s.steps)
499
+ lines.push(`- ${stepLine(step, escapeMarkdown)}`);
500
+ if (s.steps.length > 0)
501
+ lines.push("");
502
+ }
503
+ lines.push("## Findings", "");
504
+ if (ctx.findings.length === 0)
505
+ lines.push("None.", "");
506
+ if (ctx.omitted > 0)
507
+ lines.push(`Showing the worst ${ctx.findings.length}; ${ctx.omitted} more findings are not listed.`, "");
508
+ ctx.findings.forEach((f, i) => {
509
+ lines.push(`### ${i + 1}. [${f.severity}] ${f.kind}`, "", escapeMarkdown(f.title), "", `- Screen: ${escapeMarkdown(screenName(state, f.screen))}`, `- Basis: ${f.basis}`, "");
510
+ if (f.shot !== undefined)
511
+ lines.push(`![Finding ${i + 1}](${f.shot})`, "");
512
+ });
513
+ if (state.stress.length > 0) {
514
+ lines.push("## Stress moments", "", ...state.stress.map((s) => `- ${s.said}`), "");
515
+ }
516
+ const used = usedLines(state, ctx.rec);
517
+ lines.push("## Shortcuts and eyes", "", ...(used.length > 0 ? used.map((l) => `- ${l}`) : ["- None: no shortcut was used, and the journey stayed in a user's eyes."]), "");
518
+ const notes = summaryNotes(state).filter((l) => !used.includes(l) && !l.startsWith("Eyes:") && !l.startsWith("Shortcuts used"));
519
+ if (notes.length > 0)
520
+ lines.push("## Notes", "", ...notes.map((l) => `- ${l}`), "");
521
+ const rules = rulesLines(ctx);
522
+ if (rules.length > 0)
523
+ lines.push("## House rules", "", ...rules.map((r) => `- ${r}`), "");
524
+ lines.push("---", "", "How severity is chosen:", "", ...SEVERITY_RULES.map((r) => `- ${r}`), "");
525
+ return lines.join("\n");
526
+ }
527
+ export function renderHtml(ctx) {
528
+ const { state } = ctx;
529
+ const e = escapeHtml;
530
+ const img = (src, alt) => `<img src="${e(src)}" alt="${e(alt)}" loading="lazy">`;
531
+ const body = [`<h1>Journey report: ${e(state.goal)}</h1>`, `<p class="sum">${e(summaryLine(state))}<br>${e(findingsLine(ctx.counts))}</p>`];
532
+ body.push(`<ul><li>Goal: ${e(state.goal)}</li><li>Persona: ${e(personaWords(state))}</li><li>Conditions: ${e(conditionWords(state))}</li><li>Result: ${e(resultWords(state))}</li></ul>`);
533
+ if (ctx.gif !== null)
534
+ body.push(`<p>${img("journey.gif", "The journey")}</p>`);
535
+ body.push("<h2>Numbers</h2>", `<table>${numbersTable(state).map(([k, v]) => `<tr><th>${e(k)}</th><td>${e(v)}</td></tr>`).join("")}</table>`);
536
+ body.push("<h2>The path</h2>");
537
+ const screens = pathScreens(ctx);
538
+ if (screens.length === 0)
539
+ body.push("<p>No screen was listed during the journey.</p>");
540
+ for (const s of screens) {
541
+ body.push(`<h3>${e(s.title)}</h3>`);
542
+ body.push(s.picture !== undefined ? `<p>${img(s.picture, s.title)}</p>` : "<p>(no picture was kept for this screen)</p>");
543
+ if (s.steps.length > 0)
544
+ body.push(`<ul>${s.steps.map((step) => `<li>${e(stepLine(step))}</li>`).join("")}</ul>`);
545
+ }
546
+ body.push("<h2>Findings</h2>");
547
+ if (ctx.findings.length === 0)
548
+ body.push("<p>None.</p>");
549
+ if (ctx.omitted > 0)
550
+ body.push(`<p>Showing the worst ${ctx.findings.length}; ${ctx.omitted} more findings are not listed.</p>`);
551
+ ctx.findings.forEach((f, i) => {
552
+ body.push(`<section class="finding ${f.severity}"><h3>${i + 1}. <span class="sev">${f.severity}</span> ${e(f.kind)}</h3><p>${e(f.title)}</p><p class="meta">Screen: ${e(screenName(state, f.screen))}<br>Basis: ${e(f.basis)}</p>${f.shot !== undefined ? `<p>${img(f.shot, `Finding ${i + 1}`)}</p>` : ""}</section>`);
553
+ });
554
+ if (state.stress.length > 0)
555
+ body.push("<h2>Stress moments</h2>", `<ul>${state.stress.map((s) => `<li>${e(s.said)}</li>`).join("")}</ul>`);
556
+ const used = usedLines(state, ctx.rec);
557
+ body.push("<h2>Shortcuts and eyes</h2>", `<ul>${(used.length > 0 ? used : ["None: no shortcut was used, and the journey stayed in a user's eyes."]).map((l) => `<li>${e(l)}</li>`).join("")}</ul>`);
558
+ const rules = rulesLines(ctx);
559
+ if (rules.length > 0)
560
+ body.push("<h2>House rules</h2>", `<ul>${rules.map((r) => `<li>${e(r)}</li>`).join("")}</ul>`);
561
+ body.push(`<footer><p>How severity is chosen:</p><ul>${SEVERITY_RULES.map((r) => `<li>${e(r)}</li>`).join("")}</ul></footer>`);
562
+ return `<!doctype html>
563
+ <html lang="en">
564
+ <head>
565
+ <meta charset="utf-8">
566
+ <meta name="viewport" content="width=device-width, initial-scale=1">
567
+ <title>Journey report: ${e(state.goal)}</title>
568
+ <style>
569
+ :root{color-scheme:light dark;--bg:#fff;--fg:#1c1c1e;--mute:#6b6b70;--line:#d8d8dc;--high:#c4261d;--medium:#b7791f;--low:#3b6ea5}
570
+ @media (prefers-color-scheme:dark){:root{--bg:#161618;--fg:#f2f2f4;--mute:#a0a0a8;--line:#38383c;--high:#ff6b62;--medium:#e5b04a;--low:#7ab0e8}}
571
+ body{background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif;max-width:46rem;margin:0 auto;padding:1rem 16px 3rem}
572
+ img{max-width:100%;width:360px;height:auto;border:1px solid var(--line);border-radius:8px}
573
+ table{border-collapse:collapse}th,td{text-align:left;padding:.2rem .8rem .2rem 0;border-bottom:1px solid var(--line)}
574
+ .sum{font-weight:600}.meta,footer{color:var(--mute);font-size:.9rem}
575
+ .finding{border-left:4px solid var(--line);padding-left:.8rem;margin:1rem 0}
576
+ .finding.high{border-color:var(--high)}.finding.medium{border-color:var(--medium)}.finding.low{border-color:var(--low)}
577
+ .finding.high .sev{color:var(--high)}.finding.medium .sev{color:var(--medium)}.finding.low .sev{color:var(--low)}
578
+ .sev{text-transform:uppercase;font-size:.8em;letter-spacing:.04em}
579
+ </style>
580
+ </head>
581
+ <body>
582
+ ${body.join("\n")}
583
+ </body>
584
+ </html>
585
+ `;
586
+ }
587
+ /** Writes the report folder: waits for the pictures asked for, then the findings, GIF, key pictures, `report.md` and `report.html`. */
588
+ export async function writeReport(options) {
589
+ const { state, rec } = options;
590
+ const notes = [];
591
+ await rec.done();
592
+ const folder = rec.folder;
593
+ mkdirSync(join(folder, "findings"), { recursive: true });
594
+ const every = buildFindings(state, rec.seen, options.maxTaps);
595
+ // The report lists the worst 100; the counts are of all of them.
596
+ const findings = every.slice(0, MAX_FINDINGS);
597
+ const omitted = every.length - findings.length;
598
+ markSteps(folder, state, findings, rec.frames);
599
+ const counts = countFindings(every);
600
+ for (const [where, n] of rec.away)
601
+ notes.push(`Note: ${n} frame${n === 1 ? " was" : "s were"} not kept because the app was ${where === "background" ? "in the background" : where === "disconnected" ? "not connected" : "not the journey's app"}.`);
602
+ let gif = null;
603
+ try {
604
+ gif = buildGif(folder, rec.frames, options.gifMaxBytes ?? GIF_MAX_BYTES);
605
+ }
606
+ catch (error) {
607
+ notes.push(`Note: the GIF could not be made: ${error instanceof Error ? error.message : String(error)}`);
608
+ }
609
+ if (rec.frames.length === 0)
610
+ notes.push("Note: no pictures were kept (the app can't take screenshots, or none came back); add react-native-view-shot.");
611
+ const keys = [];
612
+ const last = rec.frames[rec.frames.length - 1];
613
+ if (last !== undefined) {
614
+ // Where the goal was reached, or where the journey stopped: with the last tap ringed, as the GIF shows it.
615
+ const shot = readShot(folder, last.file);
616
+ if (shot !== undefined) {
617
+ const pixels = readPixels(shot);
618
+ const marked = last.tap !== undefined ? markRing(pixels, last.tap) : pixels;
619
+ writeJpegFile(join(folder, "key-1.jpg"), jpegOf(marked).data);
620
+ keys.push("key-1.jpg");
621
+ }
622
+ }
623
+ const worst = findings.find((f) => f.shot !== undefined);
624
+ if (worst?.shot !== undefined) {
625
+ copyFileSync(join(folder, worst.shot), join(folder, "key-2.jpg"));
626
+ keys.push("key-2.jpg");
627
+ }
628
+ const ctx = { state, rec, findings, counts, omitted, gif, maxTaps: options.maxTaps, textRules: options.textRules ?? [] };
629
+ const markdown = scrubFolder(renderMarkdown(ctx), folder);
630
+ writeFileSync(join(folder, "report.md"), markdown, "utf8");
631
+ writeFileSync(join(folder, "report.html"), scrubFolder(renderHtml(ctx), folder), "utf8");
632
+ // An export uploads the report, its GIF and the pictures the report uses, not every raw frame.
633
+ // Only the report's own pictures: app text in the report can't name another file (1.5 review, finding 7).
634
+ const used = [...markdown.matchAll(/!\[[^\]]*\]\(([^)\s]+)\)/g)]
635
+ .map((m) => m[1] ?? "")
636
+ .filter((p) => isReportPicture(p) && existsSync(join(folder, p)));
637
+ const exportFiles = [...new Set(["report.md", "report.html", ...used])].sort();
638
+ const firstPicture = used.find((p) => p.startsWith("frames/"));
639
+ return { folder, name: rec.name, findings, counts, files: listFiles(folder), exportFiles, firstPicture, omitted, gif, keys, notes };
640
+ }
641
+ /** A picture inside the report folder: a relative path of plain names, no `..`, ending .png, .jpg or .gif. */
642
+ export function isReportPicture(path) {
643
+ return /^[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*\.(?:png|jpe?g|gif)$/.test(path) && !path.split("/").includes("..");
644
+ }
645
+ function listFiles(root, rel = "") {
646
+ const out = [];
647
+ for (const entry of readdirSyncSafe(join(root, rel))) {
648
+ const path = rel === "" ? entry.name : `${rel}/${entry.name}`;
649
+ if (entry.isDirectory)
650
+ out.push(...listFiles(root, path));
651
+ else
652
+ out.push(path);
653
+ }
654
+ return out.sort();
655
+ }
656
+ function readdirSyncSafe(dir) {
657
+ try {
658
+ return readdirSync(dir, { withFileTypes: true }).map((d) => ({ name: d.name, isDirectory: d.isDirectory() }));
659
+ }
660
+ catch {
661
+ return [];
662
+ }
663
+ }
664
+ //# sourceMappingURL=journeyreport.js.map