Skip to content

feat(htmlcss): Chromium-parity L0 suite — 65 fixtures + 13 renderer fixes#686

Merged
softmarshmallow merged 82 commits intomainfrom
htmlcss
Apr 23, 2026
Merged

feat(htmlcss): Chromium-parity L0 suite — 65 fixtures + 13 renderer fixes#686
softmarshmallow merged 82 commits intomainfrom
htmlcss

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 22, 2026

Large-scale Chromium-parity push for the cg htmlcss renderer: 65 L0.exact fixtures (all 100.00% vs Chromium under AA-ignore) plus 13 renderer bug fixes and two reftest infrastructure upgrades.

Renderer fixes

Fix Delta
set_alpha_f for float-native opacity (was u8 truncation) paint-opacity 96.00 → 100.00%
.round() as u8 for sRGB channel conversion (was truncation) paint-background-solid 97.92 → 100.00%
Percentage border-radius resolved at paint time against border-box paint-border-radius 98.94 → 100.00%
background-clip applied to background-color layer paint-background-clip-boxes 96.88 → 100.00%
Uniform translucent borders stroke as one RRect (no corner double-paint) paint-border-translucent 100.00%
mix-blend-mode applied via save-layer blend mode paint-mix-blend-mode 97.75 → 100.00%
Gradient dither (set_dither(true)) + premul interpolation (InPremul::Yes) gradient linear 94.09 → 98.19%
Box-shadow blur: σ = radius × 0.5 (matches Blink BlurRadiusToStdDev) paint-box-shadow-blur 37.46 → 100.00%
Inset box-shadow: symmetric frame via canvas.translate offset paint-box-shadow-inset-blur 92.97 → 100.00%
Box-shadow stacking order: reverse iteration (first shadow on top per CSS §7.2) paint-box-shadow-multiple 100.00%
flex-basis plumbed from stylo FlexBasis<Size> to Taffy layout-flex-basis 100.00%
Dashed border: Blink-parity dash ratios + SelectBestDashGap per side length paint-border-style-dashed 92.78 → 100.00%
Dotted border: [0, gap+w-ε] round-cap path + thick endpoint inset paint-border-style-dotted 92.78 → 96.24% (coverage)

Reftest infrastructure

  • Transparent-canvas content mask — scoring denominator switches from full-canvas to alpha-masked content region; html, body { background: transparent } applied via extra_css; cg surface cleared to TRANSPARENT.
  • AA-ignore by defaultpixelmatch includeAA: false; Skia/Blink AA-policy divergence excluded from score. Strict byte-exact still available via --no-aa or gate.aa: false.

L0.exact fixtures added (65 total)

Paint
paint-opacity, paint-opacity-levels, paint-opacity-nested, paint-background-solid, paint-background-clip-boxes, paint-color-hex-alpha, paint-border-radius, paint-border-radius-individual, paint-border-solid, paint-border-double-rect, paint-border-translucent, paint-border-style-dashed, paint-outline-solid, paint-outline-double-rect, paint-outline-offset, paint-outline-radius, paint-box-shadow-solid, paint-box-shadow-inset-solid, paint-box-shadow-multiple, paint-box-shadow-blur, paint-box-shadow-inset-blur, paint-clip-path-inset, paint-clip-path-circle, paint-clip-path-ellipse, paint-clip-path-polygon, paint-filter-simple, paint-filter-chain, paint-filter-blur, paint-filter-drop-shadow, paint-mix-blend-mode, paint-z-index-simple, paint-overflow-hidden, paint-visibility, paint-display-none, paint-position-absolute-simple, paint-position-relative, paint-margin-simple, paint-margin-auto-center, paint-padding-simple, paint-aspect-ratio, paint-max-min-size, paint-transform-translate, paint-transform-scale, paint-transform-skew, paint-transform-rotate (coverage), paint-transform-combined, paint-transform-origin, paint-transform-matrix, paint-individual-transform-props

Layout
layout-block-flow, layout-flex-row-basic, layout-flex-column-basic, layout-flex-align-items, layout-flex-align-self, layout-flex-grow, layout-flex-basis, layout-flex-wrap, layout-flex-direction-reverse, layout-grid-basic, layout-grid-fr, layout-grid-span, layout-grid-gap-asym, layout-grid-autoflow

L0.coverage fixtures (tracked gaps)

paint-background-gradient-linear-simple (98.19% — dither-lattice phase), paint-gradient-radial (92.76%), paint-border-style-dotted (96.24%), paint-transform-rotate (99.98% — rotation AA edge)

Sub-PRs merged

Summary by CodeRabbit

  • New Features

    • 60+ new HTML visual test fixtures covering layout, paint, transforms, filters, shadows, borders, gradients, clip-paths, and more.
    • Suite now renders transparent page backgrounds by default for alpha-based masking.
  • Bug Fixes

    • Improved paint compositing, blending, gradient rendering, shadow and border fidelity, and background-clip behavior.
    • Border-radius now supports percent-based corner radii.
  • Documentation

    • Updated fixture authoring guidance for labels and masking.
    • Anti-aliasing scoring default changed; CLI flag added to force strict (byte-exact) diffs.

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.
htmlcss: promote paint-opacity + box-padding to L0.exact
@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 2:37pm
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Apr 23, 2026 2:37pm
legacy Ignored Ignored Apr 23, 2026 2:37pm
backgrounds Skipped Skipped Apr 23, 2026 2:37pm
blog Skipped Skipped Apr 23, 2026 2:37pm
grida Skipped Skipped Apr 23, 2026 2:37pm
viewer Skipped Skipped Apr 23, 2026 2:37pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

Walkthrough

Refactors reftest scoring to use alpha-based content masks, enables AA-ignore by default, and adds CLI AA flags. Renderer and canvas now produce transparent, full-viewport PNGs (omitBackground). Significant htmlcss paint/style changes (corner radii, background-clip, gradients, shadows). Many L0 fixtures and suite defaults updated.

Changes

Cohort / File(s) Summary
Reftest docs & skill
.agents/skills/cg-reftest/SKILL.md, .agents/skills/fixtures/SKILL.md
Documents shift to alpha-based content-mask scoring, AA default semantics, fixture authoring guidance (pinned label dims, text-neutralizing stylesheet).
Reftest runner & CLI
packages/grida-reftest/src/cli.ts, packages/grida-reftest/src/compare.ts
Adds --aa/--no-aa flags; flips DEFAULT_AA to enable AA-ignore by default (maps to pixelmatch includeAA semantics).
Playwright render & PNG output
.agents/skills/cg-reftest/scripts/refbrowser_render.ts, crates/grida-canvas/examples/golden_htmlcss.rs
Adds omitBackground: true; allocate raster using viewport w/h and clear canvas to transparent so PNG alpha can serve as content mask.
Style model & extraction
crates/grida-canvas/src/htmlcss/style.rs, crates/grida-canvas/src/htmlcss/collect.rs
Adds percent-based per-axis corner radii fields and resolved(w,h); BackgroundLayer::Solid now carries { color, clip }; border-radius extraction preserved percent info; flex-basis and size parsing improved; color conversion now rounds.
Paint implementation
crates/grida-canvas/src/htmlcss/paint.rs
Large paint changes: mix-blend-mode and filters force save-layers with float alpha, omit clipping on blend/filter layers, gradient rasterization/dithering changes, radial gradient mapping adjusted, border/stroke/dash selection updated, shadow stacking and inset geometry updated.
Fixtures, suite defaults & assets
fixtures/test-html/_reftest/transparent-body.css, fixtures/test-html/suites/..., fixtures/test-html/L0/*.html, fixtures/test-html/README.md
Adds transparent-body.css and includes it in suite defaults.extra_css; enables gate AA in exact suite; adds ~40+ new L0 fixtures (layout, paint, transforms, clip-paths, filters, shadows, opacity, etc.); updates some existing fixtures (box-padding, gradient radial, opacity).

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Playwright Browser
    participant RefScript as refbrowser_render.ts
    participant Canvas as grida-canvas Renderer
    participant Scorer as Reftest Scorer

    Browser->>RefScript: request screenshot (omitBackground: true)
    RefScript->>Canvas: allocate raster surface(viewport w,h)
    Canvas->>Canvas: clear surface -> rgba(0,0,0,0)
    Canvas->>Canvas: draw picture (paint.rs: layered compositing)
    Canvas->>RefScript: encode PNG (with alpha)
    RefScript->>Scorer: supply PNG (alpha used to build content mask)
    Scorer->>Scorer: compute denominator from alpha>0 pixels
    Scorer->>Scorer: pixelmatch diff (includeAA default = false)
Loading
sequenceDiagram
    participant Author as Fixture Author
    participant Suite as Suite Config
    participant Runner as grida-reftest CLI
    participant Scorer as Reftest Scorer

    Author->>Suite: author HTML/CSS (pin label dims, include transparent-body.css)
    Suite->>Runner: suite defaults (extra_css includes transparent-body.css, gate.aa true)
    Runner->>Scorer: run compare (--aa / --no-aa affects includeAA)
    Scorer->>Scorer: compute content mask from PNG alpha and score
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding 65 L0 fixtures and 13 renderer fixes to achieve Chromium parity in the htmlcss renderer.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch htmlcss

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.

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.
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 fixture for `clip-path: inset(...)` with uniform/asymmetric/
percent/round variants. Passes byte-exact on first measurement —
promoted to L0.exact.

Exercises the `InsetCornerRadii` paint-time resolution path from
iter 4 (percent-based inset radii against the clipped rect).

Also reverts an unused gradient fast-path tried this iteration — the
dither-lattice phase between intermediate raster and direct draw
turned out to produce identical output, so the extra code path didn't
earn its keep. The iter-8 `paint.set_dither(true)` + `InPremul::Yes`
fix still stands.
Narrow fixture for inset `box-shadow` without blur: spread-only,
positive/negative offsets, spread+offset combo, and custom color.
All cases pass byte-exact against Chromium on first measurement —
the inset hollow-rect clip path plus `.round() as u8` (iter 3) are
sufficient. Promoted to L0.exact.
Narrow fixture for `transform: translate*()`: translate(x, y),
translateX, translateY, and negative offsets. All cases pass
byte-exact against Chromium on first measurement — the matrix
concat in the paint entry already aligns with Blink's approach.
Promoted to L0.exact.
Narrow fixture for `transform: scale*()`: scale(uniform),
scale(x, y), scaleX, scaleY. Passes byte-exact on first run.
Promoted to L0.exact.
Narrow fixture for `transform: rotate(90/180/-90deg)`. Scores
99.98% against Chromium — **not promoted**.

The residual 120 pixels sit on a single vertical line at the
rotated left edge (254 vs 255). Rotating by exactly 90° should
map pixel-aligned rects back to pixel-aligned rects, but Skia
and Blink pick up a sub-pixel drift from the float-precision
sin/cos computation, producing a 1-bit AA difference along one
edge. Tracked in L0.coverage as a rotation-AA-policy divergence.
Narrow fixture: six black swatches at opacity 1.0 / 0.75 / 0.5 /
0.25 / 0.1 / 0. The `set_alpha_f` fix from iter 1 plus the
`.round() as u8` channel fix from iter 3 make all six match
Chromium byte-exactly. Promoted to L0.exact.
Narrow fixture: four corner-pinned dots (top/left, top/right,
bottom/left, bottom/right) plus a translate-centered red dot inside
a relative-positioned container. Tests `position: absolute` with
top/right/bottom/left + `transform: translate(-50%, -50%)`. Passes
byte-exact on first run. Promoted to L0.exact.
Narrow fixture: three absolutely-positioned squares (red/green/blue)
with z-index 3/2/1 but source order c/b/a. Tests that z-index
overrides source-order painting. Byte-exact on first run. Promoted
to L0.exact.
Narrow fixture: two side-by-side containers with an oversized
absolutely-positioned child. Left container has overflow: hidden
(child clipped to container), right has overflow: visible (child
overflows). Byte-exact on first run; promoted to L0.exact.
Narrow fixture: three black boxes with middle one set to
`visibility: hidden`. Tests that hidden boxes reserve layout space
but paint nothing. Byte-exact on first run; promoted to L0.exact.
Narrow fixture: three boxes, middle one `display: none` (consumed
no layout space vs `visibility: hidden` which reserves the slot).
Byte-exact on first run; promoted to L0.exact.
Narrow layout fixture: five flex rows with three 80x60 items each,
exercising default packing, gap 16, justify-content: space-between,
and justify-content: flex-end. Byte-exact against Chromium;
promoted to L0.exact.

Dropped justify-content: space-around from initial draft — the
3-item/500px row gives 260px free space / 6 = 43.33px per gap,
which rounds to different pixels between Blink and Taffy (±1 px
boundary shift per item). Belongs in its own fractional-justify
fixture once the sub-pixel rounding is aligned.
Narrow layout fixture: three flex-direction: column containers
(default start / gap 12 / space-between) with three stacked items
each, laid out in a flex-row parent. Byte-exact; promoted to
L0.exact.

Dropped justify-content: flex-end column (fourth variant) to keep
the composite width within the 600px viewport preset — no
divergence, just a sizing constraint.
…er double-paint

Two related fixes surfaced by paint-background-clip-boxes:

1. `background-clip` now applies to the solid `background-color`
   layer. Per CSS Backgrounds 3 §2.5 the color uses the final
   layer's clip value; `BackgroundLayer::Solid` gains a `clip`
   field, and `paint_background` draws the color into the matching
   box-reference rect (inset radii for rounded boxes). Previously
   the color always filled border-box.

2. Uniform `solid`/`double` borders with translucent color now
   stroke once as an RRect regardless of border-radius. The per-
   side trapezoid path overlaps by one pixel at every corner
   diagonal, so `rgba(…, 0.5)` was composited twice at the
   corners → 0.75-alpha instead of 0.5. Restricted the switch to
   `solid`/`double` styles; `inset`/`outset`/`groove`/`ridge`
   still take the per-side path since they need the Blink 3D
   darken/lighten pair.

paint-background-clip-boxes: 96.88% → 100.00%. Promoted to L0.exact.
All 771 cg unit tests pass.
… 0.5

CSS Backgrounds §7.2 defines blur-radius as twice the Gaussian standard
deviation. We were passing CSS `blur-radius` directly to Skia's mask
filter as sigma, producing shadows that were 2× too blurry. Match Blink's
ShadowData::BlurRadiusToStdDev (shadow_data.h:76-82): σ = radius * 0.5
at both outer and inset mask-filter sites.

Add paint-box-shadow-blur L0.exact fixture (7 variants: blur sizes,
offset+blur, blur+spread, colored translucent, blur+border-radius).
Baseline 37.46% → 100.00% (AA-ignore) / 99.99% (--no-aa) after fix.
Existing shadow fixtures stayed at 100% because they all used 0 blur.
The inset-shadow path was shifting only the inner hole by shadow.offset
while keeping the outer rect far (`blur * 2 + 100` thick). That produced
an asymmetric frame whose inner/outer Gaussian gradients could not
overlap on the offset side, so the shadow saturated at the box edge
instead of falling off softly (93% vs Chromium on offset variants).

Match Blink (box_painter_base.cc:511-578): keep inner hole centered on
the box, size outer_rect via AreaCastingShadowInHole (outset by
blur-radius, union with pre-offset position), then canvas.translate by
shadow.offset before drawing so the whole frame shifts in one go —
equivalent to Blink's DrawLooper offset.

Add paint-box-shadow-inset-blur L0.exact fixture (7 variants: blur
sizes, offset+blur, blur+spread, colored translucent, blur+radius).
Baseline 92.97% → 100.00%. No regressions on other shadow fixtures.
…rders

CSS Backgrounds §4.2 leaves dash geometry implementation-defined, but
to match Chromium we need:
- thin lines (<3px): dash = 3×width, gap = 2×width
- thick lines (≥3px): dash = 2×width, gap = 1×width
- gap then adjusted so an integer count of dashes fits each side's
  length evenly (Blink's SelectBestDashGap)

Port styled_stroke_data.cc:40-113 to our stroke_paint builder. The
function now takes an optional path_length; per-side border painting
passes the side length, outline (RRect perimeter) passes None and
falls back to nominal intervals.

Add paint-border-style-dashed L0.exact fixture (6 variants: widths
1/2/3/6/10, colored). Baseline 92.78% → 100.00%. No regressions.
…inset

CSS Backgrounds §4.2 border-style: dotted. Our stroke used [w, w] dash
with round cap, producing 2×width dots at 2×width spacing. Blink
(styled_stroke_data.cc:115-132) uses [0, gap+width-ε] with round cap —
the zero dash + round cap yields width-diameter dots, and the gap is
picked by SelectBestDashGap so an integer count of dots fits the path
length.

Also inset each line's endpoints by width/2 for thick dotted (>3px),
matching box_border_painter.cc:528-537 so round caps don't extend
beyond the box.

Add paint-border-style-dotted L0.coverage fixture (6 variants). 92.78%
→ 96.24% (AA-ignore) / 90.36% (--no-aa). Thin (width ≤ 3) variants
still misalign; Blink's EnforceDotsAtEndpoints pixel-snap logic
(box_border_painter.cc:401-497) not yet ported — memoed as residual
for a follow-up.
6 variants cover CSS Filter Effects §9.7 drop-shadow() branches:
no-blur (2-length form), small/medium/large blur sigmas, colored
translucent, negative offsets. 100.00% on first try — Blink stores
the blur value as sigma directly (filter_effect_builder.cc:298) and
our code path does the same via image_filters::drop_shadow, so no
code change needed.
6 variants exercise CSS Transforms 1 §7.1 matrix(a,b,c,d,tx,ty):
identity, pure translate/scale/rotate via the matrix primitive,
scale+translate composed, shear. 100.00% AA-ignore / 99.43%
--no-aa (yellow AA edges on rotate + shear, same class as
iter 13 / iter 61 rotation/skew residuals). No code change.
6 variants exercise CSS Filter Effects §11.4.4 blur(<length>):
sigma 0/2/4/8/12 + colored. 100.00% on first try — spec says the
length is Gaussian σ directly (not halved like box-shadow blur-radius),
and our image_filters::blur((sigma, sigma)) call already matches
Blink. No code change.
- Extract `blur_radius_to_sigma(r)` so the CSS Backgrounds §7.2 rule
  (σ = r/2) has one home shared by outer and inset box-shadow
  mask-filter sites.
- Extract `side_length(pos, w, h)` next to the existing `side_endpoints`
  / `side_inward_normal` family; collapse the duplicate SidePos match
  in paint_border_side.
- Add a comment on the `.max(1.0)` guard in select_best_dash_gap so the
  deviation from Blink's open-path-with-1-dash branch is explicit.
- Drop the commit-message-style post-mortem comment in the inset shadow
  block; the remaining comment plus the Blink line-number citations
  carry the full intent.

No behavioral change — 771 tests pass, full L0.coverage diff scores
unchanged (average 99.61%).
feat(htmlcss): Blink-parity shadow blur + dashed/dotted borders, 8 L0 fixtures
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1750e28994

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1689 to +1693
let color_clip = bg
.background_clip
.0
.last()
.map(extract_bg_clip)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve background-color clip with cycled layer indexing

In extract_background, the background-color clip is chosen with background_clip.last(), but this property is a repeating per-layer list (and this same function already cycles shorter lists for image layers via cycle(...)). When background-image has more layers than background-clip values, .last() can select the wrong clip for the bottom layer, so the color is painted in the wrong box. Compute the color clip from the bottom layer index after list expansion instead of using the raw last token.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
let needs_layer = style.opacity < 1.0
|| !style.filter.is_empty()
|| !matches!(style.blend_mode, crate::cg::prelude::BlendMode::Normal);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid bounded save-layer for mix-blend-mode contexts

This change makes non-normal mix-blend-mode trigger save_layer, but the non-filter path still uses the element box as layer bounds; in Skia that bounds clips the layer output. As a result, blended elements lose pixels that extend outside w×h (e.g. outer box-shadow, positive outline-offset, or overflowing descendants) before compositing, which is a rendering regression specific to the new blend-mode path. Blend-mode layers should use unbounded/expanded bounds like the filter branch.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/grida-canvas/src/htmlcss/style.rs (1)

496-505: ⚠️ Potential issue | 🟡 Minor

Don’t drop percentage radii for inline decorations.

Line 496 says max_radius() is only for a presence check, but collect.rs stores this value into InlineBoxDecoration.border_radius. With percentage-only radii, inline backgrounds/borders render square because the scalar radius becomes 0.

Consider carrying CornerRadii through InlineBoxDecoration and resolving it against each decoration rect during inline painting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/style.rs` around lines 496 - 505, The current
max_radius() implementation strips percentage-based radii (returning 0 for
percentage-only corners) and that scalar is stored in
InlineBoxDecoration.border_radius via collect.rs, causing percentage-only radii
to render as square; instead, propagate the full CornerRadii through
InlineBoxDecoration (do not replace it with a single f32), add a new field like
InlineBoxDecoration.corner_radii (or replace border_radius) and update
collect.rs to assign the CornerRadii directly, then resolve/compute the final
pixel radii against each decoration rect during inline painting (where
decoration rect size is known) so percentage radii are correctly converted
per-rect before painting.
crates/grida-canvas/src/htmlcss/paint.rs (2)

201-209: ⚠️ Potential issue | 🟠 Major

Clip replaced content with content-box-adjusted radii.

Line 208 resolves radii against the border box, then paint_replaced applies them to a content-box-local rect. With borders or padding, <img> clipping uses radii that are too large instead of the inset inner curve.

Proposed fix
+        let resolved_r = style.border_radius.resolved(w, h);
+        let border_rect = Rect::from_xywh(0.0, 0.0, w, h);
+        let content_rect = Rect::from_xywh(cx, cy, cw, ch);
+        let content_radii = inset_radii(&resolved_r, border_rect, content_rect);
         paint_replaced(
             canvas,
             replaced,
             cx,
             cy,
             cw,
             ch,
-            &style.border_radius.resolved(w, h),
+            &content_radii,
             style.font.image_rendering,
             images,
         );
 fn paint_replaced(
     canvas: &Canvas,
     content: &super::style::ReplacedContent,
     x: f32,
     y: f32,
     w: f32,
     h: f32,
-    border_radius: &super::style::CornerRadii,
+    border_radii: &[skia_safe::Point; 4],
     image_rendering: types::ImageRendering,
     images: &dyn ImageProvider,
 ) {
@@
-    if !border_radius.is_zero() {
+    if border_radii.iter().any(|p| p.x > 0.0 || p.y > 0.0) {
         let mut rrect = skia_safe::RRect::new();
-        rrect.set_rect_radii(dest_rect, &border_radius.to_skia_radii());
+        rrect.set_rect_radii(dest_rect, border_radii);
         canvas.clip_rrect(rrect, ClipOp::Intersect, true);

Also applies to: 763-785

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 201 - 209, The image
clipping currently passes radii resolved against the element's border-box into
paint_replaced which expects radii for the content-box; when borders or padding
exist this yields oversized corner curves. Modify the call site that invokes
paint_replaced (and the similar call around lines 763-785) to inset the resolved
BorderRadius by the element's used border+padding (i.e., convert the border-box
radii into content-box radii) before passing them to paint_replaced; use the
same resolved(...) result but apply an inset/deflate using the computed border +
padding extents so the radii match the content rect passed (cx, cy, cw, ch).
Ensure the adjusted radii are used wherever paint_replaced is called for
replaced content.

78-147: ⚠️ Potential issue | 🟠 Major

Omit save-layer bounds when blend modes are used with visual overflow elements.

When mix-blend-mode is active, the save-layer bounds at line 139 are set to the border box [0, w] × [0, h]. Skia clips layer output to these bounds, which causes outer box-shadows and outlines (which extend beyond the border box) to be clipped before compositing. The blended result becomes visually incorrect.

The code already handles this for filters by omitting bounds when has_filter is true (line 140). The same logic must apply to blend modes: detect visual overflow (outline or non-inset shadows with blur/spread/offset) and omit bounds in those cases.

Proposed fix
+        let has_visual_overflow = style.outline.has_outline()
+            || style.box_shadow.iter().any(|s| {
+                !s.inset
+                    && (s.blur > 0.0
+                        || s.spread != 0.0
+                        || s.offset_x != 0.0
+                        || s.offset_y != 0.0)
+            });
         let bounds = Rect::from_xywh(0.0, 0.0, w, h);
-        let layer_rec = if has_filter {
+        let layer_rec = if has_filter || has_visual_overflow {
             skia_safe::canvas::SaveLayerRec::default().paint(&layer_paint)
         } else {
             skia_safe::canvas::SaveLayerRec::default()
                 .paint(&layer_paint)
                 .bounds(&bounds)
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 78 - 147, The
save-layer bounds currently get omitted only when has_filter is true, but when
mix-blend-mode (style.blend_mode) is active and the element has visual overflow
(e.g. non-inset box-shadows with blur/spread/offset or an outline) those effects
get clipped by the bounds; add a detection boolean (e.g. has_visual_overflow)
that checks style.box_shadows for any shadow where inset == false and
(blur_radius != 0.0 || spread != 0.0 || offset != (0,0)) and/or style.outline is
present, then change the layer_rec creation logic (around layer_rec /
has_filter) so bounds are omitted when has_filter || (style.blend_mode != Normal
&& has_visual_overflow) before calling canvas.save_layer; update references to
layer_paint, has_filter, layer_rec and canvas.save_layer accordingly.
🧹 Nitpick comments (4)
fixtures/test-html/L0/paint-border-solid.html (1)

21-27: Optional: reduce text rasterization noise in this paint-focused fixture.

Since this fixture validates border rendering, consider minimizing label glyph impact (e.g., smaller/no labels in capture area, or moving labels outside compared region) to keep failures focused on borders.

Also applies to: 66-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-border-solid.html` around lines 21 - 27, The
.label CSS rules currently draw text inside the capture area and can add
rasterization noise; update the .label styling or fixture markup so labels don't
overlap the border test region—for example reduce font-size further, set labels
to display:none for the paint-focused fixture, or move the label elements
outside the compared region; locate the .label block in the fixture (the rules
with font-size, color, padding-bottom, width, height) and apply the change that
removes/minimizes glyph rendering in the capture area.
fixtures/test-html/L0/paint-outline-double-rect.html (1)

43-49: Well-focused outline test cases.

The fixture appropriately tests the double outline style in two scenarios: basic 9px outline and with 6px offset. The 9px width is sufficient for the double style to render both lines clearly.

Optional enhancement: Consider adding a companion fixture for other outline styles (solid, dashed, dotted) to provide comprehensive outline rendering coverage, though the current focused approach is valid for L0 testing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-outline-double-rect.html` around lines 43 - 49,
The fixture currently tests the double outline using classes .o-9 and
.o-9-offset; as an optional enhancement, add companion test fixtures that
exercise other outline-style values (e.g., solid, dashed, dotted) by creating
corresponding classes (e.g., .o-solid, .o-dashed, .o-dotted and offset variants)
and rendering them at widths that make the style visible, so outline rendering
coverage is broader while keeping the existing L0 double-outline cases
unchanged.
fixtures/test-html/L0/paint-transform-skew.html (1)

32-40: Remove unused CSS classes or add corresponding test cases.

Two utility classes (skew-y and skew-xy) are defined but never used in the body. The fixture only renders a single box with skew-x.

Consider either:

  1. Adding test cases for skewY and combined skew transforms to match the defined classes
  2. Removing the unused classes if this fixture intentionally tests only skewX
♻️ Option 1: Add missing test cases
   <body>
     <div class="row">
       <div class="box skew-x"></div>
+      <div class="box skew-y"></div>
+      <div class="box skew-xy"></div>
     </div>
   </body>
♻️ Option 2: Remove unused classes
     .skew-x {
       transform: skewX(20deg);
     }
-    .skew-y {
-      transform: skewY(10deg);
-    }
-    .skew-xy {
-      transform: skew(15deg, 5deg);
-    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-transform-skew.html` around lines 32 - 40, The
CSS defines .skew-y and .skew-xy but only .skew-x is used in the markup; either
add test elements that exercise the missing transforms (create additional boxes
in the body using class="skew-y" and class="skew-xy" with appropriate
labels/captions so the fixture covers skewY and skew( , ) cases) or remove the
unused .skew-y and .skew-xy rules to keep the fixture focused on skewX; locate
and update the CSS block containing .skew-x, .skew-y, .skew-xy and the HTML that
renders the test box to implement one of these two options.
fixtures/test-html/L0/paint-box-shadow-inset-blur.html (1)

25-62: Consider adding a non-zero-offset + non-zero-spread + border-radius case.

The current fixtures individually cover blur, offset+blur, spread+blur, color+blur, and radius+blur, but none combine offset + spread + radius in one declaration — which is exactly the path that exercises the shrink-corner-radii-by-spread logic together with the canvas.translate offset in paint_box_shadow_inset (see crates/grida-canvas/src/htmlcss/paint.rs:2282-2362). A single combined case would close the remaining geometry gap cheaply.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-box-shadow-inset-blur.html` around lines 25 - 62,
Add a fixture that combines non-zero offset, non-zero spread, and border-radius
in one rule to exercise the shrink-corner-radii-by-spread + canvas.translate
code path in paint_box_shadow_inset; for example add a class (e.g.
.off-spread-round) that sets border-radius (e.g. 16px) and a box-shadow with
inset <non-zero-offset> <non-zero-offset> <blur> <spread> (e.g. inset 4px 4px
8px 4px rgba(0,0,0,0.8)) so the test triggers the combined geometry handling in
paint_box_shadow_inset.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.agents/skills/cg-reftest/SKILL.md:
- Around line 567-570: Update the Dimensions section text that currently reads
"**Dimensions** — cg renders at viewport dims (`width × height`); Chromium
screenshots `fullPage`..." and the earlier sentence "Set height to match cg's
cull height; mismatched dims score 0.0..." to clarify that viewport.height is
only optional for fixtures that force their own height via the paint preset
(e.g., `min-height: 800px`), and that natural layout fixtures still require an
explicit `viewport.height` matching cg's reported `WxH` to avoid a
dimension-mismatch/score 0.0 (keep references to `fullPage`, `viewport.height`,
and `@grida/reftest` in the wording).

In `@crates/grida-canvas/src/htmlcss/collect.rs`:
- Around line 1188-1191: The flex-basis handling currently maps FlexBasis::Size
-> extract_size which falls back to CssLength::Auto when encountering mixed
calc() values (because to_length()/to_percentage() fail), losing calc() like
calc(50% - 10px); update extract_size (or add a new helper like
extract_flex_basis) to detect and preserve calc() expressions instead of
returning Auto (e.g., produce a CssLength variant representing raw/calc values
or return the original computed style fragment), and then use that preserved
value when assigning el.flex_basis (the match on pos.flex_basis and callers that
rely on extract_size such as the other block at 1616-1631 should be updated
similarly) so mixed unit calc()s are retained rather than treated as Auto.

In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 1836-1850: The fast-path currently excludes dashed/dotted via
uniform_stroke_style causing uniform rounded dashed/dotted borders to fall back
to per-side straight lines and lose border-radius; update the condition so
dashed and dotted styles are treated as uniform when a non-zero radius exists:
include types::BorderStyle::Dashed and types::BorderStyle::Dotted in the
uniform_stroke_style check (or otherwise allow those styles through) and ensure
the per-side fallback is only chosen when style.border_radius.is_zero(); keep
references to uniform_stroke_style, types::BorderStyle::{Dashed,Dotted},
style.border_radius and paint_uniform_rounded_border when making the change.

In `@fixtures/test-html/L0/layout-flex-direction-reverse.html`:
- Around line 7-11: The fixture sets paint-style sizing on the global selector
"html, body" (min-height: 800px; box-sizing: border-box;) which violates the
`layout-*` guideline to avoid forcing body size; either remove those rules from
the fixture so the layout test uses natural cull height, or instead add an
explicit per-fixture viewport for this fixture in L0.coverage.json to document
the intended 800px height; locate the CSS in layout-flex-direction-reverse.html
(the "html, body" rule) and apply one of these two fixes to make the intent
explicit.

In `@fixtures/test-html/L0/layout-grid-autoflow.html`:
- Around line 39-54: The fixture currently uses two identical 3×2 grids so
grid-auto-flow differences are not visible; update the second grid (the element
with class "grid autoflow-column") to expose column-flow behavior by changing
its child count or arrangement — for example reduce the number of .cell children
(e.g., 4 instead of 6) or reorder/add cells so column filling (top-to-bottom
then left-to-right) produces a different visual layout than the default flow;
keep the first grid (class "grid") as-is so the contrast is observable.

In `@fixtures/test-html/L0/paint-border-translucent.html`:
- Around line 21-27: The fixture currently includes visible text via the .label
element which introduces font rasterization and alpha masks that pollute the
translucent-border test; remove any textual content from the HTML labels (or
replace with semantic class names / comments) and eliminate styling that forces
visible text (see the .label class) so the fixture is text-free and only
exercises border rendering; update any instances noted (including the other
similar blocks at lines referenced by the reviewer) to use non-rendered metadata
(class names or comments) instead of visible labels.

In `@fixtures/test-html/L0/paint-transform-rotate.html`:
- Around line 42-57: The test uses a uniform 120×120 .box so .r-90, .r-180 and
.r-270 produce no visible change; update the fixture so rotation is observable
by making the box asymmetric (e.g., change .box to a non-square rectangle, add
an L-shaped marker via a ::before/::after pseudo-element, or split the box into
two differently colored halves) so the transforms on .r-90, .r-180 and .r-270
produce distinct visual differences in the .frame cells.

In `@fixtures/test-html/suites/L0.coverage.json`:
- Around line 40-87: The coverage suite file L0.coverage.json contains many
layout-* fixtures without explicit viewport entries (e.g.
layout-flex-direction-reverse.html, layout-flex-grow.html,
layout-flex-wrap.html, layout-grid-basic.html, layout-flex-basis.html,
layout-block-flow.html); before promoting any of these to L0.exact.json, measure
each fixture's natural cull (the renderer's intrinsic width and height) and add
a per-fixture "viewport": { "width": <w>, "height": <h> } object to the
corresponding JSON entry in L0.coverage.json (or ensure the same is present when
copying into L0.exact.json) so the layout tests use explicit dimensions instead
of inheriting the 600×800 paint default.

In `@fixtures/test-html/suites/L0.exact.json`:
- Around line 4-7: The fixture sets gate.aa which is never read; the code
expects diff.aa (see config parser referencing diff.aa and the CLI resolving
config?.diff?.aa). Move the "aa": true entry out of the "gate" object into the
"diff" object in the JSON (add "aa": true under "diff" and remove "gate.aa"),
ensuring the config key names match the parser/CLI usage (diff.aa) so the
setting is actually consumed.

---

Outside diff comments:
In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 201-209: The image clipping currently passes radii resolved
against the element's border-box into paint_replaced which expects radii for the
content-box; when borders or padding exist this yields oversized corner curves.
Modify the call site that invokes paint_replaced (and the similar call around
lines 763-785) to inset the resolved BorderRadius by the element's used
border+padding (i.e., convert the border-box radii into content-box radii)
before passing them to paint_replaced; use the same resolved(...) result but
apply an inset/deflate using the computed border + padding extents so the radii
match the content rect passed (cx, cy, cw, ch). Ensure the adjusted radii are
used wherever paint_replaced is called for replaced content.
- Around line 78-147: The save-layer bounds currently get omitted only when
has_filter is true, but when mix-blend-mode (style.blend_mode) is active and the
element has visual overflow (e.g. non-inset box-shadows with blur/spread/offset
or an outline) those effects get clipped by the bounds; add a detection boolean
(e.g. has_visual_overflow) that checks style.box_shadows for any shadow where
inset == false and (blur_radius != 0.0 || spread != 0.0 || offset != (0,0))
and/or style.outline is present, then change the layer_rec creation logic
(around layer_rec / has_filter) so bounds are omitted when has_filter ||
(style.blend_mode != Normal && has_visual_overflow) before calling
canvas.save_layer; update references to layer_paint, has_filter, layer_rec and
canvas.save_layer accordingly.

In `@crates/grida-canvas/src/htmlcss/style.rs`:
- Around line 496-505: The current max_radius() implementation strips
percentage-based radii (returning 0 for percentage-only corners) and that scalar
is stored in InlineBoxDecoration.border_radius via collect.rs, causing
percentage-only radii to render as square; instead, propagate the full
CornerRadii through InlineBoxDecoration (do not replace it with a single f32),
add a new field like InlineBoxDecoration.corner_radii (or replace border_radius)
and update collect.rs to assign the CornerRadii directly, then resolve/compute
the final pixel radii against each decoration rect during inline painting (where
decoration rect size is known) so percentage radii are correctly converted
per-rect before painting.

---

Nitpick comments:
In `@fixtures/test-html/L0/paint-border-solid.html`:
- Around line 21-27: The .label CSS rules currently draw text inside the capture
area and can add rasterization noise; update the .label styling or fixture
markup so labels don't overlap the border test region—for example reduce
font-size further, set labels to display:none for the paint-focused fixture, or
move the label elements outside the compared region; locate the .label block in
the fixture (the rules with font-size, color, padding-bottom, width, height) and
apply the change that removes/minimizes glyph rendering in the capture area.

In `@fixtures/test-html/L0/paint-box-shadow-inset-blur.html`:
- Around line 25-62: Add a fixture that combines non-zero offset, non-zero
spread, and border-radius in one rule to exercise the
shrink-corner-radii-by-spread + canvas.translate code path in
paint_box_shadow_inset; for example add a class (e.g. .off-spread-round) that
sets border-radius (e.g. 16px) and a box-shadow with inset <non-zero-offset>
<non-zero-offset> <blur> <spread> (e.g. inset 4px 4px 8px 4px rgba(0,0,0,0.8))
so the test triggers the combined geometry handling in paint_box_shadow_inset.

In `@fixtures/test-html/L0/paint-outline-double-rect.html`:
- Around line 43-49: The fixture currently tests the double outline using
classes .o-9 and .o-9-offset; as an optional enhancement, add companion test
fixtures that exercise other outline-style values (e.g., solid, dashed, dotted)
by creating corresponding classes (e.g., .o-solid, .o-dashed, .o-dotted and
offset variants) and rendering them at widths that make the style visible, so
outline rendering coverage is broader while keeping the existing L0
double-outline cases unchanged.

In `@fixtures/test-html/L0/paint-transform-skew.html`:
- Around line 32-40: The CSS defines .skew-y and .skew-xy but only .skew-x is
used in the markup; either add test elements that exercise the missing
transforms (create additional boxes in the body using class="skew-y" and
class="skew-xy" with appropriate labels/captions so the fixture covers skewY and
skew( , ) cases) or remove the unused .skew-y and .skew-xy rules to keep the
fixture focused on skewX; locate and update the CSS block containing .skew-x,
.skew-y, .skew-xy and the HTML that renders the test box to implement one of
these two options.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c96ba1a0-558d-4a39-86ec-cb0aa84b9afa

📥 Commits

Reviewing files that changed from the base of the PR and between 344e722 and 1750e28.

📒 Files selected for processing (78)
  • .agents/skills/cg-reftest/SKILL.md
  • .agents/skills/cg-reftest/scripts/refbrowser_render.ts
  • .agents/skills/fixtures/SKILL.md
  • crates/grida-canvas/examples/golden_htmlcss.rs
  • crates/grida-canvas/src/htmlcss/collect.rs
  • crates/grida-canvas/src/htmlcss/paint.rs
  • crates/grida-canvas/src/htmlcss/style.rs
  • fixtures/test-html/L0/box-padding.html
  • fixtures/test-html/L0/layout-block-flow.html
  • fixtures/test-html/L0/layout-flex-align-items.html
  • fixtures/test-html/L0/layout-flex-align-self.html
  • fixtures/test-html/L0/layout-flex-basis.html
  • fixtures/test-html/L0/layout-flex-column-basic.html
  • fixtures/test-html/L0/layout-flex-direction-reverse.html
  • fixtures/test-html/L0/layout-flex-grow.html
  • fixtures/test-html/L0/layout-flex-row-basic.html
  • fixtures/test-html/L0/layout-flex-wrap.html
  • fixtures/test-html/L0/layout-grid-autoflow.html
  • fixtures/test-html/L0/layout-grid-basic.html
  • fixtures/test-html/L0/layout-grid-fr.html
  • fixtures/test-html/L0/layout-grid-gap-asym.html
  • fixtures/test-html/L0/layout-grid-span.html
  • fixtures/test-html/L0/paint-aspect-ratio.html
  • fixtures/test-html/L0/paint-background-clip-boxes.html
  • fixtures/test-html/L0/paint-background-gradient-linear-simple.html
  • fixtures/test-html/L0/paint-border-double-rect.html
  • fixtures/test-html/L0/paint-border-radius-individual.html
  • fixtures/test-html/L0/paint-border-solid.html
  • fixtures/test-html/L0/paint-border-style-dashed.html
  • fixtures/test-html/L0/paint-border-style-dotted.html
  • fixtures/test-html/L0/paint-border-translucent.html
  • fixtures/test-html/L0/paint-box-shadow-blur.html
  • fixtures/test-html/L0/paint-box-shadow-inset-blur.html
  • fixtures/test-html/L0/paint-box-shadow-inset-solid.html
  • fixtures/test-html/L0/paint-box-shadow-multiple.html
  • fixtures/test-html/L0/paint-box-shadow-solid.html
  • fixtures/test-html/L0/paint-clip-path-circle.html
  • fixtures/test-html/L0/paint-clip-path-ellipse.html
  • fixtures/test-html/L0/paint-clip-path-inset.html
  • fixtures/test-html/L0/paint-clip-path-polygon.html
  • fixtures/test-html/L0/paint-color-hex-alpha.html
  • fixtures/test-html/L0/paint-display-none.html
  • fixtures/test-html/L0/paint-filter-blur.html
  • fixtures/test-html/L0/paint-filter-chain.html
  • fixtures/test-html/L0/paint-filter-drop-shadow.html
  • fixtures/test-html/L0/paint-filter-simple.html
  • fixtures/test-html/L0/paint-gradient-radial.html
  • fixtures/test-html/L0/paint-individual-transform-props.html
  • fixtures/test-html/L0/paint-margin-auto-center.html
  • fixtures/test-html/L0/paint-margin-simple.html
  • fixtures/test-html/L0/paint-max-min-size.html
  • fixtures/test-html/L0/paint-mix-blend-mode.html
  • fixtures/test-html/L0/paint-opacity-levels.html
  • fixtures/test-html/L0/paint-opacity-nested.html
  • fixtures/test-html/L0/paint-opacity.html
  • fixtures/test-html/L0/paint-outline-double-rect.html
  • fixtures/test-html/L0/paint-outline-offset.html
  • fixtures/test-html/L0/paint-outline-radius.html
  • fixtures/test-html/L0/paint-outline-solid.html
  • fixtures/test-html/L0/paint-overflow-hidden.html
  • fixtures/test-html/L0/paint-padding-simple.html
  • fixtures/test-html/L0/paint-position-absolute-simple.html
  • fixtures/test-html/L0/paint-position-relative.html
  • fixtures/test-html/L0/paint-transform-combined.html
  • fixtures/test-html/L0/paint-transform-matrix.html
  • fixtures/test-html/L0/paint-transform-origin.html
  • fixtures/test-html/L0/paint-transform-rotate.html
  • fixtures/test-html/L0/paint-transform-scale.html
  • fixtures/test-html/L0/paint-transform-skew.html
  • fixtures/test-html/L0/paint-transform-translate.html
  • fixtures/test-html/L0/paint-visibility.html
  • fixtures/test-html/L0/paint-z-index-simple.html
  • fixtures/test-html/README.md
  • fixtures/test-html/_reftest/transparent-body.css
  • fixtures/test-html/suites/L0.coverage.json
  • fixtures/test-html/suites/L0.exact.json
  • packages/grida-reftest/src/cli.ts
  • packages/grida-reftest/src/compare.ts

Comment on lines +567 to +570
- **Dimensions** — cg renders at viewport dims (`width × height`);
Chromium screenshots `fullPage` at the same viewport. Setting an
explicit `viewport.height` is optional and only useful to trim
scoring area for very tall fixtures.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor doc inconsistency: "viewport.height optional" vs earlier "mismatched dims score 0.0".

Line 478-480 above still says "Set height to match cg's cull height; mismatched dims score 0.0 at diff time (@grida/reftest requires identical dimensions)." That's still true, and Chromium's fullPage will capture whatever the document's actual height is — which matches cg's viewport.height default only when the fixture forces its own height (paint preset with min-height: 800px).

For a natural-sized layout fixture, fullPage height ≠ viewport.height and you'll still get a dimension-mismatch/score=0. Consider tightening the wording: "optional for fixtures that force their own height via the paint preset; layout fixtures with natural cull still need an explicit viewport.height matching cg's reported WxH."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.agents/skills/cg-reftest/SKILL.md around lines 567 - 570, Update the
Dimensions section text that currently reads "**Dimensions** — cg renders at
viewport dims (`width × height`); Chromium screenshots `fullPage`..." and the
earlier sentence "Set height to match cg's cull height; mismatched dims score
0.0..." to clarify that viewport.height is only optional for fixtures that force
their own height via the paint preset (e.g., `min-height: 800px`), and that
natural layout fixtures still require an explicit `viewport.height` matching
cg's reported `WxH` to avoid a dimension-mismatch/score 0.0 (keep references to
`fullPage`, `viewport.height`, and `@grida/reftest` in the wording).

Comment thread crates/grida-canvas/src/htmlcss/collect.rs
Comment on lines +1836 to +1850
// Stroke once as an RRect when sides are uniform *and* the style is
// one whose rendering doesn't depend on per-side color adjustments
// (inset / outset / groove / ridge darken/lighten per side). The
// per-side trapezoid path double-paints corners for translucent colors;
// the single-stroke path avoids that.
let uniform_stroke_style = matches!(
b.top.style,
types::BorderStyle::Solid | types::BorderStyle::Double
);
if uniform
&& uniform_stroke_style
&& b.top.width > 0.0
&& b.top.style != types::BorderStyle::None
&& !style.border_radius.is_zero()
&& (b.top.style != types::BorderStyle::None || !style.border_radius.is_zero())
{
paint_uniform_rounded_border(canvas, &b.top, &style.border_radius, w, h);
paint_uniform_rounded_border(canvas, &b.top, &style.border_radius.resolved(w, h), w, h);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep rounded dashed/dotted borders on an RRect path.

The narrowed fast path excludes Dashed/Dotted, so uniform rounded dashed/dotted borders fall back to straight per-side lines and lose border-radius. Prefer the per-side path only when there is no radius, preserving the rounded path for radius cases.

Proposed fix
-    let uniform_stroke_style = matches!(
-        b.top.style,
-        types::BorderStyle::Solid | types::BorderStyle::Double
-    );
+    let has_radius = !style.border_radius.is_zero();
+    let uniform_stroke_style = matches!(
+        b.top.style,
+        types::BorderStyle::Solid | types::BorderStyle::Double
+    ) || (has_radius
+        && matches!(
+            b.top.style,
+            types::BorderStyle::Dashed | types::BorderStyle::Dotted
+        ));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Stroke once as an RRect when sides are uniform *and* the style is
// one whose rendering doesn't depend on per-side color adjustments
// (inset / outset / groove / ridge darken/lighten per side). The
// per-side trapezoid path double-paints corners for translucent colors;
// the single-stroke path avoids that.
let uniform_stroke_style = matches!(
b.top.style,
types::BorderStyle::Solid | types::BorderStyle::Double
);
if uniform
&& uniform_stroke_style
&& b.top.width > 0.0
&& b.top.style != types::BorderStyle::None
&& !style.border_radius.is_zero()
&& (b.top.style != types::BorderStyle::None || !style.border_radius.is_zero())
{
paint_uniform_rounded_border(canvas, &b.top, &style.border_radius, w, h);
paint_uniform_rounded_border(canvas, &b.top, &style.border_radius.resolved(w, h), w, h);
// Stroke once as an RRect when sides are uniform *and* the style is
// one whose rendering doesn't depend on per-side color adjustments
// (inset / outset / groove / ridge darken/lighten per side). The
// per-side trapezoid path double-paints corners for translucent colors;
// the single-stroke path avoids that.
let has_radius = !style.border_radius.is_zero();
let uniform_stroke_style = matches!(
b.top.style,
types::BorderStyle::Solid | types::BorderStyle::Double
) || (has_radius
&& matches!(
b.top.style,
types::BorderStyle::Dashed | types::BorderStyle::Dotted
));
if uniform
&& uniform_stroke_style
&& b.top.width > 0.0
&& (b.top.style != types::BorderStyle::None || !style.border_radius.is_zero())
{
paint_uniform_rounded_border(canvas, &b.top, &style.border_radius.resolved(w, h), w, h);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 1836 - 1850, The
fast-path currently excludes dashed/dotted via uniform_stroke_style causing
uniform rounded dashed/dotted borders to fall back to per-side straight lines
and lose border-radius; update the condition so dashed and dotted styles are
treated as uniform when a non-zero radius exists: include
types::BorderStyle::Dashed and types::BorderStyle::Dotted in the
uniform_stroke_style check (or otherwise allow those styles through) and ensure
the per-side fallback is only chosen when style.border_radius.is_zero(); keep
references to uniform_stroke_style, types::BorderStyle::{Dashed,Dotted},
style.border_radius and paint_uniform_rounded_border when making the change.

Comment on lines +7 to +11
html,
body {
min-height: 800px;
box-sizing: border-box;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: paint-style sizing in a layout-* fixture.

Per the README's paint-vs-layout authoring rules, layout-* fixtures should "never force a body size" and instead carry an explicit viewport in the suite entry. This fixture uses the paint preset (min-height: 800px; box-sizing: border-box;) on html/body and is registered without a per-fixture viewport in L0.coverage.json.

That's fine here because the flex containers have explicit width/height, so the body min-height doesn't contaminate the measurement — but it's worth being explicit about that choice if the intent is to standardize layout fixtures on natural cull height.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/layout-flex-direction-reverse.html` around lines 7 -
11, The fixture sets paint-style sizing on the global selector "html, body"
(min-height: 800px; box-sizing: border-box;) which violates the `layout-*`
guideline to avoid forcing body size; either remove those rules from the fixture
so the layout test uses natural cull height, or instead add an explicit
per-fixture viewport for this fixture in L0.coverage.json to document the
intended 800px height; locate the CSS in layout-flex-direction-reverse.html (the
"html, body" rule) and apply one of these two fixes to make the intent explicit.

Comment on lines +39 to +54
<div class="grid">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
<div class="grid autoflow-column">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Autoflow behavior is not observable in this fixture.

Both grids fully fill a 3×2 matrix with identical cells, so default flow and grid-auto-flow: column produce the same visual result. This can pass even if placement order is wrong.

Suggested fixture adjustment
       .cell {
         background: `#000`;
       }
+      .marker {
+        background: `#c00`;
+      }
@@
     <div class="grid">
       <div class="cell"></div>
-      <div class="cell"></div>
+      <div class="cell marker"></div>
       <div class="cell"></div>
       <div class="cell"></div>
       <div class="cell"></div>
-      <div class="cell"></div>
     </div>
     <div class="grid autoflow-column">
       <div class="cell"></div>
-      <div class="cell"></div>
+      <div class="cell marker"></div>
       <div class="cell"></div>
       <div class="cell"></div>
       <div class="cell"></div>
-      <div class="cell"></div>
     </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="grid">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
<div class="grid autoflow-column">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
.cell {
background: `#000`;
}
.marker {
background: `#c00`;
}
</style>
<div class="grid">
<div class="cell"></div>
<div class="cell marker"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
<div class="grid autoflow-column">
<div class="cell"></div>
<div class="cell marker"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/layout-grid-autoflow.html` around lines 39 - 54, The
fixture currently uses two identical 3×2 grids so grid-auto-flow differences are
not visible; update the second grid (the element with class "grid
autoflow-column") to expose column-flow behavior by changing its child count or
arrangement — for example reduce the number of .cell children (e.g., 4 instead
of 6) or reorder/add cells so column filling (top-to-bottom then left-to-right)
produces a different visual layout than the default flow; keep the first grid
(class "grid") as-is so the contrast is observable.

Comment on lines +21 to +27
.label {
font-size: 11px;
color: #666;
padding-bottom: 4px;
width: 140px;
height: 16px;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep this paint fixture text-free to avoid font-rendering noise.

The visible labels add font rasterization and unrelated alpha-mask coverage to a translucent-border test. Prefer encoding this metadata in class names/comments, or remove the labels so the fixture only scores the border rendering.

🧹 Proposed cleanup
-      .label {
-        font-size: 11px;
-        color: `#666`;
-        padding-bottom: 4px;
-        width: 140px;
-        height: 16px;
-      }
-
       .grid {
         display: flex;
         flex-direction: row;
         flex-wrap: wrap;
         gap: 24px;
@@
     <div class="grid">
-      <div>
-        <div class="label">4px red@0.5</div>
-        <div class="box b-thin"></div>
-      </div>
-      <div>
-        <div class="label">12px blue@0.5</div>
-        <div class="box b-thick"></div>
-      </div>
-      <div>
-        <div class="label">8px green@0.5</div>
-        <div class="box b-green"></div>
-      </div>
+      <div class="box b-thin"></div>
+      <div class="box b-thick"></div>
+      <div class="box b-green"></div>
     </div>

Also applies to: 56-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-border-translucent.html` around lines 21 - 27,
The fixture currently includes visible text via the .label element which
introduces font rasterization and alpha masks that pollute the
translucent-border test; remove any textual content from the HTML labels (or
replace with semantic class names / comments) and eliminate styling that forces
visible text (see the .label class) so the fixture is text-free and only
exercises border rendering; update any instances noted (including the other
similar blocks at lines referenced by the reviewer) to use non-rendered metadata
(class names or comments) instead of visible labels.

Comment on lines +42 to +57
.box {
width: 120px;
height: 120px;
background: #000;
box-sizing: border-box;
}

.r-90 {
transform: rotate(90deg);
}
.r-180 {
transform: rotate(180deg);
}
.r-270 {
transform: rotate(-90deg);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Rotation is not visually observable with a uniform square.

A solid black 120×120 square is rotationally symmetric under 90°/180°/−90°, so all three .frame cells render identically to an un-rotated box. This fixture will pass/fail identically regardless of whether transform: rotate(...) is applied at all, providing no signal for the rotate implementation. Consider using an asymmetric shape/content (e.g., a non-square rectangle, an L-shape via pseudo-elements, or two stacked colored halves) so rotation produces a visibly distinct result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/L0/paint-transform-rotate.html` around lines 42 - 57, The
test uses a uniform 120×120 .box so .r-90, .r-180 and .r-270 produce no visible
change; update the fixture so rotation is observable by making the box
asymmetric (e.g., change .box to a non-square rectangle, add an L-shaped marker
via a ::before/::after pseudo-element, or split the box into two differently
colored halves) so the transforms on .r-90, .r-180 and .r-270 produce distinct
visual differences in the .frame cells.

Comment on lines +40 to +87
{ "path": "../L0/layout-flex-row-basic.html" },
{ "path": "../L0/layout-flex-column-basic.html" },
{ "path": "../L0/paint-background-clip-boxes.html" },
{ "path": "../L0/paint-border-translucent.html" },
{ "path": "../L0/paint-margin-simple.html" },
{ "path": "../L0/paint-padding-simple.html" },
{ "path": "../L0/paint-border-double-rect.html" },
{ "path": "../L0/paint-aspect-ratio.html" },
{ "path": "../L0/paint-max-min-size.html" },
{ "path": "../L0/paint-position-relative.html" },
{ "path": "../L0/layout-flex-align-items.html" },
{ "path": "../L0/paint-margin-auto-center.html" },
{ "path": "../L0/layout-flex-grow.html" },
{ "path": "../L0/paint-color-hex-alpha.html" },
{ "path": "../L0/layout-flex-wrap.html" },
{ "path": "../L0/layout-grid-basic.html" },
{ "path": "../L0/layout-grid-fr.html" },
{ "path": "../L0/layout-grid-span.html" },
{ "path": "../L0/layout-block-flow.html" },
{ "path": "../L0/paint-outline-double-rect.html" },
{ "path": "../L0/paint-border-radius-individual.html" },
{ "path": "../L0/paint-filter-simple.html" },
{ "path": "../L0/paint-mix-blend-mode.html" },
{ "path": "../L0/paint-filter-chain.html" },
{ "path": "../L0/layout-grid-gap-asym.html" },
{ "path": "../L0/paint-transform-combined.html" },
{ "path": "../L0/paint-transform-origin.html" },
{ "path": "../L0/paint-individual-transform-props.html" },
{ "path": "../L0/paint-clip-path-circle.html" },
{ "path": "../L0/paint-clip-path-polygon.html" },
{ "path": "../L0/paint-clip-path-ellipse.html" },
{ "path": "../L0/paint-opacity-nested.html" },
{ "path": "../L0/layout-flex-direction-reverse.html" },
{ "path": "../L0/paint-box-shadow-multiple.html" },
{ "path": "../L0/paint-outline-offset.html" },
{ "path": "../L0/layout-flex-align-self.html" },
{ "path": "../L0/layout-grid-autoflow.html" },
{ "path": "../L0/layout-flex-basis.html" },
{ "path": "../L0/paint-transform-skew.html" },
{ "path": "../L0/paint-gradient-radial.html" },
{ "path": "../L0/paint-outline-radius.html" },
{ "path": "../L0/paint-box-shadow-blur.html" },
{ "path": "../L0/paint-box-shadow-inset-blur.html" },
{ "path": "../L0/paint-border-style-dashed.html" },
{ "path": "../L0/paint-border-style-dotted.html" },
{ "path": "../L0/paint-filter-drop-shadow.html" },
{ "path": "../L0/paint-transform-matrix.html" },
{ "path": "../L0/paint-filter-blur.html" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Many new layout-* fixtures registered without explicit viewport — expected for coverage suite but flag before promoting to exact.

Per the README authoring rules, layout fixtures should carry an explicit viewport: { width, height } matching cg's natural cull; paint fixtures inherit the preset. Several entries here are layout-* (e.g. layout-flex-direction-reverse.html, layout-flex-grow.html, layout-flex-wrap.html, layout-grid-*, layout-flex-basis.html, layout-block-flow.html) but have no per-fixture viewport — they'll inherit the paint-style default 600×800.

For L0.coverage.json this is fine (tracking only). But before any of these are promoted to L0.exact.json, re-measure each fixture's natural cull and attach an explicit viewport per the README, otherwise the score will be sensitive to whatever dimensional padding the default viewport introduces.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/suites/L0.coverage.json` around lines 40 - 87, The
coverage suite file L0.coverage.json contains many layout-* fixtures without
explicit viewport entries (e.g. layout-flex-direction-reverse.html,
layout-flex-grow.html, layout-flex-wrap.html, layout-grid-basic.html,
layout-flex-basis.html, layout-block-flow.html); before promoting any of these
to L0.exact.json, measure each fixture's natural cull (the renderer's intrinsic
width and height) and add a per-fixture "viewport": { "width": <w>, "height":
<h> } object to the corresponding JSON entry in L0.coverage.json (or ensure the
same is present when copying into L0.exact.json) so the layout tests use
explicit dimensions instead of inheriting the 600×800 paint default.

Comment on lines 4 to 7
"gate": {
"threshold": 0,
"aa": false,
"aa": true,
"floor": 1.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify which config namespace is used for AA diff settings.
# Expected: AA should be read from `diff.aa`; if no `gate.aa` reader exists, keep AA under `diff`.

rg -n -C3 'diffRaw|config\?\.diff|includeAA|gate.*aa|aa.*gate' \
  packages/grida-reftest/src/config.ts \
  packages/grida-reftest/src/cli.ts \
  packages/grida-reftest/src/compare.ts

Repository: gridaco/grida

Length of output: 3448


Move the AA setting under diff; gate.aa is not read by any config parser.

Line 6 sets gate.aa, but the config parser (config.ts:83–91) reads only from diff.aa, and the CLI (cli.ts:188) resolves to config?.diff?.aa ?? true. There is no code that consumes gate.aa, making this setting inert.

🔧 Proposed config adjustment
   "gate": {
     "threshold": 0,
-    "aa": true,
     "floor": 1.0
   },
+  "diff": {
+    "aa": true
+  },
   "defaults": {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fixtures/test-html/suites/L0.exact.json` around lines 4 - 7, The fixture sets
gate.aa which is never read; the code expects diff.aa (see config parser
referencing diff.aa and the CLI resolving config?.diff?.aa). Move the "aa": true
entry out of the "gate" object into the "diff" object in the JSON (add "aa":
true under "diff" and remove "gate.aa"), ensuring the config key names match the
parser/CLI usage (diff.aa) so the setting is actually consumed.

- extract_size / extract_max_size route through length_percentage_to_css
  so `calc(…)` on width/height/min-*/max-*/flex-basis no longer falls
  through to Auto (addresses PR #686 review feedback).
- mix-blend-mode save_layer omits bounds, matching the filter branch,
  so outer box-shadows, outlines, and overflowing descendants composite
  before blending instead of being clipped to the element box.
- Hoist has_filter / has_blend_mode to a single computation.

No-op for current L0.exact (64/64 scores byte-identical to baseline);
fixes correctness for fixtures that exercise calc() sizing or out-of-
box paint under mix-blend-mode.
@vercel vercel Bot temporarily deployed to Preview – backgrounds April 23, 2026 14:37 Inactive
@vercel vercel Bot temporarily deployed to Preview – grida April 23, 2026 14:37 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer April 23, 2026 14:37 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog April 23, 2026 14:37 Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/grida-canvas/src/htmlcss/paint.rs (1)

2236-2369: Minor: hoist resolved_r out of shadow loops.

Both outer and inset shadow loops recompute style.border_radius.resolved(w, h) per shadow, but the result is invariant across the iteration. Low-impact, but a trivial refactor.

♻️ Proposed refactor
 fn paint_box_shadow_outer(canvas: &Canvas, style: &StyledElement, w: f32, h: f32) {
+    let resolved_r = style.border_radius.resolved(w, h);
+    let r = &resolved_r;
     for shadow in style.box_shadow.iter().rev() {
         if shadow.inset {
             continue;
         }
         ...
-        let resolved_r = style.border_radius.resolved(w, h);
-        let r = &resolved_r;
         if r.is_zero() {

Apply the same to paint_box_shadow_inset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 2236 - 2369, The
border-radius resolution (style.border_radius.resolved(w, h)) is computed inside
both shadow loops; hoist it once before each loop to avoid redundant work:
compute let resolved_r = style.border_radius.resolved(w, h); (and let r =
&resolved_r;) outside the for shadow in style.box_shadow.iter().rev() loop in
the top-level box-shadow painting block and likewise at the start of fn
paint_box_shadow_inset so the loops reuse the same resolved_r/r values rather
than recomputing per shadow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 2119-2152: The implementation of select_best_dash_gap deviates
from Blink when min_num_dashes == 1 on open paths because the `.max(1.0)` guards
force a finite min_gap; to match Blink, allow the divisor to be zero so min_gap
can become +∞ and the selection logic picks max_gap as Blink does. Change the
computations that currently use `.max(1.0)` (for min_num_dashes and min_num_gaps
/ max_num_gaps on open paths) to remove the `.max(1.0)` clamp for the open-path
branch so min_num_gaps becomes (min_num_dashes - 1.0) and max_num_gaps becomes
(max_num_dashes - 1.0) (leaving the closed_path branches unchanged); keep the
comment about div-by-zero behavior so future readers understand the intentional
+∞ result.

---

Nitpick comments:
In `@crates/grida-canvas/src/htmlcss/paint.rs`:
- Around line 2236-2369: The border-radius resolution
(style.border_radius.resolved(w, h)) is computed inside both shadow loops; hoist
it once before each loop to avoid redundant work: compute let resolved_r =
style.border_radius.resolved(w, h); (and let r = &resolved_r;) outside the for
shadow in style.box_shadow.iter().rev() loop in the top-level box-shadow
painting block and likewise at the start of fn paint_box_shadow_inset so the
loops reuse the same resolved_r/r values rather than recomputing per shadow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7702d6ab-13af-4cff-9c4e-a55dd0647d02

📥 Commits

Reviewing files that changed from the base of the PR and between 1750e28 and 33082ac.

📒 Files selected for processing (2)
  • crates/grida-canvas/src/htmlcss/collect.rs
  • crates/grida-canvas/src/htmlcss/paint.rs

Comment on lines +2119 to +2152
fn select_best_dash_gap(
stroke_length: f32,
dash_length: f32,
gap_length: f32,
closed_path: bool,
) -> f32 {
let available = if closed_path {
stroke_length
} else {
stroke_length + gap_length
};
let min_num_dashes = (available / (dash_length + gap_length)).floor().max(1.0);
let max_num_dashes = min_num_dashes + 1.0;
// `.max(1.0)` guards div-by-zero when `min_num_dashes == 1`
// on an open path. Blink lets the divide produce +∞ and relies
// on the `max_gap <= 0.0` branch below to pick `min_gap` anyway.
let min_num_gaps = if closed_path {
min_num_dashes
} else {
(min_num_dashes - 1.0).max(1.0)
};
let max_num_gaps = if closed_path {
max_num_dashes
} else {
(max_num_dashes - 1.0).max(1.0)
};
let min_gap = (stroke_length - min_num_dashes * dash_length) / min_num_gaps;
let max_gap = (stroke_length - max_num_dashes * dash_length) / max_num_gaps;
if max_gap <= 0.0 || (min_gap - gap_length).abs() < (max_gap - gap_length).abs() {
min_gap
} else {
max_gap
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

select_best_dash_gap diverges from Blink for the min_num_dashes == 1 open-path case.

Blink lets the divisor be 0 so min_gap becomes +∞, which then forces the selection branch to pick max_gap (unless that is also non-positive). The .max(1.0) guard here produces a finite min_gap = stroke_length − dash_length, flipping the absolute-difference comparison and selecting min_gap in some cases where Blink would select max_gap.

Given this path is gated by len > dash * 2.0 for dashed and len >= per_dot for dotted, min_num_dashes >= 1 is guaranteed; the divergence is narrow but real for very short sides. The comment acknowledges it — worth flagging so it isn't forgotten once thin-dotted EnforceDotsAtEndpoints lands.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/htmlcss/paint.rs` around lines 2119 - 2152, The
implementation of select_best_dash_gap deviates from Blink when min_num_dashes
== 1 on open paths because the `.max(1.0)` guards force a finite min_gap; to
match Blink, allow the divisor to be zero so min_gap can become +∞ and the
selection logic picks max_gap as Blink does. Change the computations that
currently use `.max(1.0)` (for min_num_dashes and min_num_gaps / max_num_gaps on
open paths) to remove the `.max(1.0)` clamp for the open-path branch so
min_num_gaps becomes (min_num_dashes - 1.0) and max_num_gaps becomes
(max_num_dashes - 1.0) (leaving the closed_path branches unchanged); keep the
comment about div-by-zero behavior so future readers understand the intentional
+∞ result.

@softmarshmallow softmarshmallow merged commit fdcce57 into main Apr 23, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

canvas cg Core Graphics css enhancement New feature or request skia

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant