framewatch-mcp-server 0.1.1 → 0.2.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 (127) hide show
  1. package/README.md +797 -8
  2. package/dist/constants.d.ts +264 -0
  3. package/dist/constants.js +268 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/engine/browser.js +4 -0
  6. package/dist/engine/browser.js.map +1 -1
  7. package/dist/engine/clicks.d.ts +221 -0
  8. package/dist/engine/clicks.js +801 -0
  9. package/dist/engine/clicks.js.map +1 -0
  10. package/dist/engine/forms.d.ts +137 -0
  11. package/dist/engine/forms.js +474 -0
  12. package/dist/engine/forms.js.map +1 -0
  13. package/dist/engine/hmr.d.ts +41 -0
  14. package/dist/engine/hmr.js +91 -0
  15. package/dist/engine/hmr.js.map +1 -0
  16. package/dist/engine/inspect.d.ts +31 -0
  17. package/dist/engine/inspect.js +383 -0
  18. package/dist/engine/inspect.js.map +1 -0
  19. package/dist/engine/interaction.d.ts +6 -1
  20. package/dist/engine/interaction.js +21 -6
  21. package/dist/engine/interaction.js.map +1 -1
  22. package/dist/engine/links.d.ts +134 -0
  23. package/dist/engine/links.js +384 -0
  24. package/dist/engine/links.js.map +1 -0
  25. package/dist/engine/mocks.d.ts +53 -0
  26. package/dist/engine/mocks.js +148 -0
  27. package/dist/engine/mocks.js.map +1 -0
  28. package/dist/engine/rtl.d.ts +129 -0
  29. package/dist/engine/rtl.js +540 -0
  30. package/dist/engine/rtl.js.map +1 -0
  31. package/dist/engine/seo.d.ts +189 -0
  32. package/dist/engine/seo.js +398 -0
  33. package/dist/engine/seo.js.map +1 -0
  34. package/dist/engine/snapshot.d.ts +29 -0
  35. package/dist/engine/snapshot.js +10 -0
  36. package/dist/engine/snapshot.js.map +1 -0
  37. package/dist/engine/vue.d.ts +54 -0
  38. package/dist/engine/vue.js +419 -0
  39. package/dist/engine/vue.js.map +1 -0
  40. package/dist/index.js +42 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/tools/accessibility.js +4 -3
  43. package/dist/tools/accessibility.js.map +1 -1
  44. package/dist/tools/api-mock.d.ts +405 -0
  45. package/dist/tools/api-mock.js +186 -0
  46. package/dist/tools/api-mock.js.map +1 -0
  47. package/dist/tools/capture.d.ts +83 -23
  48. package/dist/tools/capture.js +105 -60
  49. package/dist/tools/capture.js.map +1 -1
  50. package/dist/tools/compare.js +4 -3
  51. package/dist/tools/compare.js.map +1 -1
  52. package/dist/tools/dead-clicks.d.ts +128 -0
  53. package/dist/tools/dead-clicks.js +570 -0
  54. package/dist/tools/dead-clicks.js.map +1 -0
  55. package/dist/tools/form-test.d.ts +112 -0
  56. package/dist/tools/form-test.js +477 -0
  57. package/dist/tools/form-test.js.map +1 -0
  58. package/dist/tools/index.d.ts +16 -1
  59. package/dist/tools/index.js +42 -1
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/inspect.d.ts +78 -0
  62. package/dist/tools/inspect.js +136 -0
  63. package/dist/tools/inspect.js.map +1 -0
  64. package/dist/tools/interact.d.ts +29 -16
  65. package/dist/tools/interact.js +99 -13
  66. package/dist/tools/interact.js.map +1 -1
  67. package/dist/tools/links.d.ts +129 -0
  68. package/dist/tools/links.js +640 -0
  69. package/dist/tools/links.js.map +1 -0
  70. package/dist/tools/responsive.d.ts +6 -6
  71. package/dist/tools/responsive.js +9 -9
  72. package/dist/tools/responsive.js.map +1 -1
  73. package/dist/tools/rtl.d.ts +241 -0
  74. package/dist/tools/rtl.js +410 -0
  75. package/dist/tools/rtl.js.map +1 -0
  76. package/dist/tools/save-auth.d.ts +22 -22
  77. package/dist/tools/screenshot.js +13 -5
  78. package/dist/tools/screenshot.js.map +1 -1
  79. package/dist/tools/seo.d.ts +113 -0
  80. package/dist/tools/seo.js +281 -0
  81. package/dist/tools/seo.js.map +1 -0
  82. package/dist/tools/snapshot.d.ts +122 -0
  83. package/dist/tools/snapshot.js +183 -0
  84. package/dist/tools/snapshot.js.map +1 -0
  85. package/dist/tools/wait-for.d.ts +107 -0
  86. package/dist/tools/wait-for.js +167 -0
  87. package/dist/tools/wait-for.js.map +1 -0
  88. package/dist/utils/arabic-text.d.ts +14 -0
  89. package/dist/utils/arabic-text.js +193 -0
  90. package/dist/utils/arabic-text.js.map +1 -0
  91. package/dist/utils/budget.d.ts +41 -0
  92. package/dist/utils/budget.js +182 -0
  93. package/dist/utils/budget.js.map +1 -0
  94. package/dist/utils/format.d.ts +11 -1
  95. package/dist/utils/format.js +27 -4
  96. package/dist/utils/format.js.map +1 -1
  97. package/dist/utils/highlight.d.ts +69 -0
  98. package/dist/utils/highlight.js +181 -0
  99. package/dist/utils/highlight.js.map +1 -0
  100. package/dist/utils/link-rules.d.ts +100 -0
  101. package/dist/utils/link-rules.js +284 -0
  102. package/dist/utils/link-rules.js.map +1 -0
  103. package/dist/utils/mock-rules.d.ts +144 -0
  104. package/dist/utils/mock-rules.js +224 -0
  105. package/dist/utils/mock-rules.js.map +1 -0
  106. package/dist/utils/rtl-rules.d.ts +142 -0
  107. package/dist/utils/rtl-rules.js +296 -0
  108. package/dist/utils/rtl-rules.js.map +1 -0
  109. package/dist/utils/seo-rules.d.ts +129 -0
  110. package/dist/utils/seo-rules.js +726 -0
  111. package/dist/utils/seo-rules.js.map +1 -0
  112. package/dist/utils/snapshot-rules.d.ts +33 -0
  113. package/dist/utils/snapshot-rules.js +111 -0
  114. package/dist/utils/snapshot-rules.js.map +1 -0
  115. package/dist/utils/storage-state.d.ts +42 -1
  116. package/dist/utils/storage-state.js +115 -4
  117. package/dist/utils/storage-state.js.map +1 -1
  118. package/dist/utils/style-rules.d.ts +107 -0
  119. package/dist/utils/style-rules.js +223 -0
  120. package/dist/utils/style-rules.js.map +1 -0
  121. package/dist/utils/test-data.d.ts +75 -0
  122. package/dist/utils/test-data.js +294 -0
  123. package/dist/utils/test-data.js.map +1 -0
  124. package/dist/utils/vue-rules.d.ts +72 -0
  125. package/dist/utils/vue-rules.js +108 -0
  126. package/dist/utils/vue-rules.js.map +1 -0
  127. package/package.json +6 -4
package/README.md CHANGED
@@ -11,9 +11,18 @@ The problem with screenshots as a debugging tool is volume. A five-second record
11
11
  | [`framewatch_screenshot`](#framewatch_screenshot) | What does this page look like right now? |
12
12
  | [`framewatch_capture`](#framewatch_capture) | What happens over the next few seconds — animations, loading, a replayed user flow? |
13
13
  | [`framewatch_interact`](#framewatch_interact) | Click this, then let me look; then click the next thing. |
14
+ | [`framewatch_snapshot`](#framewatch_snapshot) | What is on this page, and what do I call each thing? Refs to act on, instead of guessed selectors. |
15
+ | [`framewatch_inspect`](#framewatch_inspect) | Is this element built the way I meant — box, font, colours and contrast, spacing, alignment? |
16
+ | [`framewatch_wait_for`](#framewatch_wait_for) | I just saved a file — show me the page the moment Vite has patched it. |
14
17
  | [`framewatch_responsive`](#framewatch_responsive) | Does it hold up at phone, tablet and desktop widths? |
15
18
  | [`framewatch_accessibility`](#framewatch_accessibility) | What would an accessibility auditor flag? |
16
19
  | [`framewatch_compare`](#framewatch_compare) | What changed between before and after? |
20
+ | [`framewatch_form_test`](#framewatch_form_test) | What does this form do with empty, huge, Arabic or hostile input? |
21
+ | [`framewatch_seo`](#framewatch_seo) | What do a search engine and a link preview make of this page? |
22
+ | [`framewatch_dead_clicks`](#framewatch_dead_clicks) | Which of these buttons and links do nothing when clicked? |
23
+ | [`framewatch_links`](#framewatch_links) | Where does every link on this page actually go — and which ones are broken? |
24
+ | [`framewatch_api_mock`](#framewatch_api_mock) | What does this page do with an empty list, a 500, or an API that takes five seconds? |
25
+ | [`framewatch_rtl`](#framewatch_rtl) | Does this page survive being flipped for Arabic, Hebrew or Persian? |
17
26
  | [`framewatch_save_auth`](#framewatch_save_auth) | Sign in once, so every other tool starts past the login. |
18
27
  | [`framewatch_start_server`](#framewatch_start_server--framewatch_stop_server) / [`framewatch_stop_server`](#framewatch_start_server--framewatch_stop_server) | Get the dev server up so there is something to point at. |
19
28
 
@@ -35,6 +44,25 @@ npm run build
35
44
  claude mcp add framewatch node /absolute/path/to/framewatch/dist/index.js
36
45
  ```
37
46
 
47
+ ### Result size and `MAX_MCP_OUTPUT_TOKENS`
48
+
49
+ Claude Code caps one MCP tool result at 25,000 tokens by default and counts base64 image data toward it. A result over the cap is written to a file and replaced with a reference, so the model sees **no images at all**. A single screenshot of a real page is 180 KB of PNG, which is over on its own.
50
+
51
+ FrameWatch never lets a result cross that line. Every image goes out in the cheapest faithful encoding (palette PNG for flat UI, JPEG for anything photo-like), and a result that still would not fit is degraded in a fixed order — change-region crops first, then frames shrunk to 640px and 480px, then frames from the middle while the first, the last and every interaction frame stay — and a final line says exactly what was cut:
52
+
53
+ ```
54
+ Image budget: 4 of 9 images kept — 5 crops dropped, frames at 640px — to fit MAX_MCP_OUTPUT_TOKENS=25000 (~52 KB of images per result). Set MAX_MCP_OUTPUT_TOKENS=100000 in the shell that starts Claude Code for full results.
55
+ ```
56
+
57
+ The default cap holds one or two frames of a real page. For capture-heavy work, raise it once:
58
+
59
+ ```bash
60
+ export MAX_MCP_OUTPUT_TOKENS=100000
61
+ claude
62
+ ```
63
+
64
+ The server reads the same variable and sizes its budget to it.
65
+
38
66
  ### Requirements
39
67
 
40
68
  - **Node 20.9+**.
@@ -209,6 +237,7 @@ Perform **one** interaction and see what it did: before frame, after frame, and
209
237
  | --- | --- | --- | --- |
210
238
  | `action` | enum | — | `click`, `tap`, `type`, `key`, `scroll`, `swipe`, `navigate`, `select`, `hover` |
211
239
  | `selector` | string | — | CSS selector for the target |
240
+ | `ref` | string | — | Element ref from [`framewatch_snapshot`](#framewatch_snapshot), e.g. `e8` — instead of `selector` |
212
241
  | `value` | string | — | Text to type (`\n` presses Enter, `\t` Tab), key to press, option to select, or URL to navigate to |
213
242
  | `x`, `y` | number | — | Coordinates for `click`/`tap`/`swipe` when no selector is given |
214
243
  | `delta_x`, `delta_y` | number | — | Distance for `scroll` / `swipe` |
@@ -220,8 +249,11 @@ Perform **one** interaction and see what it did: before frame, after frame, and
220
249
  | `include_network` | boolean | `false` | Report network requests the action caused |
221
250
  | `include_dom` | boolean | `false` | Report the DOM mutations the action caused |
222
251
  | `include_performance` | boolean | `false` | Report paint timing and layout shifts around the action |
252
+ | `include_snapshot` | boolean | `false` | Append a [snapshot](#framewatch_snapshot) of the page after the action, with fresh refs for the next call |
223
253
  | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth), applied when the session page is opened |
224
254
 
255
+ On a [Vue app](#vue-apps), `navigate` with a path goes through vue-router — same document, state kept, no reload — and the headline reports the route change by name: `Vue 3.5.42 — route /login (login) → /settings (settings)`. Another origin, or a path the router does not know, is a full load, and the step says so.
256
+
225
257
  **Example call** — a session, one call at a time. Only the first needs a `url`:
226
258
 
227
259
  ```json
@@ -234,7 +266,9 @@ Perform **one** interaction and see what it did: before frame, after frame, and
234
266
  { "action": "click", "selector": "button[type=submit]", "wait_ms": 1500, "include_network": true }
235
267
  ```
236
268
 
237
- Returns a summary line (`click "#btn" on http://localhost:3000/ — 8.4% of the frame changed`), the before frame, the after frame with its `Changed: …` metadata, and a crop of the changed region.
269
+ Returns a summary line (`click "#btn" on http://localhost:3000/ — 8.4% of the frame changed — viewport 1280x720, images 800px wide (0.63×) — coordinates and regions are in viewport px`), the before frame, the after frame with its `Changed: …` metadata, and a crop of the changed region. The viewport line is on every tool that returns frames: the images are shrunk, the numbers are not, and clicking where the image says would land 1.6× off.
270
+
271
+ Instead of a selector, target an element by the `ref` a [`framewatch_snapshot`](#framewatch_snapshot) gave it — `{ "action": "click", "ref": "e11" }` — and pass `include_snapshot: true` to get the fresh refs back with the result, so a whole flow runs without a selector being guessed once. A ref that no longer resolves (the page re-rendered) says so and says to snapshot again.
238
272
 
239
273
  The same [context layers](#context-layers) as `framewatch_capture` are available here, split over the two frames: the **before** frame carries how the page got into this state (anything it logged or fetched while loading), and the **after** frame carries what the action itself caused. That is usually the fastest way to answer "why did my click do nothing":
240
274
 
@@ -253,6 +287,154 @@ Because the page is reused between calls, the layers are installed on it once an
253
287
 
254
288
  Calls are serialised — they all drive the same page, so they queue rather than interleave. The first call needs a `url`; later calls can omit it. Cookies, storage, scroll position and in-page state all carry over. Two things cannot change in place, because both are fixed when the browser context is created: touch support, and the saved auth. So the first `tap` or `swipe` on a page opened without touch reopens the page, as does naming a `storage_state` the open session was not created with — the summary says which of the two it was, because either resets page state. Passing the *same* `storage_state` again, or omitting it, leaves the session exactly where it is. The session closes when the MCP server shuts down; `framewatch_capture` and `framewatch_screenshot` are unaffected by it, as they always use a fresh, isolated browser context.
255
289
 
290
+ ### `framewatch_snapshot`
291
+
292
+ Read the page as a tree of named elements. An agent that only has a screenshot has to guess a selector for everything it wants to click; this hands it the accessible name and a short ref for every element instead. The tree is Playwright's own AI-mode aria snapshot, and the refs are the ones its `aria-ref=` locator resolves — nothing is generated here.
293
+
294
+ | Param | Type | Default | Description |
295
+ | --- | --- | --- | --- |
296
+ | `url` | string (URL) | — | Open this page first. Omit to read the page left open by `framewatch_interact` / `framewatch_inspect`. |
297
+ | `selector` | string | — | Only this container, e.g. `main` or `#checkout` |
298
+ | `mode` | `full` \| `interactive` | `full` | The whole tree with headings and text, or a flat list of only what can be clicked, typed into or chosen |
299
+ | `max_chars` | integer | `12000` | Cut the tree past this, on a line boundary, with a note saying how many lines went |
300
+ | `include_screenshot` | boolean | `false` | Also return a screenshot of the page as it was read |
301
+ | `include_components` | boolean | `false` | On a [Vue app](#vue-apps): append the component tree from the root |
302
+ | `wait_ms` | integer ≥ 0 | `500` | Settle time after opening `url` — a ceiling on a Vue app, which is read as soon as it is mounted |
303
+ | `wait_for` | string | — | CSS selector to wait for before reading |
304
+ | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) |
305
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
306
+
307
+ **Example call**
308
+
309
+ ```json
310
+ { "url": "http://localhost:3000/login", "mode": "interactive" }
311
+ ```
312
+
313
+ Returns, for the fixture in `test/fixtures/snapshot.html`:
314
+
315
+ ```
316
+ Snapshot of http://127.0.0.1:53021/snapshot.html — "FrameWatch Snapshot Fixture" — viewport 1280x720 — 16 elements, 7 interactive
317
+ Refs: pass one as `ref` to framewatch_interact (to act on it) or framewatch_inspect (to measure it). They stay valid until the page changes — snapshot again after an action that re-rendered. In a framewatch_capture script, target the same element with a selector such as role=button[name="Sign in"].
318
+
319
+ - textbox "Email" [ref=e6]
320
+ - textbox "Password" [ref=e8]
321
+ - checkbox "Remember me" [checked] [ref=e10]
322
+ - button "Sign in" [ref=e11]
323
+ - link "Pricing" [ref=e13] [cursor=pointer] → /pricing
324
+ - link "Docs" [ref=e14] [cursor=pointer] → /docs
325
+ - generic [ref=e15] [cursor=pointer]: Card
326
+ ```
327
+
328
+ `mode: "full"` keeps the hierarchy, headings and text, which is what you want when the question is "what does this page say" rather than "what can I press". Either way the next step is `framewatch_interact` with `"ref": "e11"`, or `framewatch_inspect` with `"targets": ["e11"]`.
329
+
330
+ Refs belong to the page. They are assigned when the snapshot is taken and stay valid until the DOM changes, which is why this tool reads the page `framewatch_interact` keeps open rather than a throwaway one — and why `framewatch_capture`, which always opens a fresh page, cannot take a ref. A capture script targets the same element with Playwright's role selector, written straight from the snapshot line: `role=button[name="Sign in"]`.
331
+
332
+ ### `framewatch_inspect`
333
+
334
+ Measure how elements are actually built, to check UI work against what was intended. A screenshot shows that a button looks roughly right; this says it is 101×36 at 40,80, set in 14px/20px Arial 400, white on `#3b82f6` at a contrast of 3.68:1 which fails AA, padded 8/16, radius 6px, 20px below the heading and 20px in from the panel's left edge — and boxes it on a screenshot so you can see which element those numbers describe.
335
+
336
+ | Param | Type | Default | Description |
337
+ | --- | --- | --- | --- |
338
+ | `url` | string (URL) | — | Open this page first. Omit to measure the page left open by `framewatch_interact` / `framewatch_snapshot`. |
339
+ | `targets` | string[] (≤ 12) | — | What to measure: snapshot refs (`e8`) and/or CSS selectors, in order. **Omit for a design inventory of the page.** |
340
+ | `selector` | string | — | Inventory only: count inside this container |
341
+ | `include_screenshot` | boolean | `true` | With `targets`: a screenshot with each target boxed and numbered |
342
+ | `wait_ms` | integer ≥ 0 | `500` | Settle time after opening `url` |
343
+ | `wait_for` | string | — | CSS selector to wait for before measuring |
344
+ | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) |
345
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
346
+
347
+ **Example call**
348
+
349
+ ```json
350
+ { "url": "http://localhost:3000/", "targets": ["#cta", "#note", "#narrow"] }
351
+ ```
352
+
353
+ Returns, for `test/fixtures/inspect.html`, five lines per target and one screenshot:
354
+
355
+ ```
356
+ Inspected 3 of 3 targets on http://127.0.0.1:53021/inspect.html — viewport 1280x720, images 800px wide (0.63×) — coordinates and regions are in viewport px
357
+ 1. #cta button "Get started" — <button#cta>
358
+ box: 40,80 101x36 (viewport px; centre 91,98) — visible, fully in viewport
359
+ text: 14px/20px Arial 400 — #ffffff on #3b82f6 — contrast 3.68:1 — fails AA for normal text (needs 4.5:1)
360
+ spacing: padding 8 16; margin 0
361
+ border: none; radius 6px
362
+ layout: block; position absolute; 20px from parent's left, 279px from parent's right; 20px below previous sibling, 20px right of its left edge
363
+ 2. #note paragraph — <p#note>
364
+ box: 40,130 200x20 (viewport px; centre 140,140) — visible, fully in viewport
365
+ text: 13px/20px Arial 400 — #7a7a7b (#00000080 as declared) on #f3f4f6 — contrast 3.90:1 — fails AA for normal text (needs 4.5:1)
366
+ spacing: padding 0; margin 0
367
+ border: none; radius 0px
368
+ layout: block; position absolute; 20px from parent's left, 180px from parent's right; 14px below previous sibling, left-aligned with it
369
+ 3. #narrow div — <div#narrow>
370
+ box: 120,280 60x20 (viewport px; centre 150,290) — visible, fully in viewport, text overflows its box
371
+ text: 16px/24px Arial 400 — #111111 on #f3f4f6 — contrast 17.16:1 — passes AAA for normal text
372
+ spacing: padding 0; margin 0
373
+ border: none; radius 0px
374
+ layout: block; position absolute; 100px from parent's left, 240px from parent's right; overlaps previous sibling by 20px, 80px right of its left edge
375
+ ```
376
+
377
+ Every number is as rendered, not as declared: the background is this element's own composited over its ancestors' until something is opaque, a translucent text colour is printed as the blend the eye sees (with the declared value beside it), and `display` is the computed value — an absolutely positioned `inline-block` reports `block`, because that is what it is laid out as. The box line also says when an element is off screen, partly outside the viewport, clipped by an ancestor with `overflow` set, hidden, or when its own text is wider than the box it was given. A target that resolved to nothing says so in its place — `2. #nope — matched nothing` — and a stale ref says to snapshot again.
378
+
379
+ With no `targets` the tool returns a **design inventory** instead: everything the page is built from, with counts, most-used first.
380
+
381
+ ```
382
+ Design inventory — 15 elements, 6 with text
383
+ fonts (1): Arial ×6
384
+ font sizes (4): 16px ×3, 13px ×1, 14px ×1, 24px ×1
385
+ font weights (2): 400 ×5, 700 ×1
386
+ text colours (4): #111111 ×3, #00000080 ×1, #6b7280 ×1, #ffffff ×1
387
+ backgrounds (6): #d1d5db ×3, #000000 ×1, #10b981 ×1, #3b82f6 ×1, #f3f4f6 ×1, #f59e0b ×1
388
+ spacing (3): 16px ×4, 8px ×2, 12px ×1
389
+ radii (1): 6px ×1
390
+ ```
391
+
392
+ That is the fastest way to find the one 13px label on a 14px page, or the fourth shade of grey. It measures and counts; it does not score. "This page uses four font sizes" is a fact the agent can act on, and whether that is one too many is its call.
393
+
394
+ On a [Vue app](#vue-apps) each target gets one more line — the component that rendered it, with its props, its reactive state and its ancestry:
395
+
396
+ ```
397
+ component: LoginForm (props: title="Welcome back", max=3; state: email="", password="", loading=false, items=[3], submit=fn) in RouterView > App
398
+ ```
399
+
400
+ ### `framewatch_wait_for`
401
+
402
+ Wait for the open page to reach a state worth looking at, then look. The condition that earns the tool its place is `hot_update`: after the agent saves a file, Vite patches the open page in place, and this returns the moment that patch has landed — no reload, no replayed flow, no guessed sleep — and names the file that changed.
403
+
404
+ | Param | Type | Default | Description |
405
+ | --- | --- | --- | --- |
406
+ | `url` | string (URL) | — | Open this page first. Omit to wait on the page left open by the other session tools. |
407
+ | `until` | enum | `hot_update` | `hot_update`: Vite applied a hot update or full reload newer than the last tool call on this page. `vue_ready`: a Vue app is mounted and its router has resolved. `selector`: `selector` is visible. `network_idle`: no requests for 500ms. |
408
+ | `selector` | string | — | For `until: selector` |
409
+ | `timeout_ms` | integer | `10000` | Give up after this long |
410
+ | `include_screenshot` | boolean | `true` | Screenshot once the condition holds |
411
+ | `include_snapshot` | boolean | `false` | Also return a [snapshot](#framewatch_snapshot) with fresh refs |
412
+ | `viewport`, `storage_state` | | | As elsewhere |
413
+
414
+ **Example call** — right after saving `LoginForm.vue`:
415
+
416
+ ```json
417
+ { "until": "hot_update" }
418
+ ```
419
+ ```
420
+ Hot update landed after 1428ms: /src/App.vue — Vue 3.5.42 — route /login (login) — http://localhost:5173/login — viewport 1280x720, images 800px wide (0.63×) — coordinates and regions are in viewport px
421
+ ```
422
+
423
+ "Newer than the last tool call" is the whole trick. Every session tool stamps the page on its way out, so an update that landed between your save and this call still counts, and one from an earlier edit does not — the failure says `No hot update within 10000ms — last one landed 4210ms before this call (/src/App.vue)`. A page with no Vite client at all is told so instead of timing out: `has no Vite dev-server connection … Is this the dev server, or a built page?` A full reload (`[vite] page reload`) counts too, and the tool waits for the new document to load before looking.
424
+
425
+ The other conditions replace guessed sleeps: `vue_ready` reports `Vue ready after 512ms — Vue 3.5.42 — route / (home)`, `selector` reports `"#submit" appeared after 340ms`, `network_idle` reports `Network idle after 890ms`.
426
+
427
+ ### Vue apps
428
+
429
+ FrameWatch reads what a Vue 3 app leaves in the DOM — the app on its container, and on every element a dev build rendered, the component that rendered it — and uses it in four places:
430
+
431
+ - **Every session tool settles on the app, not on a timer.** `framewatch_snapshot`, `framewatch_inspect`, `framewatch_interact` and `framewatch_wait_for` read the page the moment the app is mounted and vue-router has resolved its first navigation, then two frames later. `wait_ms` becomes a ceiling; a page without Vue gets the plain wait as before. Their headers name the app and the route: `Vue 3.5.42 — route /login (login)`.
432
+ - **`framewatch_inspect` names the component** behind each target, with props, reactive state (refs unwrapped, functions as `fn`, arrays as `[n]`) and the ancestry up to the root. **`framewatch_snapshot`** with `include_components` appends the tree — router and transition built-ins collapsed, identical leaf siblings counted.
433
+ - **`navigate` goes through the router** when the app has one and the value is a path or same-origin URL, so store state survives and nothing reloads. The step says `(vue-router)`, and interact reports `route /login (login) → /settings (settings)`.
434
+ - **`framewatch_wait_for` watches Vite** for the hot update your save produces.
435
+
436
+ Production builds keep only the app handle: the version and route are still reported, and the component line says `production build — no component data on elements`. Vue 2 is detected and named, but component details need Vue 3.
437
+
256
438
  ### `framewatch_responsive`
257
439
 
258
440
  Screenshot one page at several viewport sizes in a single call. Each size loads in its own fresh browser context and they load concurrently, so three 2s waits cost about 2s — and a mobile shot is what a phone would really get, not a resized desktop layout that already ran its `matchMedia` listeners at 1440px.
@@ -371,6 +553,8 @@ Pass `"current"` as `url_a` to compare against the page `framewatch_interact` ha
371
553
 
372
554
  ### `framewatch_save_auth`
373
555
 
556
+ Once this has written `.framewatch/auth.json`, **every page tool uses it automatically** when `storage_state` is omitted, and says so in its last line. `storage_state: "none"` opens a page signed out; `FRAMEWATCH_AUTH_STATE` moves the default file. A result whose page still shows a login form after the state was applied says the session has expired and to run this again.
557
+
374
558
  Run a login or gate flow **once** and save the browser state it produces. Every other tool takes that file as `storage_state` and opens the page already signed in, so an app behind a login stops costing seven interaction steps and ten seconds on every single call.
375
559
 
376
560
  | Param | Type | Default | Description |
@@ -434,6 +618,545 @@ What is saved is Playwright's storage state: **cookies and localStorage**, per o
434
618
 
435
619
  There is **no auto-refresh**. When a saved session expires, the login screen simply appears in the next capture — which is the clearest possible signal to run this tool again. FrameWatch does not try to detect it, because a heuristic that guesses wrong is worse than a screenshot you can see.
436
620
 
621
+ ### `framewatch_form_test`
622
+
623
+ Fill a page's forms with deliberately awkward data and report what breaks. Forms are where user-facing bugs live, and the cases that break them — nothing at all, ten thousand characters, an apostrophe, Arabic, `<img onerror=…>` — are exactly the ones nobody types by hand twice.
624
+
625
+ | Param | Type | Default | Description |
626
+ | --- | --- | --- | --- |
627
+ | `url` | string (URL) | — | Page with the form on it |
628
+ | `selector` | string | — | One form, or the container holding the fields. Omit to test every `<form>` on the page |
629
+ | `strategies` | array (1–9) | `valid`, `empty`, `special_chars`, `rtl_arabic` | Which kinds of data to try — see below |
630
+ | `submit` | boolean | `false` | Send the form after filling it |
631
+ | `wait_ms` | integer ≥ 0 | `2000` | Settle time after the submit, before the result is read |
632
+ | `wait_for` | string | — | CSS selector to wait for (visible) before the form is looked for |
633
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
634
+ | `timeout_ms` | integer ≥ 1 | `10000` | Max wait for one field to accept its value |
635
+ | `max_fields` | integer 1–60 | `60` | Maximum fields to fill per form |
636
+ | `full_page` | boolean | `false` | Photograph the whole document rather than the viewport — for a form taller than the screen |
637
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
638
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth), for a form behind a login |
639
+
640
+ | Strategy | What goes in every field |
641
+ | --- | --- |
642
+ | `valid` | Realistic data of the right shape — an email in the email field, a number inside its `min`/`max`, and name/label hints for the rest (`user_email`, `phone`, `postcode`, …) |
643
+ | `empty` | Everything cleared, boxes unticked — then submitted |
644
+ | `maxlength` | Filled to the field's own `maxlength`, or 10,000 characters when it declares none |
645
+ | `special_chars` | Quotes, angle brackets, a backslash, an SQL fragment, CJK, Arabic and an emoji |
646
+ | `rtl_arabic` | Arabic text everywhere — RTL layout, bidirectional text, and inputs that only expected Latin |
647
+ | `numbers_only` | Digits in every field, including the ones that expect words |
648
+ | `spaces_only` | Whitespace — input that looks filled in and is empty once trimmed |
649
+ | `boundary` | The edges: one character for text, `min`/`max` for numbers and dates, the last option in a select |
650
+ | `xss` | Harmless XSS payloads that set a marker instead of doing damage |
651
+
652
+ **Example call** — the four defaults without sending anything, then the full sweep including the submit:
653
+
654
+ ```json
655
+ { "url": "http://localhost:3000/signup" }
656
+ ```
657
+ ```json
658
+ {
659
+ "url": "http://localhost:3000/signup",
660
+ "selector": "#signup",
661
+ "strategies": ["valid", "empty", "maxlength", "special_chars", "rtl_arabic", "boundary", "xss"],
662
+ "submit": true,
663
+ "wait_ms": 1500
664
+ }
665
+ ```
666
+
667
+ Every strategy runs in a **fresh page**. That costs a page load each, and it is the point: a form filled with valid data and then emptied is not the same test as a form that was empty from the start (live validation has run on every field, and half the frameworks in use have marked the form dirty), and a payload that executed under one strategy would be reported under every later one if they shared a page. Strategies that only fill run concurrently; with `submit: true` they run one at a time, because submitting is a write to the app under test.
668
+
669
+ Each strategy comes back as a screenshot after the fill, a screenshot after the submit, and what the page did about it:
670
+
671
+ ```
672
+ Form test of http://localhost:3000/signup — #signup: 11 fillable fields, 5 not fillable. Ran 1 strategy, filling and
673
+ submitting the form, each in a page of its own.
674
+ Fields: #name (text, required, max 20), #email (email, required), #password (password, required), #age (number),
675
+ #website (url), #code (text), #country (select-one), #bio (textarea), #plan-basic (radio), #plan-pro (radio), … and 1 more
676
+ Not filled: #avatar (file input — a file cannot be chosen from a script), #csrf (hidden input),
677
+ #disabled-field (disabled), #readonly-field (read-only), #invisible-field (not visible)
678
+
679
+ empty — after fill: every field cleared, then submitted — does validation catch it
680
+ 9 of 11 fields filled: #name="", #email="", #password="", #age="", #website="", #code="", #country="", #bio="",
681
+ #terms=unchecked
682
+ Left alone: #plan-basic (a radio button cannot be cleared, only replaced), #plan-pro (a radio button cannot be cleared…)
683
+
684
+ empty — after submit: clicked "#submit", waited 800ms
685
+ Page validation: Please fix 4 problem(s) below., Name is required, Enter a valid email address, Password must be at
686
+ least 8 characters, You must accept the terms
687
+ Fields the page marked invalid: #name, #email, #password, #terms
688
+ Browser validation: #name — Please fill out this field., #email — Please fill out this field., #terms — Please check
689
+ this box if you want to proceed.
690
+ Network: no request was made — the submit never left the page
691
+ Console:
692
+ [error] form validation failed: name, email, password, terms
693
+ ```
694
+
695
+ Two of those lines only exist because the empty cases are printed after a submit: **"Network: no request was made"** and **"Page validation: nothing new was shown"**. A form that silently does nothing looks exactly like a form that worked, and those two lines are the difference.
696
+
697
+ `Browser validation` is the browser's own constraint checking (`required`, `type="email"`, `min`), read from the field rather than from the screen. On a `novalidate` form — or one whose JavaScript calls `preventDefault()` first — none of it is ever shown to a user, which is worth knowing on its own. It is also where the RTL run earns its keep:
698
+
699
+ ```
700
+ rtl_arabic — after fill: Arabic text in every field — catches RTL layout and bidirectional text bugs
701
+ 10 of 11 fields filled: #name="مرحبا بالعالم — اختب", #email="اختبار@example.com", #age="69", #code="مرحبا", …
702
+ Truncated by the page: #code kept 5 of 32 characters
703
+ Browser validation: #email — A part followed by '@' should not contain the symbol 'ا'., #website — Please enter a URL.
704
+ ```
705
+
706
+ **Truncation** is reported whenever a field kept less than it was given. Values are never longer than the field's own `maxlength` to begin with, so a truncation line always means a limit the markup does not declare — an `input` handler cutting the value down, a framework formatter, a paste guard.
707
+
708
+ The `xss` strategy is the one finding that gets promoted to the top of the response:
709
+
710
+ ```
711
+ Warning — an injected payload executed on this page: input is being written back into the document as markup.
712
+ That is a reflected XSS. The payloads only set a marker; a real one would not be so polite.
713
+ ```
714
+
715
+ The payloads assign a global (`window.__framewatch_xss = 1`) and nothing else, and whether that global is set afterwards is the entire test. Looking for the payload in the DOM instead would be guesswork: an input's own `value` serialises with its angle brackets intact, so a page that escaped everything perfectly would still look like a hit.
716
+
717
+ Fields nothing can fill — hidden, disabled, read-only, invisible, `type="file"` — are listed with the reason rather than skipped silently, and a field that refuses its value is reported while the rest of the form still gets filled. A page with **no `<form>` element at all** is not a page without a form: when there is none, every field on the page is treated as one form, and the submit falls back to a button whose text says what it does (`Save`, `Sign in`, `Continue`). The only thing that needs a real `<form>` is submitting with Enter.
718
+
719
+ ### `framewatch_seo`
720
+
721
+ Audit what a search engine — and a link preview — would make of a page. Title and description with their lengths, canonical, robots directives and robots.txt, Open Graph and Twitter card tags, the heading outline, images with no alt text, and JSON-LD structured data checked against what Google's rich results actually require.
722
+
723
+ Everything is read from the **rendered DOM**, not the HTML that was served. A single-page app ships an empty `<div id="app">` and writes the title, the description and the structured data afterwards; reading the source would report every client-rendered page as having no SEO at all.
724
+
725
+ | Param | Type | Default | Description |
726
+ | --- | --- | --- | --- |
727
+ | `url` | string (URL) | — | Page to audit |
728
+ | `wait_ms` | integer ≥ 0 | `1000` | Wait after load before reading the page, so a client-rendered app can finish |
729
+ | `wait_for` | string | — | CSS selector to wait for (visible) instead of, or as well as, a fixed wait |
730
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
731
+ | `check_robots` | boolean | `true` | Fetch `/robots.txt` and work out whether this page's own path is crawlable |
732
+ | `robots_user_agent` | string | `Googlebot` | Which crawler to answer the robots.txt question for |
733
+ | `check_og_image` | boolean | `true` | Fetch the `og:image` and measure it |
734
+ | `include_performance` | boolean | `false` | Also measure this load: LCP, CLS, TTFB, page weight, DOM size |
735
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
736
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
737
+
738
+ **Example call:**
739
+
740
+ ```json
741
+ { "url": "http://localhost:3000" }
742
+ ```
743
+ ```json
744
+ { "url": "http://localhost:3000/menu", "wait_for": "#app h1", "include_performance": true }
745
+ ```
746
+
747
+ Three of the checks are things the page cannot tell you about itself, and they are the ones that catch the expensive mistakes:
748
+
749
+ - **The response headers.** An `X-Robots-Tag: noindex` is invisible in the DOM and keeps the page out of the index just as thoroughly as the meta tag does.
750
+ - **robots.txt.** A page can be perfect and simply not crawlable. The verdict is worked out the way the specification says: the group named for the crawler beats the `*` group, and the *longest* matching rule wins regardless of the order the rules appear in — so `Disallow: /` followed by `Allow: /public/` permits `/public/page`, and reading top to bottom would get it exactly backwards.
751
+ - **The share image.** `og:image` is fetched and measured. One pointing at a 404, or at a 40×40 favicon, looks exactly like a working one until somebody shares the link — so the image comes back as an image, to be looked at.
752
+
753
+ ```
754
+ SEO audit of http://localhost:3000/menu — 0 problems, 0 warnings, 21 checks passed.
755
+ Nothing here would keep this page out of an index or break its share card.
756
+
757
+ Indexing
758
+ ✓ Robots directives — none — indexable by default
759
+ ✓ robots.txt — Googlebot may crawl this path — no rule in the "googlebot" group matches /menu
760
+ · Sitemap — http://localhost:3000/sitemap.xml
761
+ ✓ Canonical — http://localhost:3000/menu
762
+ ✓ Language — <html lang="en">
763
+ ✓ Viewport — "width=device-width, initial-scale=1"
764
+
765
+ Title & description
766
+ ✓ Title — "Keko Food — Order Fresh Food Online" (35 characters)
767
+ ✓ Meta description — "Order fresh food from Keko Food and have it delivered in under thirty minutes, every day of the week." (101 characters)
768
+ · Body — 73 words, 2 links (1 internal, 1 external)
769
+
770
+ Headings
771
+ ✓ H1 — "Welcome to Keko Food"
772
+ ✓ Outline — 1 h1, 2 h2, 2 h3 — no skipped levels
773
+ Outline:
774
+ h1 Welcome to Keko Food
775
+ h2 Menu
776
+ h3 Starters
777
+ h3 Mains
778
+ h2 Delivery
779
+ ...
780
+ Performance (lab)
781
+ ✓ LCP — 40ms — largest element: img (good ≤ 2500ms, poor > 4000ms)
782
+ ✓ CLS — 0 (good ≤ 0.1, poor > 0.25)
783
+ · Page weight — 2 requests, 6KB transferred — img 2× 3KB
784
+ ```
785
+
786
+ Checks that passed are printed rather than counted, for the same reason the accessibility tool counts the rules that passed: a four-line report is otherwise indistinguishable from an audit that only looked at four things. The marks are `✓` passed, `!` worth fixing, `✗` will cost you traffic, `·` a measurement with no verdict attached.
787
+
788
+ A page with something wrong leads with it:
789
+
790
+ ```
791
+ SEO audit of http://localhost:3000/staging — 6 problems, 11 warnings, 1 check passed.
792
+ Problems: noindex (this page tells search engines not to index it (<meta name=robots>: "noindex, nofollow"));
793
+ Meta description (missing); H1 (no <h1>); Alt text (2 of 3 images have no alt attribute: /img/hero.png, #logo);
794
+ JSON-LD block 1 (is not valid JSON); Product (missing required name, image (has description))
795
+
796
+ Indexing
797
+ ✗ noindex — this page tells search engines not to index it (<meta name=robots>: "noindex, nofollow")
798
+ → Remove the noindex if this page is meant to be found — it is the usual reason a live site has no search presence.
799
+ ...
800
+ Structured data
801
+ ✗ JSON-LD block 1 — is not valid JSON — Expected double-quoted property name in JSON at position 89 (line 2 column 89)
802
+ → A block that does not parse is ignored entirely, so the page has that much less structured data than it looks like.
803
+ ✗ Product — missing required name, image (has description)
804
+ → Google drops the whole item when a required property is absent.
805
+ ```
806
+
807
+ **Structured data** is checked against the properties Google's rich-result documentation requires, not against all of schema.org — which requires almost nothing and would report every page as perfect. `@graph` and top-level arrays are flattened, a type spelled as a `https://schema.org/…` URL is understood, subtypes inherit (`Restaurant` is checked as a `LocalBusiness`, `BlogPosting` as an `Article`), a property present but empty counts as absent, and a type nothing is known about is reported without being judged. A block that does not parse is a problem in its own right: it is ignored entirely by every consumer, so the page has less structured data than it looks like.
808
+
809
+ **`include_performance`** adds LCP, CLS, TTFB, FCP, page weight and DOM size from the same load, graded against Google's own boundaries. These are lab numbers from one headless load on this machine — useful for *"the LCP element is the hero image"*, worthless as a prediction of what real visitors will report, and the report says so rather than turning them into a score.
810
+
811
+ ### `framewatch_dead_clicks`
812
+
813
+ Find the elements that look clickable and do nothing. A button whose handler never got attached, a link to `#`, a `<div>` with a pointer cursor and no listener — the things users click twice, then three times, then leave.
814
+
815
+ > **This one presses every button on the page**, including the one that deletes the account. It is the only FrameWatch tool that is not read-only. Pass `exclude` to keep it away from anything destructive, or `selector` to point it at one region.
816
+
817
+ | Param | Type | Default | Description |
818
+ | --- | --- | --- | --- |
819
+ | `url` | string (URL) | — | Page to sweep |
820
+ | `wait_ms` | integer ≥ 0 | `1000` | Settle time after each load. Paid once per element that did something — the page is reloaded after those |
821
+ | `wait_for` | string | — | CSS selector to wait for (visible) after each load |
822
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
823
+ | `settle_ms` | integer ≥ 0 | `500` | How long to watch the page after each click before deciding nothing happened |
824
+ | `selector` | string | whole page | Only sweep inside this container |
825
+ | `exclude` | string | — | Never click this, or anything inside it |
826
+ | `include_pointer` | boolean | `true` | Also test elements that only a `cursor: pointer` makes clickable |
827
+ | `include_hover` | boolean | `true` | For each dead element, check whether the page reacts to hovering it |
828
+ | `max_elements` | integer 1–100 | `40` | How many to click, in document order |
829
+ | `full_page` | boolean | `false` | Photograph the whole document, so dead elements below the fold are in the picture |
830
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
831
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
832
+
833
+ **Example call:**
834
+
835
+ ```json
836
+ { "url": "http://localhost:3000" }
837
+ ```
838
+ ```json
839
+ { "url": "http://localhost:3000/account", "exclude": "#delete-account, .danger", "settle_ms": 800 }
840
+ ```
841
+
842
+ **Knowing that nothing happened is the hard part.** Clicking is easy; a handler can navigate, mutate the DOM, fetch, open a dialog, toggle a checkbox, scroll, move focus, write to `localStorage` or throw, and most of those leave no trace in any of the others. So every click is judged on all of them at once — the URL, an in-page `MutationObserver`, hashes of every field's value and of storage, scroll position, focus, plus Playwright's view of the console, network, dialogs, popups and downloads. Only silence on every channel counts as dead.
843
+
844
+ **Pages are rarely silent on their own,** so the same reading is taken once with nobody clicking, and what the page does by itself is subtracted. That is done by comparing *which* mutations happened rather than how many: two windows of the same length never catch the same number of clock ticks, but they do catch the same kind of change. Without it, one `setInterval` makes every element on the page look alive and the sweep finds nothing.
845
+
846
+ Three more things it gets right, because getting them wrong makes the answer useless:
847
+
848
+ - **`<a href="#">` has not gone anywhere.** Clicking one puts a bare `#` in the address bar. A plain URL comparison would report the single most common dead link there is as a working navigation.
849
+ - **`cursor: pointer` is inherited.** A pointer-styled card with a heading and a paragraph in it is one button, not three, so only the element the style starts at is tested — and a pointer wrapper around a real link is dropped, because the link is what gets clicked.
850
+ - **Off-site links are reported, never followed.** A link to another origin is alive by construction, and clicking it would send a request to a third party on your behalf. `mailto:`, `tel:` and anything with a `download` attribute are left alone too. `javascript:void(0)` *is* clicked — it is the link most likely to be dead.
851
+
852
+ The page is reloaded after any click that changed it, so element five is judged on the page as it shipped rather than on whatever the first four left behind. A click that changed nothing needs no reload, which is exactly the case being looked for: a page full of dead controls is also the fastest to sweep.
853
+
854
+ ```
855
+ Dead-click sweep of http://localhost:3000 — 17 clickable elements found, 14 clicked, 4 dead, 1 broken, 3 not clicked.
856
+
857
+ Dead — nothing at all happened when these were clicked:
858
+ 2. a "Pricing" (#nav-pricing)
859
+ Looks clickable: the cursor is a pointer; its href is "#", so it relies entirely on a handler; hovering it changes nothing.
860
+ 3. button "Save draft" (#save-draft)
861
+ Looks clickable: hovering it changes nothing.
862
+ 4. div "Add to cart" (#card-buy)
863
+ Looks clickable: it is a plain element styled with a pointer cursor — nothing else says it is a control; hovering it changes nothing.
864
+ 5. button "Subscribe" (#subscribe)
865
+ Looks clickable: the cursor is a pointer; hovering it changes background, text colour.
866
+
867
+ Broken — the handler ran and threw:
868
+ 1. button "Export" (#export)
869
+ the handler threw — TypeError: Cannot read properties of undefined (reading 'rows') (at HTMLButtonElement.<anonymous> (http://localhost:3000/app.js:412:19))
870
+
871
+ Marked aria-disabled, but they still work — a screen reader is told these are unavailable, and everyone else
872
+ can use them (they are clicked with Playwright's actionability checks off, which is the only way to reach one):
873
+ button "Publish" (#publish) — it wrote to localStorage or sessionStorage
874
+
875
+ Alive — 8 elements did something:
876
+ button "Show panel" (#show-panel) — 1 DOM change (~ div#panel [class])
877
+ button "Ping the API" (#refresh) — 1 request — GET http://localhost:3000/api/ok → 200
878
+ a "Go to another page" (#next) — went to http://localhost:3000/next; 1 request — GET http://localhost:3000/next → 200
879
+ ...
880
+
881
+ Not clicked (3):
882
+ a "Documentation" (#docs) — links to another site (https://docs.example.com)
883
+ a "Email us" (#contact) — hands the click to mailto
884
+ button "Delete everything" (#danger) — excluded
885
+ ```
886
+
887
+ …followed by the page with every dead element boxed in red and every broken one in orange, numbered to match.
888
+
889
+ The number 4 next to a dead element is worth reading twice: it is a `<div>` that has nothing but a pointer cursor to suggest it is a control, which is the shape a dead click actually takes in a component framework. A `<button>` at least has a default behaviour to fall back on; a `<div>` whose `@click` never bound has nothing.
890
+
891
+ ### `framewatch_links`
892
+
893
+ Check where every link on the page actually goes. 404s, server errors, redirect chains, redirects that land on an error page, hosts that never answer — and the `#fragment` links that point at nothing, which no link checker without a browser can find.
894
+
895
+ | Param | Type | Default | Description |
896
+ | --- | --- | --- | --- |
897
+ | `url` | string (URL) | — | Page to check |
898
+ | `depth` | integer 0–3 | `0` | `0` checks this page only; `1` also opens each internal page it links to and checks those links, and so on |
899
+ | `check_external` | boolean | `true` | Also check links to other origins — a real request to somebody else's server |
900
+ | `include_resources` | boolean | `true` | Also check images, scripts, stylesheets, iframes and media |
901
+ | `check_fragments` | boolean | `true` | Check that a `#pricing` link points at an element that exists |
902
+ | `timeout_ms` | integer 100–30000 | `5000` | How long one link has to answer |
903
+ | `concurrency` | integer 1–10 | `5` | How many to check at once. Lower it if a host starts answering 429 |
904
+ | `max_links` | integer 1–500 | `200` | Distinct addresses to check. Links are spent before resources |
905
+ | `max_pages` | integer 1–25 | `10` | Pages to open when `depth` > 0, counting the one you named |
906
+ | `selector` | string | whole page | Only collect links inside this container |
907
+ | `wait_ms` | integer ≥ 0 | `1000` | Settle time after each load, so a client-rendered app can finish |
908
+ | `wait_for` | string | — | CSS selector to wait for (visible) after each load |
909
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
910
+ | `full_page` | boolean | `false` | Photograph the whole document, so failing links below the fold are in the picture |
911
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
912
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
913
+
914
+ **Example call:**
915
+
916
+ ```json
917
+ { "url": "http://localhost:3000" }
918
+ ```
919
+ ```json
920
+ { "url": "http://localhost:3000", "depth": 1, "check_external": false, "selector": "footer" }
921
+ ```
922
+
923
+ **A HEAD is not the test, it is the first guess.** A great many servers — CDNs, WAFs, a fair number of frameworks — answer `405` or `404` to a HEAD and serve the very same URL perfectly to a GET. So anything that comes back an error is asked for again as a GET before it is called broken. That second request is only ever paid on links that were about to be reported.
924
+
925
+ **A refusal is not a 404.** `401`, `403`, `429` and the non-standard `999` all come back on links that work perfectly for a person with a browser. They get their own section, because a report that cries wolf on a third of your outbound links is a report you stop reading.
926
+
927
+ **The chain is the finding.** Redirects are followed one hop at a time, so a link that arrives through four hops is reported as working *and* worth fixing, a link that redirects into a `404` is told apart from a plain `404`, and a loop is told apart from both.
928
+
929
+ **The things the page already loaded are not loaded again.** By the time anything is checked, Chromium has fetched the stylesheets, scripts and images itself — and its answer is better than a second request would be, because it is what actually happened. Re-requesting them would double the load on the app under test and could disagree with the browser, which is the verdict that counts.
930
+
931
+ **And `#pricing` is checked against the document.** A fragment that matches no element is a broken link nothing over HTTP will ever catch: the request succeeds, the page loads, and the visitor simply does not arrive. `#` and `#top` always resolve, and an old-style `<a name>` counts as well as an `id`.
932
+
933
+ ```
934
+ Link check of http://localhost:3000 — 34 links found on 1 page (27 unique), 6 broken, 1 timed out, 2 unreachable, 1 dead fragment, 2 redirected, 2 blocked, 7 working, 7 not checked.
935
+ 4 same-page fragments resolved against the document rather than over HTTP.
936
+
937
+ Broken (6):
938
+ http://localhost:3000/pricing-old — 404 Not Found
939
+ from a "Pricing" (#nav-pricing)
940
+ → Point the link somewhere that exists, or restore the page.
941
+ http://localhost:3000/api/report — 500 Internal Server Error
942
+ from a "Download the report" (#report)
943
+ → This link answers an error to every visitor.
944
+ http://localhost:3000/go/docs — 404 Not Found — after 1 redirect, ending at http://localhost:3000/docs/v1
945
+ from a "Documentation" (#docs)
946
+ → Point the link somewhere that exists, or restore the page.
947
+ http://localhost:3000/img/hero@2x.png — 404 Not Found (seen when the page loaded it)
948
+ from img "Our team at work" (#hero)
949
+ → Point the link somewhere that exists, or restore the page.
950
+
951
+ Timed out (1):
952
+ https://status.example.com/uptime — no answer within the timeout
953
+ from a "Status" (footer > nav > a:nth-of-type(3))
954
+ → Raise `timeout_ms` if the host is simply slow; a link nobody's browser will wait for is broken in practice.
955
+
956
+ Could not be reached (2):
957
+ http://localhost:3000/loop — the redirects loop — http://localhost:3000/loop is visited twice
958
+ from a "Account" (#account)
959
+ → Follow the chain by hand: a redirect loop is a page nobody can reach.
960
+ https://blog.exmaple.com/post — the host name does not resolve
961
+ from a "Read the post" (#blog)
962
+ → Check the domain for a typo, and that it has not expired.
963
+
964
+ Fragments that point at nothing (1):
965
+ #pricing-table — no element on the page has that id, and no <a name> either
966
+ from a "See the plans" (#see-plans)
967
+
968
+ Redirected (2):
969
+ http://localhost:3000/blog — redirected to http://localhost:3000/blog/ (1 hop), which answered 200 OK
970
+ from a "Blog" (#nav-blog)
971
+ http://example.com/terms — redirected to https://example.com/legal/terms (3 hops), which answered 200 OK
972
+ from a "Terms" (#terms)
973
+ → Link straight to the final address — every hop in the chain is another round trip.
974
+
975
+ Blocked — the server refused the check, not necessarily the link (2):
976
+ https://www.linkedin.com/company/example — 999 — a non-standard status some sites return to automated checks — not a broken link
977
+ from a "LinkedIn" (#social-in)
978
+ https://app.example.com/dashboard — 401 Unauthorized — the server wants credentials before it will answer — the link is likely fine for a signed-in visitor
979
+ from a "Dashboard" (#dashboard)
980
+
981
+ Not checked (7):
982
+ mailto: — a mailto: with no address after it
983
+ from a "Email us" (#contact)
984
+ http:// — "http://" is not a URL a browser can resolve
985
+ from a "Partners" (#partners)
986
+ (an empty href) — an empty href reloads the current page
987
+ from a "Careers" (#careers)
988
+ ...
989
+
990
+ Working (7):
991
+ http://localhost:3000/about — 200 OK
992
+ http://localhost:3000/app.js — 200 OK (seen when the page loaded it)
993
+ ...
994
+ ```
995
+
996
+ …followed by the page with every failing link boxed in red and every dead fragment in orange, numbered to match.
997
+
998
+ The first three entries under **Not checked** are the ones worth reading: none of them is a network problem, and all three ship. A `mailto:` with nothing after it, an `href="http://"` somebody meant to finish, and an empty `href` that quietly reloads the page — a checker that only makes requests never sees any of them.
999
+
1000
+ ### `framewatch_api_mock`
1001
+
1002
+ Answer the page's API calls yourself and record what it does with the answer. The states you cannot reach on real data — an empty list, a 500, a 401, a response that takes five seconds, a body that is not valid JSON, a request that just fails — are one word each.
1003
+
1004
+ It takes everything [`framewatch_capture`](#framewatch_capture) takes, and returns the same diff cards. The only difference is what the network says back.
1005
+
1006
+ | Param | Type | Default | Description |
1007
+ | --- | --- | --- | --- |
1008
+ | `url` | string (URL) | — | Page to record |
1009
+ | `mocks` | array (1–20) | — | What to intercept and what to answer. See below |
1010
+ | `block_unmatched` | boolean | `false` | Fail every request no mock matched instead of letting it reach the real server |
1011
+ | `duration_ms` | integer 500–30000 | `5000` | How long to record |
1012
+ | `interactions` | array | — | Steps to replay while recording — same script as `framewatch_capture` |
1013
+ | `sensitivity` | number 0–1 | `0.06` | Frame change threshold |
1014
+ | `max_frames` | integer 1–30 | `20` | Maximum diff cards |
1015
+ | `wait_for` | string | — | CSS selector to wait for (visible) before recording starts |
1016
+ | `include_network` | boolean | `true` | Attach the requests to the frames they settled between |
1017
+ | `include_console` | boolean | `true` | Attach console output and uncaught errors |
1018
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
1019
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
1020
+
1021
+ Each entry in `mocks`:
1022
+
1023
+ | Field | Type | Description |
1024
+ | --- | --- | --- |
1025
+ | `url_pattern` | string | Glob matched against the **whole URL**, e.g. `**/api/products*` |
1026
+ | `scenario` | enum | `empty` (200, `[]`) · `error` (500) · `unauthorized` (401) · `not_found` (404) · `slow` (200 after 5s) · `malformed` (200, body that is not JSON) · `offline` (the request fails) |
1027
+ | `response` | object | `status`, `body`, `delay_ms`, `headers` — each one overrides the scenario |
1028
+ | `abort` | enum | `failed` · `timedout` · `connectionrefused` · `internetdisconnected` |
1029
+ | `times` | integer ≥ 1 | Only the first N matching requests; the rest fall through |
1030
+
1031
+ **Example calls:**
1032
+
1033
+ ```json
1034
+ { "url": "http://localhost:3000/products",
1035
+ "mocks": [{ "url_pattern": "**/api/products*", "scenario": "empty" }] }
1036
+ ```
1037
+ ```json
1038
+ { "url": "http://localhost:3000/products",
1039
+ "mocks": [{ "url_pattern": "**/api/products*", "scenario": "slow",
1040
+ "response": { "body": { "items": [{ "name": "Kombucha" }] } } }],
1041
+ "duration_ms": 8000 }
1042
+ ```
1043
+ ```json
1044
+ { "url": "http://localhost:3000",
1045
+ "mocks": [{ "url_pattern": "**/api/checkout", "scenario": "error", "times": 1 }],
1046
+ "interactions": [{ "action": "click", "selector": "#pay", "delay_ms": 500 },
1047
+ { "action": "click", "selector": "#pay", "delay_ms": 2000 }] }
1048
+ ```
1049
+
1050
+ ```
1051
+ Captured 2 meaningful frames from 16 raw frames (1512ms recording) of http://localhost:3000/products — "Products"
1052
+ API mocks — 1 declared, 1 served 1 request.
1053
+ ✓ **/api/products* → 200 empty ×1 (http://localhost:3000/api/products)
1054
+ Every request the page made was matched by a mock.
1055
+ Context — console: 2 entries; network: 2 requests
1056
+ Frame 1 @ 24ms [initial]
1057
+ Console:
1058
+ [log] products: loading
1059
+ [log] products: empty
1060
+ Network:
1061
+ GET http://localhost:3000/products → 200 (8ms)
1062
+ GET http://localhost:3000/api/products → 200 (2ms)
1063
+ ```
1064
+
1065
+ **The mock that matched nothing is the finding.** Mock `**/api/products*`, watch the app call `/api/product-list`, and every screenshot comes back looking perfect — because the page ran on real data and the test proved nothing. It is the only way this tool fails silently, so the tally leads the report and a zero is printed rather than omitted. When the pattern itself looks like the reason, it says so:
1066
+
1067
+ ```
1068
+ API mocks — 2 declared, 0 served 0 requests, 2 never matched.
1069
+ ✗ /api/products — no request matched it (patterns are matched against the whole URL, so a path needs a leading `**` — try `**/api/products*`).
1070
+ ✗ **/api/avatar* — no request matched it.
1071
+ Unmatched, answered by the real server:
1072
+ GET http://localhost:3000/api/products → 200
1073
+ ```
1074
+
1075
+ **What you did not mock still gets named.** Mocking one endpoint of a running app is the normal case, so everything else reaches the real server — but a run that quietly used the real backend for half its data is not the test you thought you ran. `block_unmatched: true` cuts them all off instead, which is how you ask "what does this page do with no backend at all".
1076
+
1077
+ **A string body is sent exactly as written.** `JSON.stringify("not json")` is `"not json"`, which parses perfectly — so if strings were encoded, an API that returns broken JSON would be impossible to simulate. That is the whole `malformed` scenario, and it is the failure your error boundary has probably never seen. Objects and arrays are JSON as you would expect.
1078
+
1079
+ **The page itself is never mocked.** A `**/*` pattern is a reasonable thing to write, and if the main document went through the mock there would be nothing left to photograph. The navigation is how the page got there, not something the page asked for.
1080
+
1081
+ **The first mock listed wins.** When two patterns match the same request, the one you wrote first gets it — which is what lets a narrow mock sit above a broad one. A mock with `times` falls through to the next match once it is spent, and then to the real server, so "the first call fails, the retry works" is a single call.
1082
+
1083
+ **And a delay that outlives the recording is called out**, rather than leaving you with a spinner that never resolves and no way to tell that from a rendering bug:
1084
+
1085
+ ```
1086
+ ! **/api/products* → 200 after 9000ms ×1 — delayed 9000ms, longer than the 1500ms recording, so the request never got an answer.
1087
+ ```
1088
+
1089
+ ### `framewatch_rtl`
1090
+
1091
+ Load the page twice — once as it ships, once flipped — measure every element in both, and report what failed to mirror.
1092
+
1093
+ | Param | Type | Default | Description |
1094
+ | --- | --- | --- | --- |
1095
+ | `url` | string | — | The page to test |
1096
+ | `rtl_trigger` | object | `{ type: "attribute", attr: "dir", value: "rtl", target: "html" }` | How this app switches direction. Also `{ type: "class", class }`, `{ type: "locale", locale }`, `{ type: "url", rtl_url }` |
1097
+ | `inject_arabic` | boolean | `true` | Replace visible text with length-matched Arabic, in **both** passes |
1098
+ | `selector` | string | — | Only measure inside this container |
1099
+ | `exclude` | string | — | Never measure this, or anything inside it |
1100
+ | `wait_ms` | number | `1000` | Settle time after each load — paid twice, once per direction |
1101
+ | `wait_for` | string | — | Selector to wait for after each load |
1102
+ | `max_elements` | number | `400` | Elements measured in each direction |
1103
+ | `full_page` | boolean | `false` | Screenshot the whole page rather than the viewport |
1104
+ | `viewport` | object | `1280x720` | |
1105
+ | `storage_state` | string | — | Auth state from `framewatch_save_auth` |
1106
+
1107
+ ```
1108
+ framewatch_rtl({ url: "http://localhost:3000" })
1109
+ ```
1110
+
1111
+ ```
1112
+ Tested http://localhost:3000 in both directions — compared 14 elements, 5 problems, 2 warnings.
1113
+ RTL applied by setting `dir="rtl"` on `html`.
1114
+ Text replaced with Arabic in both passes (12 strings in RTL, 12 in LTR), matched to the original lengths so any overflow found is the layout rather than the substitution.
1115
+
1116
+ Problems — 5 (these are visible to an Arabic reader):
1117
+ 1. div "→" #arrow-next
1118
+ ✗ did not mirror — the box sits at the same place in both directions
1119
+ x=60 in LTR, x=60 in RTL; mirroring a 40px box in a 1280px viewport should put it at x=1180
1120
+ ! looks directional but is drawn the same way in both directions — a 'next' arrow points backwards in RTL
1121
+ no mirroring transform in either direction; `transform: scaleX(-1)` under `[dir=rtl]` flips it
1122
+ 2. div "السابق اشترك في النشرة" #not-mirrored
1123
+ ✗ did not mirror — the box sits at the same place in both directions
1124
+ x=40 in LTR, x=40 in RTL; mirroring a 200px box in a 1280px viewport should put it at x=1040
1125
+ 3. p "خدمة التوصيل متاحة إلى جميع المناطق…" #stays-left
1126
+ ✗ stayed left-aligned in an RTL context — the text hugs the wrong edge
1127
+ text-align is "left" in both directions; use `start` (or `end`) instead of `left`
1128
+ 4. div "مرحبا بك في متجرنا الإلكتروني، السابق تواصل معنا" #overflows-rtl
1129
+ ✗ overflows the left edge of the viewport in RTL — the part that sticks out is cropped, not visible
1130
+ sticks out 300px past the left edge in RTL, and none in LTR
1131
+
1132
+ Warnings — 2 (worth checking, may be deliberate):
1133
+ 5. div #double-reversed #double-reversed
1134
+ ! is `row-reverse` in both directions — RTL reverses it again, so the items end up back to front
1135
+ flex-direction: row-reverse under `dir=rtl` lays the items out left to right
1136
+ 6. div "فريق الدعم جاهز للإجابة عن أسئلتك…" #padding-stuck
1137
+ ! keeps the same physical padding in both directions
1138
+ padding-left 48px / padding-right 0px, unchanged from LTR; `padding-inline-start`/`padding-inline-end` would swap
1139
+
1140
+ By kind: did not mirror 3, directional icon did not flip 1, text stayed left-aligned 1, new overflow in RTL 1, flex row reversed twice 1, physical padding did not swap 1.
1141
+ ```
1142
+
1143
+ Then the LTR screenshot as the page ships, and the RTL screenshot with every finding boxed and numbered to match the list — red for a problem, orange for a warning.
1144
+
1145
+ **Nothing is judged from the RTL rendering alone.** This is what separates it from a linter. `text-align: left` is correct on a code block, a number column and a Latin brand name; `padding-left` is correct on anything that should not mirror. A tool that flags those on sight produces a report whose real findings are never read. So every verdict here is a *comparison*: the only findings are the things that failed to change when the LTR measurement proves they should have. Text that is left-aligned in both directions has forgotten to mirror; text that is left in LTR and right in RTL is `start` doing exactly its job, and nothing is said about it.
1146
+
1147
+ **If the trigger does not match your app, it says so before anything else.** An app that switches direction on a class renders left-to-right no matter what `dir` says — so the tool would measure LTR twice, find every element identical, and report a perfectly clean page. That is worse than an error, because it is a confident wrong answer that looks like good news. The computed direction is read back off the document, and a run that never left LTR leads with it:
1148
+
1149
+ ```
1150
+ ✗ RTL was never applied — the document still computes `direction: ltr` after the trigger ran. Everything
1151
+ below compares the page with itself, so it proves nothing. Check that `rtl_trigger` matches how this app
1152
+ switches direction (an app that switches on a class needs `{ type: "class", class: "…" }`, one with a
1153
+ separate Arabic build needs `{ type: "url", rtl_url: "…" }`).
1154
+ ```
1155
+
1156
+ **The Arabic goes into both passes, not just the flipped one.** The two passes are compared to each other, so they have to differ in exactly one variable. Text that changed in only one of them would change every box's width for reasons of font metrics rather than direction — and the mirroring check, which asks whether a box moved, would end up measuring the typeface. The replacement is matched to the length of what it replaces for the same reason: a button whose label became three characters would shrink, and the overflow it then reported would be an artifact of the substitution rather than a property of the page. Numbers are transliterated (`24.99` → `٢٤.٩٩`) rather than replaced, because a price is still a number in an Arabic interface.
1157
+
1158
+ **Overflow is only reported when it is new in RTL.** Content that already hung off the edge before the flip is a layout bug — `framewatch_responsive` is the tool that finds it — and repeating it here would fill an RTL report with things RTL did not cause.
1159
+
437
1160
  ### `framewatch_start_server` / `framewatch_stop_server`
438
1161
 
439
1162
  Start the app's dev server so the rest of FrameWatch has something to point at, and stop it again.
@@ -519,10 +1242,27 @@ framewatch_capture { "url": "http://localhost:5173/login", "duration_ms":
519
1242
  ~ div#spinner [class]
520
1243
  ~ button#submit [disabled]
521
1244
 
522
- framewatch_interact { "url": "http://localhost:5173/login", "action": "type", "selector": "#email", "value": "test@example.com" }
523
- framewatch_interact { "action": "type", "selector": "#password", "value": "correct-horse" }
524
- framewatch_interact { "action": "click", "selector": "#submit", "wait_ms": 1500 }
525
- click "#submit" on http://localhost:5173/login — 74.2% of the frame changed
1245
+ framewatch_snapshot { "url": "http://localhost:5173/login", "mode": "interactive" }
1246
+ Snapshot of http://localhost:5173/login "Sign in" — viewport 1280x720 — 14 elements, 3 interactive
1247
+ - textbox "Email" [ref=e4]
1248
+ - textbox "Password" [ref=e6]
1249
+ - button "Sign in" [ref=e7]
1250
+
1251
+ framewatch_interact { "action": "type", "ref": "e4", "value": "test@example.com" }
1252
+ framewatch_interact { "action": "type", "ref": "e6", "value": "correct-horse" }
1253
+ framewatch_interact { "action": "click", "ref": "e7", "wait_ms": 1500, "include_snapshot": true }
1254
+ → click e7 on http://localhost:5173/login — 74.2% of the frame changed — viewport 1280x720, images 800px wide (0.63×) — coordinates and regions are in viewport px
1255
+
1256
+ Snapshot — 31 elements, 9 interactive (refs valid until the page changes)
1257
+ - heading "Dashboard" [level=1] [ref=e2]
1258
+
1259
+
1260
+ framewatch_inspect { "targets": ["e2", "e9"] }
1261
+ → Inspected 2 of 2 targets on http://localhost:5173/dashboard — viewport 1280x720, …
1262
+ 1. e2 heading "Dashboard" — <h1>
1263
+ box: 32,24 224x40 (viewport px; centre 144,44) — visible, fully in viewport
1264
+ text: 32px/40px Inter 700 — #111827 on #ffffff — contrast 17.74:1 — passes AAA for large text
1265
+
526
1266
 
527
1267
  framewatch_accessibility { "url": "http://localhost:5173/dashboard", "standard": "wcag21aa" }
528
1268
  framewatch_stop_server {}
@@ -530,7 +1270,7 @@ framewatch_stop_server {}
530
1270
 
531
1271
  Two things worth noticing. The agent did not get 66 screenshots and a guess: it got the seven moments the page actually changed, the 401 that caused frame 5, and the `#spinner` and `#submit` changes that came with it. And the failure landed on an `[animation]` frame rather than an `[interaction]` one — the click is what FrameWatch forces a frame for, but the response arrived 170ms later, and the change detector caught it on its own.
532
1272
 
533
- `framewatch_interact` starts its own session and does not inherit the page `framewatch_capture` used, so its first call carries a `url`; the ones after it do not need one.
1273
+ The agent never guessed a selector: the snapshot named every field, the refs went straight into interact, and the click brought back the next page's refs with it. `framewatch_interact`, `framewatch_snapshot` and `framewatch_inspect` share one session page and do not inherit the page `framewatch_capture` used, so the first of them carries a `url`; the ones after it do not need one.
534
1274
 
535
1275
  ## Troubleshooting
536
1276
 
@@ -544,6 +1284,14 @@ Two things worth noticing. The agent did not get 66 screenshots and a guess: it
544
1284
 
545
1285
  **An accessibility audit found nothing on a page you know is broken.** axe ran before the app rendered. Add `wait_for`, or raise `wait_ms` past the point where the content appears.
546
1286
 
1287
+ **`framewatch_interact` says a ref did not resolve.** Refs are assigned by the last `framewatch_snapshot` and die when the page re-renders. Snapshot again (or pass `include_snapshot: true` on each interact call) and use a ref from the new tree. `framewatch_capture` opens its own page and cannot take a ref at all — use `role=button[name="…"]` there.
1288
+
1289
+ **A result came back with no images, or fewer than expected.** Look for the `Image budget:` line at the end: the result was fitted to `MAX_MCP_OUTPUT_TOKENS` so it would arrive at all. Raise the variable (see [Result size](#result-size-and-max_mcp_output_tokens)), or ask for less — a lower `max_frames`, a `selector` on the snapshot, `include_screenshot: false` where you only need the text.
1290
+
1291
+ **`framewatch_wait_for` times out after every save.** Look at its message. "No Vite dev-server connection" means the page is not served by `vite` (a built preview, a different framework's server): there is no hot update to wait for. "last one landed Nms before this call" means the update arrived before the previous tool call — you looked after saving; look, then save, then wait.
1292
+
1293
+ **The change region and the image disagree.** They do not: the image is shrunk to 800px wide, the numbers are in viewport pixels, and each result's viewport line gives the factor. Multiply image coordinates by 1/factor before clicking with `x`/`y`.
1294
+
547
1295
  **`framewatch_interact` says the page was reopened.** Touch support and the saved auth are both fixed when the browser context is created, so the first `tap` or `swipe` on a page opened without touch — or a `storage_state` the open session was not created with — reopens the page and resets its state. Put the `tap` and the `storage_state` in the first call of the session.
548
1296
 
549
1297
  **`framewatch_start_server` refuses with "port already in use".** Something is already listening — often a dev server from an earlier session. Stop it, or point FrameWatch at it directly and skip `framewatch_start_server` altogether.
@@ -552,6 +1300,8 @@ Two things worth noticing. The agent did not get 66 screenshots and a guess: it
552
1300
 
553
1301
  **A tool that worked yesterday now shows the login screen.** The saved session expired. Re-run `framewatch_save_auth` — nothing refreshes it automatically, by design: the screenshot showing you a login form is a more reliable signal than any heuristic guess.
554
1302
 
1303
+ **Every tool replays the login.** It should not have to: once [`framewatch_save_auth`](#framewatch_save_auth) has written `.framewatch/auth.json`, every page tool picks it up automatically and says so — `Auth: using .framewatch/auth.json (saved 2h ago)`. Pass `storage_state: "none"` to open a page signed out, `FRAMEWATCH_AUTH_STATE=/path` to keep the file somewhere else. When the saved session has expired the result says `did not sign you in — this page shows a login form`; and the moment a login succeeds inside `framewatch_interact`, the result reminds you to save it.
1304
+
555
1305
  **`framewatch_save_auth` saved "no cookies and nothing in storage".** The flow did not actually sign in (add `wait_for` so a failure is reported instead of saved), or the app keeps its session in `sessionStorage` or in memory, neither of which can be saved. For those, drive the login with `framewatch_interact` and keep working in that session.
556
1306
 
557
1307
  ## Development
@@ -565,7 +1315,7 @@ npm run coverage # vitest with v8 coverage
565
1315
  npm run dev # tsc --watch
566
1316
  ```
567
1317
 
568
- Tests launch real Chromium against local fixture pages in `test/fixtures/`, served over HTTP by `test/helpers/fixture-server.ts` (which also answers the `/api/*` endpoints the fixtures call, including one request that is deliberately never answered):
1318
+ Tests launch real Chromium against local fixture pages in `test/fixtures/`, served over HTTP by `test/helpers/fixture-server.ts` (which also answers the `/api/*` endpoints the fixtures call including one request that is deliberately never answered, and `/api/image`, which generates a PNG of any size so the repository needs no binary assets):
569
1319
 
570
1320
  | Fixture | What it is for |
571
1321
  | --- | --- |
@@ -580,6 +1330,19 @@ Tests launch real Chromium against local fixture pages in `test/fixtures/`, serv
580
1330
  | `a11y-good.html`, `a11y-bad.html`, `a11y-frame.html`, `a11y-late.html`, `csp.html` | Clean, broken, broken-inside-an-iframe, broken-after-render, and behind a strict CSP |
581
1331
  | `compare-a.html` / `compare-b.html` | Two pages that differ in one region |
582
1332
  | `gate.html` | A gate that opens only for a cookie **and** a localStorage token — saving and restoring auth state |
1333
+ | `form.html` | Every field type worth filling, five that nothing can fill, JS validation, and one input the page truncates |
1334
+ | `form-multi.html`, `form-plain.html` | Two forms on one page (one with no submit button), and a page with no `<form>` element |
1335
+ | `form-xss.html`, `form-gated.html` | Input echoed back as markup, and a form that only exists once signed in |
1336
+ | `seo-good.html`, `seo-bad.html` | A page a search engine has nothing to complain about, and one with a fault in every category |
1337
+ | `seo-blocked.html`, `robots.txt`, `private/briefing.html` | A perfect page that robots.txt forbids, and one path allowed back for Googlebot alone |
1338
+ | `seo-og.html`, `seo-spa.html` | Every kind of `og:image` there is (`?card=`), and a page that writes its own SEO after load |
1339
+ | `dead-clicks.html` | One live control per channel (DOM, network, storage, scroll, field, title, navigation), four dead ones, one that throws, three that must never be clicked |
1340
+ | `dead-clicks-noisy.html` | A page that rewrites itself and polls the server with nobody touching it |
1341
+ | `dead-clicks-shapes.html` | What counts as clickable: inherited pointer cursors, wrappers around links, elements with no ids |
1342
+ | `links.html` | One of every kind of link: broken, redirected, looping, refused, timing out, unfetchable, and a fragment that points at nothing |
1343
+ | `links-crawl.html`, `links-crawl-b.html` | Two pages with a broken link each, and a link back — a crawl that has to notice where it has been |
1344
+ | `links-spa.html` | A page whose links are written after load |
1345
+ | `highlight.html` | A positioned `<body>` with a margin — where an overlay's coordinates go wrong |
583
1346
 
584
1347
  The dev-server tests drive `test/helpers/fake-dev-server.mjs`, a stand-in that can be told to start slowly, never open its port, exit with an error, or ignore SIGTERM. `test/packaging.test.ts` packs the tarball, unpacks it and speaks MCP to the binary inside, which is the closest thing to testing `npx framewatch-mcp-server` without publishing. The stdio integration test screenshots `https://example.com`, so that one needs network access.
585
1348
 
@@ -601,20 +1364,46 @@ MCP client (Claude Code) ◄─stdio─► FrameWatch server ◄──► Playwr
601
1364
  - `src/engine/browser.ts` — one shared Chromium: a fresh context per tool call, plus the long-lived page `framewatch_interact` works on
602
1365
  - `src/engine/recorder.ts` — captures raw PNG frames at a fixed interval (plus forced frames on navigation)
603
1366
  - `src/engine/differ.ts` — grid-based frame selection, pixel-level change regions, crops
1367
+ - `src/engine/forms.ts` — finds the forms on a page, fills them, submits them, and reads back what the page said
1368
+ - `src/engine/seo.ts` — reads the SEO-relevant parts of the rendered document, fetches robots.txt, measures the share image
1369
+ - `src/engine/clicks.ts` — finds what looks clickable, watches what a click does, and decides whether anything happened (the verdict is pure, so it is unit-tested without a browser)
1370
+ - `src/engine/links.ts` — collects every address the rendered page refers to, records what the browser already loaded, and checks the rest one redirect hop at a time
1371
+ - `src/engine/mocks.ts` — installs the routes a mock run needs, answers what they match, and keeps the tally of what each one served
604
1372
  - `src/engine/interaction.ts` — validates and executes one interaction step (click, tap, type, key, scroll, swipe, hover, select, navigate)
1373
+ - `src/engine/snapshot.ts` — the aria tree with refs, on the session page, scoped, filtered and cut
1374
+ - `src/engine/inspect.ts` — one element's box, type, colours, spacing and neighbours read in-page; the page's design inventory
1375
+ - `src/engine/vue.ts` — what a Vue app leaves in the DOM: detection, readiness, the component behind an element, the tree, router navigation
1376
+ - `src/engine/hmr.ts` — the Vite watcher on the session page, and "since the last look"
605
1377
  - `src/engine/layers/` — the context layers: `console.ts`, `network.ts` (Playwright events), `dom.ts`, `performance.ts` (injected observers), `probe.ts` (the injection plumbing they share), `session.ts` (layers for the long-lived interact page), and `index.ts` (attach, drain, split across cards)
606
1378
  - `src/utils/image.ts` — sharp wrappers, including the compare overlay
1379
+ - `src/utils/highlight.ts` — labelled boxes drawn over elements (or over a measured box) through the CSSOM, so they survive a strict CSP
1380
+ - `src/utils/snapshot-rules.ts` — what a snapshot line is: counting, the interactive filter, truncation, refs (pure)
1381
+ - `src/utils/style-rules.ts` — what a measurement means: colour maths, WCAG contrast, alignment wording, every line inspect prints (pure)
1382
+ - `src/utils/vue-rules.ts` — what a Vite console line means, router-or-load for a `navigate`, the component wording (pure)
607
1383
  - `src/utils/bounded-log.ts` — capped log that evicts ordinary entries to keep errors
608
1384
  - `src/utils/server-process.ts` — dev server process manager (spawn, port readiness, process-group kill)
609
1385
  - `src/utils/storage-state.ts` — reads and writes the saved auth state file, and the `storage_state` input every page tool shares
1386
+ - `src/utils/test-data.ts` — what to type: one value per field per form-test strategy
1387
+ - `src/utils/seo-rules.ts` — what counts as an SEO problem: robots.txt matching, structured-data expectations, every verdict (pure, so it is unit-tested without a browser)
1388
+ - `src/utils/link-rules.ts` — what an href is, and what one HTTP answer means (pure, so it is unit-tested without a socket)
1389
+ - `src/utils/mock-rules.ts` — what a mock scenario expands to, how a body is encoded, which mock wins, and how one run reads (pure, so it is unit-tested without a browser)
610
1390
  - `src/utils/format.ts` — turns diff cards into MCP content blocks
1391
+ - `src/utils/budget.ts` — the image budget: cheapest encoding, then crops, size and frames, to fit `MAX_MCP_OUTPUT_TOKENS`; applied to every tool result by `tools/index.ts`
611
1392
  - `src/tools/screenshot.ts` — the screenshot tool
612
1393
  - `src/tools/capture.ts` — the capture tool, including interaction replay
613
- - `src/tools/interact.ts` — the single-interaction tool
1394
+ - `src/tools/interact.ts` — the single-interaction tool, by selector or by snapshot ref
1395
+ - `src/tools/snapshot.ts` — the page as a tree of named elements
1396
+ - `src/tools/inspect.ts` — how elements are built; the design inventory
1397
+ - `src/tools/wait-for.ts` — wait for a hot update, a mounted Vue app, a selector or a quiet network, then look
614
1398
  - `src/tools/responsive.ts` — multi-viewport capture with the overflow check
615
1399
  - `src/tools/accessibility.ts` — the axe-core audit
616
1400
  - `src/tools/compare.ts` — before/after comparison and the diff overlay
617
1401
  - `src/tools/save-auth.ts` — runs a login flow once and saves the browser state it produced
1402
+ - `src/tools/form-test.ts` — the form tool: one strategy per page, and what each one produced
1403
+ - `src/tools/seo.ts` — the SEO audit: gathers, judges, prints
1404
+ - `src/tools/dead-clicks.ts` — the dead-click sweep: clicks everything, subtracts the page's own noise, marks up the result
1405
+ - `src/tools/links.ts` — the link check: collect, check, crawl, group by what went wrong
1406
+ - `src/tools/api-mock.ts` — the API mock tool: resolves the mocks, runs a capture behind them, reports what each one did
618
1407
  - `src/tools/server.ts` — the two dev-server tools
619
1408
 
620
1409
  All diagnostics go to stderr; stdout is reserved for the MCP protocol.