click-test
Structural DOM audit of a live app — walk every interactive element and assert semantic-HTML contracts (duplicate IDs, orphan labels, inputs without names, ARIA misuse, heading hierarchy, missing alt text, undersized touch targets). Complements /persona-test: persona-test catches narrative UX issues a real user would hit; click-test catches structural issues that hide in JS-rendered surfaces and silently break assistive tech, form submissions, or React reconciliation. Drives a browser via the shared driver contract; optional --with-modals opens each modal/dropdown and rescans; device presets and matrix mode cover responsive breakpoints. Triggers on: "click test", "click-test", "structural audit", "DOM audit", "accessibility audit", "duplicate ID check", "walk every element", "audit the DOM", "structural QA", "/click-test". Full command syntax: see the Usage section in this skill.
- License 许可
- MIT
- 最近更新
- 2026-11-12
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- click-test
- description
- Structural DOM audit of a live app — walk every interactive element and assert semantic-HTML contracts (duplicate IDs, orphan labels, inputs without names, ARIA misuse, heading hierarchy, missing alt text, undersized touch targets). Complements /persona-test: persona-test catches narrative UX issues a real user would hit; click-test catches structural issues that hide in JS-rendered surfaces and silently break assistive tech, form submissions, or React reconciliation. Drives a browser via the shared driver contract; optional --with-modals opens each modal/dropdown and rescans; device presets and matrix mode cover responsive breakpoints. Triggers on: "click test", "click-test", "structural audit", "DOM audit", "accessibility audit", "duplicate ID check", "walk every element", "audit the DOM", "structural QA", "/click-test". Full command syntax: see the Usage section in this skill.
02 资源文件 8
- 参考文档references/auth-bootstrap.md8.7 KB
- 参考文档references/browser-tool-detection.md20.2 KB
- 参考文档references/dom-scanner.md24.4 KB
- 参考文档references/input-acquisition.md4.4 KB
- 参考文档references/phase6-output-sample.md3.6 KB
- 参考文档references/scan-rules-detail.md7.2 KB
- 参考文档references/v1-scope-and-deployment.md6.6 KB
- 参考文档references/worktree-preflight.md3.3 KB
03 SKILL.md 正文
Worktree preflight — in a linked git worktree the synced tooling tree
scripts/.claude-skills/is absent — it is gitignored, sogit worktree adddoes not populate it, and every command below that uses it dies on a bareMODULE_NOT_FOUND. Runnpm run skills:hydratefirst. If this repo defines no such script,references/worktree-preflight.mdholds the entry to add — ask the user first, since it edits theirpackage.json.
Usage
Usage:
/click-test <url> — scan the URL's initial render (default device: desktop)
/click-test <url> --routes "/a,/b,/c" — scan multiple routes
/click-test <url> --with-modals — also open + scan each modal/dropdown
/click-test <url> --scope a11y|forms|ids|all — focus the assertion set (default: all)
/click-test <url> --device <preset> — run in a specific device preset
/click-test <url> --devices "<p1>,<p2>,..." — matrix mode: run in each preset, merge findings
Device presets: desktop (default) | desktop-large | tablet | mobile | mobile-small
Examples:
/click-test http://localhost:3000
/click-test https://myapp.railway.app --routes "/,/cellar,/admin"
/click-test http://localhost:3000 --with-modals --scope a11y
/click-test https://myapp.railway.app --device mobile — mobile only
/click-test https://myapp.railway.app --devices "desktop,mobile" --with-modals — cross-device coverage
Click-Test — Structural DOM Audit
This skill's last line is an end marker naming the skill between em-dashes, after the Reference files table. If that last line is not in your context (context compaction keeps only the start of a skill), re-read
.claude/skills/click-test/SKILL.mdin full before continuing — the later steps still apply.
Walk every interactive element on each target route and assert the structural
HTML contract. Not a persona test — the mechanical sibling of
/persona-test; run both, their findings rarely overlap.
→ references/scan-rules-detail.md §click-test vs /persona-test.
Phase 0 — Parse Arguments
Parse $ARGUMENTS:
<!-- host-contract: input-acquisition; grammar=path+flags; empty=ask-and-stop -->
Where $ARGUMENTS comes from — orchestrator-supplied input first, else
the host's verbatim invocation suffix, else the span of the user's current
message naming this skill or its subject. Never inferred from surrounding
conversation. This site is path+flags; on empty input, a URL is required — ask for it and stop. Never reuse a URL from earlier in the conversation; scanning the wrong origin is a real action against someone's site.
Full contract: references/input-acquisition.md.
-
url — first URL-shaped token (or
PERSONA_TEST_APP_URLenv). Validate withnew URL(value)— reject non-URL with usage error. -
routes —
--routes "/a,/b,/c"(comma-separated). When omitted, the default target is the base URL itself (preserves any path prefix —https://host/appis scanned, nothttps://host/). When provided, each token is resolved asnew URL(routeToken, base).href— supports absolute routes, root-relative routes (explicit/means origin-root), and path-relative routes. Empty/whitespace tokens dropped. Duplicates after normalisation dropped. Examples:--routesomitted, basehttps://host/app→ scanshttps://host/app--routes "/"against basehttps://host/app→ scanshttps://host/--routes "settings"against basehttps://host/app/→ scanshttps://host/app/settings
-
with_modals —
--with-modalsflag (default: off) -
scope —
--scope a11y|forms|ids|all(default:all). Unknown value → fail with usage. -
ready_selector —
--ready-selector "<css>"(optional). Default:[data-testid=app-ready], [data-click-test-ready=true]. If neither attribute exists in the app, pass an explicit selector that uniquely identifies the loaded state — do not fall back to genericmain/#root(those match on skeleton DOMs and produce false "scanned" verdicts on still-loading pages). -
ready_timeout_ms —
--ready-timeout 8000(default 8000). -
force_cache_bust —
--force-cache-bustflag (default: off, see Phase 2). -
max_triggers_per_route —
--max-triggers 50(default 50, Phase 4b only). -
device / viewport — three mutually-exclusive ways to pick the viewport (and adjacent emulation flags). Specify at most one; supplying two → fail with usage:
--device <preset>— one ofdesktop,desktop-large,tablet,mobile,mobile-small(canonical registry:scripts/lib/device-presets.mjs). Sets viewport +isMobile+hasTouchflags. Unknown name → fail.--devices "<p1>,<p2>,...">— matrix mode. Each preset runs the full route crawl independently; findings are tagged withdeviceand merged. Cost is multiplicative — be explicit, this is opt-in.--viewport <W>x<H>— legacy direct viewport (W and H in[320, 4096]). Synthesises acustomdevice record withisMobile/hasTouchinferred from width (<768 → mobile/touch). Kept for back-compat; prefer--device.- None of the above → default device =
desktop(1280×720). Identical to today's behaviour for callers passing nothing.
The selected device is applied via a single
browser_resizecall before the firstbrowser_navigateof each route (matrix mode resizes once per device-pass). UA emulation, real touch events, and DPR scaling are NOT provided — MCP browser drivers don't expose context-level launch options. For full emulation, use/persona-test --mode consistency(code-driven Playwright).
Required: url. If missing, output usage and STOP. Unknown flags → fail
with usage. Duplicate flags → last wins, log a stderr warning.
Phase 0b — Run Contract (recorded in report)
Capture these defaults at the start of each session and include them verbatim in the Phase 6 report so two runs against the same commit can be compared:
| Field | Default | Override |
|---|---|---|
| Device | desktop (1280×720, touch=false) |
--device <preset> / --devices "<list>" (matrix) / --viewport WxH (legacy) |
| Browser | whatever the selected driver provides (Playwright MCP = Chromium) | — |
| Locale / timezone | tool default (do NOT randomise) | — |
| Auth | none by default — public surface only | pre-authenticate the shared MCP session; see "Auth-gated routes" below |
| Network idle | wait for load + ready-selector or 8000ms |
--ready-timeout |
Auth-gated routes: after each navigation, BEFORE running the static scan, check:
- Cross-origin redirect (OAuth / SSO): if
new URL(location.href).origin !== new URL(targetUrl).origin, classify ascoverageStatus: "auth-required"regardless of DOM content. Catches OAuth providers (Google, Okta, Auth0) that redirect to a third-party domain — never scan accessibility of those pages and attribute findings to the audited app. - Same-origin password form: if URL is same-origin AND DOM contains
[type=password], classify ascoverageStatus: "auth-required". Catches first-party login walls.
click-test does not log in itself. If primary surfaces need a login,
pre-authenticate the shared driver connection via --storage-state before
running — see references/auth-bootstrap.md.
The OVERALL verdict
becomes at most Incomplete (never Clean) when any route is
auth-required.
→ references/scan-rules-detail.md §Auth-gated routes — read when a target is login-gated.
Phase 1 — Detect Browser Tool
Resolve the driver through the one detection oracle —
references/browser-tool-detection.md.
Do not restate its ladder here; click-test declares only what it needs.
Minimum capability set: navigate, evaluate, click, keyboard
(Escape press), wait, currentUrl. Verify each before the first scan and log
the chosen driver plus the capabilities matched.
evaluate is non-negotiable and it is what excludes static drivers. The DOM
scan is a page.evaluate call, so a fetch-and-parse driver can never serve
click-test however well it parses HTML. There is no degraded mode here: an unmet
set means abort before scanning with
[BLOCKED] <driver> missing capability: <cap> — never a clean empty pass, which
would report "no findings" from a page nobody scanned.
Phase 2 — Pre-flight Cache-bust (own-app only by default)
Service workers silently serve stale bundles, masking real deploys. The
cache-bust is mandatory for own-app hostnames, where clearing user state
costs nothing. For external URLs: do NOT cache-bust by default — clearing
serviceWorker + caches on a third-party origin can log out the operator,
remove offline data, or alter the very state being measured. Require the
explicit --force-cache-bust flag — and even then, warn loudly on stderr.
| Hostname class | Default behaviour |
|---|---|
Own-app (localhost / *.railway.app / *.vercel.app / *.netlify.app / *.local) |
Cache-bust runs unconditionally |
| External | Skip cache-bust. --force-cache-bust overrides with stderr warning |
Any driver without evaluate |
Unreachable — click-test aborts blocked before this point (Phase 1) |
Cache-bust script (when it runs). Both APIs (serviceWorker, caches) can
be undefined globally in non-secure contexts (HTTP-only test URLs) —
referencing them throws ReferenceError BEFORE optional chaining is
evaluated. Always check typeof first:
// browser_evaluate
(async () => {
let unregistered = 0, cachesDeleted = 0;
if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
const regs = await navigator.serviceWorker.getRegistrations().catch(() => []);
await Promise.all(regs.map(r => r.unregister().catch(() => {})));
unregistered = regs.length;
}
if (typeof caches !== 'undefined') {
const keys = await caches.keys().catch(() => []);
await Promise.all(keys.map(k => caches.delete(k).catch(() => {})));
cachesDeleted = keys.length;
}
return { unregistered, cachesDeleted };
})()
Then browser_navigate(url) again to force a fresh fetch. Log result:
[cache-bust] unregistered <n> SW, cleared <n> caches (own-app) or
[cache-bust] skipped (external host; pass --force-cache-bust to override).
Record cacheBustMode: "own-app" | "forced" | "skipped-external" | "n/a"
in the run contract for the Phase 6 report.
Phase 3 — Crawl Routes
Get the device-pass contract (required; do not skip)
Before any browser work, call:
node scripts/lib/device-presets.mjs prep-matrix
Optional flags — add at most one, matching what your $ARGUMENTS contained (none if neither flag was passed): --device PRESET, --devices "LIST", --viewport WxH.
<!-- host-contract: input-acquisition; grammar=path+flags; empty=default -->
This site: path+flags — neither device flag present means a single desktop pass — do not synthesise a matrix.
The CLI returns {kind: "click-test-prep", matrixMode, totalPasses, passes[]},
each pass carrying device, expectedFirstMcpCall and logLine
(→ references/scan-rules-detail.md §Device prep contract for the full shape).
The LLM does NOT pick device order, dimensions, or pass count — it walks
passes in array order, executing each expectedFirstMcpCall verbatim.
Mutual-exclusion conflicts (e.g. --device + --devices) cause the CLI
to exit non-zero with the error on stderr; surface that, do not proceed.
Device pass loop
For each pass in contract.passes (in array order):
- Echo
pass.logLineto stderr. - Call
browser_resizewithpass.expectedFirstMcpCall.argsverbatim — once per device-pass, BEFORE the first navigate. - Tag every finding from this pass with
device: pass.device.name. - Run the per-route loop below.
Cache-bust (Phase 2) runs once at session start, not per device-pass — service-worker state is global to the browser context.
Per-route loop (within a device pass)
For each route in routes:
browser_navigate({url: new URL(route, base).href})— never string-concat- Readiness protocol (replaces vague "stable string" wait):
a. Wait for
loadevent (most MCPs do this implicitly on navigate). b. Poll: ready-selector matches ANDdocument.querySelector('[aria-busy="true"], [role="progressbar"]')returns null. c. Timeout after--ready-timeoutms (default 8000). d. If timeout fires, classify the route ascoverageStatus: "readiness-timeout"and skip its scan — do NOT scan a skeleton DOM. - Route execution status — independent of finding count. Record one of:
scanned— readiness met AND scanner output validated. (May still have findings.)auth-required— landed on login (URL changed AND[type=password]present)navigation-error—browser_navigatethrew (rendered app-level 404/500 pages count asscannedunless the tool exposes a real response status and it's 4xx/5xx)readiness-timeout— readiness selector never matched within timeoutscanner-error—browser_evaluatethrew or returned schema-invalid shapeskipped-external-anchor— route resolved to a different origin
- If state is
scanned, run Phase 4 — Static scan (one pass per settled DOM state — see Reminders) - If
with_modalsAND state isscanned, run Phase 4b — Dynamic-surface scan - Record findings tagged with
{device, route, coverageStatus, via}; record per-route element counts and dynamic-surface counts in the run metrics (Phase 4 schema).
Phase 4 — Static Scan
browser_evaluate with the scanner from
references/dom-scanner.md. The scanner returns
a single result object — validate before use (a hostile or malformed
page could return anything from browser_evaluate):
// pseudo-Zod (canonical contract — runner enforces, report consumes)
ClickTestFinding = {
kind: enum("duplicate-id", "orphan-label", "input-no-name",
"button-no-name", "link-no-name", "form-field-no-name",
"duplicate-aria-label", "aria-hidden-focusable",
"empty-link", "heading-skip", "img-no-alt",
"small-touch-target", "positive-tabindex"), // 13 kinds
severity: enum("P0","P1","P2","P3"), // EFFECTIVE severity — capped to P3 when !perceivable
declaredSeverity: enum("P0","P1","P2","P3"), // the kind's intrinsic severity, pre-cap
perceivable: boolean, // was the element RENDERED in the captured state?
selector: string.max(500),
snippet: string.max(200), // outerHTML truncated; redact before persist
detail: string.max(500),
device: string, // preset name ("desktop", "mobile", …) or "custom"
}
ClickTestScanResult = {
schemaVersion: literal(1),
routeUrl: string, // the final navigated URL
device: string, // matches each finding's device tag
elementsScanned: number, // document.querySelectorAll('*').length at scan time
interactiveElementsScanned: number, // count of button/a/input/select/textarea/[role=button|link]
findings: ClickTestFinding[].max(999), // reject >999 — flags scanner-error
shadowGapCount: number, // open shadow roots not traversed
iframeGapCount: number, // iframes not traversed
}
Device-pass aggregation (when --devices produces multiple device passes):
each finding's device field is set by the runner from the active pass — the
scanner does not need to know. Findings dedupe by {device, route, via, kind, selector} —
the same duplicate-id on mobile AND desktop is reported twice (correctly:
it's two regressions that may have different fix sites if responsive CSS
hides one). The Phase 6 report's PER-DEVICE COVERAGE table surfaces which
device flagged which.
small-touch-target is device-sensitive: when both desktop and mobile
passes ran, downgrade desktop-pass small-touch-target findings by one
severity (P2 → P3) — the mobile pass is the authoritative read.
If validation fails, classify the route as coverageStatus: "scanner-error"
and skip its findings — never persist or report unvalidated output.
Per-route aggregation when --with-modals produces multiple scan results
(one static + one per opened modal): the per-route record stores the static
scan's elementsScanned / interactiveElementsScanned as the route totals
(modals are subsets, not additions). For shadowGapCount / iframeGapCount,
take the max observed across passes (NOT sum — each modal pass re-scans
the whole DOM, so the static gaps would be double-counted). findings are
unioned and deduped by {device, route, via, kind, selector}.
Each finding's via field records its origin (static or modal:<accessible-name>).
Redaction: before printing or persisting any snippet, run it through
the existing redactor (scripts/lib/redact.mjs::redact for string-typed
content; use redactObject if the runner ever boxes the snippet inside a
larger payload). DOM outerHTML can contain emails, tokens in data-*
attributes, hidden form values, customer content. The default scanner
already truncates to 200 chars; redaction is the second layer.
Finding taxonomy (canonical rule registry)
13 rules, 1:1 with the kind enum above:
| Kind | Default severity | In ids |
In forms |
In a11y |
|---|---|---|---|---|
duplicate-id |
P0 | ✓ | ✓ | ✓ |
orphan-label |
P0 | — | ✓ | ✓ |
input-no-name |
P0 | — | ✓ | ✓ |
button-no-name |
P0 | — | — | ✓ |
link-no-name |
P1 | — | — | ✓ |
form-field-no-name |
P1 / P3 when the form has no action |
✓ | ✓ | — |
duplicate-aria-label |
P2 (see note) | — | — | ✓ |
aria-hidden-focusable |
P1 | — | — | ✓ |
empty-link |
P1 | — | — | ✓ |
heading-skip |
P2 | — | — | ✓ |
img-no-alt |
P2 | — | — | ✓ |
small-touch-target |
P2 | — | — | ✓ |
positive-tabindex |
P2 | — | — | ✓ |
button-no-name and link-no-name are separate kinds (one rule per kind)
so the severity is unambiguous in the enum. The scanner emits one or the
other based on element type — never both for the same node.
--scope all → keep everything. --scope <name> → keep rows with ✓ in
that column. Invalid scope → fail with usage.
duplicate-aria-label note: high false-positive rate on grid/card layouts,
so the scanner only fires it within the same role + parent region.
→ references/scan-rules-detail.md §duplicate-aria-label FP note.
Phase 4b — Dynamic-surface Scan (--with-modals only)
Modals mount duplicate IDs only while open, which the static scan cannot see
(→ references/scan-rules-detail.md §Why --with-modals exists).
Trigger discovery (one evaluate per route, before clicking)
// browser_evaluate — returns DiscoveredTrigger[]
// DiscoveredTrigger = { idx, selector, role, accessibleName, regionKey,
// destructiveReason: string|null, disabled: bool,
// href: string|null, formAction: string|null }
The runner stores this array, then iterates by idx. Before each click:
browser_evaluatere-queries by the storedselector— if it returns null or a different element (compared byidx → selector → accessibleNametriple), skip withcoverageStatus: "trigger-stale".- If
destructiveReasonis non-null → skip with that reason. - If
disabled === true→ skip with"disabled". - Otherwise proceed to click.
The trigger candidate set: [aria-haspopup="true"], [data-modal-trigger], button[aria-controls], [role="combobox"].
Per-trigger flow
- Safety classifier — skip if the trigger is destructive (see below)
- URL snapshot — record
currentUrlbefore click browser_click(<trigger>)- Wait for modal mount: poll for
[role="dialog"],<dialog[open]>, oraria-expanded="true"on the trigger, up to 1000ms. Timeout → log "trigger opened nothing" and skip. - Re-run the scanner — tag findings with
via: "modal:<accessible-name>" - Close:
Escapekey → if dialog still mounted, click[aria-label="Close"]inside it → if still mounted, click outside the dialog at(8, 8). - Verify closed AND URL unchanged — if URL changed, treat as
navigation side-effect: re-navigate to the route's URL then re-run
the Phase 3 readiness protocol (don't assume the page is ready
immediately after navigation). Resume the trigger loop only once
readiness is met. If dialog still mounted after all 3 close attempts,
log
coverageStatus: "modal-stuck"for this route and stop the dynamic scan for that route. - Continue to next trigger until
--max-triggers(default 50) reached. Excess triggers logged asskipped: budget-exceeded.
Safety classifier — destructive-trigger filter
Skip a trigger if any of these match (case-insensitive, accent-folded):
- Accessible name (computed:
aria-label,aria-labelledby, visible text,title— in that order) matches/^(delete|destroy|remove|purge|reset|sign\s*out|log\s*out|cancel\s*subscription|deactivate|close\s*account|drop|truncate|wipe)/i data-destructive="true"ordata-actionvalue matches the regex aboverole="link"withhrefpointing to a different origin or matching/logout|signout|delete/i- Inside a form whose
actionmatches the destructive regex aria-disabled="true"or[disabled](don't click disabled controls)
Log each skipped trigger with reason: skipped-destructive: <accessible-name> (matched: <pattern>).
Deduplication: two triggers are dupes when they share (accessibleName, role, closest [role="dialog"]/section ancestor). Skip dupes after the first.
Phase 5 — Severity Model
The authoritative severity-per-kind mapping is the Phase 4 taxonomy table.
→ references/scan-rules-detail.md §Severity intuition — read when a kind's
severity is disputed (incl. the form-field-no-name P1-vs-P3 action rule).
Perceivability cap (2026-07-30)
push() runs every finding through __isPerceivable(el) — one call site, so all
13 kinds get it. A finding on an element that is not rendered in the captured
state (display:none, visibility:hidden, opacity:0,
content-visibility:hidden, [inert], detached, or zero-size) is demoted to
P3 and tagged perceivable:false; declaredSeverity preserves what the kind
would otherwise have been.
Demote, do not drop. A hidden element may become visible — that is exactly
what --with-modals re-scans for — so dropping the finding destroys signal. The
tag is state-relative: it says "not perceivable in this capture", never
"never perceivable".
Filter the headline on perceivable, and report capped findings separately
so the count stays honest — 704 findings with 6 perceivable is a very different
report from 704 undifferentiated ones.
→ references/scan-rules-detail.md §Perceivability — the field incident,
the release-scope note, and the canonical predicate source.
Touch targets are measured over activating regions
small-touch-target measures every region that activates the control, not
just the control's own border box. A wrapping <label> — or one associated via
label[for=…], both covered by the spec's el.labels — is part of the tap
target, because clicking it activates the control. Regions are contiguous (no
bounding-box union); the rule passes on the first region ≥24×24 and reports the
largest; a control whose every region is undersized still fires.
→ references/scan-rules-detail.md §Touch targets — field data and why.
Confidence is not a click-test concept — assertions are deterministic. Either the DOM violates the contract or it doesn't. If runs vary across same URL + commit, something non-deterministic (A/B test, random ID generator, load-order race) is involved; flag that meta-finding itself.
Phase 6 — Structured Report
Header (the Phase 0b run contract, verbatim):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CLICK-TEST REPORT
URL: <base>
Routes: <list>
Devices: <name(s)> (e.g. "mobile (390x844)" or "desktop, mobile" for matrix)
Scope: <scope> Modals: <on|off> Cache-bust: <own-app|forced|skipped-external|n/a>
Ready-selector: <css> Ready-timeout: <ms>
Tool: <browser_tool> — <N> elements scanned (across all device passes) — <duration>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Then, in order:
- FINDINGS (<total>) — per finding
[P<n>] <kind> <subject>then Device, Route, Via, Selector, Detail, Snippet (redacted). - ROUTE COVERAGE — one line per route:
<route> — <coverageStatus> — <n> elements — <n> findings (P0:n, …)orskipped (<reason>); under--devices, grouped perdevice=<name> (<WxH>)plus a CROSS-DEVICE section (shared / desktop-only / mobile-only counts + one interpretation line). - DYNAMIC SURFACES (
--with-modals) — per device+route: triggers total, scanned, skipped-destructive, modal-stuck. - OVERALL —
<Broken | Broken+Incomplete | Has issues | Incomplete | Clean>plus a one-sentence Reason.
→ references/phase6-output-sample.md — read before rendering, for the full sample.
OVERALL VERDICT RULES (deterministic precedence — first match wins):
Coverage condition (referenced below):
"covered" = every route coverageStatus="scanned" AND
shadowGapCount == 0 AND iframeGapCount == 0 AND
no dynamic-scan failure
(failure = modal-stuck/budget-exceeded OR trigger-stale > 25% of triggers)
"gaps" = NOT covered
1. Broken+Incomplete → any P0 finding AND gaps
2. Has issues+Incomplete → any P1/P2 finding (no P0) AND gaps
3. Broken → any P0 finding AND covered
4. Has issues → any P1/P2 finding (no P0) AND covered
5. Incomplete → zero findings AND gaps
6. Clean → zero findings AND covered
Issues are NEVER masked by coverage gaps — verdict 2 explicitly surfaces
the P1/P2 + gap combination rather than letting it fall through to
"Incomplete" (which would hide the discovered defects).
Notes:
- "Has issues" applies regardless of P1 vs P2; the count distinction lives
in the FINDINGS block, not the verdict.
- The verdict precedence is independent of coverage — a Broken+Incomplete
run with one P0 and one auth-required route is more severe than either
alone and gets its own label so /ship can gate appropriately.
Sort findings P0 first, then by kind alphabetical so similar issues group.
Phase 7 — Persistence (Out of Scope for v1)
v1 does not persist findings: no record-click-test subcommand exists, so do
not invent one or claim a store write. The local Phase 6 report is the whole
output — print, read, fix, re-run.
→ references/v1-scope-and-deployment.md — read when asked why, what v2 adds,
which R3 audit concerns were deferred, or how this skill is deployed.
Reminders
- One scanner pass per settled DOM state — one static pass per route per
device, plus one pass per successfully opened dynamic surface in
--with-modalsmode. Aggregate findings in JS using{device, route, via, kind, selector}as the dedup key so a finding present in both static + modal states (on the same device) is reported once, but the same finding on two different devices is reported twice (responsive CSS can cause a duplicate-id to surface on only one viewport).
Reference files
| File | Summary | Read when |
|---|---|---|
references/input-acquisition.md |
Where a skill's arguments come from on any host, and what to do when there are none. | Reading $ARGUMENTS on a host that does not substitute it, or deciding what empty input means at a site. |
references/browser-tool-detection.md |
The browser-driver contract — capabilities, driver table, selection order, minimum sets, degraded/blocked evidence. | Phase 1 driver selection — resolving which driver to use, or diagnosing a blocked result. |
references/auth-bootstrap.md |
Sanctioned auth-gated exploratory-testing pattern via MCP-server storageState, plus its connect-time-race escape hatch. | The target's primary surfaces need a login — pre-authenticate the shared driver connection before scanning, so auth-required routes are not coverage gaps. |
references/dom-scanner.md |
The full browser_evaluate scanner JS — every assertion's implementation, selector-stringifier helpers, severity mapping. | About to run Phase 4 or Phase 4b. |
references/scan-rules-detail.md |
Why the scan rules are what they are — perceivability, touch-target regions, severity intuition, FP notes, prep JSON. | A finding's severity or perceivability is disputed, a touch-target finding looks false, OR the prep contract shape is unclear. |
references/phase6-output-sample.md |
Full Phase 6 output sample — finding entries, single-device and matrix ROUTE COVERAGE, CROSS-DEVICE, DYNAMIC SURFACES. | About to render the Phase 6 report. |
references/v1-scope-and-deployment.md |
Why v1 has no persistence, the R3-deferred v2 concerns, and the maintainer deployment recipe + acceptance checks. | Asked about persistence, /ship integration or v2 scope, OR deploying/syncing this skill. |
references/worktree-preflight.md |
Worktree preflight remedy — the skills:hydrate npm script to add when a linked worktree lacks the tooling tree. | A command dies with MODULE_NOT_FOUND in a linked worktree AND this repo has no skills:hydrate script. |
— end of /click-test —
04 同仓库更多 Skills
stated-absent
Negative fixture — the contract's stated string never appears in this file.
nonexistent-implementation
Negative fixture — contract references an implementation file that does not exist.
document-only-with-params
Negative fixture — a document-only gate illegally carries params (schema must refuse this fake-check pattern).
lying-skill
Fixture skill for the gate-honesty self-check — every gate here is deliberately dishonest.
visual-audit
Math-first, deterministic visual/paint inspection — the 4th UX lens, complementing persona-test (journey), click-test (page), and nav-audit (system). Drives Playwright + computed styles + bounding boxes + CDP forced-pseudo-state to audit declared-token conformance, light/dark theme parity, layout physics, and affordance signifiers. A VLM is advisory-only and never gates. CI gate is drift-only on the changed contracted surface. Triggers on: "visual audit", "check styling consistency", "theme parity", "dark mode parity", "design token drift", "paint audit", "button/card consistency", "focus ring audit", "/visual-audit".
ux-lock
Generate Playwright e2e specs. Two modes: 1. LOCK mode — pin a fix's DOM contract so it doesn't regress (default). 2. VERIFY mode — check that a /plan plan was actually implemented by parsing its Acceptance Criteria section and driving the live URL. VERIFY mode grades a plan against a RUNNING app. To judge the plan document itself, before anything is built, that is /audit-plan. Triggers on: "ux lock", "lock ux", "lock in the fix", "write regression spec", "generate e2e test for", "regression test for commit", "lock this fix", "verify the plan", "verify plan implementation", "check the plan was built", "audit frontend implementation", "did we ship the plan". Full command syntax: see the Usage section in this skill.