Skip to content

fix(layout): resolve the real cascade; add a UA stylesheet and border-style - #39

Closed
yfedoseev wants to merge 6 commits into
mainfrom
render/cascade-into-layout
Closed

fix(layout): resolve the real cascade; add a UA stylesheet and border-style#39
yfedoseev wants to merge 6 commits into
mainfrom
render/cascade-into-layout

Conversation

@yfedoseev

Copy link
Copy Markdown
Owner

Five layout defects, all found by rendering a page for the first time. Every one was invisible while the engine was headless, and every one moves getBoundingClientRect() — so these are fingerprint fixes as much as rendering fixes.

Measured end to end against Chrome 150 on a page of nested divs with backgrounds, borders and text: 84.8% of pixels differing before, 11.1% after; mean per-channel delta 92.8 → 8.9.

pixels differing from Chrome mean channel delta
before 534,165 / 630,000 (84.79%) 92.84
after 69,672 / 630,000 (11.06%) 8.90

Tolerance 16/channel, so anti-aliasing differences between two rasterizers do not count as differences. The remaining 11% is almost entirely vertical rhythm: each text node is still its own block of font-size × 1.2 rather than sharing a line box with its siblings, which is inline layout's job and not in this PR. Screenshots and the diff harness live in the app repo (poc/firstpixel).

1. Layout ignored the cascade

LayoutEngine resolved every element as

let computed = ComputedStyle::resolve(&HashMap::new(), None);

— an empty cascaded map — then overlaid the style attribute. A <style> block had no effect on geometry, and neither did a stylesheet. ~8,800 LOC of CSS parse/cascade/selectors fed a layout that could not see any of it.

The gap was easy to miss because getComputedStyle has its own separate path in js_runtime::state: the reported style was right while the laid out style was not. Anything comparing the two — or comparing our geometry against a real browser's — was reading the difference.

New style module assembles what already existed: collect UA + author sheets, match selectors, cascade with real specificity and origin, resolve with inheritance from the parent. LayoutEngine::compute calls it once and reads the result; nothing re-resolves. LayoutEngine::styles() exposes the tree so a painter uses the same values that decided the geometry instead of forming a second opinion about them.

Style-attribute declarations get an unreachable specificity, because css_cascade has origin / layer / specificity / source-order but no separate tier for the attribute. @media queries are evaluated; @layer and @supports blocks are descended into so their contents are not silently dropped, though layer ordering is still unmodelled.

2. font-size did not inherit

ResolveContext.font_size was a fixed 16px for the whole document, so every em, rem and ex length anywhere on the page resolved against the wrong number. Now taken from the element's own computed style: 2em under an inherited 20px is 40px, not 32px.

3. Every element had a 3px border

border-*-width's initial value is 3px (medium) and there was no border-style property to gate it on. CSS says a border's used width is zero unless its style is set, so the engine gave every element in every document a 3px border on all four sideshtml and body each inset their contents by 3px on a page setting margin: 0; padding: 0.

Adds border-style and border-*-color as real properties, the border, border-top/right/bottom/left, border-width, border-style and border-color shorthands, and the gate in style_map. The shorthand is order-independent per spec and resets omitted components to their initial value — which is why border: solid alone draws a medium border and border: 5px alone draws nothing. That last case reads wrong until you check it against a browser, so it has a test.

4. No UA stylesheet, so <head> was visible content

head, title, meta, script and style were laid out as ordinary block boxes and pushed body ~59px down the page. Nothing gave div display: block either; taffy's default happens to be block, which is why documents looked approximately sane.

Adds style/ua.css, compiled in with include_str!. Deliberately not the whole of html.css — it covers what is observable through geometry: what does not render, what is block-level, and Chrome's default margins. body { margin: 8px } alone shifts every element on an unstyled page by 8px in both axes.

5. Whitespace between elements became a line box

Every text node got height = font_size * 1.2, including one holding only the newline and indentation between two tags. Measured 19px per inter-element newline, so an ordinary document gained one line box per element.

Text now goes through CSS white-space processing before measurement, and a node that collapses to nothing produces no box.

Measurement itself is untouched — still the char_count * font_size * 0.6 placeholder, one line, no wrapping. Only its inputs are fixed. Replacing it is the renderer's job, and the shaped Chrome-parity implementation that will replace it is already measured (12/12 fixtures, worst error 0.03px) in the app repo.

Verification

  • 10 new regression tests in layout::engine::render_regressions, one per defect plus the inverse cases — a real border must still be used, the shorthand must expand. Geometry assertions rather than style assertions, on purpose.
  • 5 new tests in style:: covering cascade order, inheritance through non-element nodes, and style-attribute precedence.
  • 612 lib tests pass. The one failure, js_runtime::extensions::perf_ext::tests::distribution_has_distinct_jitter_values, is pre-existing and reproduces identically on the base commit — deterministic here, 9 distinct values against an expected >10. Not touched by this change; worth a separate look.
  • Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.
  • cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all and cargo doc with RUSTDOCFLAGS=-D warnings all clean.

docs/LAYOUT.md documents the new pipeline and records why each defect existed.

What this does not do

  • No inline layout. Text is still one unwrapped box per node. This PR fixes what feeds the measurement, not the measurement.
  • No border-style rendering distinction. dotted, dashed, double etc. parse and are stored, but layout only asks "does this draw or not" — it has nothing to draw with. A painter will need the rest.
  • No layer ordering. @layer blocks are descended into but their cascade order is not modelled; css_cascade::layers exists and is still unwired.
  • External stylesheets are still the caller's job. <link rel=stylesheet> is found but not fetched; LayoutEngine::set_extra_css is the way in.
  • currentColor has no keyword, so the initial border-*-color is the initial color rather than a real currentColor resolution.

Note on geometry changes

This changes what getBoundingClientRect() returns for essentially every element on every page — that is the point, since the old numbers were wrong in five specific ways. But per the project's own convention that geometry is a fingerprint surface, it is worth saying plainly: the values move, and they move toward Chrome's. Anything with a recorded geometry baseline will need rebaselining, and the new baseline is the more defensible one.

…-style

Five layout defects, all found by rendering a page for the first time
(browser_oxide_app PoC-2). Every one was invisible while the engine was
headless, and every one moves getBoundingClientRect() — so these are
fingerprint fixes as much as rendering fixes.

Measured end to end against Chrome 150 on the PoC's test page: 84.8% of pixels
differing before, 11.1% after; mean per-channel delta 92.8 -> 8.9.

## 1. Layout ignored the cascade

LayoutEngine resolved every element as

    ComputedStyle::resolve(&HashMap::new(), None)

— an empty cascaded map — then overlaid the style attribute. A <style> block
had no effect on geometry, and neither did a stylesheet. ~8,800 LOC of CSS
parse/cascade/selectors fed a layout that could not see any of it.

The gap was easy to miss because getComputedStyle has its own separate path in
js_runtime::state: the *reported* style was right while the *laid out* style
was not. Anything comparing the two — or comparing our geometry against a real
browser's — was reading the difference.

New `style` module assembles what already existed: collect UA + author sheets,
match selectors, cascade with real specificity and origin, resolve with
inheritance from the parent. LayoutEngine::compute calls it once and reads the
result; nothing re-resolves. LayoutEngine::styles() exposes the tree so a
painter uses the same values that decided the geometry instead of forming a
second opinion.

Style-attribute declarations get an unreachable specificity because
css_cascade has origin/layer/specificity/source-order but no separate tier for
the attribute. @media queries are evaluated; @layer and @supports blocks are
descended into so their contents are not dropped, though layer *ordering* is
still unmodelled.

## 2. font-size did not inherit

ResolveContext.font_size was a fixed 16px for the whole document, so every em,
rem and ex length anywhere on the page resolved against the wrong number.
Now taken from the element's own computed style, which means inheritance
works: `2em` under an inherited 20px is 40px, not 32px.

## 3. Every element had a 3px border

border-*-width's initial value is 3px ("medium") and there was no border-style
property to gate it on. CSS says a border's used width is zero unless its style
is set, so the engine gave every element in every document a 3px border on all
four sides — html and body each inset their contents by 3px on a page setting
margin:0; padding:0.

Adds border-style and border-*-color as real properties, the border,
border-top/right/bottom/left, border-width, border-style and border-color
shorthands, and the gate in style_map. The shorthand is order-independent per
spec and resets omitted components to their initial value, which is why
`border: solid` alone draws a medium border and `border: 5px` alone draws
nothing — the case that reads wrong until you check it against a browser.

## 4. No UA stylesheet, so <head> was visible content

head, title, meta, script and style were laid out as ordinary block boxes and
pushed body ~59px down the page. Nothing gave div display:block either; taffy's
default happens to be block, which is why documents looked approximately sane.

Adds style/ua.css, compiled in. Deliberately not the whole of html.css — it
covers what is observable through geometry: what does not render, what is
block-level, and Chrome's default margins. `body { margin: 8px }` alone shifts
every element on an unstyled page.

## 5. Whitespace between elements became a line box

Every text node got height = font_size * 1.2, including one holding only the
newline and indentation between two tags. Measured 19px per inter-element
newline, so an ordinary document gained one line box per element.

Text now goes through CSS white-space processing before measurement, and a node
that collapses to nothing produces no box. Measurement itself is untouched —
still the char_count * font_size * 0.6 placeholder, one line, no wrapping. Only
its inputs are fixed. Replacing it is the renderer's job.

## Verification

- 10 new regression tests in layout::engine::render_regressions, one per defect
  plus the inverse cases (a real border must still be used; the border
  shorthand must expand). Geometry assertions, not style assertions.
- 5 new tests in style:: covering cascade order, inheritance through non-element
  nodes, and style-attribute precedence.
- 612 lib tests pass. The one failure,
  js_runtime::extensions::perf_ext::tests::distribution_has_distinct_jitter_values,
  is pre-existing and reproduces identically on the base commit — deterministic
  here, 9 distinct values against an expected >10. Not touched by this change
  and left for a separate fix.
- Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.
- clippy --workspace --all-targets -D warnings, cargo fmt --all, and
  cargo doc with RUSTDOCFLAGS=-D warnings all clean.

docs/LAYOUT.md documents the new pipeline and records why each defect existed.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
Replaces `char_count * font_size * 0.6`, which gave every glyph the same width
and never wrapped, with shaping, UAX #14 line breaking tailored to match
Chrome, and greedy first-fit.

Measured against Chrome 150 on the same page, continuing from the previous
commit: 11.06% of pixels differing -> 1.69%, mean per-channel delta 8.90 ->
0.84. Full trajectory across both commits: 84.79% -> 11.06% -> 1.69%.

## Where it lives

`layout/inline`, a module — not a `browser_oxide_render` crate. AGENTS.md is
explicit that the whole engine is one crate organised into modules and that the
workspace publishes exactly two, so a new crate would contradict the stated
architecture. This is layout, and it sits with layout. Paint, when it exists,
is a separate question.

## How it reaches taffy

taffy solves boxes and cannot size text. A text node is now a leaf *with
context*, and `compute_layout_with_measure` calls back once the box's available
width is known — which is what makes wrapping possible at all.

`AvailableSpace::MinContent` and `MaxContent` are answered as the widest single
word and the unwrapped width, so intrinsic sizing works rather than collapsing
to whatever the text last wrapped to.

## Font metrics follow the claimed identity, not the build host

`line-height: normal` comes out of font tables, but which tables and how the
result is rounded is a per-platform convention that differs by whole pixels on
the same font at the same size. Three conventions are implemented — FreeType
(Linux), GDI/DirectWrite (Windows), CoreText (macOS) — and selected by
`LayoutEngine::set_os_name` from the stealth profile's OS.

This matters because the numbers are a fingerprint surface: they reach scripts
through getBoundingClientRect, getClientRects and measureText. A profile
claiming Chrome on Linux while reporting Windows text metrics is exactly the
internal inconsistency the fingerprint design exists to avoid. Default is
Linux, matching the bundled Liberation faces.

Only the Windows convention is empirically verified (probed out of Chrome 150
during PoC-1). The Linux and macOS ones follow documented FreeType and CoreText
behaviour and want verifying on those hosts before any parity claim.

## Cache granularity was measured, not assumed

The obvious key is the itemised run: (face, size, script, direction, text).
Measured during PoC-1 that gives 17-75x on relayout and 0-1 cache hits out of
1-19 lookups on first paint, because an itemised run is usually the whole
paragraph and no two paragraphs are identical. The cache was cold exactly when
it mattered.

Keyed per word instead, where splitting on spaces cannot change the result, and
per whole run where it can — RTL, joining scripts, combining marks, anything
that shapes across a space. Blink caches per word for the same reason. A test
asserts the two paths produce identical widths, because if they ever diverge
the cache silently changes where lines break.

## The Chrome tailoring

UAX #14 says a break after SOLIDUS is permitted (LB13 forbids one *before* it;
nothing forbids after, so LB31 allows it). Chrome breaks there zero times — it
lets a long URL overflow, which is why authors reach for `overflow-wrap:
anywhere`. `unicode-linebreak` implements the spec faithfully, so the tailoring
lives on top of it. It was worth two fixtures out of twelve in PoC-1, and there
will be more; the list should grow from a real-site corpus, not from reading
the spec.

## Verification

- 28 tests in layout::inline covering wrapping, break-word, nowrap,
  min/max-content, the URL tailoring, CJK inter-character breaking, cache reuse,
  and the word-split/whole-run equivalence property.
- 7 end-to-end tests in layout::engine::inline_integration: a paragraph gets
  taller as it narrows, text no longer overflows its block, "WWWW" needs more
  lines than "iiii" at the same width, font-size reaches the shaper.
- 647 lib tests pass. The one failure, perf_ext's jitter distribution test, is
  pre-existing and reproduces on the base commit.
- Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.
- clippy --workspace --all-targets -D warnings and cargo fmt --all clean.
- New dependencies are unicode-bidi, unicode-linebreak, unicode-segmentation,
  unicode-script and ttf-parser, all MIT OR Apache-2.0, so deny.toml needs no
  new exception.

## What this is not

Not an inline formatting context. An inline box that wraps still produces one
box rather than several fragments, and `display: inline` maps to a taffy block,
so a <span> stretches to its parent instead of hugging its text. A test records
that behaviour explicitly so it fails loudly when real inline flow lands rather
than being quietly depended on.

No bidi reordering in layout (unicode-bidi is a dependency but measurement uses
a whole-run direction). No per-run script itemisation — a run that mixes scripts
is shaped under the dominant one, which is adequate for measurement and will not
be for painting. No `overflow-wrap` property in the engine yet, so break_word is
always false from CSS; the code path exists and is tested directly.

Layout time on a small page goes from 0.30 ms to 5.13 ms, which is shaping on a
cold cache. The cache lives on the LayoutEngine and pays back on relayout; a
caller that rebuilds the engine per frame, as the PoC does, sees the cold cost
every time.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
@yfedoseev

Copy link
Copy Markdown
Owner Author

Update — inline layout landed on this branch

Text is now shaped and wraps. layout::inline: shaping via rustybuzz, UAX #14 breaking tailored to match Chrome, greedy first-fit, reaching taffy through compute_layout_with_measure. This replaces char_count * font_size * 0.6, which gave every glyph the same width and never wrapped.

Same page, same harness, same tolerance:

engine state pixels differing from Chrome mean channel delta
before this PR 534,165 / 630,000 (84.79%) 92.84
after the five defect fixes 69,672 / 630,000 (11.06%) 8.90
after inline layout 10,644 / 630,000 (1.69%) 0.84

The residual is sub-pixel text positioning and glyph rasterization — the page is otherwise pixel-identical to Chrome.

Three things worth review attention

It is a module, not a crate. RENDERER_DESIGN.md in the app repo proposed crates/browser_oxide_render. AGENTS.md here is explicit that the engine is one crate organised into modules and that the workspace publishes exactly two, so this went to layout/inline instead. Happy to revisit if the intent was to grow a second published crate.

Font metrics follow the claimed identity, not the build host. line-height: normal comes out of font tables, but which tables and how the result is rounded is a per-platform convention differing by whole pixels. Three are implemented and selected by LayoutEngine::set_os_name from the stealth profile's OS — because these numbers reach scripts through getBoundingClientRect and measureText, and a profile claiming Chrome on Linux while reporting Windows metrics is the internal inconsistency the fingerprint design exists to avoid. Only the Windows convention is empirically verified (probed out of Chrome 150); Linux and macOS follow documented FreeType/CoreText behaviour and want verifying on those hosts.

Cache granularity was measured, not assumed. The obvious (face, size, script, direction, text) key gives 17–75× on relayout and 0–1 hits out of 1–19 lookups on first paint, because an itemised run is usually the whole paragraph. Keyed per word instead, where splitting on spaces cannot change the result, falling back to whole-run for RTL, joining scripts and combining marks. A test asserts the two paths give identical widths — if they diverge the cache silently moves line breaks.

Not done

Not an inline formatting context. A wrapping inline box still produces one box rather than fragments, and display: inline maps to a taffy block so a <span> stretches to its parent. A test records that explicitly so it fails loudly when real inline flow lands rather than being quietly depended on. No bidi reordering in layout, no per-run script itemisation, no overflow-wrap property in the engine yet.

Layout on a small page goes 0.30 ms → 5.13 ms — shaping on a cold cache. The cache lives on the LayoutEngine and pays back on relayout; a caller that rebuilds the engine per frame sees the cold cost every time.

647 lib tests pass (35 new). Canvas fingerprint unchanged. clippy/fmt clean.

The half of the engine that did not exist. Skia was a dependency used only by
<canvas>; nothing painted a background, a border, or a text run belonging to a
DOM element, and the CDP surface implemented 48 methods of which
Page.captureScreenshot was not one — a CDP server that cannot screenshot is a
CDP server with no rasterizer behind it.

It has one now. Measured against Chrome 150 on a page of nested divs with
backgrounds, borders and text at 900x700, tolerance 16 per channel:

  1.66% of pixels differ, mean per-channel delta 0.80

The residual is sub-pixel text positioning and glyph rasterization. Note the
engine renders with its bundled Liberation faces against a Chrome-on-Windows
reference using Arial; Liberation is metric-compatible with Arial by design,
which is why the geometry agrees at all.

## Pipeline

  Dom + LayoutEngine -> painter -> DisplayList -> raster -> RGBA8 / PNG

The display list in the middle is the point. Flat, ordered, and holding no
references back into the DOM, which is what makes it cacheable and diffable —
repainting a scroll should replay a diff rather than re-run layout. Nothing
exploits that yet; the structure exists so that it can.

## Entry points

- `render::render_to_png(&dom, &mut layout, w, h)`
- `Page::screenshot_png(w, h)` / `Page::screenshot_rgba(w, h)`
- CDP `Page.captureScreenshot`, honouring `clip.width`/`clip.height`. Only
  `format: png` is implemented and anything else is an explicit error rather
  than a PNG mislabelled as a JPEG.
- `cargo run --release --example screenshot -- <url|file.html> out.png`

## Invariants worth stating

Text carries positioned glyphs, never strings. A list holding strings would
reshape on every replay and shaping is the expensive half.

A FontRef carries the face's bytes, not a family name. Glyph ids index into
*that* face; handing a rasterizer a family name lets it resolve to a different
file and the same ids then draw different letters — silent, and invisible in a
screenshot.

SkFont is configured exactly as canvas2d.rs configures it: grayscale AA,
subpixel positioning, no hinting. Not cosmetic — text rasterization is a
fingerprint surface, and a browser whose page text and <canvas> text were
rasterized differently would be reporting two different renderers.

The painter reads LayoutEngine::styles() rather than re-resolving, and
layout::engine::collapse_white_space rather than its own. Both because a
painter that formed a second opinion about what layout decided would paint
boxes with styles that did not size them, and it would look like a paint bug.

Borders are four trapezoids, not four rectangles: adjacent sides meet at a
mitre and overlapping rectangles paint one colour over the other at every
corner. Used width, not specified width — a border with `border-style: none`
has a used width of zero and layout reserved no space for it.

## Verification

- 9 tests in render:: asserting at the pixel level: a background colour reaches
  the pixels, a stylesheet reaches the pixels, a border paints in its own
  colour, a border with no style paints nothing, display:none paints nothing,
  text produces glyphs and darkens pixels, PNG encodes, an unbalanced clip does
  not corrupt Skia's save stack, a zero-sized target is refused rather than
  panicking.
- 2 tests in protocol::session driving Page.captureScreenshot over CDP end to
  end and decoding the base64 PNG.
- 658 lib tests pass. The one failure, perf_ext's jitter distribution test, is
  pre-existing and reproduces on the base commit.
- Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.
- clippy --workspace --all-targets -D warnings, cargo fmt --all, and cargo doc
  with RUSTDOCFLAGS=-D warnings all clean.

docs/RENDER.md documents the module. AGENTS.md's module list now names `style`
and `render`.

## What this is not

No compositing: no layer tree, no damage tracking, no incremental
invalidation. A screenshot rasterizes the whole page every time.

No GPU surface. CPU raster only, and on a small page rasterization is ~90% of
the frame — the first thing to change if anything needs to animate.

No stacking contexts or z-index; paint order is tree order. Getting paint order
wrong is the most common source of "looks subtly broken", so this is a stated
gap rather than one to be discovered.

No images, SVG, form controls, transforms, opacity, filters, blend modes,
border radius, box shadows or gradients. No hit-testing — the display list is
not queryable by point.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
@yfedoseev

Copy link
Copy Markdown
Owner Author

Update — display list + Skia paint, and Page.captureScreenshot

The engine renders pages to pixels on its own now. No app-side prototype involved.

Dom + LayoutEngine ──► painter ──► DisplayList ──► raster ──► RGBA8 / PNG

Measured against Chrome 150, same page and harness as before, 900×700, tolerance 16/channel:

pixels differing mean channel delta
engine, before any of this work 84.79% 92.84
after the layout defect fixes 11.06% 8.90
after inline layout 1.69% 0.84
engine rendering it itself 1.66% 0.80

The last row is the engine's own render_to_png, marginally better than the app prototype's because it does everything consistently — Liberation is metric-compatible with Arial by design, which is why measuring with one and drawing the other agrees. Residual is sub-pixel text positioning and glyph rasterization. 14.7 ms for a 900×700 page.

New surface

  • render::render_to_png(&dom, &mut layout, w, h)
  • Page::screenshot_png(w, h) / screenshot_rgba(w, h)
  • CDP Page.captureScreenshot — the method whose absence gave the surface away. Honours clip.width/clip.height; format: jpeg is an explicit error rather than a PNG mislabelled as a JPEG.
  • cargo run --release --example screenshot -- <url|file.html> out.png

Three things I'd want a reviewer to check

Glyph rasterization deliberately matches <canvas>. Same SkFont settings as canvas2d.rs — grayscale AA, subpixel positioning, no hinting. Not cosmetic: text rasterization is a fingerprint surface, and a browser whose page text and <canvas> text rasterized differently would be reporting two different renderers.

A FontRef carries face bytes, not a family name. Glyph ids index into that face; a rasterizer handed a family name may resolve to a different file and the same ids then draw different letters. Silent, and invisible in a screenshot.

The painter never re-resolves anything. It reads LayoutEngine::styles() and collapse_white_space. A painter forming a second opinion about what layout decided paints boxes with styles that did not size them, and it reads as a paint bug.

Not done

No compositing — no layer tree, damage tracking or incremental invalidation; a screenshot rasterizes the whole page. No GPU surface, and on a small page raster is ~90% of the frame. No stacking contexts or z-index (paint order is tree order — stated gap, not a discovered one). No images, SVG, form controls, transforms, opacity, filters, border radius. No hit-testing.

11 new tests (9 pixel-level, 2 driving CDP end to end and decoding the base64 PNG). 658 lib tests pass. Canvas fingerprint unchanged. clippy/fmt/rustdoc clean. docs/RENDER.md added.

The fourth renderer stage. A scroll changes no layer's *content*, so a
compositor that keeps each layer's rasterized surface answers the next frame by
blitting at a new offset — no paint, no shaping, no Skia. Without it every
scroll frame re-rasterizes the page, and on a CPU surface rasterization is ~90%
of the frame.

  painter -> LayerTree -> Compositor -> RGBA8

A test asserts the claim directly: scrolling a real 2000px page rasterizes
nothing and reuses every surface.

## Promotion

Every layer costs a surface and the memory behind it, so the list of reasons is
short on purpose: opacity below 1, a non-identity transform, and position:fixed.

`fixed` is not an optimisation. A fixed element must *not* move when the page
scrolls, and the only way to say that to a compositor that scrolls by
translating surfaces is to give it a surface of its own.

`will-change` is absent because the engine has no such property yet. When it
arrives it belongs in `promotion_reason` and nowhere else.

## `transform` was never parsed

PropertyId::Transform, CssValue::Transform and TransformFunction all existed.
Nothing parsed into them, so `transform` was silently dropped on every page that
used it. Compositing found this immediately, because a transform is one of the
few reasons to promote a layer and no page ever produced one.

Adds the parser: translate/translateX/translateY/translate3d, scale/scaleX/
scaleY/scale3d, rotate, skewX/skewY, matrix. `none` parses to an empty list
rather than an error.

3D rotations and matrix3d are **rejected** rather than flattened to their 2x2
part. Approximating them would be wrong and invisible; rejecting the declaration
is wrong and visible, and CSS says an unsupported function invalidates the whole
declaration anyway.

`transform-origin` defaults to the element's centre, so a rotation spins in
place rather than swinging the element around the page origin — the classic
version of this bug, and it has a test.

## Damage

A layer is re-rasterized when its display list differs from the one it was last
rasterized from — a *structural* comparison, not pointer identity. A relayout
rebuilds the list from scratch even when nothing changed, and treating that as
damage would defeat the cache entirely. That comparison is only cheap because
the display list is flat and holds no DOM references, which is the reason it is
structured that way.

Layers entirely outside the viewport are culled before rasterization, and
surfaces for layers that no longer exist are dropped, so a long-lived compositor
over a changing document does not grow without bound.

## Coordinates

A layer's display list holds *page* coordinates — that is what lets hit regions
and damage comparison work without every layer rebasing them — while its surface
is only as big as its own bounds. Rasterization translates by -bounds.origin and
the compositor puts the surface back. Getting this wrong is why the first
version drew a fixed layer's content entirely off its own surface.

## Hit-testing

`paint_layered` returns hit regions alongside the tree, and
`render::hit_test(&regions, x, y)` returns the topmost element at a point.
Regions are in paint order so the last one containing the point wins, which is
what document.elementFromPoint means.

The display list holds no DOM references, so this is a parallel list.
RENDERER_DESIGN.md says hit-testing should reuse the fragment tree; there is no
fragment tree yet and this is the honest interim.

## Verification

- 21 new tests. The load-bearing ones: scrolling a real page rasterizes nothing;
  a fixed layer does not move while a scrolling one does; an identical relayout
  is not damage; a colour change is; offscreen layers are culled; surfaces for
  removed layers are dropped; a rotation spins about the element centre; and
  compositing produces pixel-identical output to painting directly, because it
  is an optimisation and not a rendering mode.
- One test deliberately asserts a full RGBA tuple rather than a single channel:
  checking only the red channel passes on white too, which is exactly the wrong
  thing to be reassured by. It caught a real bug.
- 679 lib tests pass. The one failure, perf_ext's jitter distribution test, is
  pre-existing and reproduces on the base commit.
- Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.
- clippy --workspace --all-targets -D warnings, cargo fmt --all, cargo doc with
  RUSTDOCFLAGS=-D warnings all clean.

## What this is not

No GPU surface — compositing removes the repaint cost of scrolling but the final
blit is still CPU. No damage *rectangles*: damage is per layer, so a one-pixel
change re-rasterizes its whole layer. No scroll containers — only the document
scrolls; `overflow: scroll` on an element clips but does not scroll. No stacking
contexts or z-index; paint order is still tree order.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
@yfedoseev

Copy link
Copy Markdown
Owner Author

Update — compositing

The fourth renderer stage, and the last one named in the programme goal.

painter ──► LayerTree ──► Compositor ──► RGBA8

The compositor keeps each layer's rasterized surface between frames. A scroll changes no layer's content, so the next frame is a blit at a new offset — no paint, no shaping, no Skia. A test asserts it directly: scrolling a real 2000px page rasterizes nothing and reuses every surface.

transform was never parsed

PropertyId::Transform, CssValue::Transform and TransformFunction all existed. Nothing parsed into them, so transform was silently dropped on every page that used it. Compositing found it immediately — a transform is one of the few reasons to promote a layer, and no page ever produced one.

Same shape as the border-color gap earlier in this PR: the type existed, the parser entry did not.

3D rotations and matrix3d are rejected rather than flattened to their 2×2 part. Approximating them would be wrong and invisible; rejecting the declaration is wrong and visible, and CSS says an unsupported function invalidates the declaration anyway.

Promotion is deliberately stingy

Opacity below 1, a non-identity transform, position: fixed. That's it — every layer is a surface and the memory behind it.

fixed is not an optimisation: a fixed element must not move when the page scrolls, and the only way to say that to a compositor that scrolls by translating surfaces is to give it its own.

Damage is structural, not identity

A relayout rebuilds the display list from scratch even when nothing changed. Comparing by pointer would treat that as damage and the cache would never hit. The comparison is only cheap because the display list is flat and holds no DOM references — which is the reason it's structured that way.

One test worth calling out

a_composited_page_looks_like_an_uncomposited_one asserts compositing produces pixel-identical output to painting directly. Compositing is an optimisation, not a rendering mode, and that needs to be enforced rather than assumed.

Another one asserts a full RGBA tuple rather than a single channel — checking only the red channel passes on white too, which is exactly the wrong thing to be reassured by. It caught a real coordinate-space bug where a fixed layer's content was drawn entirely off its own surface.

Not done

No GPU surface — compositing removes the repaint cost of scrolling, but the final blit is still CPU. No damage rectangles: damage is per layer, so a one-pixel change re-rasterizes its whole layer. No scroll containers — only the document scrolls; overflow: scroll clips but does not scroll. No stacking contexts or z-index; paint order is still tree order.

21 new tests, 679 lib tests passing. Canvas fingerprint unchanged. clippy/fmt/rustdoc clean.

Borrow the live DOM instead of consuming the Page to get it.

The DOM lives inside the JS runtime's op state, which is where V8 needs it.
The only way out was `take_dom(self)`, which consumes the whole Page — no use
to a caller that wants to keep browsing, and the app layer wants exactly that:
render the current document, then navigate again with the same tab.

`stylesheets()` hands back the sheets a navigation collected, so a caller
building its own LayoutEngine can pass them to `set_extra_css` rather than
re-fetching them.

Both are what a browser shell needs to drive rendering per frame. Used by
browser_oxide_app's desktop browser to build a layer tree from the live
document without tearing the page down.

679 lib tests pass (the one failure, perf_ext's jitter distribution test, is
pre-existing and reproduces on the base commit). Canvas fingerprint unchanged:
len=17502 fnv1a=5b1d42ee9bdc9713. clippy and fmt clean.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
`render::hit_test` answers "which node is under this point". That is not the
question a shell has to answer when the user clicks, and the gap between the
two is why none of the five shells can follow a link yet.

Two things are missing. The node under the pointer is almost never the `<a>` —
clicking a bold link lands on the `<b>`, or the text node inside it, or an
`<img>` in a card whose whole surface is wrapped in an anchor — so finding the
anchor is a walk up the tree, not a lookup. And `href` is whatever the author
wrote: `/about`, `../x`, `#top`, `mailto:`, `javascript:void(0)`. Handing that
to a navigation call unresolved is how a browser fetches `https://host/#top`.

`dom::links::link_target` does the walk; `Page::link_for` does the resolution,
and lives on `Page` because only the page knows the document's own URL.

`classify` sorts an href into navigate / scroll / hand-to-the-OS / refuse.
`javascript:` is the only refusal: running author script because the user
clicked is exactly the capability an engine should not hand out by accident,
and a shell that received the string would either execute it or show its
source. It strips control characters before checking, because HTML's own URL
parser strips them — `java\nscript:alert(1)` is a real historical bypass, and a
check that skips the stripping is checking a different string than the one that
would be navigated. `mailto:` and `tel:` are not refused; they are legitimate
and they are the platform's business, so the classification is returned and the
shell decides.

An `<a>` with no `href` returns None. HTML calls that a placeholder, it gets no
link styling, and a shell treating it as a link would send the user to the
current page on every click.

Eleven tests. Nine cover the walk and the classification against a hand-built
tree. The other two are the ones that matter: one lays a real document out,
hit-tests points inside the painted link and asserts `/about` resolves to
`https://example.com/about` against a `/docs/index.html` base — proving that
the node `hit_test` actually returns for a glyph is a node the walk can reach
the anchor from, which the unit tests cannot show. It scans regions rather than
hard-coding a coordinate, so it does not encode this month's font metrics into
a pass. The other pushes `href='java&#115;cript:alert(1)'` through the real
parser, since an attribute arrives at this code having been entity-decoded and
that is where several historical bypasses lived.

Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713.

Signed-off-by: Yury Fedoseev <yfedoseev@gmail.com>
@yfedoseev

Copy link
Copy Markdown
Owner Author

Closing: this work is not ready to be public yet. It continues privately and will be reopened when it is ready to release.

@yfedoseev yfedoseev closed this Jul 28, 2026
@yfedoseev
yfedoseev deleted the render/cascade-into-layout branch July 28, 2026 19:18
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.

1 participant