htmlcss: L0.exact suite expansion + renderer fixes + reftest scoring model#687
htmlcss: L0.exact suite expansion + renderer fixes + reftest scoring model#687softmarshmallow merged 67 commits intohtmlcssfrom
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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.
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
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-featuresession (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 componentsCornerRadii::resolved(w, h)— carry percent border-radius unresolved until paintbackground-cliphonored on color layer + border-corner double-paint fixmix-blend-modeapplied via save-layer blend modebox-shadowiteration reversed per CSS Backgrounds §7.2 (first-listed on top)flex-basisplumbed from styloFlexBasis::Size/Contentinto Taffy3. 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:omitBackground: true(inrefbrowser_render.ts)canvas.clear(Color::TRANSPARENT)+ renders at viewport dims_reftest/transparent-body.csshelper applied viaextra_css—!importantoverride so existing fixtures'body { background: #fff }lines become no-opsDEFAULT_AAflipped totrue(pixelmatchincludeAA: 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-aaor suitegate.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--aaStats
67 commits, 66 files, +4072 / −110.
Test plan
cargo run -p cg --example golden_htmlcss -- --suite fixtures/test-html/suites/L0.exact.jsonrenders all goldenspnpm --filter @grida/reftest build && pnpm --filter @grida/reftest test— 49 unit tests passL0.exactsuite hits 100% under the new defaults (verified locally)cargo test -p cgpassespnpm lint+just fmtcleanabs_color_to_cg, gradient, box-shadow order)