scenescout 3.15.0 → 3.17.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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Where a run keeps its notes, memory and report when the agent names no
3
+ * project folder (scout_attach `projectPath`).
4
+ *
5
+ * A client with no workspace (a desktop chat app, say) has no folder to offer,
6
+ * and a person testing a site should not be asked to invent one. So each tested
7
+ * site gets its own folder under the user's documents folder, such as
8
+ * `Documents/SceneScout/localhost-3000/`, created on first use.
9
+ *
10
+ * What wins, first to last:
11
+ * 1. the `projectPath` the attach names, always;
12
+ * 2. the client's workspace folder, when it offers one (MCP roots);
13
+ * 3. the per-site default, unless PROJECTS_DIR_ENV is `off`.
14
+ * The default is never placed inside a git repository below the home folder: a
15
+ * folder of recordings and saved logins does not belong in somebody's commits
16
+ * unless they chose it. A home folder that is itself a repository (dotfiles)
17
+ * does not count.
18
+ *
19
+ * Everything here is pure (the filesystem is passed in), so it is table-tested
20
+ * in memory-test.
21
+ */
22
+ import path from "node:path";
23
+ import { domainToUnicode, fileURLToPath } from "node:url";
24
+ import { MEMORY_DIRNAME } from "./memory.js";
25
+ /** The setting: an absolute folder that holds one folder per tested site, or `off`. */
26
+ export const PROJECTS_DIR_ENV = "SCENESCOUT_PROJECTS_DIR";
27
+ /** The folder made under the documents folder when the setting is unset. */
28
+ export const DEFAULT_PROJECTS_DIRNAME = "SceneScout";
29
+ /**
30
+ * The user's documents folder. Windows: `%USERPROFILE%\Documents`; Linux and
31
+ * others: XDG_DOCUMENTS_DIR from the environment or user-dirs.dirs (which the
32
+ * server reads on Linux only), else
33
+ * `~/Documents`; macOS: `~/Documents`.
34
+ */
35
+ export function documentsDir(home) {
36
+ if (home.platform === "win32") {
37
+ const profile = home.env.USERPROFILE && path.win32.isAbsolute(home.env.USERPROFILE) ? home.env.USERPROFILE : home.homedir;
38
+ return path.win32.join(profile, "Documents");
39
+ }
40
+ if (home.platform !== "darwin") {
41
+ const fromEnv = home.env.XDG_DOCUMENTS_DIR;
42
+ const fromFile = home.userDirs?.match(/^\s*XDG_DOCUMENTS_DIR\s*=\s*"([^"\n]*)"/m)?.[1];
43
+ for (const raw of [fromEnv, fromFile]) {
44
+ if (!raw)
45
+ continue;
46
+ const expanded = raw.replace(/^\$HOME(?=\/|$)/, home.homedir);
47
+ if (!path.posix.isAbsolute(expanded))
48
+ continue;
49
+ // resolve() drops a trailing slash, so "$HOME/" compares equal to the home folder.
50
+ const dir = path.posix.resolve(expanded);
51
+ // XDG says a documents folder equal to $HOME means "none": fall through to ~/Documents.
52
+ if (dir !== path.posix.resolve(home.homedir))
53
+ return dir;
54
+ }
55
+ }
56
+ return path.posix.join(home.homedir, "Documents");
57
+ }
58
+ /** Names Windows reserves, which no folder may take, whatever its extension. */
59
+ const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
60
+ /**
61
+ * The folder name for a tested site: its host as a person would write it, with
62
+ * the port when there is one. `http://localhost:3000` → `localhost-3000`,
63
+ * `https://xn--bcher-kva.example/` → `bücher.example`, `http://[::1]:8080` →
64
+ * `ipv6-__1-8080`. Two addresses of one site (http and https, any path) share
65
+ * a folder; a different port is a different site. Throws for an address with
66
+ * no host, which has no site to name.
67
+ */
68
+ export function siteFolderName(url) {
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(url);
72
+ }
73
+ catch {
74
+ throw new Error(`"${url}" is not a full address, so there is no site to name a folder after. Pass projectPath.`);
75
+ }
76
+ let host = parsed.hostname.toLowerCase().replace(/\.+$/, "");
77
+ if (!host)
78
+ throw new Error(`"${url}" has no host, so there is no site to name a folder after. Pass projectPath.`);
79
+ if (host.startsWith("["))
80
+ host = `ipv6-${host.slice(1, -1).replace(/:/g, "_")}`;
81
+ else
82
+ host = domainToUnicode(host).normalize("NFC") || host;
83
+ // A hostname holds only letters, digits, hyphens and dots, but say so here
84
+ // rather than trust it: this becomes one path segment.
85
+ host = host.replace(/[^\p{L}\p{M}\p{N}._-]/gu, "_").replace(/^\.+/, "_");
86
+ if (RESERVED.test(host))
87
+ host = `site-${host}`;
88
+ return parsed.port ? `${host}-${parsed.port}` : host;
89
+ }
90
+ /**
91
+ * The folder holding one folder per site: the setting when it names an
92
+ * absolute folder, `null` when it is `off`, else `<documents>/SceneScout`.
93
+ * A setting that is neither throws, naming the variable.
94
+ */
95
+ export function projectsRoot(home) {
96
+ const setting = home.env[PROJECTS_DIR_ENV]?.trim();
97
+ const p = home.platform === "win32" ? path.win32 : path.posix;
98
+ if (setting) {
99
+ if (setting.toLowerCase() === "off")
100
+ return null;
101
+ if (!p.isAbsolute(setting))
102
+ throw new Error(`${PROJECTS_DIR_ENV} must be an absolute folder or "off", not "${setting}".`);
103
+ return p.normalize(setting);
104
+ }
105
+ return p.join(documentsDir(home), DEFAULT_PROJECTS_DIRNAME);
106
+ }
107
+ /**
108
+ * The git repository a folder would sit inside: the nearest folder, the folder
109
+ * itself included, holding a `.git` (a directory, or a file in a worktree).
110
+ * `exists` is fs.existsSync in use; the folder need not exist yet.
111
+ *
112
+ * With `stopAt` (the home folder), the walk ends below it: a home folder that
113
+ * is itself a repository, as a dotfiles setup makes it, does not count, since
114
+ * refusing every default folder there is worse than one untracked folder in
115
+ * it. A folder outside `stopAt` is walked to the root.
116
+ */
117
+ export function enclosingRepo(dir, exists, platform = process.platform, stopAt) {
118
+ const p = platform === "win32" ? path.win32 : path.posix;
119
+ const same = (a, b) => (platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
120
+ const stop = stopAt === undefined ? undefined : p.resolve(stopAt);
121
+ for (let at = p.resolve(dir);;) {
122
+ if (stop !== undefined && same(at, stop))
123
+ return null;
124
+ if (exists(p.join(at, ".git")))
125
+ return at;
126
+ const up = p.dirname(at);
127
+ if (up === at)
128
+ return null;
129
+ at = up;
130
+ }
131
+ }
132
+ /**
133
+ * The first workspace folder a client offers as MCP roots, or null. Only `file:`
134
+ * roots name a folder, read as `platform` reads them: `file:///C:/work/app` is
135
+ * `C:\\work\\app` on Windows, and a root with no drive letter is no folder there.
136
+ */
137
+ export function workspaceFromRoots(roots, platform = process.platform) {
138
+ for (const root of roots ?? []) {
139
+ if (!root.uri.startsWith("file:"))
140
+ continue;
141
+ try {
142
+ return fileURLToPath(root.uri, { windows: platform === "win32" });
143
+ }
144
+ catch {
145
+ continue; // a file: URI naming another host, or no absolute path on this platform: not a folder here
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ /**
151
+ * Which folder a run keeps its files in, and the plain line that says so.
152
+ * `given` is the attach's projectPath; `workspace` the client's folder, if any.
153
+ */
154
+ export function chooseProjectFolder(input) {
155
+ if (input.given !== undefined)
156
+ return { dir: input.given, source: "given", note: "" };
157
+ if (input.workspace) {
158
+ return {
159
+ dir: input.workspace,
160
+ source: "workspace",
161
+ note: `📁 FILES: no projectPath was given, so this run's notes, memory, sign-ins and report are kept in your workspace folder, under ${input.workspace}.`,
162
+ };
163
+ }
164
+ let root;
165
+ let name;
166
+ try {
167
+ root = projectsRoot(input.home);
168
+ name = siteFolderName(input.url);
169
+ }
170
+ catch (err) {
171
+ return { refused: `No projectPath was given and no default folder could be chosen: ${err.message}` };
172
+ }
173
+ if (root === null)
174
+ return {
175
+ refused: `No projectPath was given, and ${PROJECTS_DIR_ENV} is "off", so there is no default folder. Pass projectPath: the folder this run's notes, memory and report go in.`,
176
+ };
177
+ const p = input.home.platform === "win32" ? path.win32 : path.posix;
178
+ const dir = p.join(root, name);
179
+ const repo = enclosingRepo(dir, input.exists, input.home.platform, input.home.homedir);
180
+ if (repo)
181
+ return {
182
+ refused: `No projectPath was given, and the default folder ${dir} would be inside the git repository ${repo}, where a run's recordings and saved logins could be committed. ` +
183
+ `Pass projectPath to choose a folder (inside that repository too, if that is what you want), or set ${PROJECTS_DIR_ENV} to a folder outside it.`,
184
+ };
185
+ return {
186
+ dir,
187
+ source: "default",
188
+ note: `📁 FILES: this site's notes, memory, sign-ins and report are kept in ${dir} — a folder SceneScout made for this site, since none was given. ` +
189
+ `The report will be ${p.join(dir, MEMORY_DIRNAME, "report.md")}. Pass projectPath, or set ${PROJECTS_DIR_ENV}, to keep them elsewhere.`,
190
+ };
191
+ }
@@ -264,6 +264,9 @@ export function planRefresh(sent, current) {
264
264
  const same = current.find((c) => c.slot === sent.slot);
265
265
  return same ? { kind: "swap", to: same } : { kind: "unknown" };
266
266
  }
267
+ export function profileLoadWhileHeld(isNavigation) {
268
+ return isNavigation ? "cookies" : "storage";
269
+ }
267
270
  /** Replace a spent token with the current one in a body, URL or header value, in whichever wire form it appears. */
268
271
  export function swapToken(text, from, to) {
269
272
  let out = text.split(from).join(to);
@@ -308,12 +311,74 @@ export function rotationStored(state, sent) {
308
311
  * page's storage state, which has no sessionStorage, with the sessionStorage
309
312
  * of the profile on disk kept beside it. Without it, an app whose sign-in also
310
313
  * lives in sessionStorage would lose that half on every brokered refresh.
311
- * A profile that could not be read (null) leaves the page's state as it is.
314
+ * The same goes for IndexedDB, which a storage state holds only when it was
315
+ * asked for: an origin the page's state has no IndexedDB entry for keeps the
316
+ * one on disk. An origin whose state does list IndexedDB, even an empty list,
317
+ * is the page's as it now is. With `storageFrom` "disk" the origins
318
+ * (localStorage and IndexedDB) are the profile's on disk and only the cookies
319
+ * are the page's (writeBackStorageFrom). A profile that could not be read
320
+ * (null) leaves the page's state as it is.
312
321
  */
313
- export function profileAfterRotation(pageState, profileOnDisk) {
322
+ export function profileAfterRotation(pageState, profileOnDisk, storageFrom = "page") {
314
323
  if (profileOnDisk === null)
315
324
  return pageState;
316
- return withSessionStorage(pageState, splitProfile(profileOnDisk).sessionStorage);
325
+ const disk = splitProfile(profileOnDisk);
326
+ if (storageFrom === "disk" && pageState && typeof pageState === "object") {
327
+ return withSessionStorage({ ...pageState, origins: disk.storageState.origins ?? [] }, disk.sessionStorage);
328
+ }
329
+ return withSessionStorage(withIndexedDB(pageState, disk.storageState.origins), disk.sessionStorage);
330
+ }
331
+ /**
332
+ * Where a write-back takes each origin's storage from (profileAfterRotation's
333
+ * `storageFrom`). A swap that loaded only the profile's cookies while it held
334
+ * a navigation (profileLoadWhileHeld) left the page's storage as it was
335
+ * before the other session's rotation, so writing that back would put spent
336
+ * tokens over the current ones on disk: the storage stays the profile's, and
337
+ * only the cookies, where the rotation it presented lives, are the page's.
338
+ * A token presented from storage is the page's to store, so its storage is
339
+ * taken from the page as it is now.
340
+ */
341
+ export function writeBackStorageFrom(loaded, presented) {
342
+ return loaded === "cookies" && presented.cookie !== undefined ? "disk" : "page";
343
+ }
344
+ /** `state` with each disk origin's IndexedDB carried over where the state has none of its own (profileAfterRotation). */
345
+ function withIndexedDB(state, diskOrigins) {
346
+ const kept = diskOrigins.filter((o) => !!o && typeof o === "object" && typeof o.origin === "string" && Array.isArray(o.indexedDB));
347
+ if (kept.length === 0 || !state || typeof state !== "object")
348
+ return state;
349
+ const origins = Array.isArray(state.origins) ? [...state.origins] : [];
350
+ for (const disk of kept) {
351
+ const at = origins.findIndex((o) => !!o && typeof o === "object" && o.origin === disk.origin);
352
+ if (at === -1)
353
+ origins.push({ origin: disk.origin, localStorage: [], indexedDB: disk.indexedDB });
354
+ else if (origins[at].indexedDB === undefined)
355
+ origins[at] = { ...origins[at], indexedDB: disk.indexedDB };
356
+ }
357
+ return { ...state, origins };
358
+ }
359
+ /**
360
+ * The lines an action's result carries for what the refresh broker did since
361
+ * the last action result (a write-back can finish after the action that
362
+ * started it): one line per kind of event, counted, in a fixed order. Counts only: no
363
+ * token, slot value or endpoint is named. Empty when it did nothing.
364
+ */
365
+ export function refreshNotice(events) {
366
+ const count = (kind) => events.filter((e) => e === kind).length;
367
+ const times = (n) => (n > 1 ? ` (${n} times)` : "");
368
+ const lines = [];
369
+ const refreshed = count("refreshed");
370
+ if (refreshed > 0)
371
+ lines.push(`↻ token refreshed under the role's lock and stored in its profile${times(refreshed)}`);
372
+ const swapped = count("swapped");
373
+ if (swapped > 0)
374
+ lines.push(`↻ another session had rotated the role's token; loaded its profile and sent the current one${times(swapped)}`);
375
+ const learned = count("learned");
376
+ if (learned > 0)
377
+ lines.push(`↻ learned ${learned} endpoint${learned === 1 ? "" : "s"} that rotate${learned === 1 ? "s" : ""} the role's token; brokered from now on`);
378
+ const failed = count("failed");
379
+ if (failed > 0)
380
+ lines.push(`⚠ ${failed === 1 ? "a refresh" : `${failed} refreshes`} could not be brokered (see the action log)`);
381
+ return lines.map((l) => `\n${l}`).join("");
317
382
  }
318
383
  /**
319
384
  * The cookies a response's Set-Cookie headers set, by name. A header value may
@@ -19,8 +19,16 @@
19
19
  * escaping and the grouping are table-tested.
20
20
  */
21
21
  import path from "node:path";
22
+ import { isSafeRelativePath } from "./plain.js";
22
23
  /** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
23
24
  export const RECORD_MAX_FRAMES = 600;
25
+ /** A name reduced to one plain path segment: letters, digits, dot, dash and underscore, never leading with a dot or dash. */
26
+ export function plainSegment(text, fallback) {
27
+ return (text
28
+ .replace(/[^a-z0-9._-]+/gi, "-")
29
+ .replace(/^[.-]+/, "")
30
+ .slice(0, 60) || fallback);
31
+ }
24
32
  /**
25
33
  * Where a recorded frame is stored, relative to the memory directory — and
26
34
  * the path the live view serves it at, so it is always written with forward
@@ -29,11 +37,7 @@ export const RECORD_MAX_FRAMES = 600;
29
37
  * nothing about where the engine writes.
30
38
  */
31
39
  export function framePath(session, index, action) {
32
- const plain = (text, fallback) => text
33
- .replace(/[^a-z0-9._-]+/gi, "-")
34
- .replace(/^[.-]+/, "")
35
- .slice(0, 60) || fallback;
36
- return `recordings/${plain(session, "session")}/${String(index).padStart(4, "0")}-${plain(action, "step")}.jpg`;
40
+ return `recordings/${plainSegment(session, "session")}/${String(index).padStart(4, "0")}-${plainSegment(action, "step")}.jpg`;
37
41
  }
38
42
  /**
39
43
  * The file a viewer's frame request names, or null when it is not one of this
@@ -136,9 +140,18 @@ export function evidenceFor(steps, foundAt, most = 4) {
136
140
  frame: x.frame,
137
141
  }));
138
142
  }
143
+ /** A finding's own picture, shown open under it: the evidence a reader looks for first. */
144
+ function renderPicture(p, id, framePrefix = "", savedAt = "") {
145
+ const src = escapeHtml(framePrefix + p.file);
146
+ return (`<figure class="picture"><a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="finding-picture-open" data-finding="${escapeHtml(id)}">` +
147
+ `<img loading="lazy" ${GONE} src="${src}"${p.width && p.height ? ` width="${p.width}" height="${p.height}"` : ""} alt="What the finding is about, when it was filed">` +
148
+ `<p class="gone-note">This picture is not beside this file. Pictures live in the run's <code>recordings/</code> folder, which travels with it.</p></a>` +
149
+ `<figcaption>${escapeHtml(p.caption)}${savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + p.file)}</span>` : ""}</figcaption></figure>`);
150
+ }
139
151
  function renderEvidence(e, framePrefix = "", savedAt = "") {
152
+ const picture = e.picture ? renderPicture(e.picture, e.id, framePrefix, savedAt) : "";
140
153
  if (e.frames.length === 0)
141
- return "";
154
+ return picture;
142
155
  const shots = e.frames
143
156
  .map((f) => `<figure><img loading="lazy" ${GONE} src="${escapeHtml(framePrefix + f.frame)}" alt="The page when this step ran">` +
144
157
  `<p class="gone-note">This frame is not beside this file. Frames live in the run's <code>recordings/</code> folder, which travels with it.</p>` +
@@ -146,8 +159,10 @@ function renderEvidence(e, framePrefix = "", savedAt = "") {
146
159
  (savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + f.frame)}</span>` : "") +
147
160
  `</figcaption></figure>`)
148
161
  .join("");
149
- return `<details class="evidence"><summary>Evidence — the ${e.frames.length} step${e.frames.length === 1 ? "" : "s"} on screen before this was filed</summary><div class="shots">${shots}</div></details>`;
162
+ return `${picture}<details class="evidence"><summary>Evidence — the ${e.frames.length} step${e.frames.length === 1 ? "" : "s"} on screen before this was filed</summary><div class="shots">${shots}</div></details>`;
150
163
  }
164
+ /** The report's sections the header links to, by heading. */
165
+ const SECTION_ANCHORS = { "In plain words": "plain", "Technical detail": "technical" };
151
166
  /**
152
167
  * The report's markdown as elements. A deliberately small subset — headings,
153
168
  * tables, lists, code fences, the report's own <details> repro blocks — built
@@ -184,12 +199,28 @@ export function renderMarkdown(md, evidence = [], framePrefix = "", savedAt = ""
184
199
  else if ((m = /^(#{1,6}) (.*)$/.exec(line))) {
185
200
  flush();
186
201
  const level = Math.min(6, m[1].length + 1);
187
- out.push(`<h${level}>${inline(m[2])}</h${level}>`);
202
+ // The two parts of the report are what the header links to.
203
+ const anchor = m[1] === "##" ? SECTION_ANCHORS[m[2]] : undefined;
204
+ out.push(`<h${level}${anchor ? ` id="${anchor}"` : ""}>${inline(m[2])}</h${level}>`);
205
+ i += 1;
206
+ }
207
+ else if ((m = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line))) {
208
+ // A finding's picture. The path is the report's own, relative to the run's
209
+ // folder, and anything else (a scheme, an absolute path, `..`) is dropped.
210
+ flush();
211
+ if (isSafeRelativePath(m[2])) {
212
+ const src = (m[2].startsWith("recordings/") ? framePrefix : "") + m[2];
213
+ out.push(`<figure class="picture"><a href="${escapeHtml(src)}" target="_blank" rel="noreferrer" data-testid="report-picture-open"><img loading="lazy" ${GONE} src="${escapeHtml(src)}" alt="${escapeHtml(m[1])}"></a>` +
214
+ `<p class="gone-note">This picture is not beside this file. It lives in the run's folder, which travels with it.</p>` +
215
+ (savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + m[2])}</span>` : "") +
216
+ `</figure>`);
217
+ }
188
218
  i += 1;
189
219
  }
190
220
  else if ((m = /^<details><summary>(.*)<\/summary>$/.exec(line))) {
191
221
  flush();
192
- out.push(`<details><summary>${inline(m[1])}</summary>`);
222
+ const testid = m[1] === "Technical detail" ? "report-technical-toggle" : "report-details-toggle";
223
+ out.push(`<details><summary data-testid="${testid}">${inline(m[1])}</summary>`);
193
224
  i += 1;
194
225
  }
195
226
  else if (/^<\/details>$/.test(line)) {
@@ -298,6 +329,10 @@ ol.steps { list-style:none; margin:0; padding:0; }
298
329
  .step .d { color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
299
330
  .step .frame { display:block; margin:4px 0 10px; }
300
331
  .step .frame img { max-width:min(100%,720px); max-height:360px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; }
332
+ figure.picture { margin:6px 0 14px; max-width:min(100%,720px); }
333
+ figure.picture img { max-width:100%; height:auto; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
334
+ figure.picture figcaption { margin-top:4px; color:var(--muted); font-size:12px; }
335
+ figure.picture figcaption .onDisk { display:block; font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
301
336
  details.evidence { margin:6px 0 18px; padding:8px 12px; background:var(--panel); border:1px solid var(--line); border-radius:8px; }
302
337
  details.evidence > summary { cursor:pointer; color:var(--muted); font-size:13px; }
303
338
  details.evidence .shots { display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; }
@@ -317,6 +352,11 @@ header .served code { font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,m
317
352
  figure.gone .gone-note, a.gone .gone-note { display:block; }
318
353
  figure.gone img, a.gone img { display:none; }
319
354
  a.frame.gone { display:block; max-width:min(100%,720px); }
355
+ figure.picture { margin:8px 0 14px; max-width:min(100%,720px); }
356
+ figure.picture img { width:auto; max-width:100%; max-height:420px; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
357
+ details > summary { cursor:pointer; }
358
+ details:not(.session):not(.evidence) { margin:4px 0 18px; padding:6px 12px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
359
+ details:not(.session):not(.evidence) > summary { color:var(--muted); font-size:13px; }
320
360
  details.evidence figcaption { margin-top:4px; color:var(--muted); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
321
361
  `;
322
362
  /**
@@ -366,6 +406,19 @@ function exitWatch(savedFile) {
366
406
  " });\n" +
367
407
  "})();</script>");
368
408
  }
409
+ /** The header's links: the report's parts it holds, then the steps. */
410
+ function navLinks(markdown) {
411
+ const has = (heading) => markdown.split("\n").includes(`## ${heading}`);
412
+ return [
413
+ has("In plain words")
414
+ ? `<a href="#plain" data-testid="report-plain-link">In plain words</a>`
415
+ : `<a href="#report" data-testid="report-top-link">Report</a>`,
416
+ has("Technical detail") ? `<a href="#technical" data-testid="report-technical-link">Technical detail</a>` : "",
417
+ `<a href="#steps" data-testid="report-steps-link">Steps</a>`,
418
+ ]
419
+ .filter(Boolean)
420
+ .join("");
421
+ }
369
422
  /** The whole document: one file, no external assets, opens from the file system. */
370
423
  export function buildReplayHtml(input) {
371
424
  const framed = input.sessions.some((s) => s.steps.some((x) => x.frame));
@@ -385,7 +438,7 @@ export function buildReplayHtml(input) {
385
438
  <h1>SceneScout run</h1>
386
439
  <span class="meta">${escapeHtml(input.project)} · written ${escapeHtml(stamp(input.at))}${input.version ? ` · v${escapeHtml(input.version)}` : ""}</span>
387
440
  ${savedAt ? `<p class="served">This page is served by the engine and goes when it does. The copy that stays is <code>${escapeHtml(savedAt)}/report.html</code>, beside the frames it shows.</p>` : ""}
388
- <nav><a href="#report">Report</a><a href="#steps">Steps</a></nav>
441
+ <nav>${navLinks(input.markdown)}</nav>
389
442
  </header>
390
443
  ${savedAt
391
444
  ? `<div id="gone-bar" role="alert" data-testid="run-engine-gone" hidden>