diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx
index 7a5e987..a9f4ef7 100644
--- a/content/docs/api/index.mdx
+++ b/content/docs/api/index.mdx
@@ -1,7 +1,7 @@
---
title: "API"
icon: "Braces"
-description: "Every way to reach Stunt Double from code or an agent: the MCP server for your workspace, the no-account Index and docs servers, Markdown docs and llms.txt, and the public discovery files."
+description: "Every way to reach Stunt Double from code or an agent: the workspace MCP server, the no-account Index and docs servers, Markdown docs and llms.txt."
---
# API and agent access
diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx
index b79b53a..dfc609e 100644
--- a/content/docs/concepts/index.mdx
+++ b/content/docs/concepts/index.mdx
@@ -1,7 +1,7 @@
---
title: "Concepts"
icon: "compass"
-description: "Short definitions of Stunt Double's building blocks (workspaces, actors, checklists, interviews, automations, issues and more) and how they relate, with a link to the page that goes deeper on each."
+description: "Short definitions of Stunt Double's building blocks (workspaces, actors, checklists, interviews, automations, issues) and how they relate."
---
# Core concepts
diff --git a/content/docs/index-ranking/index.mdx b/content/docs/index-ranking/index.mdx
index e593240..0db1c7d 100644
--- a/content/docs/index-ranking/index.mdx
+++ b/content/docs/index-ranking/index.mdx
@@ -1,7 +1,7 @@
---
title: "Stunt Double Index"
icon: "Gauge"
-description: "What the Stunt Double Index is: a public ranking of how well AI agents can use a website, how a score is made, what it measures, and how to add, claim and share a site."
+description: "The Stunt Double Index ranks how well AI agents can use a website: how a score is made, what it measures, and how to add, claim and share a site."
---
# Stunt Double Index
diff --git a/content/docs/stuntkit/browser-toolset/index.mdx b/content/docs/stuntkit/browser-toolset/index.mdx
index ad96c46..9db5718 100644
--- a/content/docs/stuntkit/browser-toolset/index.mdx
+++ b/content/docs/stuntkit/browser-toolset/index.mdx
@@ -1,7 +1,7 @@
---
title: "Browser toolset"
icon: "Globe"
-description: "Use @stunt-double/browser-toolset to let Claude or any function-calling model read and act on real pages, with guards that refuse payments, sign-ups, passwords and card numbers."
+description: "Let Claude or any function-calling model read and act on real pages with @stunt-double/browser-toolset, with guards that refuse payments and sign-ups."
---
# Give an AI agent a safe browser
diff --git a/content/docs/stuntkit/index.mdx b/content/docs/stuntkit/index.mdx
index 9031e15..519a8af 100644
--- a/content/docs/stuntkit/index.mdx
+++ b/content/docs/stuntkit/index.mdx
@@ -1,7 +1,7 @@
---
title: "StuntKit"
icon: "Package"
-description: "What StuntKit is and which package to use: the open source browser toolset, Web Agent Optimiser, icons and Localeizer behind Stunt Double, plus agent skills and how to contribute."
+description: "StuntKit, the open source toolkit behind Stunt Double: the browser toolset, Web Agent Optimiser, icons and Localeizer, plus agent skills."
---
# StuntKit
diff --git a/content/docs/stuntkit/localeizer/index.mdx b/content/docs/stuntkit/localeizer/index.mdx
index 6dc90f4..f7e4716 100644
--- a/content/docs/stuntkit/localeizer/index.mdx
+++ b/content/docs/stuntkit/localeizer/index.mdx
@@ -1,7 +1,7 @@
---
title: "Localeizer"
icon: "SpellCheck"
-description: "Use Localeizer (@stunt-double/localeizer) to rewrite text into American, British, Australian or Canadian spelling, whole words only, from American or British source text."
+description: "Rewrite text into American, British, Australian or Canadian spelling with @stunt-double/localeizer, whole words only."
---
# Localeizer
diff --git a/content/docs/stuntkit/wao/index.mdx b/content/docs/stuntkit/wao/index.mdx
index d455a7e..36bd866 100644
--- a/content/docs/stuntkit/wao/index.mdx
+++ b/content/docs/stuntkit/wao/index.mdx
@@ -1,7 +1,7 @@
---
title: "Web Agent Optimiser"
icon: "Accessibility"
-description: "Use @stunt-double/wao, a drop-in script that repairs the accessibility tree AI agents read, so legacy sites work for agents without a rebuild."
+description: "A drop-in script that repairs the accessibility tree AI agents read, so legacy sites work for agents without a rebuild. Optimise a page or audit it."
---
# Make any site readable by AI agents
@@ -15,23 +15,38 @@ those controls are missing or unnamed.
Chromium's aria snapshot of a legacy page:
```yaml
+- text: ACME
- link:
- /url: /cart
-- cell:
- - textbox
+- table:
+ - rowgroup:
+ - row "Email:":
+ - cell "Email:"
+ - cell:
+ - textbox
- text: Buy now
```
After WAO:
```yaml
+- text: ACME
- link "Shopping cart":
- /url: /cart
-- cell:
- - textbox "Email"
-- button "Buy now"
+- button "Search"
+- main:
+ - table:
+ - rowgroup:
+ - row "Email:":
+ - cell "Email:"
+ - cell:
+ - textbox "Email"
+ - button "Buy now"
```
+The search control was a `
` holding a Glyphicon, and the cart
+link held only a Font Awesome icon.
+
## How it works
- **Attributes only.** WAO never replaces, moves or restyles a node. Event
@@ -41,8 +56,23 @@ After WAO:
- **Reversible.** `restore()` puts every attribute back exactly as it was.
- **Keeps up.** A `MutationObserver` repairs content that single-page apps add
later, including inside open shadow roots.
+- **Audit mode.** `audit()` reports what WAO would change and leaves the page
+ as it was.
- **Small.** About 4 KB gzipped, with no dependencies, and no network requests.
+## Which agents it helps
+
+Most browser agents that act through text read a page as its accessibility
+tree, with a reference for each element: Playwright MCP, Chrome DevTools MCP,
+Vercel's agent-browser, Stagehand, Browser Use, and the `read_page` style
+tools in Anthropic's and OpenAI's browser toolsets, including StuntKit's own
+[browser toolset](/stuntkit/browser-toolset). These agents see what WAO
+repairs.
+
+Agents that work from screenshots alone (computer use models clicking on
+coordinates) never read the tree, so WAO neither helps nor hinders them. Its
+repairs never change what a page looks like.
+
## Install
As a script tag on any site, serving `dist/wao.global.js` from the package
@@ -97,6 +127,7 @@ script build into every page it opens, so the agent reads the repaired tree.
With Playwright:
```ts
+import type {} from "@stunt-double/wao"; // Types window.wao and window.waoOptions.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -104,27 +135,81 @@ const script = readFileSync(
fileURLToPath(import.meta.resolve("@stunt-double/wao/script")),
"utf8",
);
+const options = {
+ rules: [{ selector: "#hdr .links", role: "navigation", label: "Primary" }],
+};
+
+// One init script, options first: Playwright doesn't define the order of
+// separate init scripts. It runs before the page's own scripts.
+await context.addInitScript({
+ content: `window.waoOptions = ${JSON.stringify(options)};\n${script}`,
+});
+
+const page = await context.newPage();
+await page.goto("https://legacy.example.com");
+console.log(await page.locator("body").ariaSnapshot()); // The repaired tree.
-await context.addInitScript({ content: script });
+// What WAO changed and what it could not fix.
+const { changes, issues } = await page.evaluate(() => window.wao!.report());
```
-To pass options, put `window.waoOptions = {...};` at the start of the same
-init script, since Playwright doesn't define the order of separate ones. The
-script runs once per page, so a second injection is harmless. With Puppeteer,
-pass the same content to `page.evaluateOnNewDocument`. Pair it
-with the [browser toolset](/stuntkit/browser-toolset), whose `read_page` reads
-the same tree.
+A report is plain data: each entry's `element` is a non-enumerable property,
+so it stays in the page and the rest serializes. The same combined content
+works elsewhere:
-## What it repairs
+- **Puppeteer:** pass it to `page.evaluateOnNewDocument`.
+- **Raw CDP:** pass it as the `source` of `Page.addScriptToEvaluateOnNewDocument`.
+- **Playwright MCP:** write it to a file and pass it as an init script.
+
+```sh
+{ echo 'window.waoOptions = {};'; cat node_modules/@stunt-double/wao/dist/wao.global.js; } > wao-init.js
+npx @playwright/mcp --init-script ./wao-init.js
+```
+
+The script runs once per page, so a second injection, or a site that already
+ships WAO, is harmless.
+
+## Audit without changing the page
-| Repair | What it does |
-| ------------ | ----------------------------------------------------------------------------------------------------------------------- |
-| `clickables` | Gives a non-semantic element with a click handler `role="button"` and `tabindex="0"`, so Enter and Space click it. |
-| `names` | Names an unnamed control from a tooltip, an icon font class, the text beside a field, a field's `name` or a link's URL. |
-| `landmarks` | Gives a page with no `` `role="main"` on its one obvious candidate. |
+A harness that must see a page exactly as its users do (a test run, a
+scorecard) can ask what WAO would change without keeping any of it. Set
+`auto: false` so the script build doesn't optimise on load, then call
+`window.waoAudit()` whenever the page settles:
+
+```ts
+await context.addInitScript({
+ content: `window.waoOptions = { auto: false };\n${script}`,
+});
+// ...
+const { changes, issues } = await page.evaluate(() => window.waoAudit!());
+```
-Each repaired element is marked with `data-wao`, so an agent can tell inferred
-names from ones the page wrote. Switch a repair off with
+`changes` are the defects WAO knows how to repair (a clickable `div` with no
+role, an icon button with no name), and `issues` the ones it can't. `audit()`
+is also exported from the npm entry. It sets and removes attributes in one
+synchronous pass, so nothing renders in between, though the page's own
+attribute observers can see them.
+
+You can also turn WAO on only when an agent gets stuck: audit by default, then
+`optimise()` when a control can't be found, and record that the run had help.
+
+## What it repairs
+
+| Repair | What it does |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `clickables` | Gives a non-semantic element with a click handler, or the outermost element with `cursor: pointer`, `role="button"` and `tabindex="0"`, so Enter and Space click it. |
+| `names` | Names an unnamed control from a tooltip attribute, an icon font class (`fa-trash`, `bi-x`, `glyphicon-search`), the text beside a field, a field's `name` or a link's URL. |
+| `landmarks` | Gives a page with no `` `role="main"` on its one obvious candidate (`#main`, `#content`, ...). With several candidates, it does nothing. |
+| `dialogs` | Gives the outermost modal container (class `modal`, `dialog`, `popup` or `lightbox`) with no role `role="dialog"`, named by its first heading, so an agent can tell a modal is open. |
+
+Icon names follow the action, not the picture: `fa-times`, `bi-x` and
+`glyphicon-remove` are "Close", `fa-bars` is "Menu", `fa-cog` is "Settings"
+and `fa-ellipsis-v` is "More", because an agent told to close a dialog looks
+for "Close".
+
+Each repaired element is marked with `data-wao`, listing what changed
+(`data-wao="role focus name"`), so an agent can tell inferred roles and names
+from ones the page wrote. Switch a repair off with
`optimise({ repairs: { landmarks: false } })`, and skip shadow roots with
`shadow: false`.
@@ -142,26 +227,186 @@ optimise({
label: "Check out",
description: "Pays for everything in the basket",
},
+ { selector: "#q", role: "searchbox", label: "Search products" },
+ { selector: ".ad-slot", hidden: true },
],
});
```
-With the script build, set `window.waoOptions` before the script loads.
+`hidden: true` sets `aria-hidden`, taking noise (ads, a duplicated mobile
+menu, decoration) out of the tree an agent reads. Never hide a control a
+person can use. `description` is written as `aria-description`, which Chromium
+exposes in the accessibility tree.
+
+With the script build, set the options before the script loads:
+
+```html
+
+
+```
## Reports
-`report()` lists every change in effect and what WAO found but could not fix
-(`unnamed-control`, `image-without-alt`, `no-main`). Each issue is a good
-candidate for a rule, or for a fix in the site itself. To hear about every
-pass, including passes over content added later, pass an `onReport` callback.
-An agent driving the page can read the same report with `window.wao.report()`.
+`report()` lists every change in effect and what WAO found but could not fix:
+
+```ts
+const { changes, issues } = wao.report();
+// changes: [{ kind: 'name', target: 'a.cart', attribute: 'aria-label', value: 'Shopping cart', source: 'icon', element }]
+// issues: [{ kind: 'unnamed-control', target: 'button#b', element }]
+```
+
+Issue kinds are `unnamed-control`, `image-without-alt` and `no-main`. Each one
+is a good candidate for a rule, or for a fix in the site itself. To hear about
+every pass, including passes over content added later, pass an `onReport`
+callback. An agent driving the page can read the same report with
+`window.wao.report()`.
+
+## API
+
+### `optimise(options?)`
+
+Runs one pass now and returns a handle.
+
+| Option | Default | What |
+| ---------- | -------------------------- | -------------------------------------------------------------------------------------- |
+| `root` | `document.documentElement` | The subtree to optimise. |
+| `observe` | `true` | Optimise content added later. |
+| `keyboard` | `true` | Activate repaired clickables with Enter and Space. |
+| `shadow` | `true` | Optimise and observe inside open shadow roots. |
+| `repairs` | all `true` | `{ clickables, names, landmarks, dialogs }`. |
+| `rules` | `[]` | `{ selector, role?, label?, description?, hidden? }[]`. Invalid selectors are skipped. |
+| `signal` | | An `AbortSignal`. Aborting it restores the page, as `restore()` does. |
+| `onReport` | | Called with the report after every pass, including passes over later content. |
+
+The script build reads the same options from `window.waoOptions`, plus `auto`
+(default `true`): set it to `false` to skip optimising on load.
+
+The handle:
+
+- `report()`: changes and issues as of the last pass.
+- `refresh()`: run a full pass now, for a change the observer can't see (a
+ `cursor` set by a class toggle, for example).
+- `restore()`: undo every change, stop observing and remove the keyboard
+ handler. Calling it twice is harmless.
+
+### `audit(options?)`
+
+Takes the same options except `observe`, `keyboard`, `signal` and `onReport`,
+runs one pass, restores the page and returns the report. The script build
+exposes it as `window.waoAudit`, with `window.waoOptions` as defaults.
+
+### `accessibleName(element)` and `inferName(element)`
+
+The two building blocks, exported for tooling. `accessibleName` is the subset
+of the accessible name computation WAO uses to decide whether a control
+already has a name. `inferName` is the name WAO would give an unnamed control,
+and where it came from.
+
+## Measured
+
+### On a legacy shop
+
+The repository's
+[examples](https://github.com/stunt-double/stuntkit/tree/main/packages/wao/examples)
+load a legacy fixture shop (`div onclick` buttons, icon-only links,
+table-cell labels, a modal without a role) in Chromium with and without WAO.
+An agent that addresses controls only by role and name (`getByRole`) reaches
+none of the ten checkout steps without WAO, and all ten with it.
+
+| Page | Controls in the tree, without | With | Unnamed, without | With |
+| ---------- | ----------------------------- | ---- | ---------------- | ---- |
+| Shop | 2 | 9 | 2 | 0 |
+| Basket | 2 | 4 | 2 | 0 |
+| Order done | 1 | 1 | 1 | 0 |
+
+Full-page screenshots of every page are pixel-identical with and without WAO.
+Run the comparison yourself with
+`pnpm --filter @stunt-double/wao-examples compare`.
+
+### On real sites
+
+Well-built sites need little from WAO, and it leaves them alone. On
+Wikipedia's portal, the language picker is a `