# UI Visual Verification

> Capture live browser screenshots as proof-of-change for UI work, including states that are hard to reach through normal navigation (modals gated behind account limits or feature flags, rare data states, error/loading states, empty states). Works fully offline by driving the developer's own already-installed browser over the Chrome DevTools Protocol (CDP), so it works even when a sandboxed environment has no outbound internet access and cannot download Playwright's/Puppeteer's bundled browser or use a browser-automation MCP server. Use when the user asks to "take screenshots", "verify visually", "prove this works", "show me it renders correctly", or wants visual evidence for every state/variant of a component (modal, dialog, toast, banner, page).

- Skill: `namankr/ui-visual-verification` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add namankr/ui-visual-verification`
- Raw SKILL.md: https://api.skillmd.com/api/skills/namankr/ui-visual-verification/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: namankr (https://skillmd.com/u/namankr)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/namankr/ui-visual-verification

---


# UI Visual Verification (screenshot proof-of-change)

Produces real, live screenshots of a running web app as evidence for a UI change — not just a
code-level review. Two techniques, used together:

1. **CDP connection to an already-running, developer-installed browser** — works with zero
   internet access, since it never downloads a browser.
2. **Programmatic-render debug-hook technique** — for triggering components/states that require
   account data, feature flags, or flows too slow/fragile to reproduce by clicking through the UI.

Only use this when a live, running dev server for the app is available (and, if the UI depends on
one, a backend/API the user has started) — confirm both are up before starting.

## Prerequisites check (do this first)

- [ ] The app's dev server is running and reachable at a known local URL.
- [ ] Any backend/API/database the UI depends on is running (ask the user to confirm if unstated).
- [ ] A dedicated screenshots output folder exists for this task — create one, don't dump images
      into the repo's working tree or mix them with unrelated artifacts.
- [ ] A Playwright (or Puppeteer) install is available *somewhere* on the machine — a global
      npm install is enough; you do not need it in the current project's `node_modules`. Detect it
      rather than assuming a path:
      - Node: `npm root -g` → check for `playwright` inside that directory.
      - If nothing is found and installing a full browser download isn't possible, run
        `npm install -g playwright` (or `puppeteer-core`) — installing the **package** does not
        require downloading a browser as long as you point it at an existing local browser via
        `executablePath` (see Step 1).

## Step 1 — Launch a real browser with a CDP debug port

Both Playwright and Puppeteer can drive an **already-installed** browser via
`connectOverCDP`/CDP connection instead of downloading their own. Launch it once per session with a
**persistent user-data directory** so login/session cookies survive across script invocations:

```javascript
// launch-browser.js — run once, keep the process alive (async/background)
const { chromium } = require("<path-to-playwright-install>"); // see detection note above
const path = require("path");

(async () => {
  const context = await chromium.launchPersistentContext(
    path.join("<SCREENSHOTS_FOLDER>", "browser-profile"),
    {
      executablePath: "<path-to-installed-chrome-or-chromium>",
      headless: false,
      args: ["--remote-debugging-port=9333"]
    }
  );
  console.log("launched, CDP available on port 9333");
})();
```

Common browser executable locations (pick the one that exists on the current OS):

| OS | Typical Chrome path |
|---|---|
| Windows | `C:\Program Files\Google\Chrome\Application\chrome.exe` |
| macOS | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` |
| Linux | `/usr/bin/google-chrome` or `/usr/bin/chromium-browser` |

Run the launch script as a long-lived background/async process — do not wait for it to exit. Use a
fresh profile directory the first time so the user can log in manually once; it persists on
subsequent runs of the same task.

## Step 2 — Reusable "step" script pattern

For every subsequent action (navigate, click, screenshot), write a small throwaway script and run
it with `node`. Always:

- Require the Playwright/Puppeteer module from wherever it was detected in Step 0 — a bare
  `require("playwright")` fails if the current working directory has no local install of it.
- Connect with `connectOverCDP("http://localhost:9333")` (or your chosen port), then reuse the
  **existing** browser context/page — do not call `.launch()` again, which starts a second,
  disconnected browser instance.
- If the context's page list is empty (browser tab was closed or never opened), create a new page
  rather than erroring out.
- **Always exit the process explicitly on both success and failure** — a script that leaves the
  CDP connection open without calling `process.exit()` will hang indefinitely.
- When building file paths from a shell script into an embedded JS string, prefer forward slashes
  or a path-join utility — manual backslash interpolation across a shell layer and a JS string
  layer is a common source of silently mangled paths.

```javascript
// pw-step.js — one action, run fresh each time
const { chromium } = require("<path-to-playwright-install>");

(async () => {
  const browser = await chromium.connectOverCDP("http://localhost:9333");
  const context = browser.contexts()[0];
  let page = context.pages()[0];
  if (!page) page = await context.newPage();

  await page.setViewportSize({ width: 1400, height: 900 });
  await page.goto("<APP_URL>", { waitUntil: "networkidle", timeout: 60000 });
  await page.screenshot({ path: "<SCREENSHOTS_FOLDER>/01-example.png" });

  process.exit(0);
})().catch((e) => {
  console.error(e);
  process.exit(1);
});
```

## Step 3 — Reaching states that normal navigation can't (the debug-hook technique)

Many components worth screenshotting (limit/error dialogs, rare confirmation flows, empty/loading
states) require account data or app state that doesn't naturally exist in a dev/test environment,
and forcing them through the UI is slow and fragile. Instead, render them directly the same way the
app does, via a **temporary** debug hook.

**The key idea:** almost every UI framework has a single chokepoint used to open a modal/dialog/
toast programmatically — a service, a context provider, a global store action. Find that
chokepoint and call it directly with mock data. This is a faithful reproduction of the real
rendering (the same code path the app itself uses), not a stub or a visual mock. See
[references/framework-adapters.md](references/framework-adapters.md) for concrete examples in
Angular, React, and Vue.

High-level steps:

1. **Learn each target's data contract** — read its source to find what props/inputs/context data
   it expects, and what services it depends on.
2. **Add a temporary hook** in an always-mounted top-level component that already has access to the
   dialog/modal mechanism. Expose a function on `window` (e.g. `window.__openTestState(key)`) that
   looks up and invokes the right "open" call with realistic mock data per key.
3. **Fix dependency-injection errors** if a target depends on state/services only available deeper
   in the app's component tree — see the framework-specific notes for how to scope a temporary
   provider/context around just the debug call.
4. **Capture each target in one script** — loop through target keys, calling the hook, waiting
   briefly for render, screenshotting, then dismissing before the next one.
5. **Revert the hook — non-negotiable.** It must never be committed:
   ```
   git diff --stat <path-to-hooked-file>   # confirm the change is only the debug hook
   git checkout -- <path-to-hooked-file>   # revert
   git status --short                       # confirm a clean tree
   ```
   Wait for the dev server to rebuild once more and confirm the app compiles cleanly, so the
   running app matches the reverted source before you finish.

Full worked example (Angular `ModalService`, TypeScript, handling a route-scoped dependency
injection error) is in
[references/framework-adapters.md](references/framework-adapters.md).

## Step 4 — Organize and document the output

- Save screenshots with sequential, descriptive filenames (`19-download-limit-modal.png`, not
  `screenshot1.png`) into the dedicated output folder from the prerequisites step.
- Write or update a `README.md` in that folder listing what each screenshot shows and, for each
  one, **how it was captured** — live UI navigation vs. the debug-hook technique — so a reviewer
  knows which were reproduced through normal user flows vs. direct programmatic rendering.
- Do not zip, upload, or attach the folder anywhere unless the user explicitly asks.

## Common pitfalls

See [references/troubleshooting.md](references/troubleshooting.md) for the full table of symptoms
and fixes (empty page list, mangled paths, hanging scripts, missing local module, DI/provider
errors, stale modal state between captures).

