fix(layout): resolve the real cascade; add a UA stylesheet and border-style - #39
fix(layout): resolve the real cascade; add a UA stylesheet and border-style#39yfedoseev wants to merge 6 commits into
Conversation
…-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>
Update — inline layout landed on this branchText is now shaped and wraps. Same page, same harness, same tolerance:
The residual is sub-pixel text positioning and glyph rasterization — the page is otherwise pixel-identical to Chrome. Three things worth review attentionIt is a module, not a crate. Font metrics follow the claimed identity, not the build host. Cache granularity was measured, not assumed. The obvious Not doneNot an inline formatting context. A wrapping inline box still produces one box rather than fragments, and Layout on a small page goes 0.30 ms → 5.13 ms — shaping on a cold cache. The cache lives on the 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>
Update — display list + Skia paint, and
|
| 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. Honoursclip.width/clip.height;format: jpegis 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(®ions, 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>
Update — compositingThe fourth renderer stage, and the last one named in the programme goal. 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.
|
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='javascript: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>
|
Closing: this work is not ready to be public yet. It continues privately and will be reopened when it is ready to release. |
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.
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.2rather 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
LayoutEngineresolved every element as— an empty cascaded map — then overlaid the
styleattribute. 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
getComputedStylehas its own separate path injs_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
stylemodule assembles what already existed: collect UA + author sheets, match selectors, cascade with real specificity and origin, resolve with inheritance from the parent.LayoutEngine::computecalls 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_cascadehas origin / layer / specificity / source-order but no separate tier for the attribute.@mediaqueries are evaluated;@layerand@supportsblocks are descended into so their contents are not silently dropped, though layer ordering is still unmodelled.2.
font-sizedid not inheritResolveContext.font_sizewas a fixed 16px for the whole document, so everyem,remandexlength anywhere on the page resolved against the wrong number. Now taken from the element's own computed style:2emunder an inherited 20px is 40px, not 32px.3. Every element had a 3px border
border-*-width's initial value is3px(medium) and there was noborder-styleproperty 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 —htmlandbodyeach inset their contents by 3px on a page settingmargin: 0; padding: 0.Adds
border-styleandborder-*-coloras real properties, theborder,border-top/right/bottom/left,border-width,border-styleandborder-colorshorthands, and the gate instyle_map. The shorthand is order-independent per spec and resets omitted components to their initial value — which is whyborder: solidalone draws a medium border andborder: 5pxalone 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 contenthead,title,meta,scriptandstylewere laid out as ordinary block boxes and pushedbody~59px down the page. Nothing gavedivdisplay: blockeither; taffy's default happens to be block, which is why documents looked approximately sane.Adds
style/ua.css, compiled in withinclude_str!. Deliberately not the whole ofhtml.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.6placeholder, 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
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.style::covering cascade order, inheritance through non-element nodes, and style-attribute precedence.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.len=17502 fnv1a=5b1d42ee9bdc9713.cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --allandcargo docwithRUSTDOCFLAGS=-D warningsall clean.docs/LAYOUT.mddocuments the new pipeline and records why each defect existed.What this does not do
border-stylerendering distinction.dotted,dashed,doubleetc. 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.@layerblocks are descended into but their cascade order is not modelled;css_cascade::layersexists and is still unwired.<link rel=stylesheet>is found but not fetched;LayoutEngine::set_extra_cssis the way in.currentColorhas no keyword, so the initialborder-*-coloris the initialcolorrather than a realcurrentColorresolution.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.