How to screenshot a page without Chromium: feed the post-JS DOM to Blitz
AginxBrowser's product promise is "a single binary, zero Chromium." That leaves no room for the standard answer to screenshots — download 500MB of Chromium and drive its headless mode — because doing so would destroy the entire point. But agents need to see pages, so screenshots are a hard requirement. The contradiction forced a completely different implementation path, and it dragged us into a few traps even upstream maintainers had never hit. This is the full post-mortem.
Why not headless Chromium
Puppeteer and Playwright get screenshots from Chromium — which is itself a browser, bundling its own JS engine, renderer, and network stack. AginxBrowser already carries its own V8 and HTTP stack (the obscura core). Adding Chromium on top would mean building two browsers in one.
Our choice was Dioxus Blitz: a pure-Rust rendering stack — Stylo (Servo's CSS engine), Taffy (flexbox/grid layout), parley (text shaping), and vello_cpu (pure-CPU rasterization). The key property: vello_cpu needs no GPU/wgpu. Our production server has no discrete GPU, so this one fact decided feasibility.
The integration model: Blitz only paints what's already seen
Our first attempt fed raw curl'd HTML into Blitz. Complex sites came back entirely white. The reason: modern pages make their visible content depend on JS flipping display and CSS media queries at runtime — the raw HTML's nodes sit in display:none.
The corrected model is clean: obscura's V8 runs the page's JS first, we serialize the real final DOM to an HTML string, and hand it to Blitz purely for layout + paint. Blitz doesn't go look at the world; it draws the world the agent has already seen. JS, stealth, search, and session all stay in the original stack untouched; Blitz takes over exactly one layer: layout and rasterization.
Measured: a full Baidu SERP page (6217px tall) goes from JS render to PNG in about 4 seconds, with paint_scene itself taking single-digit milliseconds; People's Daily at 17495px tall lands in the same ballpark. vello_cpu is pure CPU — one page costs less wall time than a markdown conversion.
Trap one: all-white pages — a bug born of an upstream assumption
The first big problem after integration: real sites rendered pure white while self-built simple pages were fine. Root cause, traced layer by layer:
- We pass
net_provider: None, so Blitz usesDummyNetProvider— a fake network layer whose fetch is a no-op; - A
<link rel="stylesheet">in<head>gets unconditionally added topending_critical_resources; - DummyNetProvider never calls back → the resource state never resolves;
paint_scenechecks "any critical resources pending" at its top → returns immediately, nothing is painted.
The root cause wasn't a Stylo/Taffy capability gap — style resolution and layout were always correct. It was the "resource loading gate" not accounting for a no-net-provider scenario, and permanently blocking paint because of it. That's the blind spot of an upstream assumption that "there's always a net provider pulling resources" — and "feed it a pre-rendered DOM" is exactly the integration that trips it.
The fix: give NetProvider a default is_noop() method that DummyNetProvider overrides to true, and only count head stylesheets as pending when the provider isn't a no-op. Baidu went from 1 color (all white) to 1653, GitHub from 1 to 516, People's Daily from 1 to 615. That patch became a PR upstream and was merged (#636), retiring our fork.
Trap two: the CJK hang — why we're pinned on an older parley
After switching dependencies back to upstream mainline, heavy-CJK pages started hanging forever in line layout. Baidu wouldn't return even from a release build after 240+ seconds; profiling pinned it on parley's BreakLines::break_remaining spinning in a while break_next().is_some() {} loop with no progress. Bisection traced it to a pure dependency bump in parley 0.11 — and it triggers on non-CJK text too; the trigger is a specific arrangement of inline span nesting + CSS constraints.
We filed a minimized repro (686KB → 1.4KB, linebender/parley#752) and chose to pin blitz at 2fa6434d (parley 0.10), a known-good revision. The cost is missing a few unrelated upstream fixes; the payoff is deterministic rendering. That's the pin-vs-chase tradeoff, and for a production service the answer was obvious.
Trap three: element coordinates — parent-relative positions and a self-contradicting crop
Agents need to act on screen coordinates, not just screenshots — they need to know where an element is. Taffy's final_layout().location is parent-relative, so absolute coordinates = summing locations up the layout_parent chain (which includes anonymous block boxes, matching paint traversal). Verified against real pages: 9 Baidu result cards at x=150, width 608, stacking exactly; 17 GitHub trending cards at x=8, width 1264, evenly spaced.
Cropping hit a genuinely surprising trap. paint_scene's x/y offset params shift painted positions, but the viewport culling cancels them with translate(-initial_x) — so an element outside the unscrolled window gets culled into a blank crop. (Upstream only ever passes 0,0, so nobody had hit this path.) The correct mechanism is set_viewport_scroll, which shifts painting and culling coherently.
One honest boundary: pure inline elements (<a>text</a>) have no Taffy box (0×0) — their content belongs to the containing block's inline layout. Our fallback unions the element's descendant boxes (which rescues mixed content like <a><img></a>); when that's still empty we error out and point the caller at a block ancestor.
Why the path is worth it
Blitz is still beta; complex-site CSS is approximate rather than pixel-perfect, and image sub-resources aren't fetched (images can be missing). We document all of this honestly in the README's known limitations. What the path buys: one binary, zero Chromium, no GPU required, correct CJK rendering, and element coordinates sourced from the same layout pass as the screenshot. For putting eyes on agents, it's the sanest route we know — and every trap above is ours, with the docs, patch, and issue all reproducible.