# FrameWatch MCP Server **[framewatch.keko.dev](https://framewatch.keko.dev)** · [npm](https://www.npmjs.com/package/framewatch-mcp-server) · MIT > 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. 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. 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. ## Quick start **1. Register it with Claude Code** (one command, no install step): ```bash claude mcp add framewatch -- npx -y framewatch-mcp-server@latest ``` **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: ```bash export MAX_MCP_OUTPUT_TOKENS=100000 ``` Add that line to your `~/.zshrc` or `~/.bashrc` to make it permanent. **3. Start your app and ask.** Run your dev server as usual, open Claude Code in the project, and talk to it normally: > Open http://localhost:5173 and tell me what you see. > The header looks wrong on mobile. Check it at phone width and fix it. > Test the login flow with test@example.com / password123 and show me what happens. > I just changed the button styles — show me the page after the hot reload and check the contrast. 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. **4. Sign in once, if your app has a login.** Ask Claude Code to log in and save the session: > Log in at http://localhost:5173/login with my test account and save the session. It runs `framewatch_save_auth`, which writes `.framewatch/auth.json`. From then on every tool opens pages already signed in, and says so. 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. **Updates are automatic.** The `@latest` in the command makes every new Claude Code session run the newest release. If you registered without it, run `npx clear-npx-cache` once, or remove and re-add the server with the command above. A project that already has a `framewatch` entry needs `claude mcp remove framewatch` first. ### Other MCP clients FrameWatch is a standard MCP server over stdio, so every agent that can run one takes the same command: `npx -y framewatch-mcp-server@latest`. Where it goes: | Agent | Where | Shape | | --- | --- | --- | | Claude Code | `claude mcp add framewatch -- npx -y framewatch-mcp-server@latest` | one command; add `--scope user` for every project | | Cursor | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` | `mcpServers` | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` | | VS Code (Copilot agent mode) | `.vscode/mcp.json` | `servers`, with `"type": "stdio"` | | Codex CLI | `~/.codex/config.toml` | `[mcp_servers.framewatch]` | | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | | Claude Desktop | `claude_desktop_config.json` | `mcpServers` | | Cline | `cline_mcp_settings.json` via MCP Servers, Configure | `mcpServers` | | Continue | `~/.continue/config.yaml` | `mcpServers:` list | | Zed | `settings.json` | `context_servers`, `"source": "custom"` | | JetBrains AI Assistant | Settings, Tools, AI Assistant, MCP, Add | `mcpServers` | The `mcpServers` shape most of them share: ```json { "mcpServers": { "framewatch": { "command": "npx", "args": ["-y", "framewatch-mcp-server@latest"] } } } ``` 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). ### From a checkout ```bash git clone https://github.com/kekoDev/framewatch.git cd framewatch && npm install && npm run build claude mcp add framewatch node "$PWD/dist/index.js" ``` ### Requirements - **Node 20.9 or newer.** - **Chromium**, fetched by Playwright on install. If a tool ever reports the browser is missing, it prints the one command that fixes it. - Nothing listens on a port and nothing phones home: FrameWatch only ever talks to the URLs it is given. ## What Claude Code can do with it | Tool | Use it for | | --- | --- | | [`framewatch_screenshot`](#framewatch_screenshot) | What does this page look like right now? | | [`framewatch_capture`](#framewatch_capture) | What happens over the next few seconds — animations, loading, a replayed user flow? | | [`framewatch_interact`](#framewatch_interact) | Click this, then let me look; then click the next thing. | | [`framewatch_snapshot`](#framewatch_snapshot) | What is on this page, and what do I call each thing? Refs to act on, instead of guessed selectors. | | [`framewatch_inspect`](#framewatch_inspect) | Is this element built the way I meant — box, font, colours and contrast, spacing, alignment? | | [`framewatch_wait_for`](#framewatch_wait_for) | I just saved a file — show me the page the moment Vite has patched it. | | [`framewatch_responsive`](#framewatch_responsive) | Does it hold up at phone, tablet and desktop widths? | | [`framewatch_accessibility`](#framewatch_accessibility) | What would an accessibility auditor flag? | | [`framewatch_compare`](#framewatch_compare) | What changed between before and after? | | [`framewatch_form_test`](#framewatch_form_test) | What does this form do with empty, huge, Arabic or hostile input? | | [`framewatch_seo`](#framewatch_seo) | What do a search engine and a link preview make of this page? | | [`framewatch_dead_clicks`](#framewatch_dead_clicks) | Which of these buttons and links do nothing when clicked? | | [`framewatch_links`](#framewatch_links) | Where does every link on this page actually go — and which ones are broken? | | [`framewatch_api_mock`](#framewatch_api_mock) | What does this page do with an empty list, a 500, or an API that takes five seconds? | | [`framewatch_rtl`](#framewatch_rtl) | Does this page survive being flipped for Arabic, Hebrew or Persian? | | [`framewatch_save_auth`](#framewatch_save_auth) | Sign in once, so every other tool starts past the login. | | [`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. | 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. ## Why `MAX_MCP_OUTPUT_TOKENS` matters 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. 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: ``` 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. ``` 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. ## Tools ### `framewatch_screenshot` Take a single screenshot of a page (or of one element on it). Returns a PNG image content block (resized to max 800px wide) plus a one-line text summary. | Param | Type | Default | Description | | --- | --- | --- | --- | | `url` | string (URL) | — | Page to screenshot, e.g. `http://localhost:3000` | | `wait_ms` | integer | `1000` | Wait after page load before capturing | | `viewport` | `{ width, height }` | `1280×720` | Viewport size | | `selector` | string | — | CSS selector: screenshot only this element | | `wait_for` | string | — | CSS selector to wait for (visible) before capturing | | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` / `selector` | | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) — open the page already signed in | **Example call** — the whole page, then one element of it: ```json { "url": "http://localhost:3000/pricing", "wait_ms": 1500 } ``` ```json { "url": "http://localhost:3000/pricing", "selector": ".plan-card--pro", "wait_for": ".plan-card--pro" } ``` Failures (unreachable URL, selector never appears, invalid input) come back as MCP error results with a readable message rather than crashing the server. Error pages still produce a screenshot, with the HTTP status noted in the summary. ### `framewatch_capture` Record a page for a few seconds and return only the frames where something meaningful changed. Each kept frame is a *diff card*: the full frame as a PNG (max 800px wide), a metadata line, and a crop of the changed region with its position. Good for animations, splash/loading screens, transitions, and anything else that changes over time. | Param | Type | Default | Description | | --- | --- | --- | --- | | `url` | string (URL) | — | Page to record, e.g. `http://localhost:3000` | | `duration_ms` | integer 500–30000 | `5000` | How long to record | | `sensitivity` | number 0–1 | `0.06` | Change threshold per frame: fraction of an 8×8 grid that must change (`0` keeps every frame, `1` keeps none beyond first/last) | | `max_frames` | integer 1–30 | `20` | Maximum diff cards to return | | `interval_ms` | integer 16–2000 | `100` | Raw frame capture interval (100 = 10 fps) | | `viewport` | `{ width, height }` | `1280×720` | Viewport size | | `wait_for` | string | — | CSS selector to wait for (visible) before recording starts | | `wait_for_timeout_ms` | integer ≥ 1 | `10000` | Max wait for `wait_for` | | `interactions` | array (≤ 50) | — | Interaction script to replay while recording (see below) | | `interaction_timeout_ms` | integer ≥ 1 | `10000` | Max time one step may wait for its target element | | `include_console` | boolean | `true` | Attach console output and uncaught errors (see [Context layers](#context-layers)) | | `include_network` | boolean | `false` | Attach network requests | | `include_dom` | boolean | `false` | Attach a summary of the DOM mutations between frames | | `include_performance` | boolean | `false` | Attach paint timing, LCP and layout shifts | | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) — record the app, not its login screen | **Example call** — record a splash screen, keeping more frames than usual because the interesting parts of an animation are small: ```json { "url": "http://localhost:3000", "duration_ms": 5000, "sensitivity": 0.04, "include_console": true } ``` Recording starts as soon as the navigation commits (not after `load`), so loading and splash animations are captured from their first frame. Returns one summary text block (`Captured 5 meaningful frames from 26 raw frames (2514ms recording) of http://… — "Title"`), then for each card: 1. the full frame as a PNG image block; 2. a text block, e.g. `Frame 2 @ 501ms [animation]` followed by `Changed: 20.0% — region: 80,40 240x160` (percentage of pixels that changed versus the previous card, and the padded bounding box in full-resolution viewport coordinates); 3. a PNG crop of that region — omitted when the change covers nearly the whole frame. Which raw frames become cards: - the first frame (`[initial]`) and the last frame are always kept; - a frame is kept when more than `sensitivity` of the 8×8 grid cells differ from the last kept frame (compared at 320×240 grayscale); - frames the recorder captured because of an event (e.g. a `[navigation]`) are always kept; - kept frames closer than 200ms are merged, keeping the later, settled one — a continuous animation is thinned to roughly every 200ms; - if there are still more than `max_frames`, the first, last and event frames are kept and the rest are sampled evenly by position among the remaining kept frames. The recorder bounds every screenshot, because Chromium blocks them while a navigation is pending or the main thread is busy. A navigation tags the next frame the loop captures rather than requesting an extra screenshot at commit time — that frame reliably shows the new page, and a `history.replaceState` on every animation frame (the usual scroll-spy or router pattern) cannot flood the recording. Fragment-only URL changes are not treated as navigations, and a crashed or closed page ends the recording early with the frames captured so far. Failures come back as MCP error results, like `framewatch_screenshot`. #### Context layers A frame tells you *that* something changed. The context layers tell you *why*. Each one collects timestamped events while the page records, and each diff card is given the events from its own window — everything after the previous card, up to and including its own timestamp. All four start collecting **before** the navigation, so a script that throws on load, the request that never comes back and first paint all land on the first card instead of being missed. | Layer | Flag | What lands on a card | | --- | --- | --- | | Console | `include_console` (on) | `console.*` calls, uncaught exceptions, unhandled rejections, and the tab crashing | | Network | `include_network` | One line per request that settled — method, url, status, duration — plus any still in flight when the recording ended | | DOM | `include_dom` | Grouped mutations: elements added, removed, restyled, and text edited | | Performance | `include_performance` | First Contentful Paint, Largest Contentful Paint, and layout shifts with their summed score | ``` Frame 3 @ 1204ms [interaction] Changed: 12.4% — region: 40,220 320x180 Console: [error] TypeError: Cannot read properties of null (reading 'id') (at submit (http://localhost:3000/app.js:88:14)) Network: POST http://localhost:3000/api/login → 500 (312ms) GET http://localhost:3000/api/me → pending (1204ms) Performance: layout shifts 2 (score 0.1875) DOM: + div.error-banner in form#login ~ button#submit [disabled] ~ #spinner [style] ×18 ``` Paint and LCP are reported in milliseconds since that document's navigation start (the same numbers Lighthouse gives), and only on the card where they were measured — a value that never changes is not repeated down the page. Layout shifts are counted whether or not they followed user input, unlike Chrome's CLS: a jump right after a click is often the one being hunted. Console and network are Playwright events, so they survive a page that has frozen, navigated away or crashed. The DOM and performance layers inject an observer that pushes what it sees back out as it goes, so a navigation mid-recording does not take the old document's records with it. Both skip subframes, and the DOM layer ignores `
`, script/link/meta elements and whitespace-only text — none of it says anything about what the page looks like. Every layer is capped so one page cannot flood the response: console keeps 100 entries and network 100 events, evicting ordinary ones to make room for errors and failures; the DOM keeps 500 mutations and renders at most 12 lines per card. The summary says what was collected and what had to be dropped: ``` Context — console: 12 entries; network: 8 requests (1 still pending); DOM: 340 mutations DOM log was capped — 51 mutations dropped. ``` #### Replaying an interaction script Pass `interactions` to drive the page while it records. Each step is `{ action, ... }`, where `delay_ms` is a wait **before** that action, so the delays accumulate over the recording: ```json { "url": "http://localhost:3000/login", "duration_ms": 8000, "interactions": [ { "action": "type", "selector": "#email", "value": "test@example.com", "delay_ms": 1000 }, { "action": "type", "selector": "#password", "value": "password123", "delay_ms": 500 }, { "action": "click", "selector": "button[type=submit]", "delay_ms": 500 }, { "action": "wait", "delay_ms": 3000 } ] } ``` `framewatch_capture` accepts `click`, `tap`, `type`, `key`, `scroll`, `swipe`, `hover`, `select`, `wait` and `navigate` — the same set the executor supports, so nothing has to be worked around. A frame is forced right after every step, so the result of each action is always kept as an `[interaction]` card. The summary gains a line such as: ``` Interactions: 4/4 replayed — type "test@example.com" into "#email", type "password123" into "#password", click "button[type=submit]", wait 3000ms ``` **Pressing keys.** `type` only produces printable text, so a form that is submitted with the keyboard needs a key press. Either write it as its own step, or put it inline in the value — a `\n` in a typed value presses Enter and a `\t` presses Tab, at exactly that point in the text: ```json { "action": "type", "selector": "#search", "value": "framewatch" }, { "action": "key", "value": "Enter" } ``` ```json { "action": "type", "selector": "#search", "value": "framewatch\n" } ``` `key` takes any [Playwright key name](https://playwright.dev/docs/api/class-keyboard#keyboard-press) — `Enter`, `Escape`, `Tab`, `Backspace`, `ArrowDown`, `F5` — or a combo such as `Control+a` or `Shift+Tab`. It goes to whatever is focused; give it a `selector` to focus that element first. The first text run of a `type` still replaces the field's contents, so a script stays repeatable however many keys are in it. Typed values are echoed in that line (elided only when long) and are visible in the frames themselves, so drive these flows with throwaway test credentials — a real password ends up in the tool output and therefore in the model's context. A step that fails ends the script but **not** the capture — the frames recorded up to that point are the most useful thing FrameWatch can hand back, since they show the state the page was actually in. The failure is reported in the summary and the moment it happened is kept as an `[error]` card: ``` Interactions: 1/3 replayed — click "#btn". Step 2: click "#nope" failed: locator.click: Timeout 10000ms exceeded. ``` Touch is enabled automatically (and only) for scripts containing `tap` or `swipe`, because `hasTouch` puts `ontouchstart` on `window` and would otherwise change what a plain capture records. A `swipe` is dispatched as a real finger drag — `touchstart`, ten `touchmove`s about a frame apart, `touchend` — so velocity-sensitive UI (carousels, pull-to-refresh) behaves as it would under a thumb. ### `framewatch_interact` Perform **one** interaction and see what it did: before frame, after frame, and a crop of the change. Unlike the other tools this one is stateful on purpose — the page stays open between calls, so you can click, look, type, and look again without replaying the whole flow. | Param | Type | Default | Description | | --- | --- | --- | --- | | `action` | enum | — | `click`, `tap`, `type`, `key`, `scroll`, `swipe`, `navigate`, `select`, `hover` | | `selector` | string | — | CSS selector for the target | | `ref` | string | — | Element ref from [`framewatch_snapshot`](#framewatch_snapshot), e.g. `e8` — instead of `selector` | | `value` | string | — | Text to type (`\n` presses Enter, `\t` Tab), key to press, option to select, or URL to navigate to | | `x`, `y` | number | — | Coordinates for `click`/`tap`/`swipe` when no selector is given | | `delta_x`, `delta_y` | number | — | Distance for `scroll` / `swipe` | | `url` | string (URL) | — | Open this page first. Omit to act on the page left open by the previous call. | | `wait_ms` | integer ≥ 0 | `500` | Settle time after the action, before the "after" screenshot | | `timeout_ms` | integer ≥ 1 | `10000` | Max wait for the target element | | `viewport` | `{ width, height }` | — | Resize the page (omit to leave it as it is) | | `include_console` | boolean | `true` | Report console output and uncaught errors the action caused | | `include_network` | boolean | `false` | Report network requests the action caused | | `include_dom` | boolean | `false` | Report the DOM mutations the action caused | | `include_performance` | boolean | `false` | Report paint timing and layout shifts around the action | | `include_snapshot` | boolean | `false` | Append a [snapshot](#framewatch_snapshot) of the page after the action, with fresh refs for the next call | | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth), applied when the session page is opened | 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. **Example call** — a session, one call at a time. Only the first needs a `url`: ```json { "url": "http://localhost:3000/settings", "action": "click", "selector": "nav a[href='/settings/billing']" } ``` ```json { "action": "type", "selector": "#card-number", "value": "4242424242424242" } ``` ```json { "action": "click", "selector": "button[type=submit]", "wait_ms": 1500, "include_network": true } ``` 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. 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. 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": ``` click "#submit" on http://localhost:3000/login — 0.4% of the frame changed Context — console: 1 entry; network: 1 request After — Frame 2 @ 612ms [interaction] Changed: 0.4% — region: 300,180 60x24 Console: [error] TypeError: Cannot read properties of null (reading 'value') Network: POST http://localhost:3000/api/login → 422 (88ms) ``` Because the page is reused between calls, the layers are installed on it once and **emptied at the start of every call**, so each call reports only what it caused. A layer can be switched on mid-session — it is attached to the document that is already open, no reload needed — but one that was on for an earlier call keeps running silently, since a page cannot un-expose an injected observer. 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. ### `framewatch_snapshot` 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. | Param | Type | Default | Description | | --- | --- | --- | --- | | `url` | string (URL) | — | Open this page first. Omit to read the page left open by `framewatch_interact` / `framewatch_inspect`. | | `selector` | string | — | Only this container, e.g. `main` or `#checkout` | | `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 | | `max_chars` | integer | `12000` | Cut the tree past this, on a line boundary, with a note saying how many lines went | | `include_screenshot` | boolean | `false` | Also return a screenshot of the page as it was read | | `include_components` | boolean | `false` | On a [Vue app](#vue-apps): append the component tree from the root | | `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 | | `wait_for` | string | — | CSS selector to wait for before reading | | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) | | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) | **Example call** ```json { "url": "http://localhost:3000/login", "mode": "interactive" } ``` Returns, for the fixture in `test/fixtures/snapshot.html`: ``` Snapshot of http://127.0.0.1:53021/snapshot.html — "FrameWatch Snapshot Fixture" — viewport 1280x720 — 16 elements, 7 interactive 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"]. - textbox "Email" [ref=e6] - textbox "Password" [ref=e8] - checkbox "Remember me" [checked] [ref=e10] - button "Sign in" [ref=e11] - link "Pricing" [ref=e13] [cursor=pointer] → /pricing - link "Docs" [ref=e14] [cursor=pointer] → /docs - generic [ref=e15] [cursor=pointer]: Card ``` `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"]`. 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"]`. ### `framewatch_inspect` 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. | Param | Type | Default | Description | | --- | --- | --- | --- | | `url` | string (URL) | — | Open this page first. Omit to measure the page left open by `framewatch_interact` / `framewatch_snapshot`. | | `targets` | string[] (≤ 12) | — | What to measure: snapshot refs (`e8`) and/or CSS selectors, in order. **Omit for a design inventory of the page.** | | `selector` | string | — | Inventory only: count inside this container | | `include_screenshot` | boolean | `true` | With `targets`: a screenshot with each target boxed and numbered | | `wait_ms` | integer ≥ 0 | `500` | Settle time after opening `url` | | `wait_for` | string | — | CSS selector to wait for before measuring | | `viewport` | `{ width, height }` | — | Resize the page first (omit to leave it as it is) | | `storage_state` | string (path) | — | Auth state file from [`framewatch_save_auth`](#framewatch_save_auth) | **Example call** ```json { "url": "http://localhost:3000/", "targets": ["#cta", "#note", "#narrow"] } ``` Returns, for `test/fixtures/inspect.html`, five lines per target and one screenshot: ``` 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 1. #cta button "Get started" —