Skip to content

feat: add kuri browser experiment#152

Draft
justrach wants to merge 40 commits intomainfrom
kuri-browser
Draft

feat: add kuri browser experiment#152
justrach wants to merge 40 commits intomainfrom
kuri-browser

Conversation

@justrach
Copy link
Copy Markdown
Owner

@justrach justrach commented Apr 26, 2026

Summary

Adds the standalone kuri-browser experiment with native text-first browsing primitives, parity/readiness benches, CDP HTTP discovery, screenshot fallback through the existing Kuri/CDP renderer, and native SVG paint experiments.

Native paint update

The JS-rendered https://quotes.toscrape.com/js/ path now has a targeted card renderer that paints the serialized post-JS quote DOM with Bootstrap-like container spacing, quote borders/shadows, author styling, and tag pills.

Latest local Chrome-vs-native pixel harness at 1280x720:

  • https://example.com: 99.35% exact pixels, mean RGB delta 0.48/255
  • https://news.ycombinator.com: 88.06% exact pixels, mean RGB delta 10.58/255
  • https://quotes.toscrape.com/js/ --paint-js: 90.32% exact pixels, mean RGB delta 7.47/255

This is still not a 1:1 browser renderer. The native path remains an experimental DOM/SVG paint path while full CSS layout, raster screenshots, PDF, and broader runtime behavior are incomplete.

CDP screenshot fallback

kuri-browser screenshot <url> delegates to the existing Kuri/CDP renderer. It now supports heavier JS pages with:

  • --wait-ms <n> for explicit app-shell settling before capture
  • --wait-selector <css> and --wait-timeout-ms <n> for selector-based waits
  • --user-agent <ua> and --desktop-user-agent so the fallback can apply a desktop UA on about:blank before navigating
  • retry-on-capture when Page.captureScreenshot fires before a late-rendering app is ready

Validated locally against Singapore Airlines through Kuri/CDP:

./zig-out/bin/kuri-browser screenshot 'https://www.singaporeair.com/en_UK/sg/home#/book/bookflight' \
  --out /tmp/kuri-sia-cdp-wait.png \
  --kuri-base http://127.0.0.1:8080 \
  --desktop-user-agent \
  --wait-ms 15000

This produced the real booking homepage screenshot at 672,591 bytes. That path is Chrome/CDP fallback behavior, not native Kuri layout.

Screenshot compression

kuri-browser screenshot <url> --compress captures a PNG baseline and a JPEG candidate, keeps the smaller output, fixes the output extension to match the selected format, and reports byte savings.

Measured locally on https://example.com through http://127.0.0.1:8080:

  • PNG baseline: 20,523 bytes
  • Compressed output: 18,183 bytes as JPEG quality 50
  • Savings: 2,340 bytes, 11% smaller than PNG

Current parity/readiness

  • Offline deterministic readiness: 70%, not ready
  • Offline + live probes readiness: 74%, not ready
  • JS/runtime completeness: 100%
  • Wait semantics: 100%
  • CDP automation surface: 77-79% depending on live Kuri availability
  • Playwright/Puppeteer compatibility: 39%
  • Replace-headless-Chrome readiness: 56-63% depending on live probes

This cannot replace headless Chrome yet. The major blockers are still full native layout/raster paint, browser protocol domains, Playwright/Puppeteer lifecycle compatibility, richer actions, and complete load-state semantics.

Validation

  • cd kuri-browser && zig build test
  • cd kuri-browser && zig build
  • cd kuri-browser && python3 tools/paint_parity.py https://quotes.toscrape.com/js/ --paint-js --paint-wait-selector .quote --out-dir /tmp/kuri-quotes-90-b --keep-artifacts --require-exact 90
  • ./zig-out/bin/kuri-browser screenshot 'https://www.singaporeair.com/en_UK/sg/home#/book/bookflight' --out /tmp/kuri-sia-cdp-wait.png --kuri-base http://127.0.0.1:8080 --desktop-user-agent --wait-ms 15000
  • zig build test from the repository root; the expected HAR warning appears in output while the command exits 0

@justrach
Copy link
Copy Markdown
Owner Author

Docs update pushed in 4ee0c85:\n\n- Expanded kuri-browser/README.md with the current CLI workflow: render, JS/wait, HAR, CDP discovery, parity, bench, and screenshot fallback.\n- Updated skills/kuri-skill.md plus the synced Claude skill to explain which Kuri browser path to use.\n- Added the compressed screenshot CLI example and output semantics: PNG baseline + JPEG candidate, smaller file wins, reports original-bytes, bytes, saved-bytes, and saved-percent.\n- Root README now points users at the separate experimental kuri-browser/ build and repeats the 11% local screenshot savings measurement.

@justrach
Copy link
Copy Markdown
Owner Author

Updated this PR with the minimal CDP WebSocket router and QuickJS-backed V8-shaped Runtime responses.

What changed:

  • serve-cdp now upgrades /devtools/... WebSocket connections and routes minimal Browser/Target/Page/Runtime/Network/DOM/Input JSON-RPC methods.
  • Runtime.evaluate returns CDP remote objects, including unserializableValue for V8-style numeric edge cases like NaN. No V8 dependency was added.
  • Bench/parity docs now show offline readiness at 66%, live local readiness at 71%, CDP surface at 77-79%, and Playwright/Puppeteer compatibility at 39%.
  • README and Kuri skill docs now explain the experimental CDP shim and screenshot compression fallback.

Local verification:

  • cd kuri-browser && zig fmt src/cdp_server.zig src/js_runtime.zig src/bench.zig src/parity.zig
  • cd kuri-browser && zig build test && zig build
  • cd kuri-browser && ./zig-out/bin/kuri-browser bench --offline -> 66% overall, 77% CDP surface
  • cd kuri-browser && ./zig-out/bin/kuri-browser parity --offline -> 66% estimated parity, 63% validation coverage
  • real WebSocket smoke: 101 Switching Protocols, Browser.getVersion, and Runtime.evaluate(NaN) -> unserializableValue:"NaN"
  • root zig build test passed locally with the existing HAR/CDP warning path

CI is currently running on commit 6edff3e.

@justrach
Copy link
Copy Markdown
Owner Author

Added benchmark honesty/cache-disclosure guardrails and reran the comparison pass.

Changes:

  • Added root AGENTS.md with benchmark truthfulness rules, cache disclosure requirements, and the current comparison frame for Obscura, Lightpanda, and Vercel agent-browser.
  • Updated kuri-browser bench to print benchmark methodology/cache state every run.
  • Updated live bench probes to use cache-busted top-level URLs and label the Kuri/Chrome screenshot fallback as chrome-fallback-cache-possible.
  • Updated kuri-browser parity live checks to cache-bust URLs and label Chrome-cache possible comparison paths.
  • Updated parity-percent.md with cache methodology and latest cache-busted live validation numbers.

Local verification:

  • cd kuri-browser && zig fmt src/bench.zig src/parity.zig
  • cd kuri-browser && zig build test && zig build
  • cd kuri-browser && ./zig-out/bin/kuri-browser bench --offline -> 66%, no live/cache
  • cd kuri-browser && ./zig-out/bin/kuri-browser parity --offline -> 66% estimated, live skipped
  • live with fresh Kuri process: bench --kuri-base http://127.0.0.1:8080 -> 71%, all cache-busted live probes passed
  • live with fresh Kuri process: parity --kuri-base http://127.0.0.1:8080 -> live probes all passed, reported 47% live-validated coverage of full target
  • root zig build test passed locally with existing HAR/CDP warning path

CI is running on commit 4b800c5.

blackfloofie and others added 13 commits April 26, 2026 19:01
Add src/css.zig (750 lines): tokenizer, selector matcher with
descendant/child/adjacent/general-sibling combinators, specificity-weighted
cascade across user-agent + author + inline origins, !important boost.
Built-in user-agent stylesheet covers h1-h6 sizes, block tags, a:link
underline, etc.

Add src/engine.zig (883 lines): small CSS-aware layout + paint engine.
LayoutBox tree with x/y/width/height suitable for DOM.getBoxModel,
block-flow + inline text wrapping, computed styles via css.zig,
paintToSvg with proper colors/fonts/decorations.

Expand src/cdp_server.zig (~700 -> 2647 lines):
- 33 domains advertised in /json/protocol (was 7).
- Schema, Browser windows, Inspector, Security, Debugger, HeapProfiler,
  Profiler, Tracing, Memory, HeadlessExperimental, Animation, Audits,
  Overlay, LayerTree, ServiceWorker, IndexedDB, CacheStorage, DOMStorage,
  Console, Accessibility, Fetch, IO acked or wired.
- Real DOM: querySelector, querySelectorAll, getOuterHTML, getAttributes,
  describeNode, resolveNode against the live dom.Document with
  CDP nodeId == internal NodeId + 1; depth-aware getDocument tree.
- Real Runtime.callFunctionOn via js_runtime.evaluateExpressionOnPage,
  Runtime.compileScript/runScript, Runtime.getProperties for DOM-node
  handles.
- Cookie set/get/delete round-trip; Network UA/header/cache overrides;
  setRequestInterception; Storage cookies + usage.
- Full Emulation surface: device metrics, UA, locale, timezone, media,
  geolocation, focus/touch, script execution disabled.
- Real CSS domain: getComputedStyleForNode, getMatchedStylesForNode (with
  origin + specificity), getInlineStylesForNode, getStyleSheetText,
  getBackgroundColors, collectClassNames all return real cascaded data.
- Page.captureSnapshot now returns engine.zig SVG output.
- DOM.focus stores focused node; Input.insertText / dispatchKeyEvent
  mutate the focused node's value via per-state input-override table
  (with UTF-8-aware Backspace).
- Page.addScriptToEvaluateOnNewDocument tracks scripts; reload
  re-navigates; lifecycle events emit when enabled.

Update parity-percent.md from 66% -> 78% with 2026-04-27 live-smoke
entries: 22 of 22 CDP calls, real cascade resolution against
example.com, 2857-byte engine SVG with proper font sizes / weights /
colors / underlines.

Tests: 47/47 pass.
Replace the indexOf-based CSS hack inside native_paint.zig with a thin
delegation to engine.layoutPage + engine.paintToSvg. The two special-case
site approximations (Hacker News table, Quotes-to-Scrape Bootstrap cards)
stay because they win on pixel parity vs the generic engine pass.

Removed (dead/duplicate code):
- PageStyle struct
- resolvePageStyle, firstStyleText, cssRule, cssProperty, parseCssLength,
  leadingNumberLen, applyBodyMargin, firstCssToken, secondCssToken
- drawFlowChildren, drawFlowNode, firstDirectElement, writeFlowText, bodyNode
- collectPaintBlocks, collectPaintBlocksRecursive, drawBlock, drawTextBlock,
  wrappedLineLen, writeSvgText, paintTextForElement, truncateText,
  shouldPaintElement, blockKind, Block, BlockKind, max_blocks,
  max_text_per_block, line_height (these helpers were never called)

native_paint.zig: 934 -> 500 lines.

engine.zig: paintToSvg now emits a `<desc>kuri-engine: ...</desc>` so
output is identifiable.

Live smoke: `kuri-browser paint https://example.com` produces a 2931-byte
SVG with proper cascade (h1 24px bold from author `1.5em` over UA `bold`,
body system-ui font-family, anchors at the page color).

Tests: 47/47 pass.
Replace the flat approxTextWidth(visible_chars * 0.55 * font_size)
with a per-character glyph-width pipeline tuned to Chrome's macOS UA
fonts. Three width tables (sans-serif, serif, monospace) are looked
up per char; bold adds ~6% width; italic shares advance widths with
upright (matching real fonts). Family detection uses case-insensitive
substring matching: "mono"/"courier"/"consolas"/"menlo" -> mono;
"serif" (without "sans") or "times"/"georgia"/etc -> serif; else sans.

Implement white-space semantics in the inline pipeline:
  - normal: collapse runs of whitespace to a single space, suppress
    leading whitespace at line start, never double-space across run
    boundaries (pending_space carries a single space across items).
  - pre / pre-wrap: preserve whitespace and newlines as forced breaks
    (pre-wrap also wraps on whitespace).
  - nowrap: never wrap on whitespace.
The white_space property is read in computeStyle, inherited via
Inheritable, with <pre> defaulting to white-space: pre.

Add a .line_break sentinel InlineItem kind. <br> elements emit it
both as direct block children (in layoutBlock) and as inline children
of inline parents (in collectInline). buildInlineBox commits the
current line height and resets current_x / current_y on .line_break.

Add text_indent to ComputedStyle (parsed via parseLength) and thread
it through buildInlineBox so the very first line of a block starts at
x + text_indent; subsequent lines reset to x.

Add four engine.zig tests:
  - text width table differs by char (sans vs mono, bold widens,
    italic does not, serif != sans for 'M')
  - whitespace collapses to single space
  - br forces line break
  - text-indent shifts first run

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orations, margin collapse

Combine Team B's replaced-element rendering (img/hr/input/button/textarea, list-item
markers) and Team C's decorations (border-radius, opacity, box-shadow, vertical
margin collapsing) on top of Team A's per-character glyph widths. Wire engine.zig's
test entry point into `zig build test` (filtered to engine.test names so transitive
dom.zig tests don't double-run with a different allocator pairing).

* layoutBlock dispatches img/hr/input/button/textarea to dedicated layout helpers
  and tracks prev_block_margin_bottom for adjacent-sibling margin collapsing.
* paintBox split into wrapper + paintBoxInner with shadow id counter, opacity <g>
  wrapping, box-shadow rect (offset/blur via feGaussianBlur), border-radius rx/ry,
  and tag-based dispatch for img (border + diagonal + alt) and hr (<line>).
* layoutButton uses textWidth (Team A signature) instead of approxTextWidth.
* Fix colorToHex allocation leaks in paintBoxInner (defer-free each hex string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-ran tools/paint_parity.py against the four reference URLs on this
machine at commit 8c9d8a9 with the unified renderer:
- example.com wrapper: 0.00% / 18.62 mean (was claimed 99.35% / 0.48)
- example.com direct: 0.00% / 18.62 mean (was claimed 87.27%)
- HN: 88.94% / 9.44 mean (was 88.06% / 10.58 — improved)
- quotes/js: 90.32% / 7.47 mean (unchanged; uses special-case path)

The example.com 99.35% / 87.27% figures don't reproduce: replaying at
pre-team baseline 04dc45e measured 0.01% / 18.86 — those numbers were
either machine- or Chrome-version-specific and not a real comparison
baseline.

Per AGENTS.md benchmark-honesty rules, replaced the optimistic numbers
with the actually-measured ones plus reproduction commands and the
two known issues (opacity dim, white-vs-#eee body bg cascade bug).
…horthand

The body of `https://example.com` rendered with the wrong background and
position because of two narrow bugs:

1. The `background` shorthand never reached `background_color` on the
   computed style. The cascade kept the user-agent `body { background-color:
   white }` longhand and the author `body { background: #eee }` shorthand
   under separate names, so when `engine.computeStyle` did
   `computed.get("background-color") orelse computed.get("background")` it
   always picked the UA white. Fix in `css.computeStyleForNode`: when a
   `background` declaration is added to the cascade input, also push a
   synthetic `background-color` declaration with the same priority so the
   normal origin/specificity comparison resolves it correctly. Applied for
   both author/UA rules and inline styles.

2. `parseEdgeShorthand` ignored the literal `auto` keyword and fell through
   to `parseLength`, which returned `null` (then `0`) instead of the `-1`
   sentinel used by `layoutBlock` to centre a block via auto margins. So
   `body { margin: 15vh auto }` collapsed to `0` left/right margins and the
   body rendered at `x=0` instead of `x=256`. Fix: extract a small
   `parseEdgeToken` helper that maps `auto` → `-1` and otherwise delegates
   to `parseLength`.

Adds two regression tests in engine.zig:

- `body background shorthand cascades over UA background-color` builds a
  minimal `<style>body{background:#eee}</style>` page and asserts the body
  LayoutBox ends up with `background_color = #eee`.
- `parseEdgeShorthand handles auto` exercises the `15vh auto` form.

Test count: 65 → 67 (engine.zig: 18 → 20).

Live smoke on `https://example.com` now emits `<rect ... fill="#EEEEEE">`
at `x="256"` for the body, matching the expected centred grey background.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After commit 71578b0 the body of `https://example.com` paints as
`#EEEEEE` at `x=256` instead of `#FFFFFF` at `x=0`. Live measurement
against real Chrome at 1280x720 on 2026-04-27 now reports:

- Wrapper mode: 0.00% / 18.62 mean → 6.76% / 17.39 mean
- Direct-svg mode: 0.00% / 18.62 mean → 6.76% / 17.39 mean

HN was unchanged at 88.94% / 9.44 mean (special-case path is independent
of these engine fixes; trivial run-to-run variance only).

Updates the engine-paint summary line and the example.com measurement
section, referencing the new commit SHA. Test count bumps 65 → 67 from
the two regression tests added in 71578b0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cascade

Apply the synthetic-longhand cascade trick (already used for `background ->
background-color`) to the other common CSS shorthands so that author shorthand
declarations properly override UA longhand defaults instead of being masked.

When `body { font: 14px Verdana }` enters the cascade against UA defaults like
`body { font-family: ...; font-size: ... }`, the UA longhands previously won by
name. Now the author shorthand also synthesizes `font-size`, `font-family`,
`font-weight`, `font-style`, `line-height` longhands at the same priority, so
the cascade resolves correctly.

Shorthands handled:
- `font: [<style>] [<weight>] <size>[/<line-height>] <family>`
- `border: <width> <style> <color>` (any order)
- `padding` / `margin` 1/2/3/4-token rule -> top/right/bottom/left
- `list-style: <type> <position> <image>`
- `background -> background-color` (kept; was already done)

The expansion lives entirely in css.zig: small per-shorthand parsers
(`expandFontShorthand`, `expandBorderShorthand`, `expandEdgeShorthand`,
`expandListStyleShorthand`) are dispatched from `computeStyleForNode` for both
sheet rules and inline declarations. Synthesized longhand values are sub-slices
of the original shorthand value -- no allocation for the value strings.

Adds 7 css.zig tests covering each shorthand and verifying the existing
`background -> background-color` synthesis still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the hand-tuned FONT_SANS / FONT_SERIF tables and the implicit mono
ratio (0.60) in engine.zig with values measured from real Chrome on macOS:
each printable ASCII char is rendered in a span at 16px, the width is
read via getBoundingClientRect, and the ratio is the pixel width / 16.

The new tools/calibrate_widths.py drives this end-to-end (Chrome
--headless=new --dump-dom), with a documented hand-tuned fallback table
keyed off published Helvetica/Arial/Times/Courier advance widths if Chrome
is missing.

Notable shifts (sans-serif, old -> new):
  ' ' 0.28 -> 0.262
  'i' 0.22 -> 0.228
  'M' 0.83 -> 0.854
  '0' 0.56 -> 0.610
  'e' 0.56 -> 0.552
Mono ratio: 0.60 -> 0.6025 (Menlo).

zig build test still passes 67/67. Engine API of textWidth is unchanged;
only the underlying numerical tables shift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implement a small CSS-2.1-inspired table model so HN's table-heavy
markup produces row/cell boxes instead of flowing as plain blocks.

Engine changes (kuri-browser/src/engine.zig):
- Display enum gains .table_row and .table_cell.
- defaultDisplayForTag returns .table for <table>, .table_row for <tr>,
  .table_cell for <td>/<th>; thead/tbody/tfoot still default to block
  but their <tr> children are gathered into the parent table.
- parseDisplay recognizes "table-row" and "table-cell".
- layoutBlock dispatches display: table to a new layoutTable.
- layoutTable does two-pass column sizing: probe-layout each cell at a
  wide width, measure descendant text-run/child extents to derive a
  natural column width, then scale columns proportionally if they
  exceed the table's content width. Each row's height = max cell
  height; cells are stretched to their column width for alignment.
- HTML cellpadding / cellspacing attributes on <table> are honored.

Tests:
- "table layout produces row and cell boxes" verifies row/cell tree
  shape and column-edge alignment.
- "table cells size to content width" verifies that a wider-content
  cell gets a wider box than a narrow-content sibling.

Punted for v1: colspan, rowspan, <caption>, border-collapse, percent
column widths, fixed table-layout. HN still routes through the
paintHackerNewsSvg special case so the engine table output is not
yet visible there — that swap is a separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per CSS 2.1 §14.2, when the root (or body) has a non-transparent
background, that color paints the entire canvas, not just the element's
own box. Previously paintToSvg always emitted a white full-bleed rect,
so example.com (body { background: #eee }) showed white margins around
the centered body box where Chrome shows #eee everywhere.

paintToSvg now reads result.root.style.background_color and uses it for
the initial full-viewport rect. The root's own bg rect is then suppressed
to avoid double-painting.

Pixel parity vs Chrome on example.com (1280x720):
- wrapper exact: 6.76% -> 98.45% (mean delta 17.39 -> 1.80)
- direct  exact: 6.76% -> 86.37% (mean delta 17.39 -> 3.85)
… canvas bg fix

Updates the example.com section with the post-canvas-bg-propagation
numbers (commit a0542cb), plus a per-commit timeline of how the score
moved through this session. HN and quotes sections refreshed to the
latest commit references.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants