---
name: web-audit
description: "Audit a URL for modern web quality and (optionally) fix it. This is a FULLY AGENTIC audit. YOU
  (the model) gather multi-modal evidence about the page with the generic evidence primitives,
  decide for yourself which tools to run (Lighthouse, axe, your own ad-hoc static tests), reason
  over that evidence, and judge every principle, then emit a findings report. There are no
  hard-coded checks and no fast path. The principles are the spec, this skill is the method, and
  you supply all the intelligence. Use when asked to web-audit, UX-audit, uplift, modernise, or
  quality-audit a site."
---

# Web audit (fully agentic)

Audit `$ARGUMENTS`: a URL, plus optional flags:

- `--out <dir>` - report directory (default `reports/<host>/`).
- `--source <dir>` - path to the site's local source, so you can read the
  authored HTML/CSS/JS, not just the rendered output. The tree is REDACTED
  before it is inlined: credential-named field values become `[redacted]`, and
  credential-named files are skipped unread (see the `dom` row below).
- `--fix` - after auditing, apply fixes to local source and re-audit (requires
  `--source`). Without `--fix`, this is report mode (critique only).
- `--findings <path>` - a pre-aggregated `report.json` to start fix mode from,
  so you can skip the baseline audit and go straight to the hill-climb.
- `--max-iterations <n>` - cap on fix-mode passes (default ~4); stop earlier if
  no outstanding `issues` remain.
- `--expected <file>` - a ground-truth file to self-score against (used for the
  playground eval).

## The two ways to run this (the DEFAULT is your own session)

1. **In your own agent session (DEFAULT, uses your subscription).** You are
   already a capable model in a session (Claude Code, Codex, Gemini, Antigravity,
   Copilot, opencode). Just follow this file: run `/web-audit <url>` for an audit,
   or `/web-audit <url> --source <dir> --fix` for the hill-climb, doing the
   reasoning yourself and calling `node evidence/cli.mjs ...` for evidence. No
   headless runner, no `claude -p`, no extra API billing. This is the path an
   individual should use.
2. **Headless / CI / batch (uses API tokens).** `npm run audit`/`npm run fix`
   (or `web-uplift audit`/`web-uplift fix`) spawn an agent CLI in `-p`/`exec`
   mode to drive this same skill unattended. That bills API tokens, so it is for
   automation, not the default for a person. Same methodology either way.

## The contract: you are the auditor, not a runner

There is **no deterministic check runner** and there are **no fast paths** in
this project. Nothing decides for you whether a principle passes. You decide,
by gathering evidence and reasoning over it. The repo gives you two declarative
inputs and one generic capability:

1. **Principles** - [knowledge/principles.json](../../../knowledge/principles.json)
   in this repo, or `../knowledge/principles.json` when this skill is installed
   under `.web-uplift/skill/`:
   the spec of what good looks like, as OUTCOMES. Seventeen principles: Una
   Kravets' five modern-UX principles (respect-user-preferences,
   implement-natural-interactions, provide-guided-navigation,
   maximize-content-reduce-noise, adapt-to-the-form-factor);
   support-core-task-success; two
   widened/narrowed Lighthouse-dimension principles (be-inclusive - the former
   be-accessible, widened beyond WCAG conformance; follow-best-practices -
   narrowed so it is no longer the catch-all for security and forms); two
   unchanged Lighthouse-dimension principles (be-fast-and-stable,
   be-discoverable); six framework-derived principles
   (be-private-and-secure, be-resilient, be-internationalised, be-trustworthy,
   be-sustainable, be-agent-ready); and be-memory-efficient (derived from the
   memory-tracer leak-audit methodology). Each check has a `detectableVia` HINT (may
   MENTION candidate evidence/tools, MANDATES nothing), a `guides` list of
   Modern Web Guidance ids and/or query strings, and may have `references` for
   non-MWG standards, methods, or optional tools. These are declarative pointers
   you consult to set the bar, not hard-coded tests. Each principle also has an
   `applicability` block (`expectation: default | contextual`); see step 0 and
   step 4. The full coverage map (all 137 mwg guides -> principles) and the
   rationale for the set is in
   [docs/principles-analysis.md](../../../docs/principles-analysis.md).
2. **Guidance** - Modern Web Guidance via the `modern-web-guidance` npm feed
   (the *how*). See [knowledge/guidance.md](../../../knowledge/guidance.md) in
   this repo, or `../knowledge/guidance.md` when installed: `search`
   to find the recommended approach, `retrieve` to get the fix detail. Pin the
   catalog version in `principles.json` (`guidanceCatalogVersion`, currently
   `modern-web-guidance@0.0.193`) unless `web-uplift.json` overrides it.
3. **Evidence primitives** - [evidence/cli.mjs](../../../evidence/cli.mjs): a
   generic, judgement-free CLI you call to gather evidence. It launches the
   system Chrome and drives it over raw CDP (chrome-remote-interface). It returns
   data and artifacts; it never decides anything.

## Non-negotiable atomic coverage contract

**The checks in `principles.json`, not the 17 principle headings, are the audit
denominator.** A broad principle judgement is never a substitute for executing
and recording each defined check. At the current catalog version that means 58
check outcomes per audited site; derive the number from the file rather than
hard-coding 58.

Before gathering evidence:

1. Materialise a coverage manifest containing every expected
   `(principleId, checkId)` pair from the exact `principles.json` used by the run.
   Record its catalog version and checksum.
2. Create one planned result slot for every pair. Never invent aliases,
   aggregate several checks into a generic review, or replace a check with an
   easier proxy.
3. Decide the evidence method and target path/condition for **each check**. One
   artifact may support several checks, but every check must point to the
   evidence and reasoning that establishes its own outcome.

For every expected pair, emit exactly one `checkOutcomes` row with one of:

- `pass` — direct evidence supports this check; absence of a finding is not
  evidence and cannot produce a pass.
- `issues` — direct evidence shows a failure, linked to finding IDs.
- `not-applicable` — the check genuinely does not apply, with a check-specific
  rationale. This does **not** mean untested, unavailable, or difficult.
- `opted-out` — configuration explicitly excludes it, with the declared reason.
- `blocked` — execution was attempted but an external condition prevented it,
  with the attempted method and blocker.
- `not-run` — execution did not happen, with a reason. This is incomplete work,
  never N/A and never a pass.

A principle verdict is **derived from its check outcomes**:

- any `issues` => principle `issues`;
- otherwise any `blocked` or `not-run` => principle `incomplete`;
- otherwise all applicable checks `pass` => principle `pass`;
- a principle is `not-applicable`/`opted-out` only when all of its checks have
  that status with valid reasons.

A run may be saved with blocked/not-run rows, but it is `partial`, not
`completed`. It must report exact `executed/expected`, `blocked`, `not-run`,
`missing`, `unknown`, and `duplicate` counts. **Do not publish a score, pass
rate, “audited sites” count, or completed claim unless missing, unknown,
duplicate, blocked, and not-run are all zero.** Run the report coverage validator
before generating summaries or scorecards. Validator failure is audit failure,
not a warning to suppress.

This contract applies equally to interactive, batch, CI, and delegated audits.
Prompts, runners, schemas, database importers, and UIs must preserve the exact
check IDs and outcomes. A scale-oriented runner that only gathers a subset is an
evidence/recon pass and must not call its output an audit.

## The evidence primitives (your senses)

```sh
node evidence/cli.mjs <primitive> <url> [options]
```

Use whichever invocation resolves in your context (they run the SAME CLI):

- **In this repo:** `node evidence/cli.mjs <primitive> <url> [options]`.
- **Per-project install** (files vendored under `.web-uplift/`):
  `node .web-uplift/evidence/cli.mjs <primitive> <url> [options]`.
- **Global package install** (e.g. this skill loaded from a pi package, so no
  local copy exists): `web-uplift evidence <primitive> <url> [options]`, or
  `npx -y web-uplift evidence <primitive> <url> [options]` if `web-uplift` is not
  on `PATH`. This form works from any cwd and needs nothing vendored.

Pick the first that exists; the rest of this file writes `node evidence/cli.mjs`
for brevity, but the `web-uplift evidence ...` form is equivalent everywhere.

Primitives, all content- and tool-agnostic:

| Primitive | What it gives you | Key CDP |
|---|---|---|
| `screenshot` | a PNG (full or `--selector`-clipped) under any emulated condition | Page.captureScreenshot |
| `video` | an MP4 of an interaction window, frames assembled with ffmpeg; `--interact "<js>"` triggers the transition/animation | Page.startScreencast |
| `heap` | a readable heap summary (types/constructors by size); `--interact` to exercise first, for leak hunting. Take a baseline and a post-interaction snapshot and compare retained growth (the memory-tracer methodology) to judge `be-memory-efficient` | HeapProfiler.takeHeapSnapshot |
| `layout` | layout metrics, a CLS/layout-shift observer, long tasks, overflow at the current viewport | Page.getLayoutMetrics + observers |
| `dom` | DOM, computed styles for `--selector` list, page HTML/CSS, and (`--source <dir>`) the local source files. A `--source` read is REDACTED before it is inlined: the value of every credential-named field becomes `[redacted]` (the same names-based pass the HAR bodies use), and a file whose NAME says credential (`.env*`, `*credentials*`, `*secret*`, `*.pem`, `firebase.json`, `wrangler.toml`) is skipped unread and listed in `source.skippedFiles`. `source.redaction` records what was applied AND its residual: a secret that no credential-looking name carries still reaches the artifact, and a skipped file's contents are absent evidence. So a redacted source read is evidence, not ground truth about credential values. | DOM/CSS/Runtime |
| `evaluate` | runs your own `--expr "<js>"` in the page: ad-hoc probes and static tests you write on the spot | Runtime.evaluate |
| `trace` | a DevTools performance trace over the load (+ `--interact`): a devtools-loadable `trace.json` AND a compact `*-summary.json` (FCP/LCP, long tasks, total blocking time). Read the summary, never the raw trace | Tracing.start/end |
| `har` | a valid HAR 1.2 of the network over the load (+ `--interact`/`--duration`; `--bodies` to include response bodies) AND a compact `*-summary.json` of network SIGNALS: totals + by-resource-type, first/third-party origins by bytes, render-blocking candidates (grounded in the CDP initiator + priority + renderBlockingStatus when exposed, each with a stated `basis`; confirm against the DOM), weight offenders (largest/slowest), and hygiene (uncompressed text, missing cache headers, redirects, HTTP errors). Read the HAR summary, never the raw HAR. Feeds be-fast-and-stable (request weight, render-blocking), be-sustainable (bytes over the wire), and be-private-and-secure (third parties) | Network domain |
| `discoverability` | how much of the page a crawler that does NOT run JavaScript can see: fetches the RAW server HTML (a plain request, no JS) and diffs it against the rendered DOM, reporting `coveragePct` (share of rendered content words also present in the raw HTML), `isJsShell`, empty SPA mounts (`#root`/`#app`/`#__next` shipped empty), and whether the title/h1/meta-description survive without JS. Also captures a **browser-view vs crawler-view screenshot pair** (`-rendered.png` with JS on, `-crawler.png` with JS disabled) - for a shell site the crawler view is blank, the single most legible evidence. Reference both in the finding's `artifacts` so they render in the report and scorecard. This is the url-influence failure mode (JS-rendered SPAs reach AI crawlers and search as empty shells) made measurable per-site. Feeds `be-discoverable` and `be-agent-ready` | fetch + DOM |
| `secrets` | scans page HTML, inline scripts, external JS resources, and meta tags for exposed API keys, tokens, and credentials (AWS keys, Google API keys, Stripe keys, GitHub/Slack tokens, JWTs, private keys, DB connection strings, generic API keys). Returns structured findings with redacted matches. This is a DESCRIPTIVE signal, not a verdict: you MUST reason about each finding. Legitimate **public** API keys (Google Maps keys, Stripe *publishable* keys, Firebase config keys) are EXPECTED to be client-side and are NOT security issues. Actual **sensitive** secrets (AWS keys, Stripe *secret* keys, JWTs, private keys, DB connection strings with credentials, GitHub/Slack tokens) exposed client-side ARE critical `be-private-and-secure` failures. Look at the page context to determine which: is the key near a `<script src="https://maps.googleapis.com">` (legitimate Maps key) or in a config object with `secret_access_key` (critical leak)? Feeds `be-private-and-secure` | Runtime.evaluate + fetch |
| `headers` | inspects the main document's security response headers: Content-Security-Policy (present? unsafe-inline/unsafe-eval?), Strict-Transport-Security (HSTS), X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy. Returns structured presence/absence + issues per header. Descriptive signal — judge against `be-private-and-secure`. Missing CSP/HSTS/nosniff are security gaps; unsafe-inline/unsafe-eval weakens XSS protection. Pair with `cookies` for the full transport-security picture | Network domain |
| `cookies` | audits all cookies set by the page via CDP Network.getCookies: checks Secure flag, SameSite (Strict/Lax/None), HttpOnly, expiry duration, and whether each is third-party. Flags auth-like cookies (session/auth/token/id) that lack HttpOnly, cookies with SameSite=None, and long-lived (>365 day) third-party cookies. Descriptive signal — judge against `be-private-and-secure`. Long-lived third-party cookies also indicate tracking | Network domain |
| `trackers` | enumerates all third-party request origins during page load and matches against a built-in list of known tracker/analytics domains (Google Analytics, DoubleClick, Facebook, Hotjar, Segment, Mixpanel, etc.). Reports total origins, third-party count, known trackers found, and top third-party by request count. Descriptive signal — judge against `be-private-and-secure` (tracking footprint) and `be-sustainable` (third-party bytes). A large third-party count indicates a heavy privacy footprint | Network domain |
| `images` | inspects all `<img>` elements: checks for width/height attributes (CLS risk), loading="lazy" on below-fold images, oversized images (naturalWidth > 2x displayWidth), missing srcset/responsive sources, legacy vs modern formats (avif/webp/svg vs jpg/png/gif), and missing alt text. Descriptive signal — judge against `be-fast-and-stable` (missing dimensions cause CLS, oversized images waste bandwidth), `be-sustainable` (legacy formats, missing srcset), and `be-inclusive` (missing alt) | Runtime.evaluate |
| `console` | what the page logged while it was being measured: console errors and warnings, uncaught exceptions, and browser log errors/warnings (failed subresource requests, CSP violations, deprecations). Identical messages are deduplicated with a repeat count and counted in slices (`consoleErrorCount`, `warningCount`, `exceptionCount`, `networkErrorCount`) because a failed request is not the same finding as a page-authored error. `--interact` catches what only fires when the page is driven. This is the first-party path for `no-console-errors`: a probe that runs after load cannot see what fired during it. The same block rides along on every other primitive's output when the page logged something | Runtime.consoleAPICalled + Runtime.exceptionThrown + Log.entryAdded |
| `axe` | accessibility violations from the VENDORED axe-core, injected with the page's policy lifted for the injection only - the page is navigated and analysed with its policy enforced, and the result records the bypass - so strict `script-src` sites still work, grouped by impact with node targets and failure summaries. The `a11ytree` primitive is the follow-up for what axe reports as "incomplete" | Runtime.evaluate + Page.setBypassCSP |
| `targets` | WCAG 2.2 SC 2.5.8 target-size inventory: every pointer target's box in CSS px with `underMin` (< 24x24), plus the two exceptions geometry can read - `inlineInText` (text nodes beside the target in its parent, and not a block-level box) and `spacingPasses` (a 24px-diameter circle centred on the box clears every other target and every other undersized target's circle). Measured at BOTH a 1280x720 desktop and a 360x800 narrow layout by default (`--viewport` gives one pass) because the answer differs by form factor. `underMinNoKnownExemptionCount` is the number that needs your judgement, not a verdict | DOM + Runtime |
| `features` | modern-CSS and overlay census from the LIVE CSSOM (document sheets and same-origin `@import`s, constructed/adopted sheets, shadow-root sheets, inline styles): at-rules, conditional preludes, declared properties, functions and pseudo-selectors with counts, a `tracked` 0-or-count row per feature the checks turn on, and the overlay census (native `dialog` / `[popover]` / `details` against `role=dialog`, `aria-modal` and high z-index). Read this instead of grepping `dom`'s capped css string. `censusComplete: false` means a cross-origin sheet could not be read, so a zero in `tracked` is not conclusive - the skipped URLs are listed. It deliberately carries NO Baseline table: check Baseline through Modern Web Guidance / webstatus.dev | CSS + DOM + Runtime |
| `resilience` | offline and installable evidence: service worker registrations and versions from the ServiceWorker CDP domain (each flagged with whether it belongs to the audited origin, because the domain also reports the browser's own workers) plus the page's controller, the resolved manifest fields/icons and the mechanical installability signals, then a REAL offline reload (`Network.emulateNetworkConditions` offline) reporting the net error or what actually rendered from cache, with an `-offline.png` artifact. A failed navigation with no worker is the "unusable offline" evidence; the same probe is the failure injection for `network-and-http-failure-states` | ServiceWorker + Network + Page |
| `a11ytree` | what assistive technology actually receives: the computed accessibility tree (roles, computed names, ignored subtrees with reasons, and the focusable/level/checked/... flags) - so an `aria-label` overridden by `aria-labelledby`, a name that comes from `title`, or a subtree hidden by an ancestor `aria-hidden` shows up here and NOT in a DOM-attribute probe - plus the REAL tab order walked with CDP key events, where each stop records the element, its on-screen state, its computed focus indicator, and whether it sits inside `aria-hidden` (which removes it from the a11y tree but NOT from the tab order). Judgement against `be-inclusive` names-and-roles and structure-and-focus. Capped by default at 400 projected nodes and 60 focus stops, with the caps echoed (tree.maxNodes, focusOrder.maxStops) whenever a sample is truncated; raise them with `--max-nodes <n>` and `--max-stops <n>` on a page bigger than that | Accessibility.getFullAXTree + Input.dispatchKeyEvent |

Common options the harness simply applies (you choose them, it does not):
`--emulate-media prefers-color-scheme=dark,prefers-reduced-motion=reduce`,
`--viewport 360x800`, `--wait <ms>`, `--selector <css>`, `--interact "<js>"`,
`--cpu-throttle <n>`, `--network slow-3g|fast-3g|slow-4g|fast-4g|mobile-lighthouse`,
`--locale de-DE`, `--timezone Asia/Tokyo`, `--out <path>`, `--source <dir>`,
`--no-screenshots`, `--max-nodes <n>`, `--max-stops <n>`, `--bodies`,
`--cdp-deadline <ms>` (bound every CDP attach/navigation wait; default 30000; env
`WEB_UPLIFT_CDP_DEADLINE_MS`), `--fetch-deadline <ms>` (bound each raw-HTML fetch
exchange; default 30000; env `WEB_UPLIFT_FETCH_DEADLINE_MS`).

**Truncated evidence is not absence.** Every cap a primitive applies is
reported - a sibling `<name>Total`/`<name>Chars` plus `<name>Truncated` when
the sample was cut (`dom` css/outerHTML, `secrets` findings and scanned
scripts, `cookies`, `images`, the `har` lists, `layout` samples, `trace` long
tasks, `targets` targets, `features` conditions/properties, `a11ytree`
nodes/stops) - and the same facts are warned on stderr. A miss in a truncated
sample is not evidence that the page lacks something, and it cannot produce a
`pass`: gather the rest before you judge, by probing the live DOM/CSSOM with
`evaluate --expr`, reading the `--source` tree, or re-running the primitive
under the condition that moves the cap. If you cannot gather the rest, the
check is `partial`, never `pass`. The same rule applies to the other honesty
signals: `features` sets `censusComplete: false` when a cross-origin sheet
could not be read, and `discoverability` refuses to score coverage when the
render produced no content (`coveragePct: null`) - read those fields before
trusting a zero. It likewise reports the whole comparison as UNKNOWN when the
raw document was never retrieved: a failed or timed-out fetch
(`--fetch-deadline`), or a non-2xx response - a 404 page is ABOUT the resource,
not the document, while a completed empty 200 stays usable, because observed
emptiness is real evidence. Every raw-derived COMPARISON field is then null
(`rawComparisonUsable: false` says why) - the coverage, the shell verdict, the
presence comparisons, the empty-mount list and the raw size stats - while the
RUN FACTS survive by design: `fetchedStatus` records the status when one
arrived, `fetchError` records the failure and `finalUrl` records the final URL once the exchange
resolves (the requested URL only when it failed before a response), so an operator reads a 404 as a status rather than as an absence. A fetch
failure is a network condition, so a short budget produces ABSENCE OF EVIDENCE,
never evidence of absence.

You may also run **any other tool you judge useful** at inspection time. None of
these is wired into the runtime; you invoke them yourself when they help:

- **Lighthouse**: `npx -y lighthouse <url> --output=json --quiet
  --chrome-flags="--headless=new"` for LCP/CLS/TBT and the a11y /
  best-practices / SEO audits. Do NOT add `--no-sandbox`: Lighthouse launches
  its own Chrome against the same untrusted page, and the auditor's posture
  (web-uplift-d2l) is that the OS sandbox stays ON unless Chrome cannot start
  it at all - running as root or in a restricted container - in which case add
  `--no-sandbox` knowingly, for that run only. Use it to corroborate the
  Lighthouse-dimension principles, or skip it and gather the same signal
  first-party with `layout` and `evaluate`.
- **axe-core**: use the `axe` evidence primitive
  (`node evidence/cli.mjs axe <url>`). It injects the VENDORED axe-core, lifting
  the page's Content-Security-Policy for that injection only: the page is
  navigated and analysed with its policy enforced, so a script the page's own
  policy blocks stays blocked, and the result records the bypass. It therefore
  works on strict-`script-src` sites where fetching axe from a CDN through
  `evaluate` silently fails. Returns violations grouped by impact with node
  targets and failure summaries. Or write your own contrast/label/role probes
  with `evaluate`.
- **Chrome DevTools MCP skills** (optional): if your agent environment exposes
  Chrome DevTools MCP, its `memory-leak-debugging` skill provides a stronger
  workflow for `be-memory-efficient`: baseline, target and final heap snapshots,
  memlab analysis, and common leak-pattern diagnosis. Do not make it a
  dependency; the package native `heap` primitive is still the default evidence
  path.
- **Your own static tests**: when no tool fits, write a probe with `evaluate`
  (e.g. focus an element and read its computed outline; diff two heap summaries;
  measure the same component in two containers). This is the point of leaning on
  the model: you can invent the test the situation needs.

## Method

### 0. Read the project config (if present) and set the bar

Before anything else:

1. **Read `web-uplift.json`** if one exists (at the site root, the `--source`
   dir, or passed explicitly). It conforms to
   [schema/config.schema.json](../../../schema/config.schema.json) and lets the
   developer declare `siteType`, `scope`, per-principle `optOut` (with a
   reason), and `intent`. **Honour it.** A declared `optOut` means you report
   that principle as `opted-out` with the developer's reason and do NOT count it
   as an issue. `intent` sets context you judge against; it does not silence
   findings. If no config is present, proceed with judgement (step 4). Record in
   the report's `config` field whether one was loaded.
2. **Consult the mapped guidance UP FRONT.** For each principle you will judge,
   look at its checks' `guides` lists and `search`/`retrieve` the relevant
   Modern Web Guidance guides from the live feed (at the pinned
   `guidanceCatalogVersion`) BEFORE you judge, so you set the bar from the
   current recommended approach rather than from memory. Also read any
   check-level `references`; these cover non-MWG standards, methods, and optional
   tools such as the Chrome DevTools MCP memory-leak-debugging skill for memory
   analysis. The `guides` and `references` entries are declarative pointers, not
   tests; you still decide what evidence proves the outcome. Cache
   `list`/`retrieve` for the run.

### 1. Recon and coverage (decide which pages to audit)

Use `dom` (with `--source` if you have it) and a `screenshot` to understand the
entry page: SPA or MPA, framework, the meaningful surfaces (routes, forms,
overlays, key flows). For a hash-routed SPA each route is reached as
`<url>#<route>` and a real reload is needed to re-run per-route styles; the
primitives navigate via about:blank already, so just pass the route URL. Note an
auth wall or bot block and stop with `status: blocked` if you cannot proceed.

Then decide COVERAGE, do not just audit the homepage. A site is its templates,
and most quality problems hide on the pages people actually use (articles,
product or detail pages, listings, forms, checkout, search, account). Work out
whether to navigate and where:

- Discover candidate routes: read the nav/menu and prominent internal links from
  the DOM; fetch `/sitemap.xml` and `/robots.txt` (an `evaluate` `fetch(...)`, or
  point a primitive at those URLs); check the web app manifest (the
  `<link rel="manifest">` target) for `start_url` and `shortcuts`; for an SPA,
  enumerate routes from the router or the visible links.
- Select a REPRESENTATIVE set, not every URL: the homepage PLUS one example of
  each distinct page archetype/template the site has, e.g. a content/article or
  detail page, a section or listing/index page, a primary form or interactive
  flow, search results, and any surface a specific principle targets. Aim for a
  handful (roughly 4 to 8) covering the distinct templates and the
  highest-traffic or highest-risk journeys. De-duplicate pages that share a
  template (audit one, note it represents the rest).
- Justify it: in the plan and in the report's `paths`, list each page you will
  audit and WHY (which archetype or journey it represents), and state explicitly
  what you are NOT covering and why. No silent homepage-only. If the site
  genuinely is a single page, say so as an explicit judgement, do not assume it.

Principles are then judged across this set: some are global (security headers,
manifest, transport) and judged once; many are per-page (layout, color-scheme,
headings, content, forms) and must be checked on the representative pages, not
only the entry URL. Aggregate findings across pages and record which page each
came from.

**User flows.** When the important surfaces are reached by a JOURNEY (a checkout,
a signup, a search, an SPA route sequence) rather than a static URL, replay a
recorded flow instead of guessing selectors: `node runner/flow.mjs replay
<flow.json> --out <dir>` (accepts web-uplift's own `flow record` output, a Chrome
DevTools Recorder export, or a hand-authored flow.json). It drives the steps over
CDP and captures a screenshot per step into `<dir>`; judge the per-step states as
additional paths and record the flow in `paths`.

**Replay safety invariant:** Audits must not mutate target state without operator
consent. Flow replay defaults to dry-run mode for mutating steps (form submission
on Enter, clicks on submit controls); passing `--allow-mutations` is required to
execute real submissions against target sites. Flows needing entered sensitive
values cannot replay faithfully without either passing `--capture-sensitive`
during recording, hand-authoring test values in `flow.json`, or passing
`--allow-mutations` during replay (redacted fields are skipped during replay by
default to prevent clearing DOM values).

### 2. Build the check manifest and plan evidence, per check

First generate the full `(principleId, checkId)` manifest required by the atomic
coverage contract. Verify its expected count against `principles.json`; do not
start from a hand-written list of 17 principle names. Read every check's
`detectableVia` HINT, `guides`, and any `references` (which you already consulted
up front in step 0). For each manifest row, record what evidence WOULD let you
judge it, under which condition and on which representative path. Do not begin
judgement until every row has a plan or an explicit anticipated blocker. Examples (not a
script; you adapt to the actual page):

- respects-color-scheme -> `screenshot`/`dom --selector` under
  `--emulate-media prefers-color-scheme=dark`; does the surface re-tint?
- respects-reduced-motion -> `video --interact` or an `evaluate` of
  `getAnimations()` under `--emulate-media prefers-reduced-motion=reduce`.
- responsive-no-horizontal-scroll -> `layout --viewport 360x800`; is there
  horizontal overflow? a `screenshot` shows the clipping.
- component-level-responsiveness -> `dom --selector` of the same component in a
  wide vs narrow container, or CSS inspection for `@container`.
- input-modality-aware (focus) -> an `evaluate` probe that focuses the control
  and reads the computed outline.
- be-fast-and-stable -> `layout` (CLS + long tasks) AND a `trace` (capture a
  performance trace; read its `*-summary.json` for navigationStart->FCP/LCP, long
  tasks, total blocking time), and/or Lighthouse. Record the trace artifact path
  in the finding so the perf claim is backed by concrete evidence. CWV thresholds
  are calibrated against mid-tier mobile on variable networks, so take the
  mobile-archetype measurement under a throttled profile (e.g. `--viewport
  360x800 --network mobile-lighthouse`, which also applies the 4x CPU slowdown),
  not unthrottled: an unthrottled headless desktop is the one configuration
  guaranteed to pass and surfaces nothing about real users. The output records
  the `conditions` it ran under - state that device class in the finding.
- network-relevant principles (be-fast-and-stable's request weight,
  be-private-and-secure's transport/third-party surface, be-sustainable's bytes
  over the wire) -> a `har` capture; read its `*-summary.json` (totals,
  by-resource-type, first/third-party origins, render-blocking candidates, weight
  offenders, hygiene signals), NEVER the raw multi-MB HAR, and record the `.har`
  artifact path in the finding. renderBlockingCandidates are GROUNDED in the real
  CDP signals the har capture records per request - the rich `_initiator`
  (parser|script|preload, with the inserting document url/line or the script call
  frame), the `_priority` (initial + final), and `_renderBlockingStatus` when the
  Chrome build exposes it - and each candidate carries an explicit `basis`. The
  HAR alone is partial (it does not by itself show `<head>` placement or
  async/defer/type=module), so treat the list as the STARTING signal and CONFIRM
  it against the live DOM: use `dom`/`evaluate` to read the real element's
  placement and its `async` / `defer` / `type=module` attributes before asserting
  a resource is render-blocking (e.g. a parser-inserted module script is deferred
  by spec and is not render-blocking). The harness as a whole CAN determine this;
  combine the HAR initiator/priority with the DOM.
- be-memory-efficient -> the memory-tracer-style before/after methodology with
  the `heap` primitive: take a BASELINE `heap` snapshot, then take a POST snapshot
  after repeating a representative interaction ~10x via `--interact` (open/close a
  modal, navigate a route and back, infinite-scroll a list), and COMPARE the two
  summaries for retained growth (totals: nodeCount/totalSelfSizeBytes; and by
  constructor: a growing Detached* population). Corroborate with
  Performance.getMetrics (JSHeapUsedSize, Nodes) across the same window, and an
  `evaluate` probe sampling listener/timer counts before vs after. If Chrome
  DevTools MCP is available, consult its `memory-leak-debugging` skill: capture
  baseline, target and final snapshots, then use memlab or the provided compare
  workflow to identify leak traces. The package-native `heap` primitive remains
  the default path. Read the heap SUMMARY, never the raw .heapsnapshot unless a
  dedicated heap-analysis tool is doing the analysis. This pairs naturally with
  the multi-page coverage (step 1): run the leak test on the INTERACTIVE
  archetypes (SPA routes, feeds, editors, modal-heavy pages), not a static
  content page. If a page has no representative interaction to repeat, do not
  fabricate one - mark no-leak-under-repeated-interaction not-applicable with a
  rationale (bounded-footprint and the detached-DOM/listener check still apply
  from a single state). detached nodes can be intentional caches, so judge
  confidence rather than asserting a bug.
- be-inclusive -> the `axe` primitive, and/or Lighthouse a11y, and/or your own
  contrast/label probes; plus a screenshot to judge legibility/alignment.
- be-internationalised -> observe the RENDERED page under a second locale and a
  second time zone, not just the source: `evaluate`/`screenshot` with `--locale
  ar-EG` and `--timezone Asia/Tokyo` (etc.) and diff the rendered dates, numbers
  and direction against the default run. A page can call Intl.DateTimeFormat
  correctly and still hard-code a calendar assumption, or pass a naive Date
  through a formatter and land on the wrong day across a DST boundary; only the
  rendered diff shows it. Each side records its `conditions`, so the finding can
  state both locales/zones observed.
- follow-best-practices / be-discoverable -> a `dom`/`evaluate` probe for
  doctype, charset, title, meta description, viewport, anchor hrefs; and/or
  Lighthouse.
- be-discoverable / be-agent-ready -> the `discoverability` primitive: it
  reports how much of the rendered content is present in the RAW server HTML
  (`coveragePct`), whether the page is a JS shell, and whether title/h1/meta
  survive without JavaScript. A low coverage / `isJsShell: true` means AI
  crawlers and search see little to nothing (the url-influence failure mode) -
  a real `be-discoverable`/`be-agent-ready` finding. Confirm a surprising result
  against the raw HTML and the `dom` primitive before asserting it.
- be-private-and-secure -> run the `secrets` primitive to scan for exposed API
  keys, tokens, and credentials in the page HTML, inline scripts, external JS,
  and meta tags. REASON about each finding: is it a legitimate **public** key
  (Google Maps, Stripe publishable, Firebase config — expected client-side, NOT
  an issue) or an actual **sensitive** secret (AWS keys, Stripe *secret* keys,
  JWTs, private keys, DB connection strings, GitHub/Slack tokens — a CRITICAL
  leak)? Use the `dom`/`evaluate` primitive to read the surrounding context
  (what script/config the key appears in) to distinguish. Also run `har` to
  inspect third-party origins and request hygiene.

You own this mapping. If the HINT names a tool you do not want to use, use a
different one. If you want evidence the HINT does not mention, gather it.

### 3. Gather the evidence

Run the primitives and tools you planned. Keep artifacts (screenshots, videos,
heap summaries, layout JSON, Lighthouse JSON) under the report directory:
everything a run writes stays inside that run directory, which is also the
headless runner's write scope. Ad-hoc helper scripts and fetched reference
material belong in `<report directory>/scratch/`. Capture enough that a reader
could verify each finding.

### 4. Judge every check, then derive every principle (quality without shaming)

Work through the coverage manifest one check at a time. Record exactly one
`checkOutcomes` row per expected pair, including status, confidence, method,
evidence, path/artifact references, and any reason/finding IDs required by that
status. Never initialise principles to pass and flip only those with findings;
that is absence-of-evidence scoring and invalidates the audit.

After every check is recorded, derive `principleOutcomes` mechanically from the
check statuses using the atomic coverage contract. For each principle, decide
**applicability** and **verdict** as follows:

- **Opted out.** If `web-uplift.json` declared an `optOut` for this principle
  (or check), report it as `opted-out` with the developer's reason and move on.
  Do not raise findings against it.
- **Applicability by expectation.** Read the principle's
  `applicability.expectation`:
  - `default` principles (respect-user-preferences,
    implement-natural-interactions, provide-guided-navigation,
    maximize-content-reduce-noise, adapt-to-the-form-factor, be-fast-and-stable,
    be-inclusive, follow-best-practices, be-discoverable, be-private-and-secure,
    be-trustworthy, be-memory-efficient) are expected of essentially every site;
    absence is a finding. (be-memory-efficient is `default`, but its
    no-leak-under-repeated-interaction check is contextual on the page having a
    representative interaction to repeat - see below.)
  - `contextual` principles (be-resilient's offline/installable aspect,
    be-internationalised, be-sustainable's absolute weight bar, be-agent-ready,
    and public discoverability for a deliberately gated site) legitimately may
    not apply. If there is no declared `intent`/`optOut`, **make a judgement
    call** on applicability from the recon (siteType, surfaces). If you judge it
    does not apply, mark it `not-applicable` WITH A RATIONALE rather than
    penalising the site. If you judge it does apply, judge it normally.
- **Verdict.** For each applicable principle check, weigh the evidence and decide
  pass or issue. Be honest about `confidence` for subjective judgements.

The reported statuses are distinct: `pass`, `issues`, `incomplete`,
`not-applicable` (you judged it does not apply, with a reason), and `opted-out`
(the developer declared it, with their reason). Check outcomes additionally use
`blocked` and `not-run`, which derive an `incomplete` principle and a `partial`
run. Never silently drop a default principle, convert untested work to N/A, or
penalise a contextual one you reasonably judged out of scope.

For each issue, use the check's `guides` (already consulted in step 0) to cite
the most relevant Modern Web Guidance `id`; run a fresh `search` if you need to
refine. Search the feed ad hoc for anything you observe that no principle names.

### 5. Findings and task list

Classify each divergence into a finding tied to a `principleId` /
`principleCheckId` and/or a guidance `id`. Severity: `critical` (core
content/flow unusable under a common condition), `high` (principle clearly
violated on a primary path), `medium` (meaningful divergence or partial
support), `low` (polish / missed modern technique). Every finding needs a
concrete `evidence` string naming the modality you used (e.g. "screenshot under
prefers-color-scheme: dark shows .ndm-card still #fff" or "layout primitive
reported CLS 0.18 from a banner injected at ~600ms"). Then derive a prioritised,
deduplicated `taskList` (highest leverage first), each task citing its
`findingIds` and a `guidanceId`.

### 6. Report (and where it goes: retained, comparable runs)

Runs are RETAINED so they can be compared over time. Write each run into its own
timestamped directory and keep a `latest` pointer:

- Default report dir: `reports/<host>/<runId>/` where `<runId>` is a real
  timestamp (an ISO timestamp with `:`/`.` replaced by `-`, e.g.
  `2026-06-13T17-30-00-000Z`; use the actual current time). `--out <dir>`
  overrides this.
- After writing, point `reports/<host>/latest` at the run dir (a symlink, or a
  `latest.txt` naming the run dir where symlinks are unavailable). The
  `runner/run-history.mjs` helpers (`runDir`, `updateLatest`) compute these for
  you if you drive them from a script; otherwise just follow the layout.
- Keep evidence artifacts under the run dir (e.g. `<runId>/evidence/...`) so a
  run is self-contained and its before/after artifacts move with it.

Write two files in the run dir:

- `report.json` - MUST validate against
  [schema/findings.schema.json](../../../schema/findings.schema.json) **and pass
  the atomic coverage validator**. Set `evidenceUsed` (the modalities and tools
  you actually ran), `config` (whether a `web-uplift.json` was loaded),
  `checkOutcomes` (exactly one row for every manifest pair),
  `principleOutcomes` (derived from those rows, so `incomplete`, `opted-out`, and
  `not-applicable` remain distinct from pass/issue), `coverage` (the exact
  denominator and completion counts), and the structured `artifacts` manifest
  (one entry per evidence file you kept: `{type, path, caption, condition,
  findingIds}` with `path` relative to the run dir). Each finding SHOULD list the
  artifact paths that evidence it in `finding.artifacts`, so every "action to fix"
  ties back to concrete before-evidence. If scoring against `--expected`, include
  your own precision/recall under an `eval` field.
- `report.md` - human-readable: page profile, the evidence you gathered, an
  artifacts-manifest table, findings grouped by principle (embed screenshots
  inline with `![](path)` and link video/trace/har/heap), the prioritised task
  list, and anything skipped or low-confidence.

Before writing `report.md`, scoring, aggregation, or a completion claim, run the
validator and include its output in the run log:

```sh
web-uplift validate <run-dir>/report.json
# In this repo: node schema/validate-report.mjs knowledge/principles.json <run-dir>/report.json
# Per-project install: node .web-uplift/schema/validate-report.mjs .web-uplift/knowledge/principles.json <run-dir>/report.json
```

If it reports missing, unknown, duplicate, blocked, or not-run rows, keep the
report as `partial`; do not emit a score or call the site audited. A human-readable report may explain partial
work, but it may not hide the denominator.

### 6b. Compare runs (before/after)

To measure progress across runs, diff two retained runs:

```sh
node aggregate/compare.mjs <host|url> [runA] [runB]   # web-uplift compare ...
```

Defaults to the two most recent runs (older = before, newer = after) and writes
`compare.md` + `compare.json` into the newer run: principle status changes,
per-finding resolved/new/persisting, metric deltas (LCP/INP/CLS, Lighthouse
scores where present), network/HAR deltas (request count, transferred bytes),
and PAIRED before/after screenshots (matched by capture condition). Fix mode
(below) runs this automatically at the end, so a fix run shows the before->after.

### 6c. Scorecard (complete runs only)

Only after the atomic coverage validator reports `complete: true` (and after any
compare in fix mode), generate the interactive scorecard and use it as the close
of your reply. For a partial run, do not score it: close with the exact coverage
counts and blockers/not-run checks instead.

```sh
node aggregate/scorecard.mjs <host|url>   # web-uplift scorecard <host>
```

This rolls ALL retained runs for the host into a self-contained
`reports/<host>/scorecard.html` (Lighthouse-style outcome gauges, a top-3, a
per-finding deep-dive, a score-over-time history with per-run and per-outcome
deltas, and a before/after panel). It also prints a compact **text scorecard**
to stdout.

End your run by:

1. Showing that text scorecard inline (overall score, the six outcome scores,
   and the top-3 "do these first"). It leads with outcomes, not a raw findings
   dump. Do NOT re-type a long list of every finding above it.
2. Linking the interactive report: `reports/<host>/scorecard.html` (and mention
   the History tab shows the deltas over time once there is more than one run).

Fix mode emits the scorecard automatically only for a coverage-complete run; for
a complete report-only run, run the command above yourself. Partial runs must
not enter score aggregation.

### 7. Fix mode (`--fix --source <dir>`, the model-driven hill-climb)

**The headless fixer requires an isolation assertion.** It drives a write-capable
agent whose context carries untrusted page content and does not sandbox it, so
`web-uplift fix` refuses to spawn anything unless you name the boundary you are
providing (`--isolation docker|bwrap|vm|host-permission-model|...`) and it records
that assertion as UNVERIFIED in `<out>/run-security.json`. It then snapshots the
tree around each agent run and refuses the run when a change lands outside
`--target`/`--out` - detection, not confinement; the gaps are documented in
README.md ("Running it safely"), which lists what the operator boundary must guarantee (the tool and dependency trees, credentials and other checkouts not agent-writable, no agent write access to the report history or publication pointer, the source tree as the only writable area, network egress out of scope). Running
the loop INSIDE your own session (below) is unaffected: you are the model there.

Only with local source. This is a MODEL-DRIVEN hill-climb: YOU write every edit
based on Modern Web Guidance. There are no canned transforms anywhere. You can
run this loop entirely INSIDE your own session (the default, subscription path)
- you do not need the headless runner; you ARE the model.

Inputs: the findings + `taskList` you just produced (step 5), or a pre-aggregated
report passed with `--findings <path>` (in which case you may skip the baseline
audit and go straight to the climb).

The loop, highest-leverage task first:

1. `retrieve` the task's guidance guide and query the baseline oracle via
   `node knowledge/baseline.mjs <feature>` (or `node .web-uplift/knowledge/baseline.mjs <feature>`,
   or `web-uplift baseline <feature>`).
   Check the exact Baseline status rather than asserting it from model memory:
   assume Baseline Widely available (`widely`) is safe; if Newly available
   (`newly`) or Limited (`limited`), follow the guide's fallback advice (e.g.
   `@supports`, `prefers-*` media queries, or feature detection) - the fallback
   is MANDATORY. Record the resolved status on the finding (`finding.baseline`
   with `featureId`, `featureName`, `status`, `lowDate`, `fallbackMandatory`).
2. Write the fix into the local source under `<dir>`. You are the coding agent.
   Honour `web-uplift.json`: never "fix" a principle reported `opted-out` or
   `not-applicable` - those are out of scope, not issues.
3. Re-gather the relevant evidence (the SAME primitive and emulated condition you
   used to find it) and confirm the issue is gone. Compare against a known-good
   reference where one exists (e.g. a `?mode=fixed` variant). If a fix introduces
   a new issue, log it and continue.
4. After a pass, recount the OUTSTANDING issue-findings: findings whose
   `principleId` is NOT one you reported `not-applicable`/`opted-out`. This is the
   number that must climb DOWN; print it so the descent is visible (e.g.
   `9 -> 5 -> 0`).
5. Repeat passes until there are zero outstanding `issues` (a clean audit may
   still carry `not-applicable`/`opted-out` principles - that is a pass), or a
   pass makes no further progress, or `--max-iterations` is hit. Record
   `budget.auditPasses`. Optionally open a PR (branch, commit only the source
   changes, `gh pr create`).
   - **Goal-directed stop (`--goal-overall <n>` / `--goal-min <outcome>=<n>` /
     `--goal-max-critical <n>` / `--goal-max-high <n>`):** when a goal is set,
     also stop as soon as the scorecard meets it, so you can climb to a target
     (e.g. `overall>=80`, no criticals) without chasing every last low-severity
     issue. Print the score per pass.
6. **Emit the before/after comparison.** Preserve the baseline as a `-before`
   run and the final state as an `-after` run (see step 6's run layout) and run
   the compare (step 6b) so the fix produces a `compare.md` showing the measurable
   before->after: outstanding `9 -> 0`, which findings resolved, and the metric /
   network / screenshot deltas. The headless `web-uplift fix` does this for you;
   in-session, run `node aggregate/compare.mjs <host> <before> <after>` yourself.

Never edit source outside `<dir>`. Never run fix mode against a site whose
source you do not have locally.

The headless `web-uplift fix` / `npm run fix` command drives exactly this loop
unattended via an agent CLI (API tokens); the in-session path above is the same
methodology with no extra billing.

## Why fully agentic (and why no fast path)

Tooling normally bakes judgement into a check runner and falls back to it. This
project deliberately does not automate **judgement**: web quality is open-ended,
the modern platform moves fast, and fixed pass/fail heuristics rot. It does,
however, enforce **coverage deterministically**. `principles.json` is the exact
registry and denominator; schemas and validators must reject missing, unknown,
or duplicate check outcomes. The model chooses how to gather evidence and judge
each registered check, but it may not choose to omit one. By leaning on the
model for evidence and judgement while using deterministic coverage validation,
the method stays current without allowing silent gaps.

## Cross-agent note (this is NOT Claude-only)

This skill is plain markdown methodology any agent can follow. It runs as
`/web-audit <url>` in Claude Code, Codex, Gemini CLI, Antigravity, GitHub
Copilot, and opencode from this repo, and as a raw prompt in anything else
("Read the file .claude/skills/web-audit/SKILL.md and follow its instructions
exactly, with these arguments: <url>"). Each CLI is wired to this one canonical
SKILL.md via a thin per-agent entry point; see the support matrix and the
"How to add an agent" section in [README.md](../../../README.md) and
[runner/README.md](../../../runner/README.md).

**No MCP server is required.** The evidence primitives are a plain Node CLI
(`node evidence/cli.mjs <primitive> <url> ...`, raw CDP via
chrome-remote-interface) that any agent able to run shell commands and read this
file can use directly. The repo's `web-uplift` MCP server only *distributes*
this SKILL.md to MCP-aware hosts as a convenience; it is optional and there is no
browser-automation MCP server anywhere in this project. The only hard
requirements on the host are: Node, a `google-chrome-stable` (override with
`CHROME_BIN`), `ffmpeg` (for the video primitive), and network access for the
Modern Web Guidance feed and optional Lighthouse (`npx`).

Finish your reply with a one-paragraph TLDR. For a complete run: finding count
by severity, evidence modalities/tools used, the single highest-leverage fix,
and (in fix mode) how many findings you verified fixed. For a partial run: exact
checks judged/expected, blocked, not-run, missing, unknown and duplicate counts,
plus what prevents completion; never substitute a score.
