Recursive AI

Chrome geometry differential

All onerender docs

Screen layout is checked against Chrome: for every page of a corpus set, every element's border box as Chrome lays it out is compared with the box our engine gives the same element. This document says what is measured, how, and how to reproduce it. The tooling is tools/geomdiff/ (C#, ClientWebSocket DevTools client, source-generated JSON); nothing under src/ depends on it.

Pins

WhatValue
Chrome for Testing156.0.8078.47, linux-x64, chrome-linux64.zip, sha256 e6c94490…8774a50 (full hash in tools/geomdiff/scripts/fetch-chrome.sh)
FontsLiberation 2.1.5, Carlito @3a810cab, Caladea @336a529c; upstream URLs and sha256 in tools/geomdiff/scripts/fonts.tsv
Tailwind CSSv4.3.3 standalone CLI, sha256 in build-tailwind.sh
daisyUI, Flowbitenpm releases 5.7.50 and 4.0.2 (MIT), pinned by the registry's sha512 in build-tailwind.sh

Chrome is a black box: only DevTools Protocol output is read. It is fetched to $ONERENDER_HOME/cache/chrome/ (default ~/onerender), never committed, never redistributed, and only ever loads frozen content with the network off.

Fonts

scripts/fontconfig.sh <fonts-dir> <out.conf> writes a fontconfig file that exposes exactly the font files under <fonts-dir> and nothing else (Chrome is started with FONTCONFIG_FILE pointing at it). <fonts-dir> is either the cache filled by scripts/fetch-fonts.sh or the fonts package folder (src/OneRender.Fonts/fonts); both have the same layout and bytes. Set FONTS_DIR for run-set.sh to choose.

The file maps names the way the fonts package does: sans-serif, system-ui, ui-sans-serif, Arial, Helvetica → Liberation Sans; serif, ui-serif, Times, Times New Roman, cursive, fantasy → Liberation Serif; monospace, ui-monospace, Courier, Courier New → Liberation Mono; Calibri → Carlito; Cambria → Caladea. A pattern that names nothing else ends at Liberation Sans. Hinting and sub-pixel rendering are off (see "Chrome settings").

onerender-geomdiff fonts-check loads testdata/geomdiff/fonts-check.html and asks Chrome (CSS.getPlatformFontsForNode) which platform fonts rendered each line; it fails if any is outside the five shipped families or not the expected one. The result is results/geometry/fonts-check.md.

Corpus sets

A set is a directory of frozen pages. Each page directory holds index.html (UTF-8), page.json (format onerender-snapshot/1: page id, the URL it is loaded from, and the list of resources with their URL, file and content type) and res/. Chrome replays a page from these files only: its own HTML, the listed resources, an empty answer for any other stylesheet or sub-document, and a blocked request for everything else (scripts, images, fonts, media, fetches). Scripts never run. Images and web fonts therefore never load, on either side; data: URLs do.

SetPagesHow it is made
onehtml-2026-10a600A stratified sample of OneHtml's frozen crawl. Strata are crawl source (two) × body size third (three), equal allocation (100 each, taken in body-hash order). Each page is loaded offline from the crawl's stored bytes with scripts disabled; the serialized DOM (doctype + outerHTML, <script> elements removed) and every stylesheet served become the snapshot. Local only: never committed, never leaves the host; only aggregate numbers are published (results/geometry/onehtml-2026-10a/, no page identifiers).
tailwind7testdata/tailwind/pages/*.html: our own pages using Tailwind utilities, daisyUI components and Flowbite-style component markup, compiled by scripts/build-tailwind.sh. The Flowbite theme CSS is imported; its JavaScript plugin does not load in the standalone CLI, so Flowbite-style pages use plain utilities.
text-block18testdata/text-block/: our own articles, documentation, blog index, plain email, mixed scripts, footnotes, positioned badges and sticky bars, white-space blocks, images with fixed sizes, box-model and typography specimens, and four small clearing probes. Only block, inline, floats, positioning and overflow: no flex, grid, tables, forms or generated content. This is the gate set for flow layout. Where the engine follows Chrome beyond the specifications, layout-conventions.md says so.
templates6testdata/templates/: invoice, statement (180 rows), receipt, certificate, email (table layout), annual report (18 chapters). Generated by testdata/templates/generate.py (seeded; output committed). Reused later for PDF output and benchmarks.
chrome-probes9testdata/chrome-probes/: one small page per Chrome-matching rule recorded in layout-conventions.md (line-height quantization, break tailoring, balanced columns, auto margins in over-wide boxes, flex and grid baselines, aspect-ratio on flex containers, closed details, @property initial values). Each page was probed against Chrome before the rule was written; the set stays at 100% on both viewports and guards those rules.
forms4testdata/forms/: a sign-up form, a settings page, a checkout form and a dense gallery of every control in each state. Gate: at least 99% per viewport (1280x800: 100%, 390x844: 99.70%; the one miss is a drop-down select inside a grid's min-content measure). Controls follow forms.md.

Element identity

An element is identified by its path: the indexes of the element and of each ancestor element among the element children of its parent, joined by /. The document element is 0, its second element child 0/1, the first child of that 0/1/0. Text, comments and the doctype are not counted.

Why: it needs only the parsed tree, which both sides build from the same HTML bytes by the HTML parsing standard, so it needs no ids and no instrumentation of the page. If two parsers build different trees the paths disagree, and the tag name stored beside each path exposes it (tag conflicts in the summary). On Chrome's side, pseudo-elements, shadow trees (the insides of form controls), <template> contents and other documents (frames) are not part of the tree and are skipped.

Capture format

<out>/<page id>/<W>x<H>.json.gz, format onerender-geometry/1:

{ "format": "onerender-geometry/1", "source": "chrome", "page": "<id>",
  "viewport": {"width": 1280, "height": 800}, "documentWidth": 1280, "documentHeight": 4210,
  "elements": [ {"path": "0/1/0", "tag": "div", "display": "block", "text": true, "box": [x, y, w, h]}, … ] }

Boxes are CSS pixels, origin at the document's top-left (scroll offset zero), rounded to 4 decimals. box is absent when the element generates none (display: none, display: contents). For an inline element that wraps, the box is the union of its line boxes. text is true when the element has a direct text child that is not only white space. display is Chrome's computed value. <out>/run.json (onerender-geometry-run/1) records the browser version, user agent, every flag, the font set id (a hash of the font files), the viewports and any page that failed.

Viewports. 1280×800 (the size OneHtml's own captures use) and 390×844 (a phone's CSS width; device scale factor 1, no mobile emulation, no <meta viewport> handling). Every set is captured at both.

Chrome settings

--headless=new, device scale factor 1, --hide-scrollbars, --font-render-hinting=none and --disable-lcd-text (linear glyph advances with sub-pixel positioning, which is what an outline-based text engine produces), site isolation off, HttpsUpgrades off (a page recorded under an http: address must keep it), and a dead proxy plus an empty host resolver as a second wall behind request interception. One browser context per page. The full list is in run.json.

To make two captures of one page agree, the capture waits for the load event, a short settle, every non-lazy image to finish (a blocked image reports its failure after the load event and changes the layout), and then puts every CSS animation and transition at its end state (animations that never end are cancelled, which restores the base style). An inspector style sheet sets content-visibility: visible !important on everything, because auto skips the layout of content away from the viewport and does so differently from run to run. The reference is therefore the settled, fully laid-out page; the engine is compared against that: no animation in progress, content-visibility treated as visible. The snapshot step does none of this to the stored HTML.

Comparison

For each page and viewport the comparer pairs elements by path. An element is counted when it has a box on at least one side. It is within tolerance when, for each axis, the position and the size differ from Chrome's by at most max(1 px, 0.5% of Chrome's extent on that axis) (x and width use the width; y and height use the height).

ClassMeaning
position-onlyx or y out of tolerance, size within
size-onlywidth or height out of tolerance, position within
position-and-sizeboth
text-runa size difference (with or without a position difference) on an element that holds text of its own or is display: inline: its extent comes from glyph advances and line breaking, not the box model
missing-oursChrome has a box, we have none (or an element with that path but another tag)
missing-chromewe have a box, Chrome has none

Every mismatch also records whether it is a root cause: no ancestor of it mismatches (other than by being missing). A shifted subtree is one root cause and many position-only descendants, so the root-cause counts are the better measure of how many things are wrong.

Rate = within tolerance ÷ counted, pooled over a set's pages. The gate is 99% per viewport; summary.json also gives pages at the gate and the median and minimum page rate.

--engine asks our side for boxes through OneRender.GeometryProbe.LayOut(html, documentUrl, resolveResource, width, height, fonts): HTML in, ElementBox (path, tag, border box) out. It runs the engine's whole front half (parse, cascade, box tree, layout) through HtmlPage and reads each element's border box from the fragment tree. Elements without a box are absent, which the comparison counts as missing-ours. The tool passes the default fonts. Results under engine are from this path; stub-engine holds the baseline from before the engine could lay anything out.

Results

results/geometry/<set>/: capture-run.json (the Chrome run's run.json), and for each of chrome-vs-chrome (two independent captures: determinism and the font pin), perturbed (Chrome against a deliberately wrong copy; expected-classes.json holds the counts the wrongness must be reported as, and the comparer exits non-zero if they differ) and engine: summary.json (onerender-geometry-summary/1: tolerance, browser, font set, one row per viewport and a total with class counts, root-cause counts, tag conflicts, pages at the gate, median and minimum page rate, the ten tags with the most mismatches, gateMet), summary.md, and for the sets we wrote, pages.md (one row per page and viewport). Per-page mismatch detail (onerender-geometry-detail/1: path, tag, class, root cause, both boxes) is written beside the captures on the machine that ran them and is not committed.

The perturbation moves about 2% of boxes, resizes 2%, moves and resizes 2%, resizes 2% of text-bearing boxes, drops 1% and adds one element Chrome does not have per page, chosen by a seeded hash of page and path.

Known residual non-determinism

Two captures of the same pages agree on 99.985% of counted elements in onehtml-2026-10a; all differences are in two pages: zero-size inline spans whose x differs between runs, and SVG icons that measure 12 px in one run and 14 px in the other. They are left as measured and bound what "Chrome against Chrome" can show. Nothing else has differed between captures.

Reproduce

On a host with .NET 10, curl, unzip, tar and openssl (state goes to $ONERENDER_HOME, default ~/onerender):

cd tools/geomdiff/scripts
./build.sh                                   # builds the tool in Release
./fetch-fonts.sh && ./fontconfig.sh ~/onerender/cache/fonts ~/onerender/cache/fonts.conf
./geomdiff.sh fonts-check --page ../../../testdata/geomdiff/fonts-check.html --out fonts-check.md \
    --chrome "$(./fetch-chrome.sh | tail -1)" --fontconfig ~/onerender/cache/fonts.conf
./geomdiff.sh snapshot-static --src ../../../testdata/templates --out ~/onerender/corpus/templates
./build-tailwind.sh && ./geomdiff.sh snapshot-static --src ~/onerender/corpus/tailwind-src --out ~/onerender/corpus/tailwind
./geomdiff.sh snapshot-corpus --corpus <crawl dir with manifest.jsonl and bodies/> --count 600 --jobs 6 \
    --out ~/onerender/corpus/onehtml-2026-10a --chrome <chrome> --fontconfig ~/onerender/cache/fonts.conf
JOBS=6 ./run-set.sh <set> ~/onerender/corpus/<set> ../../../results/geometry --page-table

run-set.sh takes PHASES (a, b, compare) to split a long set into separate jobs, and NO_SANDBOX=1 for hosts that forbid Chrome's sandbox (only frozen offline content is loaded). Each command's options are listed by running the tool without arguments.

Non-finite engine boxes

An engine box coordinate that is NaN or infinite is a defect. The comparison does not stop on it: the coordinate is replaced by 1e12, which mismatches any real box, and a line naming the page, viewport and element path goes to standard error.