diff --git a/AGENTS.md b/AGENTS.md
index 1b88a31..10fdf43 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -197,6 +197,16 @@ self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.t
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
+A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js`
+over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey
+there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's.
+A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
+one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
+channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
+The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
+document, its error code — leaving the corpus's pairing, uniqueness, source positions and
+byte-level equality to the Node suite that owns them.
+
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
diff --git a/README.md b/README.md
index 557bd58..4b2c793 100644
--- a/README.md
+++ b/README.md
@@ -148,4 +148,5 @@ Personas, never named consumers (AGENTS.md §7):
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
-Deno and Bun. Contract: `AGENTS.md` §5–6.
+Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
+Contract: `AGENTS.md` §5–6.
diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js
new file mode 100644
index 0000000..0d44e36
--- /dev/null
+++ b/browser-tests/convert-corpus.js
@@ -0,0 +1,13 @@
+try {
+ const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
+ window.convertCorpus = (corpus) => ({
+ errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
+ normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
+ roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
+ const adf = JSON.parse(json)
+ return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) }
+ }),
+ })
+} catch (cause) {
+ window.adfCodecFault = `/dist/index.js did not load: ${cause}`
+}
diff --git a/browser-tests/page.html b/browser-tests/page.html
new file mode 100644
index 0000000..e11fcaf
--- /dev/null
+++ b/browser-tests/page.html
@@ -0,0 +1,4 @@
+
+
+
@larvit/adf-codec in a browser
+
diff --git a/browser-tests/run.js b/browser-tests/run.js
new file mode 100644
index 0000000..0d0c9d8
--- /dev/null
+++ b/browser-tests/run.js
@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict'
+import { readFileSync, readdirSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { extname, join } from 'node:path'
+
+const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
+const driver = 'http://127.0.0.1:4444'
+const repoRoot = join(import.meta.dirname, '..')
+const corpusRoot = join(repoRoot, 'corpus')
+const corpusEntries = readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
+
+function checking(name, result, assertions) {
+ assert.equal(result?.name, name, `the browser answered ${JSON.stringify(result?.name)} where ${name} was sent`)
+ try {
+ assertions()
+ } catch (cause) {
+ throw new Error(`${name} in the browser — ${cause.message}`, { cause })
+ }
+}
+
+async function command(method, path, body) {
+ const response = await fetch(`${driver}${path}`, {
+ body: body === undefined ? undefined : JSON.stringify(body),
+ headers: { 'content-type': 'application/json' },
+ method,
+ signal: AbortSignal.timeout(180_000),
+ })
+ const payload = await response.json()
+ if (!response.ok) throw new Error(`webdriver ${method} ${path}: ${JSON.stringify(payload.value)}`)
+ return payload.value
+}
+
+function fileBody(file) {
+ try {
+ return readFileSync(file)
+ } catch {
+ return undefined
+ }
+}
+
+function fixture(name, extension) {
+ return readFileSync(join(corpusRoot, `${name}${extension}`), 'utf8')
+}
+
+function fixtureNames(kind, extension) {
+ return corpusEntries
+ .filter((name) => name.startsWith(`${kind}/`) && name.endsWith(extension))
+ .map((name) => name.slice(0, -extension.length))
+ .sort()
+}
+
+function refusal(result) {
+ return result.ok ? '' : `${result.error.code}: ${result.error.message}`
+}
+
+async function whenDriverAnswers() {
+ const deadline = Date.now() + 60_000
+ for (;;) {
+ const status = await command('GET', '/status').catch(() => undefined)
+ if (status?.ready === true) return
+ if (Date.now() > deadline) throw new Error(`no WebDriver answered ${driver}/status within 60s`)
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ }
+}
+
+const corpus = {
+ errors: fixtureNames('errors', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
+ normalization: fixtureNames('normalization', '.md').map((name) => ({ markdown: fixture(name, '.md'), name })),
+ roundTrip: fixtureNames('round-trip', '.json').map((name) => ({ json: fixture(name, '.json'), markdown: fixture(name, '.md'), name })),
+}
+assert.deepEqual(
+ readdirSync(corpusRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort(),
+ ['errors', 'normalization', 'round-trip'],
+ 'a corpus kind the browser leg does not convert',
+)
+for (const [kind, fixtures] of Object.entries(corpus)) assert.ok(fixtures.length > 0, `the browser leg found no ${kind} fixture to convert`)
+
+const server = createServer((request, response) => {
+ const file = join(repoRoot, new URL(request.url ?? '/', 'http://127.0.0.1').pathname)
+ const body = file.startsWith(repoRoot) ? fileBody(file) : undefined
+ if (body === undefined) response.writeHead(404).end()
+ else response.writeHead(200, { 'content-type': contentTypes[extname(file)] ?? 'application/octet-stream' }).end(body)
+})
+await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+
+await whenDriverAnswers()
+const session = await command('POST', '/session', {
+ capabilities: { alwaysMatch: { browserName: 'firefox', 'moz:firefoxOptions': { args: ['-headless'] }, timeouts: { pageLoad: 60_000, script: 120_000 } } },
+})
+await command('POST', `/session/${session.sessionId}/url`, { url: `http://127.0.0.1:${server.address().port}/browser-tests/page.html` })
+const results = await command('POST', `/session/${session.sessionId}/execute/sync`, {
+ args: [corpus],
+ script: 'if (!window.convertCorpus) throw new Error(window.adfCodecFault ?? "the page defined no convertCorpus"); return window.convertCorpus(arguments[0])',
+})
+server.close()
+
+for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
+ const result = results.roundTrip[index]
+ checking(name, result, () => {
+ assert.ok(result.isDocument, `${name}.json is no ADF document`)
+ assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
+ assert.equal(result.emitted.value, markdown)
+ assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
+ assert.deepEqual(result.parsed.value, JSON.parse(json))
+ })
+}
+
+for (const [index, { name }] of corpus.normalization.entries()) {
+ const result = results.normalization[index]
+ checking(name, result, () => {
+ assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
+ assert.deepEqual(result.parsed.value, JSON.parse(fixture(name, '.json')))
+ })
+}
+
+for (const [index, { name }] of corpus.errors.entries()) {
+ const result = results.errors[index]
+ checking(name, result, () => {
+ assert.ok(!result.parsed.ok, `it was not refused, it built ${JSON.stringify(result.parsed.value)}`)
+ assert.equal(result.parsed.error.code, fixture(name, '.error').trimEnd())
+ })
+}
+
+console.log(
+ `Firefox ${session.capabilities.browserVersion} converted ${corpus.roundTrip.length} round-trip, ${corpus.normalization.length} normalization and ${corpus.errors.length} error fixtures`,
+)
diff --git a/ci.sh b/ci.sh
index d389e45..5ab9650 100755
--- a/ci.sh
+++ b/ci.sh
@@ -26,3 +26,5 @@ in_image "$node_image" sh -c 'set -e
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null'
in_image "$node_image" npx tsc -p package-tests
in_image "$floor_image" node package-tests/node-floor.js
+
+with_firefox in_image "$node_image" node browser-tests/run.js
diff --git a/docker-runner.sh b/docker-runner.sh
index 66ed149..504109f 100644
--- a/docker-runner.sh
+++ b/docker-runner.sh
@@ -1,10 +1,23 @@
bun_image=oven/bun:1.4.0-alpine
deno_image=denoland/deno:2.9.6
+firefox_image=selenium/standalone-firefox:153.0.4
floor_image=node:18.20.8-alpine3.21
node_image=node:24.19.0-alpine3.24
in_image() {
local image=$1 entrypoint=$2
shift 2
- docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
+ docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
+}
+
+with_firefox() {
+ local container in_image_network status=0
+ container=$(docker run -d --rm "$firefox_image")
+ # The id is baked in: the trap fires after this function's locals are gone.
+ trap "docker rm -f $container >/dev/null 2>&1" EXIT
+ trap 'exit 130' INT
+ trap 'exit 143' TERM
+ in_image_network="container:$container"
+ "$@" || status=$?
+ return $status
}
diff --git a/renovate.json b/renovate.json
index 1c30dd2..daf367a 100644
--- a/renovate.json
+++ b/renovate.json
@@ -47,6 +47,15 @@
"managerFilePatterns": [".gitea/workflows/renovate.yml"],
"matchStrings": ["renovate/renovate:(?[0-9][^\\s\"']*)"],
"versioningTemplate": "docker"
+ },
+ {
+ "customType": "regex",
+ "datasourceTemplate": "docker",
+ "depNameTemplate": "selenium/standalone-firefox",
+ "description": "Pin the Firefox image the gate's browser leg runs",
+ "managerFilePatterns": ["docker-runner.sh"],
+ "matchStrings": ["selenium/standalone-firefox:(?[0-9][^\\s\"']*)"],
+ "versioningTemplate": "docker"
}
],
"extends": ["config:recommended"],
diff --git a/todo-history.md b/todo-history.md
index 68b9b53..1442fbf 100644
--- a/todo-history.md
+++ b/todo-history.md
@@ -512,3 +512,23 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
names them.
+- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
+ browser can load — the compile gate names no host API, and a real page converting the corpus
+ is the other half. Headless Firefox is that page, settling both at once: the browser proof,
+ and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
+ JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
+ browser leg wants an image, a driver and a way to carry a verdict back out, none of which
+ the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
+ Firefox image beside the node one in a shared network namespace, so the page's server and
+ the driver are each other's `127.0.0.1` and no user-defined network, container name or
+ geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
+ id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
+ serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
+ Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
+ of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
+ timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
+ broken build into SpiderMonkey's own message rather than an undefined global.
+ **Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
+ `instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
+ SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
+ Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
diff --git a/todo.md b/todo.md
index 0140fa1..8c5559a 100644
--- a/todo.md
+++ b/todo.md
@@ -129,13 +129,7 @@ The numbering is the order the work was planned in, not the order it ships.
- [x] **5b3 — The code list and the flavour's gaps.**
- [x] **5b4 — The README's consumer surface.**
- [x] **5c — The build and the release pipeline.**
-- [ ] **5d — The browser leg (`0.1.0`).** §6's browser half is checkable on the emitted
- `dist/index.js` a browser can load — the compile gate names no host API, and a real page
- converting the corpus is the other half. Headless Firefox is that page, settling both at
- once: the browser proof, and the only SpiderMonkey there is, the gate's three engine legs
- being two V8s and a JavaScriptCore that is not Safari's. The mechanism is the decision this
- item opens with: a browser leg wants an image, a driver and a way to carry a verdict back
- out, none of which the gate's plain `docker run` per engine has.
+- [x] **5d — The browser leg.**
- [ ] **6 — The HTML dialect spec (`0.3.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.3.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /