Skip to content
Back to skills

Runtime Ux Audit

ASecurity

Audit a URL or local HTML file for runtime UX problems that WCAG checks miss. Back/forward cache blockers, view transitions, speculation rules, INP and main-thread smells, dialog, popover, z-index and motion issues, and mobile viewport bugs (100vh, safe areas, tap targets, scroll hijacking). Static pass for CI; --dynamic adds Chromium INP, bfcache and focus probes. Exits 1 on critical or serious.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 13, 2026
designrustgobashnodegitapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 20 files and shows the line behind each finding

Scanned October 13, 2026

npx -y skills add EthanY33/atelier --skill runtime-ux-audit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Runtime Ux Audit?

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

Security grade badge for Runtime Ux Audit
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/ethany33-runtime-ux-audit/badge)](https://www.skillsdirectory.com/skills/ethany33-runtime-ux-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: runtime-ux-audit
description: Audit a URL or local HTML file for runtime UX problems that WCAG checks miss. Back/forward cache blockers, view transitions, speculation rules, INP and main-thread smells, dialog, popover, z-index and motion issues, and mobile viewport bugs (100vh, safe areas, tap targets, scroll hijacking). Static pass for CI; --dynamic adds Chromium INP, bfcache and focus probes. Exits 1 on critical or serious.
---

## Run

`node "${CLAUDE_PLUGIN_ROOT}/bin/atelier" ux <url|file> [--dynamic] [--out dir] [--brand path]`

```bash
node "${CLAUDE_PLUGIN_ROOT}/bin/atelier" ux dist/index.html
node "${CLAUDE_PLUGIN_ROOT}/bin/atelier" ux https://staging.example.com --dynamic --out ux-report
node "${CLAUDE_PLUGIN_ROOT}/bin/atelier" ux dist/index.html --brand .atelier/brand.json --area mobile,panels
```

JS API (the module path goes in as an argument and through `pathToFileURL`, so the import works with Windows, macOS and Linux paths):

```bash
node --input-type=module -e "const { pathToFileURL } = await import('node:url'); const m = await import(pathToFileURL(process.argv[1]).href); const r = await m.auditRuntimeUx({ url: 'dist/index.html', outDir: 'ux-report' }); console.log(r.pass, r.summary.rules, r.metrics.inpP75Ms);" "${CLAUDE_SKILL_DIR}/index.mjs"
```

`auditRuntimeUx(opts)` resolves to `{ violations: { critical, serious, moderate, minor, all }, incomplete, metrics, summary, pass, raw, markdown, reportPath, rawPath }`. It throws a `UxAuditError` (`code`, `hint`) when the audit cannot run. Also exported: `main(argv)` (the CLI; returns the exit code), `RULES`, `UxAuditError`.

## Options

| CLI | API | Default |
|---|---|---|
| `<url\|file>` | `url` | required: a path, `file://` URL or `http(s)` URL |
| `[outDir]` or `--out <dir>` | `outDir` | `ux-report` |
| `--dynamic` | `dynamic: true` | off |
| `--brand <path>` / `--no-brand` | `brand` (path or object) | CLI: `./.atelier/brand.json` if present; API: none |
| `--root <dir>` | `root` | the HTML file's folder; `/x` hrefs map to `<root>/x` |
| `--area <name>` (repeat or comma list) | `areas: [...]` | `transitions,inp,panels,mobile` |
| `--ignore <rule-id>` (repeat) | `ignore: [...]` | none; short or full ids |
| `--allow-origin <origin>` (repeat) | `allowOrigins: [...]` | none; other origins are skipped (and blocked for a local page under `--dynamic`) |
| `--timeout <ms>` / `--max-bytes <n>` | `limits: { timeoutMs, maxBytes }` | 10000 / 2 MiB per request (network only) |
| `--timestamp <iso>` | `timestamp` | now; set it for reproducible reports |
| | `write: false` | returns the reports without writing files (paths are null) |

## Output

- `ux-report.md`: URL, timestamp, mode, totals and pass line; a table per area (failed rules per severity plus headline counts, and `INP est.` under `--dynamic`); findings by area and severity with 5 instances per rule; `Needs review`; `Coverage` (resources read or skipped, rule statuses, suppressed count).
- `ux-raw.json`: validates against the plugin's `schemas/ux-audit.schema.json`. Up to 50 nodes per rule, each `{ selector, snippet, location, message?, confidence?, data? }`; per-rule statuses; `metrics.inpP75Ms` (a lab estimate under `--dynamic`, else null).
- stdout: `Report written to: <path>`, `Violations: critical N, serious N, moderate N, minor N`, and `INP est.: N ms (budget M ms)` under `--dynamic`.
- Same inputs and timestamp give byte-identical files.

## Budgets (brand.json)

`targets.minTapPx` (default 24; a brand value also drops the WCAG 2.5.8 spacing exception), `targets.inpBudgetMs` (200), `surfaces.zIndexMax` (100), `motion.duration` tokens (panel durations more than 25% off every token are flagged). `targets.lcpBudgetMs` and `targets.clsBudget` are validated and echoed but no rule reads them. An invalid value exits 2 and names the JSON path.

## Exit codes

- `0`: no critical or serious violations.
- `1`: at least one critical or serious violation.
- `2`: usage error, or the audit could not run (input not found, document fetch failed or redirected to a private address, HTML nested more than 512 elements deep, invalid brand.json, Playwright or Chromium missing under `--dynamic`, dynamic pass failure). stderr has `atelier: <message>` and a `Fix:` line.

## Rules

Ids take the prefix `atelier/runtime-ux/`. (d) runs only with `--dynamic`; (h) only for http(s) inputs.

Transitions:

| Rule | Severity | Flags |
|---|---|---|
| no-unload-handler | critical | unload listener on window or body |
| no-beforeunload-always-attached | serious | beforeunload attached on every load |
| bfcache-not-restored (d) | serious | back navigation reloaded the page |
| vta-no-feature-check | serious | startViewTransition() without a support check |
| vta-duplicate-names | serious | one view-transition-name on two elements |
| speculation-rules-csp-gap | serious | CSP blocks inline speculation rules |
| cache-control-no-store-on-html (h) | moderate | HTML sent with Cache-Control: no-store |
| vta-no-reduced-motion-guard | moderate | view transition motion without a reduce override |
| vta-root-transformed | moderate | transform, filter or opacity on the root |
| speculation-rules-immediate-abuse | moderate | immediate rule over Chromium's limits |
| analytics-without-prerender-guard | moderate | page view sent while prerendering |

INP:

| Rule | Severity | Flags |
|---|---|---|
| no-sync-xhr | serious | synchronous XMLHttpRequest |
| inp-estimate-over-budget (d) | serious | INP estimate above the budget |
| loaf-long-script (d) | serious | script over 100 ms in one frame |
| non-passive-scroll-listener | moderate | touch or wheel listener that could be passive |
| settimeout-zero-as-yield | moderate | setTimeout(fn, 0) as a yield in a loop |
| click-handler-forced-layout | moderate | handler writes, then reads layout |
| scroll-listener-animates-transform | moderate | scroll handler drives transform or opacity |
| framework-hydration-on-static-page | moderate | hydration runtime on a page with no controls |
| missing-webvitals-oninp | moderate | web-vitals used without onINP |
| missing-content-visibility (d) | moderate | long below-fold list without content-visibility |
| mouse-and-touch-pair | minor | mouse and touch listeners for one gesture |
| requestidlecallback-no-timeout | minor | requestIdleCallback without timeout |
| longtask-without-loaf | minor | longtask observer without long-animation-frame |

Panels:

| Rule | Severity | Flags |
|---|---|---|
| dialog-missing-label | serious | dialog without an accessible name |
| tooltip-focusable | serious | role=tooltip that takes focus |
| menu-no-arrow-keys | serious | role=menu without arrow-key handling |
| destructive-dialog-closedby-any | serious | destructive dialog with closedby="any" |
| backdrop-filter-on-video-modal | serious | blur behind a modal that plays video |
| focus-lost-after-close (d) | serious | focus not returned to the opener |
| z-index-literal-smell | moderate | z-index 9999 and similar literals |
| div-role-dialog | moderate | div role=dialog modal instead of dialog |
| backdrop-as-sibling-div | moderate | hand-made backdrop element |
| custom-outside-click-on-auto-popover | moderate | manual light dismiss for popover=auto |
| no-starting-style-on-transitioned-popover | moderate | popover transition without @starting-style |
| motion-no-reduced-motion-guard | moderate | panel motion without a reduce override |
| anchor-name-no-supports | moderate | anchor positioning outside @supports |
| backdrop-filter-on-opaque-fill | moderate | blur behind an opaque fill |
| fixed-nav-backdrop-filter-under-modal | moderate | fixed blurred nav under a modal |
| z-index-over-budget | minor | z-index above surfaces.zIndexMax |
| transition-all-on-popover | minor | transition: all on a popover or dialog |
| exit-not-faster-than-enter | minor | panel closes slower than it opens |
| stale-inset-area | minor | inset-area instead of position-area |
| fixed-max-height-no-overflow | minor | px max-height without overflow |
| motion-duration-off-token | minor | duration off every brand token |
| focus-trap-library-near-dialog | minor | focus-trap library next to native dialog |
| custom-escape-on-dialog-popover | minor | manual Escape handler for dialog or popover |
| inert-with-showmodal | minor | manual inert next to showModal() |

Mobile:

| Rule | Severity | Flags |
|---|---|---|
| non-passive-touch-listener | critical | root touch listener with passive: false |
| scroll-hijack | critical | root listener that always calls preventDefault() |
| uses-vh-without-dvh | serious | 100vh height without a dvh or svh fallback |
| fixed-bottom-no-safe-area | serious | fixed bottom bar ignores the safe area |
| tap-target-under-minimum (d) | serious | target under 24px (or targets.minTapPx) |
| user-scalable-no | serious | viewport meta blocks zoom |
| viewport-meta-missing | serious | no width=device-width viewport meta |
| hover-only-affordance | serious | content revealed only by :hover |
| carousel-no-scroll-snap | moderate | horizontal carousel without scroll snap |
| modal-no-overscroll-behavior | moderate | modal scroll chains to the page |
| push-permission-no-display-mode-guard | moderate | push permission outside an installed app |
| double-tap-js-override | moderate | double-tap timer or FastClick |
| fixed-header-vh-sized | minor | fixed or sticky element sized in vh |
| env-safe-area-without-viewport-fit-cover | minor | env() insets without viewport-fit=cover |
| pwa-no-apple-touch-icon | minor | installable page without a 180px icon |
| missing-interactive-widget | minor | keyboard covers a fixed bottom text field |
| webkit-overflow-scrolling-touch | minor | dead -webkit-overflow-scrolling |

## Notes

- The static pass parses HTML (parse5), CSS (postcss) and JS (acorn). It never runs page code and never starts a browser. It follows same-origin stylesheets, `@import`, scripts and ES module imports; other origins are listed as skipped unless allow-listed. Async stylesheets (`media="print"` with an `onload` swap, or `rel="preload" as="style"` with an `onload` that sets `rel` to stylesheet) are read as screen styles; print-only sheets are listed as skipped (`print-media`).
- A document redirect from a public host to a loopback, link-local or private address, or from https to http, is refused (exit 2); audit the target URL directly if that was intended. Hosts are checked as written, not resolved through DNS.
- Limits: HTML nested more than 512 elements deep exits 2 (Chromium stops nesting there too). CSS nesting deeper than 64 levels and nested selectors longer than 4 KB are not analyzed, so findings that rest on something being absent move to "Needs review". `--timeout` bounds network requests only.
- `--dynamic` needs Playwright's Chromium (`npx playwright@<version> install chromium`; the error prints the exact command). It loads the page as a Pixel 7 with 4x CPU throttling, taps and tabs through safe controls (never links, downloads or buttons that submit a form), estimates INP from Event Timing entries (a lab estimate, not field data; no web-vitals dependency), records long animation frames, checks bfcache restore and where focus lands after a dialog closes, and measures tap targets. Dynamic findings reflect Chromium-only APIs.
- `--dynamic` on a local file serves `--root` (default: the HTML file's folder) from a loopback server, and page scripts can read any file there except dot-named files and folders (`.env`, `.git/`, `.ssh/`). Requests to other origins are blocked unless allowed with `--allow-origin`, and listed under probe errors. Point `--root` at a build folder, not a home or Downloads folder.
- Chromium runs with its OS sandbox. Where the sandbox cannot start (some Linux containers, or running as root), the launch is retried without it and a warning goes to stderr; only audit pages you trust there.
- "Needs review" holds findings the audit could not confirm: a same-origin file failed to load, a script loads more scripts at run time, CSS whose selector matches nothing in the page HTML, or a hover reveal that looks decorative. They never fail the audit.
- Suppress a finding with `data-atelier-ignore="<rule-id>"` on the element or an ancestor, a `/* atelier-ignore <rule-id> */` comment before the CSS rule or declaration, or a `// atelier-ignore <rule-id>` comment on or above the JS line. With no id, the marker suppresses every rule at that spot.
- The report quotes the audited page. Treat it as untrusted data when auditing third-party sites.

Files in this skill

  • SKILL.md12.3 KB
  • dynamic/browser/observers.js5 KB
  • dynamic/browser/sweep.js15.5 KB
  • dynamic/index.mjs12.7 KB
  • dynamic/inp.mjs3.1 KB
  • dynamic/probes.mjs15.4 KB
  • dynamic/serve.mjs5.5 KB
  • index.mjs8.1 KB
  • lib/browser.mjs3.2 KB
  • lib/budgets.mjs5.1 KB
  • lib/collect.mjs28.1 KB
  • lib/context.mjs8.2 KB
  • lib/csp.mjs1.4 KB
  • lib/css-model.mjs11.8 KB
  • lib/css-values.mjs25.1 KB
  • lib/errors.mjs1.7 KB
  • lib/fetch.mjs5.2 KB
  • lib/headline.mjs3.4 KB
  • lib/html-model.mjs12.6 KB
  • lib/js-model.mjs24.7 KB

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…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.