webview-preview 1.2.0 → 1.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/AGENTS.md CHANGED
@@ -76,6 +76,12 @@ npx webview-preview /screen <steps to reach it> annotate:review
76
76
 
77
77
  - It opens a **real window** and **waits for the person** — there is no run limit. Start it in the background (or
78
78
  tell the user to run it) and wait for the process to end; do not poll the window.
79
+ - Put the steps that reach the screen (sign-in macro, navigation) **before** `annotate`: after a full reload they run
80
+ again, so the person does not end up on a sign-in page while you fix things.
81
+ - With `--keep-open` the person sends notes in **rounds** and the process keeps running: each round prints
82
+ `annotate <name>: sent (round N) · …` and its file paths at once. Watch the process output for these lines (a
83
+ monitor / tail on its output), fix, and let the person look at the result in the same window (hot reload); the
84
+ last round says `done` and the process ends.
79
85
  - Read the stdout summary first: one line per note with the component, `file:line` and the note's text. The full
80
86
  data is in `<prefix>annotate-review.json` next to the snapshots; pictures are `annotate-review.png` (with markers)
81
87
  and `annotate-review-e1.png` … (each element). Open a picture only after the JSON.
package/README.md CHANGED
@@ -303,7 +303,8 @@ the other engines without `headless`.
303
303
  | Notes | `N` | The list of notes. It also opens when you pick an element, with the cursor in the note's text. |
304
304
  | Freeze | `F` | Counts down 3 s (on the button) — hover what has to stay open meanwhile. Then animations stop and the page no longer hears the mouse leave or focus go (`mouseleave`, `pointerout`, `blur`, `focusout`, mouse moves), so tooltips and menus stay open to be picked. `F` again unfreezes. A menu that closes on a timer or by its own logic can still close. |
305
305
  | Key bindings | — | Click an action, press the new key. Saved for the next runs in your user settings (`~/.config/webview-preview/annotate.json` on Linux, `%APPDATA%\webview-preview\` on Windows, `~/Library/Application Support/webview-preview/` on macOS). |
306
- | Done / Cancel | `Ctrl+Enter` | Done writes the report; Cancel or closing the window ends with nothing. |
306
+ | Done / Cancel | `Ctrl+Enter` | Done writes the report and ends the step (the window closes when the run ends); Cancel or closing the window ends with nothing. |
307
+ | Send | `Ctrl+Enter` | With `--keep-open` only: writes the report for the notes so far right away, clears the panel and **keeps the window open** — keep checking the page while it reloads with your fixes. Done (the tick) then sends what is left and ends. |
307
308
 
308
309
  - **Click** — a new note; **Shift+click** — adds the element to the open note ("align these two"); clicking an
309
310
  element that already has a note opens that note.
@@ -311,12 +312,21 @@ the other engines without `headless`.
311
312
  - **Escape** closes the notes card; the notes stay. The **general note** at the bottom is about the whole screen.
312
313
  - **Several pages:** go to another screen (turn Pick off, navigate, turn it on) — the notes stay, also when the page
313
314
  reloads. Elements are cut out the moment you pick them; coming back to a page shows their markers again.
315
+ - **Full reloads** (a dev server reloading the page after a change, which loses a session kept in memory): the
316
+ steps before `annotate` run again — sign in, open the screen — then the page goes back to the address you were on
317
+ and the panel comes back with your notes. Snapshots, `print:` and asserts are not repeated; a replayed step that
318
+ fails (you were still signed in, say) only prints `replay: …` and does not fail the run.
314
319
  - Keys follow the physical key, so they work in any keyboard layout.
315
320
 
316
321
  ![Two notes: the second one is about two buttons at once and is open in the notes card](assets/readme/annotate-notes.png)
317
322
 
318
323
  ![Freeze: the tooltip that appears on hover stays open, so it can be picked](assets/readme/annotate-freeze.png)
319
324
 
325
+ **One round or many.** By default the window closes after Done. With `--keep-open` (or `annotate: { keepOpen: true }`
326
+ in the config) every Send is a round of its own: the summary is printed and the files are written at once, named
327
+ `annotate-<name>`, then `annotate-<name>-2`, `-3` …; the status says `sent`, the last one `done`. Good for working
328
+ with someone fixing things while you look — the dev server's hot reload shows the fixes in the same window.
329
+
320
330
  **What you get.** A summary on stdout, one line per note:
321
331
 
322
332
  ```
@@ -80,6 +80,7 @@ Options:
80
80
  --init <code> run code before the page's own scripts, on every load (repeatable)
81
81
  --init-file <f> same, from a file (repeatable)
82
82
  --private <css> annotate: hide these elements' text and pixels in the report too (repeatable)
83
+ --keep-open annotate: a Send button reports the notes and keeps the window open for more
83
84
  --mock-file <f> answer fetch/XHR requests from a .json/.mjs file of mocks (repeatable);
84
85
  every request is listed in window.__webviewPreviewRequests
85
86
  --log-requests only list fetch/XHR requests in window.__webviewPreviewRequests
@@ -126,6 +127,10 @@ const parseArgs = argv => {
126
127
  options.headed = true;
127
128
  continue;
128
129
  }
130
+ if (arg === "--keep-open") {
131
+ options.keepOpen = true;
132
+ continue;
133
+ }
129
134
  if (arg === "--report") {
130
135
  options.report = true;
131
136
  continue;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "webview-preview",
3
- "version": "1.2.0",
4
- "description": "Open your UI in the engine your desktop app really runs on (WebKitGTK, Edge/WebView2, WebKit), drive it with steps and save PNG snapshots.",
3
+ "version": "1.2.1",
4
+ "description": "Check a Tauri or Wails UI in the engine it really runs on — WebKitGTK, Edge (WebView2), WebKit — from the command line: steps, snapshots, baselines, and an annotate window to point at elements and say what to change.",
5
5
  "license": "MIT",
6
6
  "author": "n0t4u404",
7
7
  "repository": {
@@ -14,9 +14,19 @@
14
14
  "webview",
15
15
  "webkitgtk",
16
16
  "webview2",
17
+ "webkit",
18
+ "playwright",
17
19
  "screenshot",
18
20
  "visual-testing",
19
- "playwright"
21
+ "visual-regression",
22
+ "ui-testing",
23
+ "annotation",
24
+ "design-review",
25
+ "developer-tools",
26
+ "cli",
27
+ "ai-agents",
28
+ "react",
29
+ "vue"
20
30
  ],
21
31
  "type": "module",
22
32
  "main": "./src/index.mjs",
@@ -54,5 +64,6 @@
54
64
  },
55
65
  "devDependencies": {
56
66
  "playwright-core": "^1.64.1"
57
- }
67
+ },
68
+ "homepage": "https://github.com/n0t4u404/webview-preview#readme"
58
69
  }
@@ -40,6 +40,7 @@ export const writeAnnotationReport = ({ annotation, out, prefix, engine, url, ba
40
40
  baseUrl,
41
41
  name: annotation.name,
42
42
  status: annotation.status,
43
+ round: annotation.round ?? 1,
43
44
  page: {
44
45
  url: result?.url ?? url,
45
46
  route: routeOf(result?.url ?? url, baseUrl),
@@ -58,7 +59,7 @@ export const writeAnnotationReport = ({ annotation, out, prefix, engine, url, ba
58
59
  delete result.url;
59
60
  delete result.viewport;
60
61
  }
61
- const base = path.join(out, `${prefix}annotate-${annotation.name}`);
62
+ const base = path.join(out, `${prefix}annotate-${annotation.name}${annotation.round > 1 ? `-${annotation.round}` : ""}`);
62
63
  writeFileSync(`${base}.md`, markdown(report));
63
64
  delete report.baseUrl;
64
65
  writeFileSync(`${base}.json`, JSON.stringify(report, null, "\t"));
@@ -36,6 +36,16 @@ const stopProcessTree = child => {
36
36
  }
37
37
  }
38
38
 
39
+ const STOP_TIMEOUT_MS = 3000;
40
+
41
+ const waitForClose = async (port, host) => {
42
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
43
+ while (Date.now() < deadline) {
44
+ if (!await isPortOpen(port, host)) return;
45
+ await new Promise(resolve => setTimeout(resolve, 100));
46
+ }
47
+ }
48
+
39
49
  export const startServer = async server => {
40
50
  if (!server) return () => {}
41
51
  const { command, port, host = "localhost", cwd = process.cwd(), readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS } = server;
@@ -51,12 +61,13 @@ export const startServer = async server => {
51
61
  const stop = () => {
52
62
  running.delete(stop);
53
63
  stopProcessTree(child);
64
+ return waitForClose(port, host);
54
65
  }
55
66
  running.add(stop);
56
67
  try {
57
68
  await waitForPort(port, host, readyTimeoutMs);
58
69
  } catch (error) {
59
- stop();
70
+ await stop();
60
71
  throw error;
61
72
  }
62
73
  return stop;
@@ -19,7 +19,7 @@ const loadPlaywright = async () => {
19
19
  export const runPlaywright = async ({
20
20
  engine, url, steps, init = [], size, locale,
21
21
  colorScheme, isMotionReduced = false, out, prefix, scale,
22
- snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
22
+ snapMargin = 8, isHeaded = false, waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, onAnnotation, log
23
23
  }) => {
24
24
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Infinity;
25
25
  const playwright = await loadPlaywright();
@@ -81,51 +81,75 @@ export const runPlaywright = async ({
81
81
  }
82
82
  }
83
83
 
84
+ const report = annotation => {
85
+ annotations.push(annotation);
86
+ onAnnotation?.(annotation);
87
+ }
88
+
84
89
  const annotate = async step => {
85
- const base = `${prefix}annotate-${step.value}`;
86
- const images = {}
90
+ let round = 1;
91
+ const base = () => `${prefix}annotate-${step.value}${round > 1 ? `-${round}` : ""}`;
92
+ let images = {}
87
93
  let draft = null;
94
+ let lastUrl = null;
88
95
  await page.evaluate(step.start);
89
96
  for (;;) {
90
97
  if (page.isClosed()) {
91
- annotations.push({ name: step.value, status: "cancelled", result: null });
98
+ report({ name: step.value, status: "cancelled", result: null, round });
92
99
  return;
93
100
  }
94
101
  if (Date.now() > deadline) {
95
- annotations.push({ name: step.value, status: "cancelled", result: null });
102
+ report({ name: step.value, status: "cancelled", result: null, round });
96
103
  throw new Error(`timed out after ${timeoutMs} ms`);
97
104
  }
98
105
  const text = await page.evaluate(step.check).catch(() => null);
99
106
  const answer = text && text !== "missing" ? JSON.parse(text) : null;
100
107
  if (text === "missing") {
108
+ for (const earlier of step.replay ?? []) {
109
+ try {
110
+ await runStep(earlier);
111
+ } catch (error) {
112
+ log.error(`replay: ${error.message.split("\n")[0]}`);
113
+ }
114
+ }
115
+ if (lastUrl && page.url() !== lastUrl) {
116
+ await page.evaluate(`location.href = ${JSON.stringify(lastUrl)}`).catch(() => undefined);
117
+ await page.waitForTimeout(JS_SETTLE_MS);
118
+ }
101
119
  await page.evaluate(`${draft ? `window.${step.restoreKey} = ${JSON.stringify(draft)};` : ""}${step.start}`).catch(() => undefined);
102
120
  } else if (answer?.status === "open") {
121
+ if (answer.url) lastUrl = answer.url;
103
122
  if (answer.draft) draft = answer.draft;
104
123
  if (answer.captures?.length) {
105
124
  await page.evaluate(step.hide).catch(() => undefined);
106
125
  await page.waitForTimeout(80);
107
126
  for (const capture of answer.captures) {
108
- const image = await cutOut(capture.rect, path.join(out, `${base}-${capture.id}.png`));
127
+ const image = await cutOut(capture.rect, path.join(out, `${base()}-${capture.id}.png`));
109
128
  if (image) images[capture.id] = image;
110
129
  }
111
130
  await page.evaluate(step.show).catch(() => undefined);
112
131
  }
113
132
  } else if (answer) {
114
- const annotation = { ...answer, name: step.value, scale: scale ?? 1 }
115
- if (annotation.status === "done" && annotation.result) {
116
- await page.screenshot({ path: path.join(out, `${base}.png`) });
117
- annotation.image = `${base}.png`;
133
+ const annotation = { ...answer, name: step.value, round, scale: scale ?? 1 }
134
+ const isSent = annotation.status === "sent";
135
+ if ((annotation.status === "done" || isSent) && annotation.result) {
136
+ await page.screenshot({ path: path.join(out, `${base()}.png`) });
137
+ annotation.image = `${base()}.png`;
118
138
  await page.evaluate(step.cleanup).catch(() => undefined);
119
139
  await page.waitForTimeout(160);
120
140
  for (const [id, element] of Object.entries(annotation.result.elements)) {
121
141
  element.image = element.isLive
122
- ? await cutOut(element.rect, path.join(out, `${base}-${id}.png`)) ?? images[id] ?? null
142
+ ? await cutOut(element.rect, path.join(out, `${base()}-${id}.png`)) ?? images[id] ?? null
123
143
  : images[id] ?? null;
124
144
  }
125
- await page.evaluate(step.dispose).catch(() => undefined);
145
+ if (!isSent) await page.evaluate(step.dispose).catch(() => undefined);
126
146
  }
127
- annotations.push(annotation);
128
- return;
147
+ report(annotation);
148
+ if (!isSent) return;
149
+ round += 1;
150
+ images = {}
151
+ draft = null;
152
+ await page.evaluate(step.resume).catch(() => undefined);
129
153
  }
130
154
  await new Promise(resolve => setTimeout(resolve, step.pollMs));
131
155
  }
@@ -13,7 +13,7 @@ export const runWebkitGtk = ({
13
13
  url, steps, init = [], size, colorScheme,
14
14
  isMotionReduced = false, out, prefix, snapMargin = 8,
15
15
  isHeaded = false, isStorageKept = false,
16
- waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, log
16
+ waitForTimeoutMs = WAIT_FOR_TIMEOUT_MS, timeoutMs = TIMEOUT_MS, onAnnotation, log
17
17
  }) => new Promise((resolve, reject) => {
18
18
  if (!isWebkitGtkAvailable()) {
19
19
  reject(new Error("WebKitGTK engine needs Linux with python3-gi and WebKit2 4.1 (gir1.2-webkit2-4.1)"));
@@ -62,7 +62,10 @@ export const runWebkitGtk = ({
62
62
  const value = JSON.parse(line.slice(tab + 1));
63
63
  if (kind === "saved") saved.push(value);
64
64
  else if (kind === "film") films.push(value);
65
- else if (kind === "annotate") annotations.push(value);
65
+ else if (kind === "annotate") {
66
+ annotations.push(value);
67
+ onAnnotation?.(value);
68
+ }
66
69
  else if (kind === "print") log.info(value);
67
70
  else if (kind === "error") log.error(value);
68
71
  else if (kind === "init-errors") reportInitErrors(value, log);
@@ -119,6 +119,9 @@ def main():
119
119
  return False
120
120
 
121
121
  def step_failed(message):
122
+ if state.get("replaying"):
123
+ emit("error", f"replay: {message}")
124
+ return
122
125
  state["failed"] = True
123
126
  emit("error", message)
124
127
 
@@ -332,9 +335,12 @@ def main():
332
335
  GLib.idle_add(run_step)
333
336
 
334
337
  def annotate(step):
335
- base = f"annotate-{step['value']}"
336
- images = {}
337
- memory = {"draft": None}
338
+ memory = state.setdefault("annotate_memory", {}).setdefault(
339
+ step["value"], {"draft": None, "round": 1, "images": {}, "url": None}
340
+ )
341
+
342
+ def base():
343
+ return f"annotate-{step['value']}" + (f"-{memory['round']}" if memory["round"] > 1 else "")
338
344
 
339
345
  def later(callback, ms):
340
346
  GLib.timeout_add(ms, lambda: (callback(), False)[1])
@@ -347,45 +353,65 @@ def main():
347
353
  code = (f"window.{step['restoreKey']} = {json.dumps(draft)};" if draft else "") + step["start"]
348
354
  evaluate(code, lambda _: again(), lambda _: again())
349
355
 
356
+ def replay():
357
+ resume = {**step, "isResume": True}
358
+ steps[state["index"]:state["index"]] = [*step["replay"], resume]
359
+ state["replaying"] = True
360
+ next_step()
361
+
362
+ def go_back_then_restart():
363
+ url = memory["url"]
364
+ if not url:
365
+ return restart()
366
+ code = f"if (location.href !== {json.dumps(url)}) location.href = {json.dumps(url)};"
367
+ evaluate(code, lambda _: later(restart, args.js_settle_ms), lambda _: restart())
368
+
350
369
  def grab(captures):
351
370
  def shoot():
352
371
  for capture in captures:
353
- path = crop(f"{base}-{capture['id']}", capture["rect"], False)
372
+ path = crop(f"{base()}-{capture['id']}", capture["rect"], False)
354
373
  if path:
355
- images[capture["id"]] = os.path.basename(path)
374
+ memory["images"][capture["id"]] = os.path.basename(path)
356
375
  evaluate(step["show"], lambda _: again(), lambda _: again())
357
376
 
358
377
  evaluate(step["hide"], lambda _: later(shoot, ANNOTATE_SETTLE_MS), lambda _: shoot())
359
378
 
360
379
  def finish_annotate(answer):
361
- state["annotating"] = None
380
+ is_sent = answer.get("status") == "sent"
381
+ if not is_sent:
382
+ state["annotating"] = None
362
383
  result = answer.get("result")
363
384
  width = view.get_allocated_width()
364
- answer.update(name=step["value"], scale=full_pixbuf().get_width() / width if width else 1)
365
- if answer.get("status") != "done" or not result:
385
+ answer.update(name=step["value"], round=memory["round"], scale=full_pixbuf().get_width() / width if width else 1)
386
+ if answer.get("status") not in ("done", "sent") or not result:
366
387
  emit("annotate", answer)
367
388
  return next_step()
368
- save(base, full_pixbuf(), False)
369
- answer["image"] = f"{args.prefix}{base}.png"
389
+ save(base(), full_pixbuf(), False)
390
+ answer["image"] = f"{args.prefix}{base()}.png"
370
391
 
371
392
  def crops():
372
393
  for element_id, element in result["elements"].items():
373
- path = crop(f"{base}-{element_id}", element["rect"], False) if element.get("isLive") else None
374
- element["image"] = os.path.basename(path) if path else images.get(element_id)
394
+ path = crop(f"{base()}-{element_id}", element["rect"], False) if element.get("isLive") else None
395
+ element["image"] = os.path.basename(path) if path else memory["images"].get(element_id)
375
396
  emit("annotate", answer)
376
- evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
397
+ if not is_sent:
398
+ return evaluate(step["dispose"], lambda _: next_step(), lambda _: next_step())
399
+ memory.update(draft=None, images={}, round=memory["round"] + 1)
400
+ evaluate(step["resume"], lambda _: again(), lambda _: again())
377
401
 
378
402
  evaluate(step["cleanup"], lambda _: later(crops, ANNOTATE_SETTLE_MS * 2), lambda _: crops())
379
403
 
380
404
  def poll():
381
405
  def on_value(text):
382
406
  if text == "missing":
383
- return restart()
407
+ return replay() if step.get("replay") else restart()
384
408
  if not text or text == "null":
385
409
  return again()
386
410
  answer = json.loads(text)
387
411
  if answer.get("status") != "open":
388
412
  return finish_annotate(answer)
413
+ if answer.get("url"):
414
+ memory["url"] = answer["url"]
389
415
  if answer.get("draft"):
390
416
  memory["draft"] = answer["draft"]
391
417
  if answer.get("captures"):
@@ -395,6 +421,9 @@ def main():
395
421
  evaluate(step["check"], on_value, lambda _: again())
396
422
 
397
423
  state["annotating"] = step["value"]
424
+ if step.get("isResume"):
425
+ state["replaying"] = False
426
+ return go_back_then_restart()
398
427
  evaluate(step["start"], lambda _: poll(), lambda error: (step_failed(f"annotate failed: {error}"), next_step()))
399
428
 
400
429
  def run_step():
@@ -500,6 +529,13 @@ def main():
500
529
 
501
530
  sys.stdout.flush()
502
531
  sys.stderr.flush()
532
+ try:
533
+ view.terminate_web_process()
534
+ window.destroy()
535
+ while Gtk.events_pending():
536
+ Gtk.main_iteration_do(False)
537
+ except Exception:
538
+ pass
503
539
  os._exit(1 if state["failed"] else 0)
504
540
 
505
541
 
package/src/index.d.ts CHANGED
@@ -66,7 +66,7 @@ export interface PreviewConfig {
66
66
  globals?: Record<string, unknown>;
67
67
  locale?: string;
68
68
  macros?: Record<string, Step[] | string[]>;
69
- annotate?: { private?: string[] }
69
+ annotate?: { private?: string[]; keepOpen?: boolean }
70
70
  failOnError?: boolean;
71
71
  }
72
72
 
@@ -93,6 +93,7 @@ export type InitEntry = string | { file: string }
93
93
 
94
94
  export interface PreviewOptions extends PreviewConfig {
95
95
  private?: string | string[];
96
+ keepOpen?: boolean;
96
97
  target?: string;
97
98
  steps?: Step[] | string[];
98
99
  initFile?: string | string[];
@@ -136,7 +137,8 @@ export interface AnnotationElement {
136
137
  export interface Annotation {
137
138
  version: 1;
138
139
  name: string;
139
- status: "done" | "cancelled";
140
+ status: "done" | "sent" | "cancelled";
141
+ round: number;
140
142
  page: { url: string; route: string; engine: Exclude<Engine, "auto">; size: { width: number; height: number }; scale: number; takenAt: string }
141
143
  image: string | null;
142
144
  notes: { id: number; text: string; elements: string[]; url: string; isFrozen: boolean }[];
package/src/index.mjs CHANGED
@@ -74,7 +74,14 @@ export const preview = async (options = {}) => {
74
74
  const parsedSteps = parseSteps(expandMacros(merged.steps.length ? merged.steps : DEFAULTS.steps, macros));
75
75
  const savedKeys = parsedSteps.some(step => step.kind === "annotate") ? loadAnnotateKeys() : null;
76
76
  const privateSelectors = [...(config.annotate?.private ?? []), ...[].concat(options.private ?? [])].map(String);
77
- const steps = parsedSteps.map(step => step.kind === "annotate" ? annotateStep(step.value, savedKeys, privateSelectors) : step);
77
+ const keepOpen = !!(options.keepOpen ?? config.annotate?.keepOpen);
78
+ const REPLAYED = new Set(["js", "jsnow", "wait", "waitfor", "key", "type", "hover", "click", "offline", "online", "latency"]);
79
+ const steps = parsedSteps.map((step, index) => step.kind === "annotate"
80
+ ? {
81
+ ...annotateStep(step.value, savedKeys, privateSelectors, keepOpen),
82
+ replay: parsedSteps.slice(0, index).filter(earlier => REPLAYED.has(earlier.kind) && !earlier.assert).map(earlier => ({ ...earlier, isReplay: true }))
83
+ }
84
+ : step);
78
85
  const out = path.resolve(baseDir, merged.out);
79
86
  const url = resolveUrl(merged.target ?? merged.url ?? "/", merged.baseUrl);
80
87
  const sizes = parseSizes(merged.size);
@@ -134,6 +141,31 @@ export const preview = async (options = {}) => {
134
141
  const hasAnnotate = steps.some(step => step.kind === "annotate");
135
142
  for (const size of sizes) {
136
143
  if (sizes.length > 1) output.log.info(`size ${size}`);
144
+ const reports = [];
145
+ const handleAnnotation = async annotation => {
146
+ try {
147
+ await resolveSources(annotation, baseDir);
148
+ } catch (error) {
149
+ output.log.error(`annotate: source files not resolved — ${error.message}`);
150
+ }
151
+ if (annotation.keys) {
152
+ try {
153
+ output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
154
+ } catch (error) {
155
+ output.log.error(`annotate: keys not saved — ${error.message}`);
156
+ }
157
+ }
158
+ delete annotation.keys;
159
+ for (const line of formatAnnotation(annotation)) output.log.info(line);
160
+ if (annotation.status !== "done" && annotation.status !== "sent") {
161
+ result.annotations.push(annotation);
162
+ return;
163
+ }
164
+ const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
165
+ output.log.info(` → ${files.json}`);
166
+ output.log.info(` → ${files.markdown}`);
167
+ result.annotations.push(files.report);
168
+ }
137
169
  const run = await runEngine(engine, {
138
170
  url,
139
171
  steps: [...steps, consoleStep],
@@ -149,34 +181,14 @@ export const preview = async (options = {}) => {
149
181
  isHeaded: merged.headed === undefined ? hasAnnotate : !!merged.headed,
150
182
  isStorageKept: !!merged.keepStorage,
151
183
  waitForTimeoutMs: Number(merged.waitTimeout ?? 10000),
184
+ onAnnotation: annotation => reports.push(handleAnnotation(annotation)),
152
185
  timeoutMs: merged.timeout !== undefined ? Number(merged.timeout)
153
186
  : hasAnnotate ? 0
154
187
  : runTimeoutMs([...steps, consoleStep], Number(merged.waitTimeout ?? 10000)),
155
188
  log: output.log
156
189
  });
157
190
  result.saved.push(...run.saved);
158
- for (const annotation of run.annotations ?? []) {
159
- try {
160
- await resolveSources(annotation, baseDir);
161
- } catch (error) {
162
- output.log.error(`annotate: source files not resolved — ${error.message}`);
163
- }
164
- if (annotation.keys) {
165
- try {
166
- output.log.info(`annotate keys saved → ${saveAnnotateKeys(annotation.keys)}`);
167
- } catch (error) {
168
- output.log.error(`annotate: keys not saved — ${error.message}`);
169
- }
170
- }
171
- delete annotation.keys;
172
- for (const line of formatAnnotation(annotation)) output.log.info(line);
173
- if (annotation.status === "done") {
174
- const files = writeAnnotationReport({ annotation, out, prefix: sizes.length > 1 ? `${prefix}${size}-` : prefix, engine, url, baseUrl: merged.baseUrl, size: parseSize(size) });
175
- output.log.info(` → ${files.json}`);
176
- output.log.info(` → ${files.markdown}`);
177
- result.annotations.push(files.report);
178
- } else result.annotations.push(annotation);
179
- }
191
+ await Promise.all(reports);
180
192
  for (const film of run.films ?? []) {
181
193
  try {
182
194
  output.log.info(stitchFilm(film, snapMargin));
@@ -207,7 +219,7 @@ export const preview = async (options = {}) => {
207
219
  if (report) baseLog.info(`report → ${report}`);
208
220
  return { engine, url, sizes, ...result, consoleErrors, failedAsserts, report, failed }
209
221
  } finally {
210
- stopServer();
222
+ await stopServer();
211
223
  }
212
224
  }
213
225
 
@@ -1,9 +1,10 @@
1
1
  export const formatAnnotation = annotation => {
2
2
  const lines = [];
3
3
  const { name, status, result } = annotation;
4
- if (status !== "done" || !result) return [`annotate ${name}: cancelled`];
4
+ if ((status !== "done" && status !== "sent") || !result) return [`annotate ${name}: cancelled`];
5
5
  const elementCount = Object.keys(result.elements).length;
6
- lines.push(`annotate ${name}: done · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
6
+ const round = annotation.round > 1 || status === "sent" ? ` (round ${annotation.round ?? 1})` : "";
7
+ lines.push(`annotate ${name}: ${status}${round} · ${result.notes.length} note(s) · ${elementCount} element(s)${result.generalNote ? " · 1 general note" : ""}${result.maskedCount ? ` · ${result.maskedCount} private element(s) hidden` : ""}`);
7
8
  for (const note of result.notes) {
8
9
  const targets = note.elements.map(id => {
9
10
  const element = result.elements[id];
@@ -7,6 +7,7 @@ export const ICONS = {
7
7
  freeze: icon("<path d='m10 20-1.25-2.5L6 18'/><path d='M10 4 8.75 6.5 6 6'/><path d='m14 20 1.25-2.5L18 18'/><path d='m14 4 1.25 2.5L18 6'/><path d='m17 21-3-6h-4'/><path d='m17 3-3 6 1.5 3'/><path d='M2 12h6.5L10 9'/><path d='m20 10-1.5 2 1.5 2'/><path d='M22 12h-6.5L14 15'/><path d='m4 10 1.5 2L4 14'/><path d='m7 21 3-6-1.5-3'/><path d='m7 3 3 6h4'/>"),
8
8
  notes: icon("<path d='M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z'/><path d='M7 11h10'/><path d='M7 15h6'/><path d='M7 7h8'/>"),
9
9
  keys: icon("<path d='M10 8h.01'/><path d='M12 12h.01'/><path d='M14 8h.01'/><path d='M16 12h.01'/><path d='M18 8h.01'/><path d='M6 8h.01'/><path d='M7 16h10'/><path d='M8 12h.01'/><rect width='20' height='16' x='2' y='4' rx='2'/>"),
10
+ send: icon("<path d='M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z'/><path d='m21.854 2.147-10.94 10.939'/>"),
10
11
  done: icon("<path d='M20 6 9 17l-5-5'/>"),
11
12
  cancel: icon("<path d='M18 6 6 18'/><path d='m6 6 12 12'/>")
12
13
  }
@@ -14,7 +14,7 @@ export const ANNOTATE_KEY = "__webviewPreviewAnnotate";
14
14
  export const ANNOTATE_POLL_MS = 250;
15
15
  export const ANNOTATE_CLEANUP = `window.${ANNOTATE_KEY}?.cleanup?.()`;
16
16
 
17
- export const annotateStart = (name, keys = null, privateSelectors = []) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS })}, {
17
+ export const annotateStart = (name, keys = null, privateSelectors = [], keepOpen = false) => `(${annotatePanel})(${JSON.stringify({ key: ANNOTATE_KEY, name, markup: MARKUP, styles: STYLES, icons: ICONS, keepOpen })}, {
18
18
  keys: (${pageKeys})(${JSON.stringify(keys)}),
19
19
  selectors: (${pageSelectors})(${JSON.stringify(privateSelectors)}),
20
20
  react: (${pageReact})(),
@@ -29,17 +29,19 @@ export const ANNOTATE_CHECK = `(() => {
29
29
  const draft = state.version !== state.sentVersion ? (state.sentVersion = state.version, state.draft()) : null;
30
30
  const captures = state.captures.splice(0);
31
31
  if (captures.length) state.capturing = captures;
32
- return draft || captures.length ? JSON.stringify({ status: "open", draft, captures }) : null;
32
+ const url = location.href !== state.sentUrl ? (state.sentUrl = location.href) : null;
33
+ return draft || captures.length || url ? JSON.stringify({ status: "open", draft, captures, url }) : null;
33
34
  }
34
35
  return JSON.stringify({ status: state.status, result: state.result, keys: state.keys });
35
36
  })()`;
36
37
 
37
38
  export const annotateRestore = draft => `window.${ANNOTATE_KEY}Draft = ${JSON.stringify(draft)};`;
38
39
 
39
- export const annotateStep = (name, keys, privateSelectors = []) => ({
40
+ export const annotateStep = (name, keys, privateSelectors = [], keepOpen = false) => ({
40
41
  kind: "annotate",
41
42
  value: name,
42
- start: annotateStart(name, keys, privateSelectors),
43
+ start: annotateStart(name, keys, privateSelectors, keepOpen),
44
+ resume: `window.${ANNOTATE_KEY}?.resume?.()`,
43
45
  check: ANNOTATE_CHECK,
44
46
  hide: `window.${ANNOTATE_KEY}?.setHidden?.(true)`,
45
47
  show: `window.${ANNOTATE_KEY}?.setHidden?.(false)`,
@@ -7,7 +7,7 @@ export function pageKeys(saved) {
7
7
  ["toggle", "Show / hide tools"],
8
8
  ["parent", "Outline the parent"],
9
9
  ["child", "Back to the child"],
10
- ["done", "Done"]
10
+ ["done", "Done (Send with --keep-open)"]
11
11
  ];
12
12
  const bindings = { ...DEFAULT_KEYS, ...(saved ?? {}) }
13
13
 
@@ -16,6 +16,7 @@ export const MARKUP = `
16
16
  <button type="button" class="tool freeze" data-action="freeze" data-tip="Freeze">${ICONS.freeze}<span class="badge freeze-badge"></span></button>
17
17
  <button type="button" class="tool keys" data-tip="Key bindings">${ICONS.keys}</button>
18
18
  <div class="divider"></div>
19
+ <button type="button" class="tool accent send" data-action="done" data-tip="Send the notes — the window stays open" hidden>${ICONS.send}</button>
19
20
  <button type="button" class="tool accent done" data-action="done" data-tip="Done">${ICONS.done}</button>
20
21
  <button type="button" class="tool cancel" data-tip="Cancel">${ICONS.cancel}</button>
21
22
  </div></div></div>
@@ -1,4 +1,4 @@
1
- export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { keys, selectors, react, vue, dom }) {
1
+ export function annotatePanel({ key, name, markup, styles, icons: ICONS, keepOpen = false }, { keys, selectors, react, vue, dom }) {
2
2
  const { DEFAULT_KEYS, KEY_ACTIONS, bindings, showKey, comboOf, actionFor } = keys;
3
3
  const { textOf, describe, shortName, buildSelector, lookOf, isPrivate, privateElements } = selectors;
4
4
  const componentOf = element => react.componentOf(element) ?? vue.componentOf(element);
@@ -300,6 +300,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
300
300
  }
301
301
 
302
302
  const pick = (element, isAdding) => {
303
+ if (state.status !== "open") return;
303
304
  hovered = null;
304
305
  showOutline(null);
305
306
  const id = elementId(element);
@@ -336,6 +337,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
336
337
  const action = button.dataset.action;
337
338
  if (action === "toggle") return isCollapsed ? "Show tools" : "Hide tools";
338
339
  if (action === "pick") return isPicking ? "Pick elements — on" : "Pick elements — off";
340
+ if (button.classList.contains("done") && keepOpen) return "Finish — sends what is left and closes the window";
339
341
  if (action === "freeze") return isFrozen ? "Unfreeze" : freezeTimer ? "Freezing… press again to stop" : `Freeze in ${FREEZE_SECONDS} s — hover what has to stay open`;
340
342
  return button.dataset.tip;
341
343
  }
@@ -344,7 +346,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
344
346
  const action = button.dataset.action;
345
347
  tip.replaceChildren();
346
348
  tip.append(tipText(button));
347
- if (action && bindings[action]) {
349
+ if (action && bindings[action] && !(keepOpen && button.classList.contains("done"))) {
348
350
  const shortcut = document.createElement("kbd");
349
351
  shortcut.textContent = showKey(bindings[action]);
350
352
  tip.append(shortcut);
@@ -380,7 +382,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
380
382
  ["Shift+click", "add to the open note"],
381
383
  [`Alt+wheel, ${showKey(bindings.parent)} ${showKey(bindings.child)}`, "parent / child"],
382
384
  [showKey(bindings.pick), "pick on / off"],
383
- [showKey(bindings.done), "done"]
385
+ [showKey(bindings.done), keepOpen ? "send" : "done"]
384
386
  ];
385
387
  parts.forEach(([keys, text], index) => {
386
388
  const shortcut = document.createElement("kbd");
@@ -559,7 +561,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
559
561
  }
560
562
 
561
563
  const run = action => {
562
- if (action === "done") finish("done");
564
+ if (action === "done") keepOpen ? send() : finish("done");
563
565
  else if (action === "freeze") toggleFreeze();
564
566
  else if (action === "pick") setPicking(!isPicking);
565
567
  else if (action === "toggle") setCollapsed(!isCollapsed);
@@ -576,6 +578,7 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
576
578
  }
577
579
 
578
580
  const onKey = event => {
581
+ if (state.status !== "open") return;
579
582
  const fromUs = isOurs(event);
580
583
  const combo = comboOf(event);
581
584
  if (listeningFor) {
@@ -645,6 +648,45 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
645
648
  frame = requestAnimationFrame(tick);
646
649
  }
647
650
 
651
+ const send = () => {
652
+ if (state.status !== "open" || (notes.length === 0 && !general.value.trim())) return;
653
+ setFrozen(false);
654
+ try {
655
+ state.result = collect();
656
+ } catch (error) {
657
+ state.result = { notes: [], generalNote: general.value.trim() || null, url: location.href, viewport: { width: innerWidth, height: innerHeight }, elements: {}, error: String(error) }
658
+ }
659
+ state.status = "sent";
660
+ activeNote = null;
661
+ syncMasks();
662
+ drawMasks();
663
+ drawPicks();
664
+ for (const part of [dock, tip, outline]) part.style.display = "none";
665
+ state.cleanup = () => {
666
+ picks.style.display = "none";
667
+ }
668
+ state.resume = () => {
669
+ notes.length = 0;
670
+ records.clear();
671
+ for (const entry of markers.values()) {
672
+ entry.box.remove();
673
+ entry.marker.remove();
674
+ }
675
+ markers.clear();
676
+ pending.clear();
677
+ nextElement = 1;
678
+ nextNote = 1;
679
+ general.value = "";
680
+ picks.style.display = "";
681
+ for (const part of [dock, tip, outline]) part.style.display = "";
682
+ state.result = null;
683
+ state.status = "open";
684
+ changed();
685
+ showSheet(null);
686
+ update();
687
+ }
688
+ }
689
+
648
690
  const finish = status => {
649
691
  if (state.status !== "open") return;
650
692
  setFrozen(false);
@@ -681,7 +723,9 @@ export function annotatePanel({ key, name, markup, styles, icons: ICONS }, { key
681
723
  freezeButton.addEventListener("click", () => run("freeze"));
682
724
  toggleButton.addEventListener("click", () => run("toggle"));
683
725
  keysButton.addEventListener("click", () => showSheet(openSheet === "keys" ? null : "keys"));
684
- $(".done").addEventListener("click", () => run("done"));
726
+ $(".done").addEventListener("click", () => finish("done"));
727
+ $(".send").addEventListener("click", () => send());
728
+ $(".send").hidden = !keepOpen;
685
729
  $(".cancel").addEventListener("click", () => finish("cancelled"));
686
730
  $(".reset-keys").addEventListener("click", () => {
687
731
  Object.assign(bindings, DEFAULT_KEYS);
@@ -29,12 +29,14 @@ export const STYLES = `
29
29
  button { font: inherit; color: inherit }
30
30
  .tool { position: relative; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; color: #939393; background: none; border: none; border-radius: 8px; cursor: pointer; transition: color 0.2s ease-out, background-color 0.2s ease-out }
31
31
  .tool:hover { color: #ffffff }
32
+ .tool[hidden] { display: none }
32
33
  .tool[aria-pressed="true"] { color: #ffffff; background: rgba(255, 255, 255, 0.08) }
33
34
  .tool.accent { color: #0a77ff }
34
35
  .tool.accent:hover { color: #3d95ff }
35
36
  .tool[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed }
36
37
  .tool[aria-disabled="true"]:hover { color: #939393 }
37
- .tool:focus-visible, .button:focus-visible, .kbd:focus-visible, textarea:focus-visible { outline: none; box-shadow: 0 0 0 2px #0a0a0a, 0 0 0 4px #0a77ff }
38
+ .tool:focus-visible { outline: none; box-shadow: inset 0 0 0 2px #0a77ff }
39
+ .button:focus-visible, .kbd:focus-visible, textarea:focus-visible { outline: none; box-shadow: 0 0 0 2px #0a0a0a, 0 0 0 4px #0a77ff }
38
40
  .tip { position: fixed; z-index: 2; padding: 4px 8px; border-radius: 8px; background: #111111; border: 1px solid rgba(255, 255, 255, 0.05); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); white-space: nowrap; font-size: 12px; pointer-events: none; display: none }
39
41
  .tip kbd { margin-left: 8px; color: #939393; font: 11px ui-monospace, monospace }
40
42
  .sheet { width: 300px; max-width: calc(100vw - 72px); max-height: calc(100vh - 16px); overflow: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s ease-out, transform 0.2s ease-out }