ADR-0020 The live demo reaches the real engine server-side only
#Context
ADR-0008 committed the docs site to running the system's real components live, on the grounds that §11's ninety-second demo requires the receipt to *appear* rather than be paraphrased: "the failing check is the portfolio." Phase 6 built that integration as /api/salience, a Node.js route handler that calls the real measureScene() and computeScene() from @wolf-rayet/budget and returns the engine's own verdict to the page.
That route works in development and fails once deployed: measureScene() imports chromium from playwright, and Playwright's browser binary is not packaged for Vercel's serverless runtime. The demo therefore has a real engine it cannot reach in the environment it ships to.
This raised a question worth settling permanently rather than per-incident: does the engine need a server at all? computeScene() is pure — no filesystem, no Node-only API, a function of a declared DOM graph and a pixel field. If a live browser tab could supply that pixel field about its own DOM natively, the server dependency disappears, and with it the packaging problem. This session tested that directly against the two fixtures CI already gates, rather than reasoning about it.
What the engine actually consumes. computeScene() destructures scene.pixels as { light, width, height, pixelRatio }. light is not RGB and not per-element: it is a Float64Array of length width * height — 1024000 at the pinned 1280 × 800 viewport, pixelRatio 1 — holding one OKLab L value per pixel, row-major, indexed y * width + x, over the whole clipped viewport, decoded from a PNG screenshot. Reproducing that shape in a browser tab is the whole of the client-native question.
What was measured. A normal tab can rasterize its own DOM, by exactly one route: an SVG <foreignObject> serialized to a data: URL, loaded as an Image, drawn to a canvas, read back with getImageData, and converted by the package's own unmodified lightnessField(). It works — the canvas is not tainted, and the scene's metric font survives as an inlined data URI. It is also the only route: a blob-URL SVG taints the canvas (SecurityError), and createImageBitmap() on an SVG blob fails outright (InvalidStateError).
Fed into the actual unedited computeScene(), with the DOM graph held at measureScene()'s own so the pixel question was isolated, it came very close and did not land:
| fixture | pixels identical | scope | ground truth | client-native |
|---|---|---|---|---|
| 09-indicator-calm | 1023944 / 1024000 | #scope-fleet | 0.058164 | 0.058164 |
#scope-queue | 0.054784 | 0.054784 | ||
#scope-telemetry | 0.048587 | 0.048588 | ||
| 10-indicator-two-demands | 1023963 / 1024000 | #scope-fleet | 0.058164 | 0.058164 |
#scope-queue | 0.060173 | 0.060173 | ||
#scope-telemetry | 0.048587 | 0.048588 |
Everything categorical agreed. Both view verdicts matched (09 at 0/1 pass, 10 at 2/1 fail), the offending elements matched — #queue-demand-a "Line 3 halted" and #queue-demand-b "Seal press over temperature", both level 4 — and their salience shares matched exactly at 0.005196 and 0.005487. Five of six emission loads matched exactly. One did not, in both fixtures, reproducibly.
The disagreement is 56 pixels in fixture 09 and 37 in fixture 10, each off by one to four in a single channel on antialiased text edges. Four of them fall inside #scope-telemetry, whose 249280 governed pixels turn them into a raw load difference of 1.8122e-7 — five times *smaller* than one unit in the last displayed place, surfacing only because the true value sits within 1.8e-7 of the six-decimal rounding boundary at 0.0485875.
It is not a configuration difference. The same capture run inside the pinned measurement Chromium — --disable-lcd-text, --disable-font-subpixel-positioning, --force-color-profile=srgb, --disable-gpu — produced the same 56 pixels at the same maximum delta, and the ordinary tab's capture is byte-for-byte identical to the pinned browser's. Chromium's SVG-image rasterization path differs from its own compositor deterministically, host-independently, and with no knob for it from inside a page.
#Decision
Client-native pixel capture is rejected as a permanent path. The server route, reached through /api/salience, remains the sole path to the real budget engine for the live demo. Two findings from this session decide it, either sufficient on its own.
First, the raster delta violates ADR-004's reproducibility tolerance. REPRODUCIBILITY_TOLERANCE is 0 — same-machine renders must agree *exactly*, and config.js says why in the constant's own note: anything else "is a bug in the engine or a non-determinism in the renderer, and either is worth failing a build over." That zero is a deliberate choice, not a threshold with room to move informally. A client-native path disagrees with the CLI on a load the receipt displays, so adopting it would mean either relaxing that zero — the one tolerance in this system that exists precisely so it cannot be relaxed quietly — or publishing a demo whose numbers differ from the numbers CI gates on. The measured 1.8122e-7 being three orders inside PLATFORM_TOLERANCE is not a defence: platform drift is the cross-machine question, and this is the same-machine one.
Second, the DOM graph cannot cross into a browser without forking the source of truth. computeScene() needs the declared graph as well as the pixels, and the function that produces it is a module-local constant inside measure.js, not exported — and measure.js cannot load in a browser at all, because it imports playwright and node:url. A shipped client-native path would therefore require a permanent second copy of that extraction logic, maintained in parallel, with no shared source of truth. The vocabulary that copy reads — ATTR, DATASET, GROUND_OF, EXEMPT_SLOTS — is the same vocabulary the fixtures write and the lint checks, and measure.js already documents keeping it to one definition as the point. Two extractions that agree by habit is exactly the duplication this repository has refused everywhere else.
#Rejected options
Client-native pixel capture — the live page rasterizes its own DOM through an SVG <foreignObject>, converts it with the package's own lightnessField(), and calls the real computeScene() in the browser, with no server in the path. Its merit was substantial and specific: it removes the serverless packaging problem entirely rather than working around it, because there is no browser binary to bundle — the browser is already there, running the page. It very nearly worked, agreeing with the CLI on every scope, every offending element, every salience share, both view verdicts, and five of six emission loads, at over 99.99% of pixels byte-identical. Rejected for the two reasons above: the residual raster delta breaks a tolerance defined as exact, and the DOM-graph half would need a permanently forked second implementation. Either alone would have been enough; both together make it not a near miss but a wrong direction.
#Consequences
/api/salience stays as it is, and stays the only way the docs site reaches the real engine. measure.js and cli.js are untouched by this decision — this session's investigation deliberately fed the browser-captured field into the unedited engine rather than adapting the engine to the browser, so nothing in packages/budget changed to reach this conclusion.
What this makes easier: the demo's numbers and CI's numbers are the same numbers, produced by one code path, and stay that way by construction. There is no second extraction to keep in step, and no tolerance to renegotiate the next time a renderer detail shifts.
What this makes harder: the docs site keeps a Node.js runtime dependency on Playwright, which is a real deployment constraint rather than an incidental one.
The deployment failure already found stands as a packaging problem, to be solved on its own terms — not as evidence to revisit this decision. The route failing on Vercel's serverless runtime is a question about how a browser binary is packaged and where the route runs; it is not evidence that the engine should move into the page. This ADR is what stops that failure from being re-litigated as an architecture question each time it is hit.
Enforcement: none added. This decision removes a candidate path rather than adding a rule; it adds no row to §10's enforcement table, and the exactness it defends is already enforced by REPRODUCIBILITY_TOLERANCE in the budget engine's own CI run.