{"_id":"@a11y-pulse/skip-link-audit","_rev":"4-cf7d5b67852915cddbd144d95382eeb8","name":"@a11y-pulse/skip-link-audit","dist-tags":{"latest":"0.1.2"},"versions":{"0.0.0":{"name":"@a11y-pulse/skip-link-audit","version":"0.0.0","keywords":["accessibility","a11y","wcag","bypass-blocks","skip-link","audit"],"author":{"name":"A11y Pulse Limited"},"license":"SEE LICENSE IN LICENSE.md","_id":"@a11y-pulse/skip-link-audit@0.0.0","maintainers":[{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"}],"homepage":"https://github.com/A11y-Pulse/audits/tree/main/packages/skip-link-audit#readme","bugs":{"url":"https://github.com/A11y-Pulse/audits/issues"},"dist":{"shasum":"80c0ada50d4026b33cf99f2ec9be6747360c9fd1","tarball":"https://registry.npmjs.org/@a11y-pulse/skip-link-audit/-/skip-link-audit-0.0.0.tgz","fileCount":10,"integrity":"sha512-szVd2i+e4HIjmmCHP/dHyQE+lrjTMPfvHnNjXn0YQiuNDYNCeRhogAM4Hbqb6mNL4oop9Szuo1IM9nvtXRhX8w==","signatures":[{"sig":"MEYCIQCjxVB12yTsNqMXmq/Np7SkCh/DH+n5JbhnwfEtS5OEjAIhAL705QHb4hHTz8p5T22hH2GxA9ShvZb9XWLc5290qWdy","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":42921},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"42f332636b5d0603bdb5451d77c2d085d0f39023","scripts":{"lint":"biome check","test":"npm run typecheck && npm run test:unit","build":"tsup && tsc -p tsconfig.build.json","lint:fix":"biome check --fix","test:unit":"vitest run","typecheck":"tsc --noEmit","prepublishOnly":"npm run build","test:integration":"vitest run --config vitest.integration.config.ts"},"_npmUser":{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"},"repository":{"url":"git+https://github.com/A11y-Pulse/audits.git","type":"git","directory":"packages/skip-link-audit"},"_npmVersion":"11.11.0","description":"WCAG 2.4.1 Bypass Blocks audit that finds skip-link-like in-page anchors in the first tab stops and verifies that activating them moves keyboard focus.","directories":{},"_nodeVersion":"24.13.0","dependencies":{"@a11y-pulse/browser-adaptor":"*"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"happy-dom":"^20.11.2","puppeteer":"^25.5.0"},"_npmOperationalInternal":{"tmp":"tmp/skip-link-audit_0.0.0_1787210787899_0.5587327752908156","host":"s3://npm-registry-packages-npm-production"}},"0.1.0":{"name":"@a11y-pulse/skip-link-audit","version":"0.1.0","keywords":["accessibility","a11y","wcag","bypass-blocks","skip-link","audit"],"author":{"name":"A11y Pulse Limited"},"license":"SEE LICENSE IN LICENSE.md","_id":"@a11y-pulse/skip-link-audit@0.1.0","maintainers":[{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"}],"homepage":"https://github.com/A11y-Pulse/audits/tree/main/packages/skip-link-audit#readme","bugs":{"url":"https://github.com/A11y-Pulse/audits/issues"},"dist":{"shasum":"85f76be422008ef1da0c44a911c5a6ec67cee55e","tarball":"https://registry.npmjs.org/@a11y-pulse/skip-link-audit/-/skip-link-audit-0.1.0.tgz","fileCount":10,"integrity":"sha512-K/aNk9iUVdJ2sKwirWnuhlOGEYAItfxwd+5GQjLxCeaXVh5u+SJLlje9UxPOW2K0i41gDtunGBO8Wv6qPA2iBw==","signatures":[{"sig":"MEUCIQCfZ6shgvrFzdXODdYZHEBDqO7upO9GGH3vdsBi+E8AsgIgCpDyWrUbNVs0+hJ31ziszJuhJC8kHRcxF+pabGcHn3s=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@a11y-pulse%2fskip-link-audit@0.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":42921},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"gitHead":"f05e0097f3dc9b26151bb00db20bf4ba98707bda","scripts":{"lint":"biome check","test":"npm run typecheck && npm run test:unit","build":"tsup && tsc -p tsconfig.build.json","lint:fix":"biome check --fix","test:unit":"vitest run","typecheck":"tsc --noEmit","prepublishOnly":"npm run build","test:integration":"vitest run --config vitest.integration.config.ts"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:cb25dc4d-add8-4ba8-a38c-cd2eee2f4681"}},"repository":{"url":"git+https://github.com/A11y-Pulse/audits.git","type":"git","directory":"packages/skip-link-audit"},"_npmVersion":"12.0.2","description":"WCAG 2.4.1 Bypass Blocks audit that finds skip-link-like in-page anchors in the first tab stops and verifies that activating them moves keyboard focus.","directories":{},"_nodeVersion":"22.23.2","dependencies":{"@a11y-pulse/browser-adaptor":"*"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"happy-dom":"^20.11.2","puppeteer":"^25.5.0"},"_npmOperationalInternal":{"tmp":"tmp/skip-link-audit_0.1.0_1787212757813_0.34558971513011194","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@a11y-pulse/skip-link-audit","version":"0.1.1","keywords":["accessibility","a11y","wcag","bypass-blocks","skip-link","audit"],"author":{"name":"A11y Pulse"},"license":"SEE LICENSE IN LICENSE.md","_id":"@a11y-pulse/skip-link-audit@0.1.1","maintainers":[{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"}],"homepage":"https://github.com/A11y-Pulse/audits/tree/main/packages/skip-link-audit#readme","bugs":{"url":"https://github.com/A11y-Pulse/audits/issues"},"dist":{"shasum":"a7bd19cab5a836289e42c1ff848bebafb721692d","tarball":"https://registry.npmjs.org/@a11y-pulse/skip-link-audit/-/skip-link-audit-0.1.1.tgz","fileCount":10,"integrity":"sha512-Xyb1Z6+6lV2XzGzl15FJpuLUXAfDXSh2cLlb3HPh5/mOshlsEN0ixdc8LyUyGg92OLojfNQUFYHYVyTvHiA8bA==","signatures":[{"sig":"MEYCIQDgLcWM7fMd4I+W2ZbD/V6reWFbOZevFZONgIKg1XIR0wIhALlrsnUa0c4zHqEnt1QEaBPniNO3tjtI/SNE6XdczTtn","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@a11y-pulse%2fskip-link-audit@0.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":42738},"type":"module","engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","source":"./src/index.ts"}},"gitHead":"8431177b6098e09064306dc08bddfb59796b444d","scripts":{"lint":"biome check","test":"npm run typecheck && npm run test:unit","build":"tsup && tsc -p tsconfig.build.json","lint:fix":"biome check --fix","test:unit":"vitest run","typecheck":"tsc --noEmit","prepublishOnly":"npm run build","test:integration":"vitest run --config vitest.integration.config.ts"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:cb25dc4d-add8-4ba8-a38c-cd2eee2f4681"}},"repository":{"url":"git+https://github.com/A11y-Pulse/audits.git","type":"git","directory":"packages/skip-link-audit"},"_npmVersion":"12.0.2","description":"WCAG 2.4.1 Bypass Blocks audit that finds skip-link-like in-page anchors in the first tab stops and verifies that activating them moves keyboard focus.","directories":{},"_nodeVersion":"22.23.2","dependencies":{"@a11y-pulse/browser-adaptor":"*"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"happy-dom":"^20.12.0","puppeteer":"^25.9.0"},"_npmOperationalInternal":{"tmp":"tmp/skip-link-audit_0.1.1_1788834841564_0.6891774769965868","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"_id":"@a11y-pulse/skip-link-audit@0.1.2","bugs":{"url":"https://github.com/A11y-Pulse/audits/issues"},"dist":{"shasum":"4f3284aa17b9811b4696240b26216648fd95e954","tarball":"https://registry.npmjs.org/@a11y-pulse/skip-link-audit/-/skip-link-audit-0.1.2.tgz","fileCount":10,"integrity":"sha512-kzfq02fd8VxgySD1f7UFoAjwHZJoca490F2TRjXiFvIt0SZQKAikxdwgJeqe+wlwo/OOZW2hBNIjtaXT6vapug==","signatures":[{"sig":"MEYCIQChkWK4z27NBmm6bKB4BCxa7ZSz04z90BZwsF+auT36AAIhAPkX4bs/wcXQpG+2XpPKDdGW1R70l3PGTQFm5qSfLyOT","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCITxzcy1wTXdiXNl+/f7mCtX9cxraQLA6bEPaxmt33bwIhAOD68iZnQXB+upSy1aYfu1sxHEkLw6I3vPta7VJfpt63"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@a11y-pulse%2fskip-link-audit@0.1.2","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":43310},"name":"@a11y-pulse/skip-link-audit","type":"module","author":{"name":"A11y Pulse"},"engines":{"node":">=22"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","source":"./src/index.ts"}},"gitHead":"facc34c09fbb3982d29a1dc04944b5d035af7780","license":"SEE LICENSE IN LICENSE.md","scripts":{"lint":"biome check","test":"npm run typecheck && npm run test:unit","build":"tsup && tsc -p tsconfig.build.json","lint:fix":"biome check --fix","test:unit":"vitest run","typecheck":"tsc --noEmit","prepublishOnly":"npm run build","test:integration":"vitest run --config vitest.integration.config.ts"},"version":"0.1.2","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:cb25dc4d-add8-4ba8-a38c-cd2eee2f4681"}},"homepage":"https://github.com/A11y-Pulse/audits/tree/main/packages/skip-link-audit#readme","keywords":["accessibility","a11y","wcag","bypass-blocks","skip-link","audit"],"repository":{"url":"git+https://github.com/A11y-Pulse/audits.git","type":"git","directory":"packages/skip-link-audit"},"_npmVersion":"12.0.2","description":"WCAG 2.4.1 Bypass Blocks audit that finds skip-link-like in-page anchors in the first tab stops and verifies that activating them moves keyboard focus.","directories":{},"maintainers":[{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"}],"_nodeVersion":"22.23.2","dependencies":{"@a11y-pulse/browser-adaptor":"*"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"happy-dom":"^20.12.0","puppeteer":"^25.10.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/skip-link-audit_0.1.2_1789286342448_0.8955767869841269"}}},"time":{"created":"2026-08-20T07:26:27.577Z","modified":"2026-09-13T07:59:02.851Z","0.0.0":"2026-08-20T07:26:28.029Z","0.1.0":"2026-08-20T07:59:17.953Z","0.1.1":"2026-09-08T02:34:01.704Z","0.1.2":"2026-09-13T07:59:02.542Z"},"bugs":{"url":"https://github.com/A11y-Pulse/audits/issues"},"author":{"name":"A11y Pulse"},"license":"SEE LICENSE IN LICENSE.md","homepage":"https://github.com/A11y-Pulse/audits/tree/main/packages/skip-link-audit#readme","keywords":["accessibility","a11y","wcag","bypass-blocks","skip-link","audit"],"repository":{"url":"git+https://github.com/A11y-Pulse/audits.git","type":"git","directory":"packages/skip-link-audit"},"description":"WCAG 2.4.1 Bypass Blocks audit that finds skip-link-like in-page anchors in the first tab stops and verifies that activating them moves keyboard focus.","maintainers":[{"name":"wildlyinaccurate","email":"joseph@wildlyinaccurate.com"}],"readme":"# @a11y-pulse/skip-link-audit\n\n[![npm version](https://img.shields.io/npm/v/@a11y-pulse/skip-link-audit)](https://www.npmjs.com/package/@a11y-pulse/skip-link-audit)\n[![CI](https://github.com/A11y-Pulse/audits/actions/workflows/ci.yml/badge.svg)](https://github.com/A11y-Pulse/audits/actions/workflows/ci.yml)\n[![License: PolyForm Shield 1.0.0](https://img.shields.io/badge/license-PolyForm%20Shield%201.0.0-blue)](./LICENSE.md)\n\nAn accessibility audit that aims to verify the skip-link technique for [**WCAG 2.4.1 Bypass Blocks**](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html). When a skip-link-like in-page fragment anchor appears in the first few tab stops, it activates the link with Enter and checks that keyboard focus moves to the target. It is built to be framework-agnostic and can be used in any environment that allows you to programmatically tab, press Enter, and read the focused element, such as Puppeteer, Playwright, or Selenium.\n\nThis audit was developed by [A11y Pulse](https://www.a11ypulse.com/) for its accessibility monitoring service. It is released as source-available under the [PolyForm Shield License 1.0.0](#license).\n\nWCAG 2.4.1 can also be satisfied by landmarks or a heading structure. A page with no skip link is not necessarily failing. This audit only evaluates pages that have a skip-link-like anchor; it stays silent otherwise.\n\n## Install\n\n```bash\nnpm install @a11y-pulse/skip-link-audit puppeteer\n```\n\n`puppeteer` is an optional peer dependency. It is only required if you use the bundled [Puppeteer adaptor](#adaptors). Other frameworks can supply their own adaptor without installing Puppeteer at all.\n\n## Quickstart\n\n```js\nimport { runSkipLinkAudit } from \"@a11y-pulse/skip-link-audit\";\nimport { PuppeteerAdaptor } from \"@a11y-pulse/skip-link-audit/puppeteer\";\nimport puppeteer from \"puppeteer\";\n\nconst browser = await puppeteer.launch();\nconst page = await browser.newPage();\nawait page.goto(\"https://who.likesdogs.nz/\");\n\nconst result = await runSkipLinkAudit(new PuppeteerAdaptor(page));\n\nconsole.log(result.summary);\n// { found: 1, passed: 1, failed: 0 }\n\nconsole.log(result.skipLinks);\n// [\n//   {\n//     selector: 'a[href=\"#main\"]',\n//     html: '<a href=\"#main\">',\n//     fragment: \"#main\",\n//     tabIndex: 1,\n//     passed: true,\n//     failureReason: null\n//   }\n// ]\n\nawait browser.close();\n```\n\nSee [`examples/puppeteer`](./examples/puppeteer) for a complete, runnable example.\n\n## Browser support\n\n| Adaptor | Browser | Supported |\n| --- | --- | --- |\n| Puppeteer | Chrome | Yes |\n| Playwright | Chromium | Yes |\n| Playwright | WebKit | **No.** WebKit does not move focus to links when Tab is pressed, and a skip link is a link, so the audit finds nothing. |\n| Playwright | Firefox | Yes |\n\nVerified by this repo's integration suites, which run every audit against each of these engines. Unsupported and partial cases are skipped there with the reason printed alongside them.\n\n## Options\n\nThe following options can be passed to `runSkipLinkAudit` as `SkipLinkOptions`:\n\n| Option           | Type     | Default | Description                                                                                          |\n| ---------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------- |\n| `candidateLimit` | `number` | `3`     | Max tab stops to scan for skip-link candidates. A skip link beyond this limit is not evaluated.      |\n\n## Result shape\n\n`runSkipLinkAudit` resolves to a `SkipLinkResult`:\n\n```ts\ntype SkipLinkResult = {\n  /** Skip-link candidates found in the first tab stops. Empty when none were found. */\n  skipLinks: Array<{\n    selector: string;\n    html: string;\n    fragment: string;\n    tabIndex: number;\n    passed: boolean;\n    failureReason: \"target-missing\" | \"activation-no-effect\" | null;\n  }>;\n  summary: {\n    found: number;\n    passed: number;\n    failed: number;\n  };\n};\n```\n\nWhen no candidate is found, `skipLinks` is empty and the summary is zeros. Landmark-only pages are not flagged.\n\n## How it works\n\n1. Enable focus reporting so Tab, Enter, and `:focus` behave as they would in a foreground tab.\n2. Press Tab up to `candidateLimit` times. After each press, inspect the focused element (descending open shadow roots). An `<a>` whose `href` is an in-page fragment (`#name`, not bare `#` and not `#top`) is recorded as a candidate, along with whether a matching `id` or `name` exists. The scan stops early on `<body>` or if focus cycles.\n3. For each candidate whose target is missing, record `target-missing`.\n4. For each candidate whose target exists: re-focus the link, press Enter, and poll for up to 250ms. The candidate passes if `document.activeElement` is the target or inside it. If not, press Tab once and pass if the newly focused element is inside the target (browsers set the sequential focus navigation starting point on fragment navigation even when the target is not focusable). Otherwise record `activation-no-effect`.\n\nActivation uses a real Enter keypress rather than a synthetic click, so the keyboard path is the one being checked.\n\n## Adaptors\n\nThe audit itself is framework-agnostic: it drives a page through an **adaptor**, a small interface of primitives (evaluate JS in the page, press Tab, press Enter) that the audit calls without knowing which browser automation library is behind it.\n\n[`@a11y-pulse/browser-adaptor`](../browser-adaptor) ships two implementations, `PuppeteerAdaptor` and `PlaywrightAdaptor`. Other environments (Selenium, WebDriver) can be supported by implementing the same interface, exported as `SkipLinkAuditAdaptor` (aliased as `BrowserAdaptor` from the package root).\n\n### `SkipLinkAuditAdaptor` / `BrowserAdaptor`\n\n| Method                   | Description                                                                                                                                         |\n| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `evaluate(fn, ...args)`  | Runs `fn` in the page context, passing in any serialisable `args`, and returns its result.                                                          |\n| `evaluateHandle(fn)`     | Runs `fn` in the page context and returns an opaque `ElementRef` handle to the `Element` it returns, without serialising it.                        |\n| `disposeRef(ref)`        | Releases a handle previously returned by `evaluateHandle`.                                                                                           |\n| `pressTab()`             | Presses the Tab key, advancing focus to the next focusable element.                                                                                  |\n| `pressEnter()`           | Presses the Enter key, activating the focused element.                                                                                               |\n| `ensureFocusReporting()` | Ensures the page reports focus for its lifetime, in particular that `document.hasFocus()` works and `:focus` styles apply even when the page is not the foreground tab/window. Must not throw. |\n\n### Writing a new adaptor\n\nImplement `SkipLinkAuditAdaptor` from `@a11y-pulse/skip-link-audit` (or its `BrowserAdaptor` alias) against your automation library's page/session object, then pass an instance to `runSkipLinkAudit`:\n\n```ts\nimport type { SkipLinkAuditAdaptor } from \"@a11y-pulse/skip-link-audit\";\n\nclass MyFrameworkAdaptor implements SkipLinkAuditAdaptor {\n  // ...implement evaluate, evaluateHandle, disposeRef, pressTab,\n  // pressEnter, and ensureFocusReporting for your framework\n}\n```\n\nUse [`@a11y-pulse/browser-adaptor`'s `src/adaptors/puppeteer.ts`](../browser-adaptor/src/adaptors/puppeteer.ts) as a reference implementation. It is a small, self-contained example of every method the audit needs.\n\n## Limitations\n\n- **Chromium focus emulation.** Accurate `:focus` / `document.hasFocus()` reporting for a backgrounded page relies on Chromium's CDP focus emulation (used by `PuppeteerAdaptor.ensureFocusReporting`). Other browser engines may not offer an equivalent, and results may be less reliable if the page genuinely loses focus during the audit.\n- **First tab stops only.** Skip links beyond `candidateLimit` (default 3) are not detected. The audit stays silent, which never flags a conformant page.\n- **Fragment anchors only.** Button-based skip controls and JavaScript-only focus movers are out of scope.\n- **Closed shadow roots** are opaque; a skip link inside one is invisible to the scan.\n- **Landmarks and headings** that satisfy 2.4.1 without a skip link are out of scope by design.\n\n## Releasing\n\nReleases are managed in the [A11y-Pulse/audits](https://github.com/A11y-Pulse/audits) monorepo with [Changesets](https://github.com/changesets/changesets). Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC). There is no long-lived `NPM_TOKEN`.\n\n### Ship a change\n\n1. Open a PR against `main` that includes a changeset (`npx changeset`) naming `@a11y-pulse/skip-link-audit`.\n2. After merge, the Release workflow opens a Version PR. Merging that PR publishes this package to npm and tags `@a11y-pulse/skip-link-audit@<version>`.\n\nTrusted Publisher on npm must stay configured for:\n\n| Field | Value |\n| --- | --- |\n| Organization or user | `A11y-Pulse` |\n| Repository | `audits` |\n| Workflow filename | `release.yml` |\n\n### Consumers (e.g. the A11y Pulse runner)\n\nBumping the published version in downstream apps is a separate change. Update the dependency range / lockfile there after the npm release lands.\n\n## License\n\nReleased under the [PolyForm Shield License 1.0.0](./LICENSE.md), in plain language:\n\n- **Source-available.** The source is public and you can read, fork, and modify it.\n- **Permitted for non-competing use.** You can use this package freely in your own products and services, as long as they don't compete with A11y Pulse.\n- **Competing products are forbidden.** You may not use this software (or a modified version of it) to build a product or service that competes with A11y Pulse's accessibility monitoring offering.\n\nSee [LICENSE.md](./LICENSE.md) for the full, binding terms.\n","readmeFilename":"README.md"}