framewatch-mcp-server 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +872 -30
  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 +7 -2
  20. package/dist/engine/interaction.js +22 -7
  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 +84 -25
  48. package/dist/tools/capture.js +106 -62
  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 +13 -3
  95. package/dist/utils/format.js +29 -6
  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 +35 -0
  113. package/dist/utils/snapshot-rules.js +117 -0
  114. package/dist/utils/snapshot-rules.js.map +1 -0
  115. package/dist/utils/storage-state.d.ts +44 -3
  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 +235 -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 +7 -5
package/README.md CHANGED
@@ -1,47 +1,128 @@
1
1
  # FrameWatch MCP Server
2
2
 
3
- > An MCP server that gives AI coding agents visual eyes — smart frame capture, diffing, and interaction replay for web apps.
3
+ **[framewatch.keko.dev](https://framewatch.keko.dev)** · [npm](https://www.npmjs.com/package/framewatch-mcp-server) · MIT
4
4
 
5
- An AI coding agent can write a CSS transition but not watch it run. It can read the DOM but not see that the modal opened behind the header. FrameWatch closes that gap: it launches a real (headless Chromium) browser and returns what it sees as images an MCP client such as Claude Code looks at natively — alongside the console output, network requests, DOM mutations and paint timings from the same moment.
5
+ > Give your AI coding agent eyes. FrameWatch opens your web app in a real browser and shows Claude Code what it looks like, what changed, and why.
6
6
 
7
- The problem with screenshots as a debugging tool is volume. A five-second recording at 10fps is fifty near-identical images, and fifty images is a flooded context window. So FrameWatch records everything and returns almost none of it: only the frames where something meaningful actually changed, each cropped to the region that changed, each carrying the context that explains why.
7
+ You ask Claude Code to fix the login page. It edits the code and says "done". Was it? Without FrameWatch, neither of you knows until you open the browser yourself. With it, Claude Code opens the page, sees the result, reads the console, checks the spacing and the colours, clicks the button, and fixes what it got wrong before it tells you it is done.
8
+
9
+ FrameWatch is an [MCP](https://modelcontextprotocol.io) server. Once registered, Claude Code gets 18 tools it calls on its own: screenshots, recordings that keep only the frames where something changed, a way to name and click every element on a page, measurements of how each element is built, form and link and accessibility checks, and a Vue-aware wait that returns the moment your dev server has hot-reloaded your edit.
10
+
11
+ ## Quick start
12
+
13
+ **1. Register it with Claude Code** (one command, no install step):
14
+
15
+ ```bash
16
+ claude mcp add framewatch npx framewatch-mcp-server
17
+ ```
18
+
19
+ **2. Let it see full results.** Claude Code caps a tool result at 25,000 tokens and counts images toward it, which holds about two screenshots of a real page. Raise it once, in the shell you start Claude Code from:
20
+
21
+ ```bash
22
+ export MAX_MCP_OUTPUT_TOKENS=100000
23
+ ```
24
+
25
+ Add that line to your `~/.zshrc` or `~/.bashrc` to make it permanent.
26
+
27
+ **3. Start your app and ask.** Run your dev server as usual, open Claude Code in the project, and talk to it normally:
28
+
29
+ > Open http://localhost:5173 and tell me what you see.
30
+
31
+ > The header looks wrong on mobile. Check it at phone width and fix it.
32
+
33
+ > Test the login flow with test@example.com / password123 and show me what happens.
34
+
35
+ > I just changed the button styles — show me the page after the hot reload and check the contrast.
36
+
37
+ Claude Code picks the tools itself. You never call them by name, though you can: "use framewatch_dead_clicks on the settings page" works too.
38
+
39
+ **4. Sign in once, if your app has a login.** Ask Claude Code to log in and save the session:
40
+
41
+ > Log in at http://localhost:5173/login with my test account and save the session.
42
+
43
+ It runs `framewatch_save_auth`, which writes `.framewatch/auth.json`. From then on every tool opens pages already signed in, and says so.
44
+
45
+ That is the whole setup. The first tool call downloads Chromium if Playwright has not already; if that fails, run `npx playwright install chromium` once.
46
+
47
+ ### Other MCP clients
48
+
49
+ FrameWatch is a standard MCP server over stdio, so every agent that can run one takes the same command: `npx -y framewatch-mcp-server`. Where it goes:
50
+
51
+ | Agent | Where | Shape |
52
+ | --- | --- | --- |
53
+ | Claude Code | `claude mcp add framewatch -- npx -y framewatch-mcp-server` | one command; add `--scope user` for every project |
54
+ | Cursor | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` | `mcpServers` |
55
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
56
+ | VS Code (Copilot agent mode) | `.vscode/mcp.json` | `servers`, with `"type": "stdio"` |
57
+ | Codex CLI | `~/.codex/config.toml` | `[mcp_servers.framewatch]` |
58
+ | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
59
+ | Claude Desktop | `claude_desktop_config.json` | `mcpServers` |
60
+ | Cline | `cline_mcp_settings.json` via MCP Servers, Configure | `mcpServers` |
61
+ | Continue | `~/.continue/config.yaml` | `mcpServers:` list |
62
+ | Zed | `settings.json` | `context_servers`, `"source": "custom"` |
63
+ | JetBrains AI Assistant | Settings, Tools, AI Assistant, MCP, Add | `mcpServers` |
64
+
65
+ The `mcpServers` shape most of them share:
66
+
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "framewatch": { "command": "npx", "args": ["-y", "framewatch-mcp-server"] }
71
+ }
72
+ }
73
+ ```
74
+
75
+ The `MAX_MCP_OUTPUT_TOKENS` cap is specific to Claude Code; other clients have their own limits or none. The exact snippet for each agent, ready to paste, is on [framewatch.keko.dev](https://framewatch.keko.dev/#install).
76
+
77
+ ### From a checkout
78
+
79
+ ```bash
80
+ git clone https://github.com/kekoDev/framewatch.git
81
+ cd framewatch && npm install && npm run build
82
+ claude mcp add framewatch node "$PWD/dist/index.js"
83
+ ```
84
+
85
+ ### Requirements
86
+
87
+ - **Node 20.9 or newer.**
88
+ - **Chromium**, fetched by Playwright on install. If a tool ever reports the browser is missing, it prints the one command that fixes it.
89
+ - Nothing listens on a port and nothing phones home: FrameWatch only ever talks to the URLs it is given.
90
+
91
+ ## What Claude Code can do with it
8
92
 
9
93
  | Tool | Use it for |
10
94
  | --- | --- |
11
95
  | [`framewatch_screenshot`](#framewatch_screenshot) | What does this page look like right now? |
12
96
  | [`framewatch_capture`](#framewatch_capture) | What happens over the next few seconds — animations, loading, a replayed user flow? |
13
97
  | [`framewatch_interact`](#framewatch_interact) | Click this, then let me look; then click the next thing. |
98
+ | [`framewatch_snapshot`](#framewatch_snapshot) | What is on this page, and what do I call each thing? Refs to act on, instead of guessed selectors. |
99
+ | [`framewatch_inspect`](#framewatch_inspect) | Is this element built the way I meant — box, font, colours and contrast, spacing, alignment? |
100
+ | [`framewatch_wait_for`](#framewatch_wait_for) | I just saved a file — show me the page the moment Vite has patched it. |
14
101
  | [`framewatch_responsive`](#framewatch_responsive) | Does it hold up at phone, tablet and desktop widths? |
15
102
  | [`framewatch_accessibility`](#framewatch_accessibility) | What would an accessibility auditor flag? |
16
103
  | [`framewatch_compare`](#framewatch_compare) | What changed between before and after? |
104
+ | [`framewatch_form_test`](#framewatch_form_test) | What does this form do with empty, huge, Arabic or hostile input? |
105
+ | [`framewatch_seo`](#framewatch_seo) | What do a search engine and a link preview make of this page? |
106
+ | [`framewatch_dead_clicks`](#framewatch_dead_clicks) | Which of these buttons and links do nothing when clicked? |
107
+ | [`framewatch_links`](#framewatch_links) | Where does every link on this page actually go — and which ones are broken? |
108
+ | [`framewatch_api_mock`](#framewatch_api_mock) | What does this page do with an empty list, a 500, or an API that takes five seconds? |
109
+ | [`framewatch_rtl`](#framewatch_rtl) | Does this page survive being flipped for Arabic, Hebrew or Persian? |
17
110
  | [`framewatch_save_auth`](#framewatch_save_auth) | Sign in once, so every other tool starts past the login. |
18
111
  | [`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
112
 
20
- ## Install
113
+ Why not just screenshots? Volume. A five-second recording at 10fps is fifty near-identical images, and fifty images is a flooded context window. FrameWatch records everything and returns almost none of it: only the frames where something meaningful changed, each cropped to the region that changed, each carrying the console output, network requests and DOM changes from the same moment — the context that explains why.
21
114
 
22
- ```bash
23
- # Run directly
24
- npx framewatch-mcp-server
115
+ ## Why `MAX_MCP_OUTPUT_TOKENS` matters
25
116
 
26
- # Register with Claude Code
27
- claude mcp add framewatch npx framewatch-mcp-server
28
- ```
117
+ 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.
29
118
 
30
- From a checkout:
119
+ 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:
31
120
 
32
- ```bash
33
- npm install
34
- npm run build
35
- claude mcp add framewatch node /absolute/path/to/framewatch/dist/index.js
121
+ ```
122
+ 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.
36
123
  ```
37
124
 
38
- ### Requirements
39
-
40
- - **Node 20.9+**.
41
- - **Chromium**, via Playwright. `npm install` normally fetches it; if it did not, run `npx playwright install chromium` once. Every tool detects a missing browser and answers with that exact command rather than a stack trace.
42
- - **No network access needed** beyond whatever the pages under test require — FrameWatch only ever talks to the URLs it is given.
43
-
44
- FrameWatch speaks stdio only, and the client spawns it: nothing listens on a port, and there is nothing to configure beyond the line above.
125
+ The default cap holds one or two frames of a real page, which is why step 2 of the quick start raises it. The server reads the same variable and sizes its budget to it.
45
126
 
46
127
  ## Tools
47
128
 
@@ -209,6 +290,7 @@ Perform **one** interaction and see what it did: before frame, after frame, and
209
290
  | --- | --- | --- | --- |
210
291
  | `action` | enum | — | `click`, `tap`, `type`, `key`, `scroll`, `swipe`, `navigate`, `select`, `hover` |
211
292
  | `selector` | string | — | CSS selector for the target |
293
+ | `ref` | string | — | Element ref from [`framewatch_snapshot`](#framewatch_snapshot), e.g. `e8` — instead of `selector` |
212
294
  | `value` | string | — | Text to type (`\n` presses Enter, `\t` Tab), key to press, option to select, or URL to navigate to |
213
295
  | `x`, `y` | number | — | Coordinates for `click`/`tap`/`swipe` when no selector is given |
214
296
  | `delta_x`, `delta_y` | number | — | Distance for `scroll` / `swipe` |
@@ -220,8 +302,11 @@ Perform **one** interaction and see what it did: before frame, after frame, and
220
302
  | `include_network` | boolean | `false` | Report network requests the action caused |
221
303
  | `include_dom` | boolean | `false` | Report the DOM mutations the action caused |
222
304
  | `include_performance` | boolean | `false` | Report paint timing and layout shifts around the action |
305
+ | `include_snapshot` | boolean | `false` | Append a [snapshot](#framewatch_snapshot) of the page after the action, with fresh refs for the next call |
223
306
  | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth), applied when the session page is opened |
224
307
 
308
+ 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.
309
+
225
310
  **Example call** — a session, one call at a time. Only the first needs a `url`:
226
311
 
227
312
  ```json
@@ -234,7 +319,9 @@ Perform **one** interaction and see what it did: before frame, after frame, and
234
319
  { "action": "click", "selector": "button[type=submit]", "wait_ms": 1500, "include_network": true }
235
320
  ```
236
321
 
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.
322
+ 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.
323
+
324
+ 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
325
 
239
326
  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
327
 
@@ -253,6 +340,154 @@ Because the page is reused between calls, the layers are installed on it once an
253
340
 
254
341
  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
342
 
343
+ ### `framewatch_snapshot`
344
+
345
+ 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.
346
+
347
+ | Param | Type | Default | Description |
348
+ | --- | --- | --- | --- |
349
+ | `url` | string (URL) | — | Open this page first. Omit to read the page left open by `framewatch_interact` / `framewatch_inspect`. |
350
+ | `selector` | string | — | Only this container, e.g. `main` or `#checkout` |
351
+ | `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 |
352
+ | `max_chars` | integer | `12000` | Cut the tree past this, on a line boundary, with a note saying how many lines went |
353
+ | `include_screenshot` | boolean | `false` | Also return a screenshot of the page as it was read |
354
+ | `include_components` | boolean | `false` | On a [Vue app](#vue-apps): append the component tree from the root |
355
+ | `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 |
356
+ | `wait_for` | string | — | CSS selector to wait for before reading |
357
+ | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) |
358
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
359
+
360
+ **Example call**
361
+
362
+ ```json
363
+ { "url": "http://localhost:3000/login", "mode": "interactive" }
364
+ ```
365
+
366
+ Returns, for the fixture in `test/fixtures/snapshot.html`:
367
+
368
+ ```
369
+ Snapshot of http://127.0.0.1:53021/snapshot.html — "FrameWatch Snapshot Fixture" — viewport 1280x720 — 16 elements, 7 interactive
370
+ 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"].
371
+
372
+ - textbox "Email" [ref=e6]
373
+ - textbox "Password" [ref=e8]
374
+ - checkbox "Remember me" [checked] [ref=e10]
375
+ - button "Sign in" [ref=e11]
376
+ - link "Pricing" [ref=e13] [cursor=pointer] → /pricing
377
+ - link "Docs" [ref=e14] [cursor=pointer] → /docs
378
+ - generic [ref=e15] [cursor=pointer]: Card
379
+ ```
380
+
381
+ `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"]`.
382
+
383
+ 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"]`.
384
+
385
+ ### `framewatch_inspect`
386
+
387
+ 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.
388
+
389
+ | Param | Type | Default | Description |
390
+ | --- | --- | --- | --- |
391
+ | `url` | string (URL) | — | Open this page first. Omit to measure the page left open by `framewatch_interact` / `framewatch_snapshot`. |
392
+ | `targets` | string[] (≤ 12) | — | What to measure: snapshot refs (`e8`) and/or CSS selectors, in order. **Omit for a design inventory of the page.** |
393
+ | `selector` | string | — | Inventory only: count inside this container |
394
+ | `include_screenshot` | boolean | `true` | With `targets`: a screenshot with each target boxed and numbered |
395
+ | `wait_ms` | integer ≥ 0 | `500` | Settle time after opening `url` |
396
+ | `wait_for` | string | — | CSS selector to wait for before measuring |
397
+ | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) |
398
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
399
+
400
+ **Example call**
401
+
402
+ ```json
403
+ { "url": "http://localhost:3000/", "targets": ["#cta", "#note", "#narrow"] }
404
+ ```
405
+
406
+ Returns, for `test/fixtures/inspect.html`, five lines per target and one screenshot:
407
+
408
+ ```
409
+ 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
410
+ 1. #cta button "Get started" — <button#cta>
411
+ box: 40,80 101x36 (viewport px; centre 91,98) — visible, fully in viewport
412
+ text: 14px/20px Arial 400 — #ffffff on #3b82f6 — contrast 3.68:1 — fails AA for normal text (needs 4.5:1)
413
+ spacing: padding 8 16; margin 0
414
+ border: none; radius 6px
415
+ layout: block; position absolute; 20px from parent's left, 279px from parent's right; 20px below previous sibling, 20px right of its left edge
416
+ 2. #note paragraph — <p#note>
417
+ box: 40,130 200x20 (viewport px; centre 140,140) — visible, fully in viewport
418
+ text: 13px/20px Arial 400 — #7a7a7b (#00000080 as declared) on #f3f4f6 — contrast 3.90:1 — fails AA for normal text (needs 4.5:1)
419
+ spacing: padding 0; margin 0
420
+ border: none; radius 0px
421
+ layout: block; position absolute; 20px from parent's left, 180px from parent's right; 14px below previous sibling, left-aligned with it
422
+ 3. #narrow div — <div#narrow>
423
+ box: 120,280 60x20 (viewport px; centre 150,290) — visible, fully in viewport, text overflows its box
424
+ text: 16px/24px Arial 400 — #111111 on #f3f4f6 — contrast 17.16:1 — passes AAA for normal text
425
+ spacing: padding 0; margin 0
426
+ border: none; radius 0px
427
+ 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
428
+ ```
429
+
430
+ 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.
431
+
432
+ With no `targets` the tool returns a **design inventory** instead: everything the page is built from, with counts, most-used first.
433
+
434
+ ```
435
+ Design inventory — 15 elements, 6 with text
436
+ fonts (1): Arial ×6
437
+ font sizes (4): 16px ×3, 13px ×1, 14px ×1, 24px ×1
438
+ font weights (2): 400 ×5, 700 ×1
439
+ text colours (4): #111111 ×3, #00000080 ×1, #6b7280 ×1, #ffffff ×1
440
+ backgrounds (6): #d1d5db ×3, #000000 ×1, #10b981 ×1, #3b82f6 ×1, #f3f4f6 ×1, #f59e0b ×1
441
+ spacing (3): 16px ×4, 8px ×2, 12px ×1
442
+ radii (1): 6px ×1
443
+ ```
444
+
445
+ 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.
446
+
447
+ 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:
448
+
449
+ ```
450
+ component: LoginForm (props: title="Welcome back", max=3; state: email="", password="", loading=false, items=[3], submit=fn) in RouterView > App
451
+ ```
452
+
453
+ ### `framewatch_wait_for`
454
+
455
+ 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.
456
+
457
+ | Param | Type | Default | Description |
458
+ | --- | --- | --- | --- |
459
+ | `url` | string (URL) | — | Open this page first. Omit to wait on the page left open by the other session tools. |
460
+ | `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. |
461
+ | `selector` | string | — | For `until: selector` |
462
+ | `timeout_ms` | integer | `10000` | Give up after this long |
463
+ | `include_screenshot` | boolean | `true` | Screenshot once the condition holds |
464
+ | `include_snapshot` | boolean | `false` | Also return a [snapshot](#framewatch_snapshot) with fresh refs |
465
+ | `viewport`, `storage_state` | | | As elsewhere |
466
+
467
+ **Example call** — right after saving `LoginForm.vue`:
468
+
469
+ ```json
470
+ { "until": "hot_update" }
471
+ ```
472
+ ```
473
+ 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
474
+ ```
475
+
476
+ "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.
477
+
478
+ 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`.
479
+
480
+ ### Vue apps
481
+
482
+ 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:
483
+
484
+ - **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)`.
485
+ - **`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.
486
+ - **`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)`.
487
+ - **`framewatch_wait_for` watches Vite** for the hot update your save produces.
488
+
489
+ 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.
490
+
256
491
  ### `framewatch_responsive`
257
492
 
258
493
  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 +606,8 @@ Pass `"current"` as `url_a` to compare against the page `framewatch_interact` ha
371
606
 
372
607
  ### `framewatch_save_auth`
373
608
 
609
+ 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.
610
+
374
611
  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
612
 
376
613
  | Param | Type | Default | Description |
@@ -434,6 +671,545 @@ What is saved is Playwright's storage state: **cookies and localStorage**, per o
434
671
 
435
672
  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
673
 
674
+ ### `framewatch_form_test`
675
+
676
+ 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.
677
+
678
+ | Param | Type | Default | Description |
679
+ | --- | --- | --- | --- |
680
+ | `url` | string (URL) | — | Page with the form on it |
681
+ | `selector` | string | — | One form, or the container holding the fields. Omit to test every `<form>` on the page |
682
+ | `strategies` | array (1–9) | `valid`, `empty`, `special_chars`, `rtl_arabic` | Which kinds of data to try — see below |
683
+ | `submit` | boolean | `false` | Send the form after filling it |
684
+ | `wait_ms` | integer ≥ 0 | `2000` | Settle time after the submit, before the result is read |
685
+ | `wait_for` | string | — | CSS selector to wait for (visible) before the form is looked for |
686
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
687
+ | `timeout_ms` | integer ≥ 1 | `10000` | Max wait for one field to accept its value |
688
+ | `max_fields` | integer 1–60 | `60` | Maximum fields to fill per form |
689
+ | `full_page` | boolean | `false` | Photograph the whole document rather than the viewport — for a form taller than the screen |
690
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
691
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth), for a form behind a login |
692
+
693
+ | Strategy | What goes in every field |
694
+ | --- | --- |
695
+ | `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`, …) |
696
+ | `empty` | Everything cleared, boxes unticked — then submitted |
697
+ | `maxlength` | Filled to the field's own `maxlength`, or 10,000 characters when it declares none |
698
+ | `special_chars` | Quotes, angle brackets, a backslash, an SQL fragment, CJK, Arabic and an emoji |
699
+ | `rtl_arabic` | Arabic text everywhere — RTL layout, bidirectional text, and inputs that only expected Latin |
700
+ | `numbers_only` | Digits in every field, including the ones that expect words |
701
+ | `spaces_only` | Whitespace — input that looks filled in and is empty once trimmed |
702
+ | `boundary` | The edges: one character for text, `min`/`max` for numbers and dates, the last option in a select |
703
+ | `xss` | Harmless XSS payloads that set a marker instead of doing damage |
704
+
705
+ **Example call** — the four defaults without sending anything, then the full sweep including the submit:
706
+
707
+ ```json
708
+ { "url": "http://localhost:3000/signup" }
709
+ ```
710
+ ```json
711
+ {
712
+ "url": "http://localhost:3000/signup",
713
+ "selector": "#signup",
714
+ "strategies": ["valid", "empty", "maxlength", "special_chars", "rtl_arabic", "boundary", "xss"],
715
+ "submit": true,
716
+ "wait_ms": 1500
717
+ }
718
+ ```
719
+
720
+ 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.
721
+
722
+ Each strategy comes back as a screenshot after the fill, a screenshot after the submit, and what the page did about it:
723
+
724
+ ```
725
+ Form test of http://localhost:3000/signup — #signup: 11 fillable fields, 5 not fillable. Ran 1 strategy, filling and
726
+ submitting the form, each in a page of its own.
727
+ Fields: #name (text, required, max 20), #email (email, required), #password (password, required), #age (number),
728
+ #website (url), #code (text), #country (select-one), #bio (textarea), #plan-basic (radio), #plan-pro (radio), … and 1 more
729
+ Not filled: #avatar (file input — a file cannot be chosen from a script), #csrf (hidden input),
730
+ #disabled-field (disabled), #readonly-field (read-only), #invisible-field (not visible)
731
+
732
+ empty — after fill: every field cleared, then submitted — does validation catch it
733
+ 9 of 11 fields filled: #name="", #email="", #password="", #age="", #website="", #code="", #country="", #bio="",
734
+ #terms=unchecked
735
+ Left alone: #plan-basic (a radio button cannot be cleared, only replaced), #plan-pro (a radio button cannot be cleared…)
736
+
737
+ empty — after submit: clicked "#submit", waited 800ms
738
+ Page validation: Please fix 4 problem(s) below., Name is required, Enter a valid email address, Password must be at
739
+ least 8 characters, You must accept the terms
740
+ Fields the page marked invalid: #name, #email, #password, #terms
741
+ Browser validation: #name — Please fill out this field., #email — Please fill out this field., #terms — Please check
742
+ this box if you want to proceed.
743
+ Network: no request was made — the submit never left the page
744
+ Console:
745
+ [error] form validation failed: name, email, password, terms
746
+ ```
747
+
748
+ 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.
749
+
750
+ `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:
751
+
752
+ ```
753
+ rtl_arabic — after fill: Arabic text in every field — catches RTL layout and bidirectional text bugs
754
+ 10 of 11 fields filled: #name="مرحبا بالعالم — اختب", #email="اختبار@example.com", #age="69", #code="مرحبا", …
755
+ Truncated by the page: #code kept 5 of 32 characters
756
+ Browser validation: #email — A part followed by '@' should not contain the symbol 'ا'., #website — Please enter a URL.
757
+ ```
758
+
759
+ **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.
760
+
761
+ The `xss` strategy is the one finding that gets promoted to the top of the response:
762
+
763
+ ```
764
+ Warning — an injected payload executed on this page: input is being written back into the document as markup.
765
+ That is a reflected XSS. The payloads only set a marker; a real one would not be so polite.
766
+ ```
767
+
768
+ 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.
769
+
770
+ 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.
771
+
772
+ ### `framewatch_seo`
773
+
774
+ 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.
775
+
776
+ 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.
777
+
778
+ | Param | Type | Default | Description |
779
+ | --- | --- | --- | --- |
780
+ | `url` | string (URL) | — | Page to audit |
781
+ | `wait_ms` | integer ≥ 0 | `1000` | Wait after load before reading the page, so a client-rendered app can finish |
782
+ | `wait_for` | string | — | CSS selector to wait for (visible) instead of, or as well as, a fixed wait |
783
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
784
+ | `check_robots` | boolean | `true` | Fetch `/robots.txt` and work out whether this page's own path is crawlable |
785
+ | `robots_user_agent` | string | `Googlebot` | Which crawler to answer the robots.txt question for |
786
+ | `check_og_image` | boolean | `true` | Fetch the `og:image` and measure it |
787
+ | `include_performance` | boolean | `false` | Also measure this load: LCP, CLS, TTFB, page weight, DOM size |
788
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
789
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
790
+
791
+ **Example call:**
792
+
793
+ ```json
794
+ { "url": "http://localhost:3000" }
795
+ ```
796
+ ```json
797
+ { "url": "http://localhost:3000/menu", "wait_for": "#app h1", "include_performance": true }
798
+ ```
799
+
800
+ Three of the checks are things the page cannot tell you about itself, and they are the ones that catch the expensive mistakes:
801
+
802
+ - **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.
803
+ - **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.
804
+ - **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.
805
+
806
+ ```
807
+ SEO audit of http://localhost:3000/menu — 0 problems, 0 warnings, 21 checks passed.
808
+ Nothing here would keep this page out of an index or break its share card.
809
+
810
+ Indexing
811
+ ✓ Robots directives — none — indexable by default
812
+ ✓ robots.txt — Googlebot may crawl this path — no rule in the "googlebot" group matches /menu
813
+ · Sitemap — http://localhost:3000/sitemap.xml
814
+ ✓ Canonical — http://localhost:3000/menu
815
+ ✓ Language — <html lang="en">
816
+ ✓ Viewport — "width=device-width, initial-scale=1"
817
+
818
+ Title & description
819
+ ✓ Title — "Keko Food — Order Fresh Food Online" (35 characters)
820
+ ✓ Meta description — "Order fresh food from Keko Food and have it delivered in under thirty minutes, every day of the week." (101 characters)
821
+ · Body — 73 words, 2 links (1 internal, 1 external)
822
+
823
+ Headings
824
+ ✓ H1 — "Welcome to Keko Food"
825
+ ✓ Outline — 1 h1, 2 h2, 2 h3 — no skipped levels
826
+ Outline:
827
+ h1 Welcome to Keko Food
828
+ h2 Menu
829
+ h3 Starters
830
+ h3 Mains
831
+ h2 Delivery
832
+ ...
833
+ Performance (lab)
834
+ ✓ LCP — 40ms — largest element: img (good ≤ 2500ms, poor > 4000ms)
835
+ ✓ CLS — 0 (good ≤ 0.1, poor > 0.25)
836
+ · Page weight — 2 requests, 6KB transferred — img 2× 3KB
837
+ ```
838
+
839
+ 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.
840
+
841
+ A page with something wrong leads with it:
842
+
843
+ ```
844
+ SEO audit of http://localhost:3000/staging — 6 problems, 11 warnings, 1 check passed.
845
+ Problems: noindex (this page tells search engines not to index it (<meta name=robots>: "noindex, nofollow"));
846
+ Meta description (missing); H1 (no <h1>); Alt text (2 of 3 images have no alt attribute: /img/hero.png, #logo);
847
+ JSON-LD block 1 (is not valid JSON); Product (missing required name, image (has description))
848
+
849
+ Indexing
850
+ ✗ noindex — this page tells search engines not to index it (<meta name=robots>: "noindex, nofollow")
851
+ → Remove the noindex if this page is meant to be found — it is the usual reason a live site has no search presence.
852
+ ...
853
+ Structured data
854
+ ✗ JSON-LD block 1 — is not valid JSON — Expected double-quoted property name in JSON at position 89 (line 2 column 89)
855
+ → A block that does not parse is ignored entirely, so the page has that much less structured data than it looks like.
856
+ ✗ Product — missing required name, image (has description)
857
+ → Google drops the whole item when a required property is absent.
858
+ ```
859
+
860
+ **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.
861
+
862
+ **`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.
863
+
864
+ ### `framewatch_dead_clicks`
865
+
866
+ 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.
867
+
868
+ > **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.
869
+
870
+ | Param | Type | Default | Description |
871
+ | --- | --- | --- | --- |
872
+ | `url` | string (URL) | — | Page to sweep |
873
+ | `wait_ms` | integer ≥ 0 | `1000` | Settle time after each load. Paid once per element that did something — the page is reloaded after those |
874
+ | `wait_for` | string | — | CSS selector to wait for (visible) after each load |
875
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
876
+ | `settle_ms` | integer ≥ 0 | `500` | How long to watch the page after each click before deciding nothing happened |
877
+ | `selector` | string | whole page | Only sweep inside this container |
878
+ | `exclude` | string | — | Never click this, or anything inside it |
879
+ | `include_pointer` | boolean | `true` | Also test elements that only a `cursor: pointer` makes clickable |
880
+ | `include_hover` | boolean | `true` | For each dead element, check whether the page reacts to hovering it |
881
+ | `max_elements` | integer 1–100 | `40` | How many to click, in document order |
882
+ | `full_page` | boolean | `false` | Photograph the whole document, so dead elements below the fold are in the picture |
883
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
884
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
885
+
886
+ **Example call:**
887
+
888
+ ```json
889
+ { "url": "http://localhost:3000" }
890
+ ```
891
+ ```json
892
+ { "url": "http://localhost:3000/account", "exclude": "#delete-account, .danger", "settle_ms": 800 }
893
+ ```
894
+
895
+ **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.
896
+
897
+ **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.
898
+
899
+ Three more things it gets right, because getting them wrong makes the answer useless:
900
+
901
+ - **`<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.
902
+ - **`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.
903
+ - **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.
904
+
905
+ 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.
906
+
907
+ ```
908
+ Dead-click sweep of http://localhost:3000 — 17 clickable elements found, 14 clicked, 4 dead, 1 broken, 3 not clicked.
909
+
910
+ Dead — nothing at all happened when these were clicked:
911
+ 2. a "Pricing" (#nav-pricing)
912
+ Looks clickable: the cursor is a pointer; its href is "#", so it relies entirely on a handler; hovering it changes nothing.
913
+ 3. button "Save draft" (#save-draft)
914
+ Looks clickable: hovering it changes nothing.
915
+ 4. div "Add to cart" (#card-buy)
916
+ Looks clickable: it is a plain element styled with a pointer cursor — nothing else says it is a control; hovering it changes nothing.
917
+ 5. button "Subscribe" (#subscribe)
918
+ Looks clickable: the cursor is a pointer; hovering it changes background, text colour.
919
+
920
+ Broken — the handler ran and threw:
921
+ 1. button "Export" (#export)
922
+ the handler threw — TypeError: Cannot read properties of undefined (reading 'rows') (at HTMLButtonElement.<anonymous> (http://localhost:3000/app.js:412:19))
923
+
924
+ Marked aria-disabled, but they still work — a screen reader is told these are unavailable, and everyone else
925
+ can use them (they are clicked with Playwright's actionability checks off, which is the only way to reach one):
926
+ button "Publish" (#publish) — it wrote to localStorage or sessionStorage
927
+
928
+ Alive — 8 elements did something:
929
+ button "Show panel" (#show-panel) — 1 DOM change (~ div#panel [class])
930
+ button "Ping the API" (#refresh) — 1 request — GET http://localhost:3000/api/ok → 200
931
+ a "Go to another page" (#next) — went to http://localhost:3000/next; 1 request — GET http://localhost:3000/next → 200
932
+ ...
933
+
934
+ Not clicked (3):
935
+ a "Documentation" (#docs) — links to another site (https://docs.example.com)
936
+ a "Email us" (#contact) — hands the click to mailto
937
+ button "Delete everything" (#danger) — excluded
938
+ ```
939
+
940
+ …followed by the page with every dead element boxed in red and every broken one in orange, numbered to match.
941
+
942
+ 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.
943
+
944
+ ### `framewatch_links`
945
+
946
+ 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.
947
+
948
+ | Param | Type | Default | Description |
949
+ | --- | --- | --- | --- |
950
+ | `url` | string (URL) | — | Page to check |
951
+ | `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 |
952
+ | `check_external` | boolean | `true` | Also check links to other origins — a real request to somebody else's server |
953
+ | `include_resources` | boolean | `true` | Also check images, scripts, stylesheets, iframes and media |
954
+ | `check_fragments` | boolean | `true` | Check that a `#pricing` link points at an element that exists |
955
+ | `timeout_ms` | integer 100–30000 | `5000` | How long one link has to answer |
956
+ | `concurrency` | integer 1–10 | `5` | How many to check at once. Lower it if a host starts answering 429 |
957
+ | `max_links` | integer 1–500 | `200` | Distinct addresses to check. Links are spent before resources |
958
+ | `max_pages` | integer 1–25 | `10` | Pages to open when `depth` > 0, counting the one you named |
959
+ | `selector` | string | whole page | Only collect links inside this container |
960
+ | `wait_ms` | integer ≥ 0 | `1000` | Settle time after each load, so a client-rendered app can finish |
961
+ | `wait_for` | string | — | CSS selector to wait for (visible) after each load |
962
+ | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` |
963
+ | `full_page` | boolean | `false` | Photograph the whole document, so failing links below the fold are in the picture |
964
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
965
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
966
+
967
+ **Example call:**
968
+
969
+ ```json
970
+ { "url": "http://localhost:3000" }
971
+ ```
972
+ ```json
973
+ { "url": "http://localhost:3000", "depth": 1, "check_external": false, "selector": "footer" }
974
+ ```
975
+
976
+ **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.
977
+
978
+ **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.
979
+
980
+ **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.
981
+
982
+ **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.
983
+
984
+ **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`.
985
+
986
+ ```
987
+ 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.
988
+ 4 same-page fragments resolved against the document rather than over HTTP.
989
+
990
+ Broken (6):
991
+ http://localhost:3000/pricing-old — 404 Not Found
992
+ from a "Pricing" (#nav-pricing)
993
+ → Point the link somewhere that exists, or restore the page.
994
+ http://localhost:3000/api/report — 500 Internal Server Error
995
+ from a "Download the report" (#report)
996
+ → This link answers an error to every visitor.
997
+ http://localhost:3000/go/docs — 404 Not Found — after 1 redirect, ending at http://localhost:3000/docs/v1
998
+ from a "Documentation" (#docs)
999
+ → Point the link somewhere that exists, or restore the page.
1000
+ http://localhost:3000/img/hero@2x.png — 404 Not Found (seen when the page loaded it)
1001
+ from img "Our team at work" (#hero)
1002
+ → Point the link somewhere that exists, or restore the page.
1003
+
1004
+ Timed out (1):
1005
+ https://status.example.com/uptime — no answer within the timeout
1006
+ from a "Status" (footer > nav > a:nth-of-type(3))
1007
+ → Raise `timeout_ms` if the host is simply slow; a link nobody's browser will wait for is broken in practice.
1008
+
1009
+ Could not be reached (2):
1010
+ http://localhost:3000/loop — the redirects loop — http://localhost:3000/loop is visited twice
1011
+ from a "Account" (#account)
1012
+ → Follow the chain by hand: a redirect loop is a page nobody can reach.
1013
+ https://blog.exmaple.com/post — the host name does not resolve
1014
+ from a "Read the post" (#blog)
1015
+ → Check the domain for a typo, and that it has not expired.
1016
+
1017
+ Fragments that point at nothing (1):
1018
+ #pricing-table — no element on the page has that id, and no <a name> either
1019
+ from a "See the plans" (#see-plans)
1020
+
1021
+ Redirected (2):
1022
+ http://localhost:3000/blog — redirected to http://localhost:3000/blog/ (1 hop), which answered 200 OK
1023
+ from a "Blog" (#nav-blog)
1024
+ http://example.com/terms — redirected to https://example.com/legal/terms (3 hops), which answered 200 OK
1025
+ from a "Terms" (#terms)
1026
+ → Link straight to the final address — every hop in the chain is another round trip.
1027
+
1028
+ Blocked — the server refused the check, not necessarily the link (2):
1029
+ https://www.linkedin.com/company/example — 999 — a non-standard status some sites return to automated checks — not a broken link
1030
+ from a "LinkedIn" (#social-in)
1031
+ 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
1032
+ from a "Dashboard" (#dashboard)
1033
+
1034
+ Not checked (7):
1035
+ mailto: — a mailto: with no address after it
1036
+ from a "Email us" (#contact)
1037
+ http:// — "http://" is not a URL a browser can resolve
1038
+ from a "Partners" (#partners)
1039
+ (an empty href) — an empty href reloads the current page
1040
+ from a "Careers" (#careers)
1041
+ ...
1042
+
1043
+ Working (7):
1044
+ http://localhost:3000/about — 200 OK
1045
+ http://localhost:3000/app.js — 200 OK (seen when the page loaded it)
1046
+ ...
1047
+ ```
1048
+
1049
+ …followed by the page with every failing link boxed in red and every dead fragment in orange, numbered to match.
1050
+
1051
+ 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.
1052
+
1053
+ ### `framewatch_api_mock`
1054
+
1055
+ 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.
1056
+
1057
+ It takes everything [`framewatch_capture`](#framewatch_capture) takes, and returns the same diff cards. The only difference is what the network says back.
1058
+
1059
+ | Param | Type | Default | Description |
1060
+ | --- | --- | --- | --- |
1061
+ | `url` | string (URL) | — | Page to record |
1062
+ | `mocks` | array (1–20) | — | What to intercept and what to answer. See below |
1063
+ | `block_unmatched` | boolean | `false` | Fail every request no mock matched instead of letting it reach the real server |
1064
+ | `duration_ms` | integer 500–30000 | `5000` | How long to record |
1065
+ | `interactions` | array | — | Steps to replay while recording — same script as `framewatch_capture` |
1066
+ | `sensitivity` | number 0–1 | `0.06` | Frame change threshold |
1067
+ | `max_frames` | integer 1–30 | `20` | Maximum diff cards |
1068
+ | `wait_for` | string | — | CSS selector to wait for (visible) before recording starts |
1069
+ | `include_network` | boolean | `true` | Attach the requests to the frames they settled between |
1070
+ | `include_console` | boolean | `true` | Attach console output and uncaught errors |
1071
+ | `viewport` | `{ width, height }` | `1280×720` | Viewport size |
1072
+ | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) |
1073
+
1074
+ Each entry in `mocks`:
1075
+
1076
+ | Field | Type | Description |
1077
+ | --- | --- | --- |
1078
+ | `url_pattern` | string | Glob matched against the **whole URL**, e.g. `**/api/products*` |
1079
+ | `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) |
1080
+ | `response` | object | `status`, `body`, `delay_ms`, `headers` — each one overrides the scenario |
1081
+ | `abort` | enum | `failed` · `timedout` · `connectionrefused` · `internetdisconnected` |
1082
+ | `times` | integer ≥ 1 | Only the first N matching requests; the rest fall through |
1083
+
1084
+ **Example calls:**
1085
+
1086
+ ```json
1087
+ { "url": "http://localhost:3000/products",
1088
+ "mocks": [{ "url_pattern": "**/api/products*", "scenario": "empty" }] }
1089
+ ```
1090
+ ```json
1091
+ { "url": "http://localhost:3000/products",
1092
+ "mocks": [{ "url_pattern": "**/api/products*", "scenario": "slow",
1093
+ "response": { "body": { "items": [{ "name": "Kombucha" }] } } }],
1094
+ "duration_ms": 8000 }
1095
+ ```
1096
+ ```json
1097
+ { "url": "http://localhost:3000",
1098
+ "mocks": [{ "url_pattern": "**/api/checkout", "scenario": "error", "times": 1 }],
1099
+ "interactions": [{ "action": "click", "selector": "#pay", "delay_ms": 500 },
1100
+ { "action": "click", "selector": "#pay", "delay_ms": 2000 }] }
1101
+ ```
1102
+
1103
+ ```
1104
+ Captured 2 meaningful frames from 16 raw frames (1512ms recording) of http://localhost:3000/products — "Products"
1105
+ API mocks — 1 declared, 1 served 1 request.
1106
+ ✓ **/api/products* → 200 empty ×1 (http://localhost:3000/api/products)
1107
+ Every request the page made was matched by a mock.
1108
+ Context — console: 2 entries; network: 2 requests
1109
+ Frame 1 @ 24ms [initial]
1110
+ Console:
1111
+ [log] products: loading
1112
+ [log] products: empty
1113
+ Network:
1114
+ GET http://localhost:3000/products → 200 (8ms)
1115
+ GET http://localhost:3000/api/products → 200 (2ms)
1116
+ ```
1117
+
1118
+ **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:
1119
+
1120
+ ```
1121
+ API mocks — 2 declared, 0 served 0 requests, 2 never matched.
1122
+ ✗ /api/products — no request matched it (patterns are matched against the whole URL, so a path needs a leading `**` — try `**/api/products*`).
1123
+ ✗ **/api/avatar* — no request matched it.
1124
+ Unmatched, answered by the real server:
1125
+ GET http://localhost:3000/api/products → 200
1126
+ ```
1127
+
1128
+ **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".
1129
+
1130
+ **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.
1131
+
1132
+ **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.
1133
+
1134
+ **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.
1135
+
1136
+ **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:
1137
+
1138
+ ```
1139
+ ! **/api/products* → 200 after 9000ms ×1 — delayed 9000ms, longer than the 1500ms recording, so the request never got an answer.
1140
+ ```
1141
+
1142
+ ### `framewatch_rtl`
1143
+
1144
+ Load the page twice — once as it ships, once flipped — measure every element in both, and report what failed to mirror.
1145
+
1146
+ | Param | Type | Default | Description |
1147
+ | --- | --- | --- | --- |
1148
+ | `url` | string | — | The page to test |
1149
+ | `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 }` |
1150
+ | `inject_arabic` | boolean | `true` | Replace visible text with length-matched Arabic, in **both** passes |
1151
+ | `selector` | string | — | Only measure inside this container |
1152
+ | `exclude` | string | — | Never measure this, or anything inside it |
1153
+ | `wait_ms` | number | `1000` | Settle time after each load — paid twice, once per direction |
1154
+ | `wait_for` | string | — | Selector to wait for after each load |
1155
+ | `max_elements` | number | `400` | Elements measured in each direction |
1156
+ | `full_page` | boolean | `false` | Screenshot the whole page rather than the viewport |
1157
+ | `viewport` | object | `1280x720` | |
1158
+ | `storage_state` | string | — | Auth state from `framewatch_save_auth` |
1159
+
1160
+ ```
1161
+ framewatch_rtl({ url: "http://localhost:3000" })
1162
+ ```
1163
+
1164
+ ```
1165
+ Tested http://localhost:3000 in both directions — compared 14 elements, 5 problems, 2 warnings.
1166
+ RTL applied by setting `dir="rtl"` on `html`.
1167
+ 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.
1168
+
1169
+ Problems — 5 (these are visible to an Arabic reader):
1170
+ 1. div "→" #arrow-next
1171
+ ✗ did not mirror — the box sits at the same place in both directions
1172
+ x=60 in LTR, x=60 in RTL; mirroring a 40px box in a 1280px viewport should put it at x=1180
1173
+ ! looks directional but is drawn the same way in both directions — a 'next' arrow points backwards in RTL
1174
+ no mirroring transform in either direction; `transform: scaleX(-1)` under `[dir=rtl]` flips it
1175
+ 2. div "السابق اشترك في النشرة" #not-mirrored
1176
+ ✗ did not mirror — the box sits at the same place in both directions
1177
+ x=40 in LTR, x=40 in RTL; mirroring a 200px box in a 1280px viewport should put it at x=1040
1178
+ 3. p "خدمة التوصيل متاحة إلى جميع المناطق…" #stays-left
1179
+ ✗ stayed left-aligned in an RTL context — the text hugs the wrong edge
1180
+ text-align is "left" in both directions; use `start` (or `end`) instead of `left`
1181
+ 4. div "مرحبا بك في متجرنا الإلكتروني، السابق تواصل معنا" #overflows-rtl
1182
+ ✗ overflows the left edge of the viewport in RTL — the part that sticks out is cropped, not visible
1183
+ sticks out 300px past the left edge in RTL, and none in LTR
1184
+
1185
+ Warnings — 2 (worth checking, may be deliberate):
1186
+ 5. div #double-reversed #double-reversed
1187
+ ! is `row-reverse` in both directions — RTL reverses it again, so the items end up back to front
1188
+ flex-direction: row-reverse under `dir=rtl` lays the items out left to right
1189
+ 6. div "فريق الدعم جاهز للإجابة عن أسئلتك…" #padding-stuck
1190
+ ! keeps the same physical padding in both directions
1191
+ padding-left 48px / padding-right 0px, unchanged from LTR; `padding-inline-start`/`padding-inline-end` would swap
1192
+
1193
+ 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.
1194
+ ```
1195
+
1196
+ 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.
1197
+
1198
+ **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.
1199
+
1200
+ **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:
1201
+
1202
+ ```
1203
+ ✗ RTL was never applied — the document still computes `direction: ltr` after the trigger ran. Everything
1204
+ below compares the page with itself, so it proves nothing. Check that `rtl_trigger` matches how this app
1205
+ switches direction (an app that switches on a class needs `{ type: "class", class: "…" }`, one with a
1206
+ separate Arabic build needs `{ type: "url", rtl_url: "…" }`).
1207
+ ```
1208
+
1209
+ **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.
1210
+
1211
+ **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.
1212
+
437
1213
  ### `framewatch_start_server` / `framewatch_stop_server`
438
1214
 
439
1215
  Start the app's dev server so the rest of FrameWatch has something to point at, and stop it again.
@@ -519,10 +1295,27 @@ framewatch_capture { "url": "http://localhost:5173/login", "duration_ms":
519
1295
  ~ div#spinner [class]
520
1296
  ~ button#submit [disabled]
521
1297
 
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
1298
+ framewatch_snapshot { "url": "http://localhost:5173/login", "mode": "interactive" }
1299
+ Snapshot of http://localhost:5173/login "Sign in" — viewport 1280x720 — 14 elements, 3 interactive
1300
+ - textbox "Email" [ref=e4]
1301
+ - textbox "Password" [ref=e6]
1302
+ - button "Sign in" [ref=e7]
1303
+
1304
+ framewatch_interact { "action": "type", "ref": "e4", "value": "test@example.com" }
1305
+ framewatch_interact { "action": "type", "ref": "e6", "value": "correct-horse" }
1306
+ framewatch_interact { "action": "click", "ref": "e7", "wait_ms": 1500, "include_snapshot": true }
1307
+ → 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
1308
+
1309
+ Snapshot — 31 elements, 9 interactive (refs valid until the page changes)
1310
+ - heading "Dashboard" [level=1] [ref=e2]
1311
+
1312
+
1313
+ framewatch_inspect { "targets": ["e2", "e9"] }
1314
+ → Inspected 2 of 2 targets on http://localhost:5173/dashboard — viewport 1280x720, …
1315
+ 1. e2 heading "Dashboard" — <h1>
1316
+ box: 32,24 224x40 (viewport px; centre 144,44) — visible, fully in viewport
1317
+ text: 32px/40px Inter 700 — #111827 on #ffffff — contrast 17.74:1 — passes AAA for large text
1318
+
526
1319
 
527
1320
  framewatch_accessibility { "url": "http://localhost:5173/dashboard", "standard": "wcag21aa" }
528
1321
  framewatch_stop_server {}
@@ -530,7 +1323,7 @@ framewatch_stop_server {}
530
1323
 
531
1324
  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
1325
 
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.
1326
+ 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
1327
 
535
1328
  ## Troubleshooting
536
1329
 
@@ -544,6 +1337,14 @@ Two things worth noticing. The agent did not get 66 screenshots and a guess: it
544
1337
 
545
1338
  **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
1339
 
1340
+ **`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.
1341
+
1342
+ **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.
1343
+
1344
+ **`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.
1345
+
1346
+ **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`.
1347
+
547
1348
  **`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
1349
 
549
1350
  **`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 +1353,8 @@ Two things worth noticing. The agent did not get 66 screenshots and a guess: it
552
1353
 
553
1354
  **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
1355
 
1356
+ **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.
1357
+
555
1358
  **`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
1359
 
557
1360
  ## Development
@@ -565,7 +1368,7 @@ npm run coverage # vitest with v8 coverage
565
1368
  npm run dev # tsc --watch
566
1369
  ```
567
1370
 
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):
1371
+ 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
1372
 
570
1373
  | Fixture | What it is for |
571
1374
  | --- | --- |
@@ -580,6 +1383,19 @@ Tests launch real Chromium against local fixture pages in `test/fixtures/`, serv
580
1383
  | `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
1384
  | `compare-a.html` / `compare-b.html` | Two pages that differ in one region |
582
1385
  | `gate.html` | A gate that opens only for a cookie **and** a localStorage token — saving and restoring auth state |
1386
+ | `form.html` | Every field type worth filling, five that nothing can fill, JS validation, and one input the page truncates |
1387
+ | `form-multi.html`, `form-plain.html` | Two forms on one page (one with no submit button), and a page with no `<form>` element |
1388
+ | `form-xss.html`, `form-gated.html` | Input echoed back as markup, and a form that only exists once signed in |
1389
+ | `seo-good.html`, `seo-bad.html` | A page a search engine has nothing to complain about, and one with a fault in every category |
1390
+ | `seo-blocked.html`, `robots.txt`, `private/briefing.html` | A perfect page that robots.txt forbids, and one path allowed back for Googlebot alone |
1391
+ | `seo-og.html`, `seo-spa.html` | Every kind of `og:image` there is (`?card=`), and a page that writes its own SEO after load |
1392
+ | `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 |
1393
+ | `dead-clicks-noisy.html` | A page that rewrites itself and polls the server with nobody touching it |
1394
+ | `dead-clicks-shapes.html` | What counts as clickable: inherited pointer cursors, wrappers around links, elements with no ids |
1395
+ | `links.html` | One of every kind of link: broken, redirected, looping, refused, timing out, unfetchable, and a fragment that points at nothing |
1396
+ | `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 |
1397
+ | `links-spa.html` | A page whose links are written after load |
1398
+ | `highlight.html` | A positioned `<body>` with a margin — where an overlay's coordinates go wrong |
583
1399
 
584
1400
  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
1401
 
@@ -601,20 +1417,46 @@ MCP client (Claude Code) ◄─stdio─► FrameWatch server ◄──► Playwr
601
1417
  - `src/engine/browser.ts` — one shared Chromium: a fresh context per tool call, plus the long-lived page `framewatch_interact` works on
602
1418
  - `src/engine/recorder.ts` — captures raw PNG frames at a fixed interval (plus forced frames on navigation)
603
1419
  - `src/engine/differ.ts` — grid-based frame selection, pixel-level change regions, crops
1420
+ - `src/engine/forms.ts` — finds the forms on a page, fills them, submits them, and reads back what the page said
1421
+ - `src/engine/seo.ts` — reads the SEO-relevant parts of the rendered document, fetches robots.txt, measures the share image
1422
+ - `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)
1423
+ - `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
1424
+ - `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
1425
  - `src/engine/interaction.ts` — validates and executes one interaction step (click, tap, type, key, scroll, swipe, hover, select, navigate)
1426
+ - `src/engine/snapshot.ts` — the aria tree with refs, on the session page, scoped, filtered and cut
1427
+ - `src/engine/inspect.ts` — one element's box, type, colours, spacing and neighbours read in-page; the page's design inventory
1428
+ - `src/engine/vue.ts` — what a Vue app leaves in the DOM: detection, readiness, the component behind an element, the tree, router navigation
1429
+ - `src/engine/hmr.ts` — the Vite watcher on the session page, and "since the last look"
605
1430
  - `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
1431
  - `src/utils/image.ts` — sharp wrappers, including the compare overlay
1432
+ - `src/utils/highlight.ts` — labelled boxes drawn over elements (or over a measured box) through the CSSOM, so they survive a strict CSP
1433
+ - `src/utils/snapshot-rules.ts` — what a snapshot line is: counting, the interactive filter, truncation, refs (pure)
1434
+ - `src/utils/style-rules.ts` — what a measurement means: colour maths, WCAG contrast, alignment wording, every line inspect prints (pure)
1435
+ - `src/utils/vue-rules.ts` — what a Vite console line means, router-or-load for a `navigate`, the component wording (pure)
607
1436
  - `src/utils/bounded-log.ts` — capped log that evicts ordinary entries to keep errors
608
1437
  - `src/utils/server-process.ts` — dev server process manager (spawn, port readiness, process-group kill)
609
1438
  - `src/utils/storage-state.ts` — reads and writes the saved auth state file, and the `storage_state` input every page tool shares
1439
+ - `src/utils/test-data.ts` — what to type: one value per field per form-test strategy
1440
+ - `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)
1441
+ - `src/utils/link-rules.ts` — what an href is, and what one HTTP answer means (pure, so it is unit-tested without a socket)
1442
+ - `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
1443
  - `src/utils/format.ts` — turns diff cards into MCP content blocks
1444
+ - `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
1445
  - `src/tools/screenshot.ts` — the screenshot tool
612
1446
  - `src/tools/capture.ts` — the capture tool, including interaction replay
613
- - `src/tools/interact.ts` — the single-interaction tool
1447
+ - `src/tools/interact.ts` — the single-interaction tool, by selector or by snapshot ref
1448
+ - `src/tools/snapshot.ts` — the page as a tree of named elements
1449
+ - `src/tools/inspect.ts` — how elements are built; the design inventory
1450
+ - `src/tools/wait-for.ts` — wait for a hot update, a mounted Vue app, a selector or a quiet network, then look
614
1451
  - `src/tools/responsive.ts` — multi-viewport capture with the overflow check
615
1452
  - `src/tools/accessibility.ts` — the axe-core audit
616
1453
  - `src/tools/compare.ts` — before/after comparison and the diff overlay
617
1454
  - `src/tools/save-auth.ts` — runs a login flow once and saves the browser state it produced
1455
+ - `src/tools/form-test.ts` — the form tool: one strategy per page, and what each one produced
1456
+ - `src/tools/seo.ts` — the SEO audit: gathers, judges, prints
1457
+ - `src/tools/dead-clicks.ts` — the dead-click sweep: clicks everything, subtracts the page's own noise, marks up the result
1458
+ - `src/tools/links.ts` — the link check: collect, check, crawl, group by what went wrong
1459
+ - `src/tools/api-mock.ts` — the API mock tool: resolves the mocks, runs a capture behind them, reports what each one did
618
1460
  - `src/tools/server.ts` — the two dev-server tools
619
1461
 
620
1462
  All diagnostics go to stderr; stdout is reserved for the MCP protocol.