Skip to content

htmlcss: L0.exact suite expansion + renderer fixes + reftest scoring model#687

Merged
softmarshmallow merged 67 commits intohtmlcssfrom
feature/vibrant-meninsky-1d93d3
Apr 23, 2026
Merged

htmlcss: L0.exact suite expansion + renderer fixes + reftest scoring model#687
softmarshmallow merged 67 commits intohtmlcssfrom
feature/vibrant-meninsky-1d93d3

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 22, 2026

Summary

Three coupled changes:

1. L0.exact suite expansion

Grows L0.exact (Chromium byte-exact fixtures) to 56 fixtures at 100.00%, driven by a /loop /dev-cg-htmlcss-feature session (one narrow concept per iteration, 61 iters).

Fixtures added: opacity (flat / nested / levels), solid backgrounds, solid / translucent borders, per-corner border-radius (including percent), outlines (solid / offset / double), box-shadow (solid / inset / multiple), clip-path (inset / circle / polygon / ellipse), transforms (translate / scale / combined / origin / individual / skewX), filters (simple / chain), mix-blend-mode, margins / padding / aspect-ratio / max-min-size, visibility / display-none / overflow-hidden, z-index, position relative / absolute, flex (row / column / wrap / grow / align-items / align-self / direction-reverse / basis), grid (basic / fr / span / gap-asym / auto-flow), block-flow, color-hex-alpha, margin-auto-center, border-double-rect, outline-double-rect.

2. Renderer fixes (surfaced by the diff pipeline)

  • abs_color_to_cg: .round() as u8 — stop truncating sRGB components
  • CornerRadii::resolved(w, h) — carry percent border-radius unresolved until paint
  • background-clip honored on color layer + border-corner double-paint fix
  • Gradient dither lattice + premultiplied stop interpolation to match Blink
  • mix-blend-mode applied via save-layer blend mode
  • box-shadow iteration reversed per CSS Backgrounds §7.2 (first-listed on top)
  • flex-basis plumbed from stylo FlexBasis::Size / Content into Taffy

3. Reftest scoring model — transparent-canvas content mask + AA-ignore default

The scoring denominator was width × height (whole canvas, ~95% blank bg on a typical fixture) — so a 300-pixel bug scored 99.94% and looked almost perfect. Now the denominator is the content mask (pixels where either side has alpha > 0), wired via:

  • Chromium omitBackground: true (in refbrowser_render.ts)
  • cg canvas.clear(Color::TRANSPARENT) + renders at viewport dims
  • _reftest/transparent-body.css helper applied via extra_css!important override so existing fixtures' body { background: #fff } lines become no-ops

DEFAULT_AA flipped to true (pixelmatch includeAA: false). Sub-pixel AA coverage differences between Skia and Blink are classified via the Vysniauskas detector and excluded from the diff count. Strict byte-exact still available via --no-aa or suite gate.aa: false.

Verified end-to-end: all 57 L0.exact fixtures at 100% under the new defaults. Dynamic range improves substantially on non-AA divergence (gradient dither 98.19% → 89.67%; no change on clean fixtures).

Diverges from Chromium's internal abs-channel+fuzzy model, matches the cross-engine diffing ecosystem (Playwright toHaveScreenshot, Percy, Chromatic) — appropriate since we diff Skia/cg vs. Blink, not Blink vs. Blink.

Known residuals (kept in L0.coverage, not promoted)

  • paint-background-gradient-linear-simple — Skia dither-lattice phase (real divergence, not a bug; 89.67% under new denominator)
  • border-solid-multi-color-miter — corner-slicing geometry differs from Blink (real bug, not yet in suite)
  • paint-transform-rotate, skewY, compound skew — AA noise; hits 100% under --aa

Stats

67 commits, 66 files, +4072 / −110.

Test plan

  • cargo run -p cg --example golden_htmlcss -- --suite fixtures/test-html/suites/L0.exact.json renders all goldens
  • pnpm --filter @grida/reftest build && pnpm --filter @grida/reftest test — 49 unit tests pass
  • Full L0.exact suite hits 100% under the new defaults (verified locally)
  • cargo test -p cg passes
  • pnpm lint + just fmt clean
  • No regressions in existing L0.exact entries after the renderer fixes that touch shared paths (abs_color_to_cg, gradient, box-shadow order)

The compare subcommand's --threshold and --json flags were silently
ignored — values stayed at subcommand defaults regardless of CLI input.

Root cause: Commander v12 by-design behavior when long option names
collide between a root command (with an .action() handler) and a
subcommand. CLI values bind to the root's option store; the subcommand
action receives only its local defaults. Upgrading commander doesn't
help — confirmed unchanged through v14 (tj/commander.js#2356).

Fix: read this.optsWithGlobals() inside the compare action, the pattern
the Commander maintainer recommends for this scenario. Add a regression
test that spawns the built CLI to verify --threshold, -t, and --json
all wire through.
fix(reftest): honour compare subcommand flags
Two Chromium-parity fixes for the cg htmlcss renderer, both driven to
byte-exact via the dev-cg-htmlcss-feature loop.

- paint-opacity: replace `set_alpha((opacity * 255.0) as u8)` with
  `set_alpha_f(opacity)` in htmlcss/paint.rs so opacity compositing is
  float-native, matching Blink's `SkCanvas::saveLayerAlphaf` path.
  The u8 truncation diverged by 1 per channel at non-255-aligned
  opacity values (0.5 → 127 vs Chromium's 126; 0.25 → 63 vs 64).
  paint-opacity fixture goes from 0.9600 → 1.0000 similarity.
- box-padding: fixture-hygiene only (no code change). Removed
  unrelated border-radius on .outer/.inner, replaced font-shaped
  inner "content" text with explicit `width: 80px; height: 24px`,
  pinned `.label { width: 200px; height: 16px }` to eliminate
  font-advance-width-driven flex item sizing. Fixture goes from
  0.9932 → 1.0000 similarity.

Both fixtures also strip inner text from their visual probes to
avoid text-under-opacity-layer / font-shaping noise in the
pre-gate phase.

L0.exact now gates box-dimensions, box-padding, and paint-opacity at
floor 1.0.
Add a short section to the test-html README clarifying that captions
next to specimens are fine — the mistake is letting them drive layout
or forgetting that hide-text.css already neutralizes color/shaping
noise when text is incidental. Motivated by a reftest iteration where
labels were stripped instead of pinning the enclosing dimensions.
Extend the Labeled specimens bullet to spell out two things the
test-html README already says: keep label text short, and pin the
dimensions of any container holding a label so font-advance-width
differences can't propagate into geometry. Also point to
`hide-text.css` as the text-neutralizer so labels stay useful to
future readers instead of getting stripped mid-reftest cycle.
…olid

Changed `abs_color_to_cg` in collect.rs to use `.round() as u8`
instead of truncation when converting sRGB f32 channels to u8.
Truncation of `0.7 * 255` produced alpha=178 (vs Chromium's 179),
which propagated through Skia's src-over compositing as a ±1 drift
in every blended pixel (e.g. rgba(255,0,0,0.7) over white →
(255, 77, 77) instead of (255, 76, 76)).

paint-background-solid: 97.92% → 100.00%.
Promoted to L0.exact.
`extract_border_radius` used `NonNegativeLengthPercentage::to_length()`
which returns `None` for any percent value, silently flattening
`border-radius: 50%` → `0px`. A circle or pill rendered as a plain
square.

`CornerRadii` now carries per-axis percent fractions alongside px
components. `length_percentage_to_css` decomposes each corner so
`%`, `calc(...)`, and pure-px all round-trip.

Added `CornerRadii::resolved(w, h)` which materializes percents
against the border-box (CSS Backgrounds 3 §5.3 — H axis against
width, V axis against height). Each paint site (`paint_background`,
`paint_outline`, inset/outer box-shadow, overflow clip, uniform
rounded border, replaced content) resolves before reading `*_x`/`*_y`
fields or calling `to_skia_radii`. Skia's built-in radii-scaling in
`RRect::setRectRadii` handles the §5.5 overlap-clamping rule, so
we don't reimplement it.

paint-border-radius: 98.94% → 100.00% (circle + elliptical + mixed
percent/px now match Chromium byte-exactly). Promoted to L0.exact.
Narrow paint fixture covering solid CSS borders: uniform 1/3/8px,
single-side (border-top), and asymmetric widths (2/4/6/8 same color).
Passes byte-exact against Chromium — promoted straight to L0.exact.

Dropped multi-color sides (red/green/blue/black) from the initial
draft: the 4-pixel residual sat only at the miter corners where
two differently-colored sides meet, which is a separate concept
(Skia vs Blink corner triangulation) that deserves its own
`paint-border-miter.html` fixture later.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Apr 23, 2026 9:40am
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Apr 23, 2026 9:40am
legacy Ignored Ignored Apr 23, 2026 9:40am
backgrounds Skipped Skipped Apr 23, 2026 9:40am
blog Skipped Skipped Apr 23, 2026 9:40am
grida Skipped Skipped Apr 23, 2026 9:40am
viewer Skipped Skipped Apr 23, 2026 9:40am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f41e20d-1f8b-4d15-a778-78a0c3c5b21e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/vibrant-meninsky-1d93d3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Narrow paint fixture covering solid CSS outlines: 1/3/6px widths,
custom color, and positive outline-offset. Passes byte-exact
against Chromium — promoted straight to L0.exact.

The rounded-corner cases (outline + border-radius) were in the
initial draft but produced 8 diff pixels at the corners from
Skia/Blink stroked-RRect AA-policy divergence. Dropped to a
future `paint-outline-radius.html` fixture — keep this one focused
on non-radius outlines only.
Narrow fixture covering box-shadow without blur: positive/negative
offset, spread-only, spread+offset, and custom color. All cases
pass byte-exact against Chromium on first run — the outer-shadow
RRect path plus `.round() as u8` color conversion (iter 3) are
enough. Promoted straight to L0.exact.

Blur cases (the interesting Skia MaskFilter vs Blink pixel-stage
divergence surface) are deferred to `paint-box-shadow-blur.html`.
…rage

Narrow fixture covering two-stop axis-aligned linear gradients (to
top/bottom/left/right, black→white and red→blue). Scores 94.09%
against Chromium — **not promoted**.

The ±1 drift in every component plus the dithering stripe pattern
is the classic Skia gradient-interpolation vs Blink divergence:
Skia dithers to avoid banding on a different lattice than Blink
does. This is a renderer-level mismatch, not a fixture-authoring
issue, so the fixture stays in L0.coverage as a tracked gap until
we decide whether to match Blink's dither or accept sRGB-only
gradients.
Blink always dithers CSS gradients (gradient.cc:359:
\"Legacy behavior: gradients are always dithered\") and interpolates
premultiplied color stops (gradient.cc:282-285). Our
`rasterize_gradient` left dithering off and our interpolation used
`InPremul::No`, producing a pronounced banding pattern plus ±1
drift on the stop boundary.

- `rasterize_gradient` now calls `paint.set_dither(true)` on the
  fill paint used against the intermediate raster surface.
- `to_skia_interpolation` sets `in_premul = Yes` for every CSS
  gradient, matching Blink's `premultiplied_alpha_ = kPremultiplied`
  for all CSS gradient types.

paint-background-gradient-linear-simple: 94.09% → 98.19%. Residual
drift is dither-lattice phase differences between our intermediate
bitmap and Blink's direct-to-surface gradient path; tracked in
L0.coverage for a follow-up.
@vercel vercel Bot temporarily deployed to Preview – viewer April 22, 2026 17:55 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog April 22, 2026 17:55 Inactive
Narrow layout fixture: 3×2 grid with fixed 100×60 cells, 8px gap,
alternating black/red items. Byte-exact on first run; promoted to
L0.exact.
Narrow layout fixture: three grid rows exercising fr tracks —
1fr 1fr 1fr (equal thirds, 96px each), 1fr 2fr (100/200 split),
and 80px 1fr 80px (fixed + flexible sandwich). Widths chosen so
all tracks divide to integers, avoiding the ±1 fractional-
position divergence from iter 20/32. Promoted to L0.exact.
Narrow layout fixture: two 4×2 grids exercising `grid-column: span N`
and `grid-row: span 2`. Byte-exact on first run; promoted to
L0.exact.
Narrow layout fixture: four stacked block children inside a 400×_
container, exercising normal block flow with full-width, 75%, and
50% child widths. Byte-exact; promoted to L0.exact (75% → integer
288px; 80% dropped because it gave fractional 307.2px).
Narrow fixture: rectangular outlines with `outline: 9px double`
and an offset variant. Validates the double-stroke outline path
(two 1/3-width rings with 1/3 gap). Byte-exact on first run;
promoted to L0.exact.
Narrow fixture: six boxes exercising `border-top-left-radius`,
`border-top-right-radius`, `border-bottom-right-radius`,
`border-bottom-left-radius` individually, plus a two-top-corner
and diagonal variant. Complements the shorthand paint-border-
radius fixture. Byte-exact on first run; promoted to L0.exact.
Narrow fixture: five red swatches exercising CSS filter
functions — none, invert(1), grayscale(1), brightness(0.5),
opacity(0.5). Byte-exact; promoted to L0.exact.
Narrow fixture: three red/blue swatch pairs with
mix-blend-mode: multiply / screen / difference. Scores 97.75% —
**not promoted**.

The overlay box renders at full opacity (blue over red shows pure
blue) instead of blending. `mix-blend-mode` is parsed in the
stylo layer and stored on `style.blend_mode` but never applied in
paint — the paint path doesn't pass a blend mode into its
save_layer call. Separate renderer fix; tracked in L0.coverage.
`mix-blend-mode` was parsed into `style.blend_mode` but never
reached paint — the element always composited with src-over.
`paint_element` now opens a save-layer when `blend_mode` is
non-normal and passes the Skia blend mode via
`Paint::set_blend_mode`, matching CSS Compositing 1 §5.

paint-mix-blend-mode: 97.75% → 100.00%. Promoted to L0.exact.
All 771 cg unit tests pass.
Narrow fixture: three red swatches with chained CSS filters —
invert + grayscale, opacity + brightness, sepia + saturate.
Byte-exact on first run; promoted to L0.exact.
Narrow layout fixture: 3×3 grid with row-gap: 24px and
column-gap: 8px. Validates that row-gap and column-gap are
applied separately (vs shorthand gap). Byte-exact on first run;
promoted to L0.exact.
Narrow fixture: two boxes combining translate+scale in both orders
(translate-then-scale and scale-then-translate). Validates CSS
transform function composition (order matters: scale in the second
form multiplies the translate offset). Byte-exact on first run;
promoted to L0.exact.
Narrow fixture: three boxes scaled to 0.5 with transform-origin
0 0, 50% 50%, and 100% 100%. Validates that transform-origin
anchors scale correctly in all four corners. Byte-exact on first
run; promoted to L0.exact.
Narrow fixture: three boxes using CSS Transforms Module Level 2
individual properties — `translate: 16px 24px`, `scale: 0.5`, and
combined `translate + scale` applied as separate declarations.
Byte-exact on first run; promoted to L0.exact.
Narrow fixture: three boxes with clip-path: circle() /
circle(30px) / circle(20px). Byte-exact after iterating on the
radius — default (bounding-box-derived), 30px, and 20px hit
pixel-aligned edges where Skia and Blink agree. 40px and 60px
radii trigger Skia-vs-Blink AA edge-sampling divergence at the
rasterized circle perimeter; deferred to a future fixture.
Promoted to L0.exact.
Narrow fixture: two boxes with polygon clip-paths — a corner
triangle and an axis-aligned rectangle-subset polygon. Dropped
the inverse-direction triangle and diamond variants because
their oblique edges trigger Skia-vs-Blink diagonal-fill-side AA
divergence (which side of the rasterized diagonal gets partial
coverage). Promoted to L0.exact.
stylo's pos.flex_basis is a FlexBasis<Size> enum (Size | Content); the
Size variant wraps our existing extract_size input. Without this, items
with only flex-basis (no width) were collapsing to zero in Taffy.

Rewrote layout-flex-basis fixture to exercise pure flex-basis (no
redundant width) — stays at 100.00% vs Chromium.
CSS Backgrounds §7.2: the first shadow listed is on top. Previous code
iterated in list order, so the last-listed shadow painted on top — the
opposite of spec. Reversed iteration in both paint_box_shadow_outer
and paint_box_shadow_inset.

Re-added the 3-stack (red/green/blue) case to paint-box-shadow-multiple
(dropped in iter 54 due to this bug); now 100.00% vs Chromium.
skewX(20deg) on an 80x80 box — verified 100.00% byte-exact against
Chromium. skewY and compound skew(x,y) were narrowed out: tilted
horizontal edges show sub-pixel AA divergence (same class as the
known paint-transform-rotate residual).
Reftest scoring denominator switches from full-canvas (width × height)
to the alpha-masked content region (alpha > 0 on either side). Powered
by a three-part coupling:

- Chromium screenshots with `omitBackground: true` so PNG alpha
  encodes "the CSS cascade drew here"
- cg clears its Skia surface with `Color::TRANSPARENT` and renders
  at viewport dims
- Both sides apply a new `_reftest/transparent-body.css` helper via
  `extra_css` to force `html, body { background: transparent }`
  without requiring per-fixture edits

`DEFAULT_AA` flipped to `true` (pixelmatch `includeAA: false`):
sub-pixel AA coverage differences between Skia and Blink get
classified via the Vysniauskas detector and excluded. Strict
byte-exact remains available via `--no-aa` or suite `gate.aa: false`.

Verified: all 57 L0.exact fixtures at 100% under the new defaults;
dynamic range improves substantially on non-AA divergence (gradient
dither 98.19 → 89.67%).

Diverges from Chromium's internal abs-channel+fuzzy model but matches
the cross-engine pixel-diffing ecosystem (Playwright `toHaveScreenshot`,
Percy, Chromatic) — appropriate since we diff Skia/cg vs Blink rather
than Blink vs Blink.
…ninsky-1d93d3

# Conflicts:
#	crates/grida-canvas/src/htmlcss/paint.rs
#	fixtures/test-html/suites/L0.exact.json
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