Skip to content
Back to skills

Responsive Audit

ASecurity

Audit a web interface at phone, tablet and desktop widths with Playwright, measure what's broken (horizontal overflow, small touch targets, tiny text, console errors), make a contact sheet to review by eye, fix, and re-run until clean. Use when the user asks to check or fix mobile, responsive or "looks wrong on my phone" issues.

  • 4 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 6, 2026
ai-agentsgonode

Works with

  • cli

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 6, 2026

npx -y skills add ham7a311/Vitrine --skill responsive-audit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Responsive Audit?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Responsive Audit
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ham7a311-responsive-audit/badge)](https://www.skillsdirectory.com/skills/ham7a311-responsive-audit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: responsive-audit
description: Audit a web interface at phone, tablet and desktop widths with Playwright, measure what's broken (horizontal overflow, small touch targets, tiny text, console errors), make a contact sheet to review by eye, fix, and re-run until clean. Use when the user asks to check or fix mobile, responsive or "looks wrong on my phone" issues.
---

# /responsive-audit

Measure first, then look, then fix. Numbers catch overflow you can't see; your eyes catch layouts that are technically fine and still wrong.

## 1. Set up

- The app must be running locally (for example `npm run dev`). Find its URL and the pages to check: every route, or the pages the user named.
- Playwright must be available: `npm i -D playwright && npx playwright install chromium` if it isn't.
- Work in a scratch directory, not in the project.

## 2. Run the measurement

Save this as `audit.mjs` in the scratch directory and run it:

```
node audit.mjs <base-url> <out-dir> <path> [path…]
```

For example `node audit.mjs http://localhost:3000 ./audit / /pricing /about`.

```js
// Responsive audit: measures each page at three widths, saves a screenshot per page and width,
// writes report.json, and builds sheet.html (a contact sheet) to review by eye.
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";

const [base, out, ...paths] = process.argv.slice(2);
if (!base || !out || !paths.length) {
  console.error("usage: node audit.mjs <base-url> <out-dir> <path> [path…]");
  process.exit(1);
}
const WIDTHS = [
  { name: "phone", width: 375, height: 812, mobile: true },
  { name: "tablet", width: 768, height: 1024, mobile: true },
  { name: "desktop", width: 1280, height: 800, mobile: false },
];
fs.mkdirSync(out, { recursive: true });
const browser = await chromium.launch();
const report = [];

for (const p of paths) {
  for (const w of WIDTHS) {
    const ctx = await browser.newContext({ viewport: { width: w.width, height: w.height }, isMobile: w.mobile, hasTouch: w.mobile });
    const page = await ctx.newPage();
    const errors = [];
    page.on("console", (m) => m.type() === "error" && errors.push(m.text().slice(0, 200)));
    page.on("pageerror", (e) => errors.push(String(e).slice(0, 200)));
    await page.goto(new URL(p, base).href, { waitUntil: "load", timeout: 60000 });
    await page.waitForTimeout(1200);
    const m = await page.evaluate(() => {
      const W = innerWidth;
      const visible = (el) => {
        const s = getComputedStyle(el);
        return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0.05;
      };
      // Covered by something else (an overlay, a sticky bar)? Then it isn't what the user sees.
      const onTop = (el) => {
        const r = el.getBoundingClientRect();
        const x = r.left + r.width / 2;
        const y = r.top + r.height / 2;
        if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return true;
        const hit = document.elementFromPoint(x, y);
        return !hit || hit === el || el.contains(hit) || hit.contains(el);
      };
      // An element past the right edge only matters if no ancestor clips it.
      const clipped = (el) => {
        for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
          const s = getComputedStyle(a);
          if (s.overflowX !== "visible" || s.clipPath !== "none") {
            const r = a.getBoundingClientRect();
            if (r.right <= W + 2 && r.left >= -2) return true;
          }
        }
        return false;
      };
      const label = (el) => `${el.tagName.toLowerCase()}${el.id ? "#" + el.id : ""}${typeof el.className === "string" && el.className ? "." + el.className.split(" ")[0] : ""}`;
      const overflow = [];
      for (const el of document.body.querySelectorAll("*")) {
        const r = el.getBoundingClientRect();
        if (!r.width || !r.height) continue;
        if ((r.right > W + 2 || r.left < -2) && visible(el) && !clipped(el)) {
          overflow.push(`${label(el)} spans ${Math.round(r.left)}→${Math.round(r.right)}px`);
          if (overflow.length >= 5) break;
        }
      }
      const small = [];
      for (const el of document.querySelectorAll("a[href],button,input:not([type=hidden]),select,textarea,[role=button],[role=tab],[role=checkbox],[role=radio],[role=switch],[role=slider],[role=option]")) {
        const r = el.getBoundingClientRect();
        // Screen-reader-only elements (1px boxes) aren't targets anyone taps.
        if (r.width <= 2 || r.height <= 2 || !visible(el) || !onTop(el)) continue;
        if (r.width < 24 || r.height < 24) small.push(`${(el.getAttribute("aria-label") || el.textContent || label(el)).trim().slice(0, 30)} (${Math.round(r.width)}×${Math.round(r.height)})`);
      }
      const tiny = [];
      for (const el of document.body.querySelectorAll("*")) {
        if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
        const box = el.getBoundingClientRect();
        if (box.width <= 2 || !visible(el) || !onTop(el)) continue;
        const size = parseFloat(getComputedStyle(el).fontSize);
        if (size < 10.5) tiny.push(`"${el.textContent.trim().slice(0, 24)}" at ${size}px`);
      }
      return { scrollWidth: document.documentElement.scrollWidth, width: W, overflow, small, tiny };
    });
    const file = `${p.replace(/[^a-z0-9]+/gi, "_") || "home"}-${w.name}.png`;
    await page.screenshot({ path: path.join(out, file) });
    report.push({ path: p, viewport: w.name, file, errors, ...m, horizontalScroll: m.scrollWidth > m.width + 1 });
    await ctx.close();
  }
}
await browser.close();

fs.writeFileSync(path.join(out, "report.json"), JSON.stringify(report, null, 2));
const cells = report.map((r) => `<figure><img src="${r.file}"><figcaption>${r.path} · ${r.viewport}${r.horizontalScroll || r.overflow.length ? " · OVERFLOW" : ""}</figcaption></figure>`).join("");
fs.writeFileSync(path.join(out, "sheet.html"), `<!doctype html><meta charset="utf-8"><style>body{margin:0;padding:16px;background:#111;color:#ddd;font:12px system-ui;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}figure{margin:0}img{width:100%;border:1px solid #333}</style>${cells}`);

for (const r of report) {
  const issues = [];
  if (r.horizontalScroll || r.overflow.length) issues.push(`overflow: ${r.overflow.join("; ") || `page is ${r.scrollWidth}px wide`}`);
  if (r.small.length) issues.push(`${r.small.length} small targets: ${r.small.slice(0, 4).join(", ")}`);
  if (r.tiny.length) issues.push(`${r.tiny.length} tiny text: ${r.tiny.slice(0, 3).join(", ")}`);
  if (r.errors.length) issues.push(`console: ${r.errors[0]}`);
  console.log(`${r.path} [${r.viewport}] ${issues.length ? issues.join(" | ") : "ok"}`);
}
console.log(`\nReport: ${path.join(out, "report.json")}  Contact sheet: ${path.join(out, "sheet.html")}`);
```

## 3. Look

Open `sheet.html` in a browser, or screenshot it with Playwright if you can't open files, and review every cell. The script can't see these, so look for them:

- A desktop layout squeezed into a phone: side-by-side columns, a sidebar taking most of the width.
- Text overlapping other text, or controls covering content (arrows over cards, sticky bars over headings).
- Content cut off at the bottom of a fixed-height box with no way to scroll to it.
- Huge empty areas, or everything crammed into the top third.
- Tablet: the awkward middle, where desktop grids are too narrow and phone stacks are too wide.

## 4. Judge what the numbers mean

- **Overflow** (the page scrolls sideways, or an element pokes out) is always a bug. Fix it at its cause: a fixed width, a `nowrap`, a long word or URL, a grid without `minmax(0, 1fr)`, a transform or scale that grows past its box. Don't hide it with `overflow-x: hidden` on the body.
- **Small targets** under 24×24px fail WCAG 2.5.8 unless there's spacing around them or an equivalent larger control. On touch, grow the hit area (padding, or an `::after` with negative inset) rather than the visual.
- **Tiny text** under 10.5px: fine for deliberate uppercase mono labels with wide tracking; not fine for anything a person has to read.
- **Console errors**: fix them all; hydration and key warnings often come from layout code.

## 5. Fix, then re-run

- Prefer the component's own container width (container queries, `%`, `min()`) over new viewport breakpoints, so the component works wherever it's placed.
- If a preview or demo renders components inline inside a narrower box, remember `@media` still sees the window. Test in a real viewport, or render the demo in an iframe.
- Re-run the script after the fixes. Finish only when overflow and console errors are at zero and every cell of the contact sheet looks deliberate.

## 6. Report

List what failed, what you changed per page, and anything you left alone on purpose (and why).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…