Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions FIDELITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ The committed PNGs under `testdata/renders/` back every claim here. Reproduce
them with the commands at the bottom. The measured-vs-Chrome numbers live in
[`bench/REPORT.md`](bench/REPORT.md).

## 2026-09-28 (round 146) — a checkbox/radio's own cascaded style was structurally unreachable from its paint function at all, so `appearance:none` custom-toggle patterns always rendered as this engine's generic square regardless of the author's own CSS (engine#246)

Ruled out `transform: scale()`/the standalone `scale` property first (this round's own initial candidate, given `github.com/go-images/images`' own `resample` package is already a dependency): no reachable real-world trigger anywhere in the ten-page bench corpus — tailwindcss.com declares `scale-*` utilities in its compiled CSS but never applies any `scale-*` class in its actual HTML, and react.dev's only usage (`active:scale-95`) is gated behind the `:active` pseudo-class, invisible in this engine's static, non-interactive renders. Delegated a fresh systematic CSS-property audit in its place, which surfaced a structural gap rather than a missing property: `paint/paint.go`'s `paintFormControl` dispatches checkbox/radio controls to a SEPARATE function, `paintCheckboxLike`, whose signature had no `*css.Style` parameter at all — unlike the non-checkbox path two lines below it in the same function, which already reads `it.Style.Background`/`it.Style.Border` (round 65's own fix, engine#154). Independently verified before writing any code: read `paintCheckboxLike`'s own body directly (confirmed it draws a hardcoded `formFieldBg`/`formAccent`/`formBorder` square, branching only on the `checked` attribute) and fetched developer.mozilla.org's own live HTML directly (not trusted from the audit report) to confirm its `<mdn-switch>` custom element's declarative-shadow-DOM CSS sets `appearance:none;background-color:...;background-image:radial-gradient(...);border-radius:9999px` on a real `<input type=checkbox class="switch__input">` UNCONDITIONALLY — the base pill shape is not gated behind `:checked` at all, only the knob's position/colour REFINE on `:checked` via an ordinary rule this engine's cascade already re-evaluates automatically.

- **Fixed**: `css/value.go`/`css/parse.go` — new non-inherited `AppearanceNone bool` field, backing `appearance`/`-webkit-appearance: none` (the only value modelled; `auto`/`initial`/`unset` reset it, any other CSS Basic UI keyword like `textfield`/`menulist-button` is syntactically valid but has no confirmed trigger and is left untouched, matching every other unsupported-value convention in this codebase). `paint/paint.go` — `paintCheckboxLike` now takes `dst`, the element's `*css.Style` and `bgImgs`; when `AppearanceNone` is set it paints like any other styled box (solid background, `paintBackgroundLayers` for gradient/bitmap layers — reused verbatim from a real `layout.Box`'s own background path via a small signature generalisation from `*layout.Box` to `(st *css.Style, bx image.Rectangle)`, its only two actual uses — `styleRadius` for `border-radius`, and `paintEdges` for the per-side border) instead of the hardcoded square; without it, nothing changed at all.
- **A pre-existing UA-stylesheet comment predicted this exact fix and needed correcting, not just the code**: `css/ua.go`'s `input[type=checkbox], input[type=radio] { background-color: transparent; border: 0 }` rule already carried a comment stating "paint's own checkbox-square renderer never consults Style.Background/Border for them" — true before this round, false now for the `appearance:none` branch. Updated the comment rather than leaving a confidently-wrong claim next to the very code it was describing; the transparent/0 default itself needed no change; it is exactly a real browser's own reset for a native-chrome-suppressed checkbox with no author styling (invisible until the author's own rule paints something), not a leftover value that happened to become newly load-bearing.
- **8 new tests**: `css/props_test.go`'s `TestApplyAppearanceProperty` (none, auto/initial/unset reset, the `-webkit-` alias, an unsupported keyword left untouched, explicit `inherit`) and six new cases in `paint/formcontrol_test.go` — a regression guard proving a checkbox WITHOUT `appearance:none` still ignores author style entirely (the dispatch condition itself, not just the generic look), then `appearance:none` honouring background colour, a linear-gradient background-image (reusing the existing `linearGradStyle` test helper), a custom border (plus the bare/no-author-style case painting literally nothing at the edge), and `border-radius:50%` producing an actual rounded/pill shape (corner pixel stays unpainted, centre fills). Git-stash-confirmed: reverting the four production files alone fails to even compile in both `css` and `paint` (`unknown field AppearanceNone`). Coverage floors held on the first attempt: `css` 99.5%, `paint` 100.0% (unchanged from before this round).
- **Verified directly on the real live page's own exact markup, not just unit tests**: developer.mozilla.org's `<mdn-switch>` dropdown is behind a closed, JS-driven language-switcher menu invisible in this engine's static render (the same "real cascade trigger, interaction-gated visibility" situation as round 142's `caniuse.com` `<details>` case) — so built an isolated repro reproducing its EXACT real shadow-DOM markup/CSS byte-for-byte (declarative shadow DOM, already supported) instead of trusting the unit tests alone. Rendered it standalone: an unchecked switch now shows a grey pill with a white knob on the left, a checked one a blue pill with the knob slid right — both the background/background-image/border-radius fix AND the `:checked` colour/position swap (no special-casing needed; it flows through the ordinary cascade) confirmed on the real component's own markup. A plain, unstyled `<input type=checkbox>` (the common case) re-rendered identically to before this round — the generic square, unaffected.
- **Bench: six of the ten pages' own webengine-side renders are byte-identical to round 145's committed ones; the other four moved, and each is individually attributed to already-documented, unrelated live-content churn — none reach a checkbox at all.** `github.com/golang/go`'s tiny diff is its own commit counter ticking 572→573 (the same live-repository-activity noise documented every round this page has shown any movement). `news.ycombinator.com`'s large diff is its own already-extensively-documented rotating front page — screenshotted both panels and confirmed an entirely different thirty-story list, points, and comment counts. `tailwindcss.com`'s diff is its own already-documented JS-driven sponsor-logo carousel (base ui/KIRO/namespace/... → Resend/greptile/momentic/...), not the (still off-screen, behind a closed dropdown) `<mdn-switch>`-style trigger this round targets. `caniuse.com`'s own webengine panel is BYTE-IDENTICAL (`diff.getbbox()` returns `None`) — its 0.001 SSIM tick is 100% the live Chrome reference image changing between fetches, nothing on this engine's own side. None of the ten corpus pages' static renders contain an `appearance:none` checkbox reachable outside a closed interactive menu, so this fix is real and demonstrated (the isolated `<mdn-switch>` repro above) but bench-invisible, the same "confirmed, narrowly-scoped, bench-invisible" pattern as pkg.go.dev's multicol fix (round 144) and tailwindcss.com's own P3-colours rotation (round 145).

## 2026-09-28 (round 145) — `transform:rotate()` AND the newer standalone `rotate` property were both entirely unimplemented; the real confirmed trigger turned out to use the property this session's own existing doc comment never even considered (engine#245)

Continuing the bibliography-first, re-scope-a-deferred-gap method rounds 141/144 both used successfully: FIDELITY.md's own Known gaps already flagged `rotate`/`scale`/`skew`/`matrix` as unsupported `transform` functions, alongside the ALREADY-implemented `translate` (round 87). Read the actual CSS Transforms mechanics (via MDN) before assuming scope, and found `rotate` alone (no scale/skew/matrix mixed in) plausibly tractable: unlike a full affine transform, this engine's existing `paint.go` group-buffer machinery (render a box's subtree to an offscreen buffer, transform it, composite back — already used for `filter`/opacity/`mask-image`) is architecturally the exact shape a "render, rotate the pixels, composite" implementation needs, and the pinned `github.com/go-images/images` dependency already ships a ready-made, arbitrary-angle, bilinear, resize-to-fit `Rotate` function (reproducing scikit-image's own `rotate`) — no rotation math needed from scratch.
Expand Down Expand Up @@ -5295,6 +5305,17 @@ columns.
pixel-perfect UA chrome (close enough to read as intentional, not exact),
and no actual popup/listbox surface for `<select>` (this engine has no
click-driven interactive UI beyond a settle pass at all).
- **A checkbox/radio with `appearance:none` now honours its own cascaded
background/background-image/border/border-radius instead of always taking
this engine's generic square (2026-09-28, round 146, engine#246)** —
confirmed load-bearing live: developer.mozilla.org's own `<mdn-switch>`
toggle-switch component. Without `appearance:none` (the common case — an
ordinary, unstyled checkbox) nothing changed: the generic square, with no
real checkmark glyph and no distinction between checkbox/radio shape, is
still what paints (round 138's own disclosed limit). Only the bare
`none`/`auto` keywords are modelled for `appearance` itself — the CSS Basic
UI vendor-specific keywords (`textfield`, `menulist-button`, `searchfield`,
...) have no confirmed trigger and are left unsupported.
- **The settle loop's empty-render guard (`reskin()`, engine#84) cannot
restyle a node that a rejected script pass REMOVED from the DOM — and this
is a BIGGER-BLAST-RADIUS gap than earlier rounds' own write-up suggested.**
Expand Down
22 changes: 11 additions & 11 deletions bench/REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<!-- AUTO-GENERATED: this header, the results table and the montage list are regenerated by `go run ./cmd/compare`. The hand-written analysis below the BEGIN ANALYSIS marker is preserved across re-runs. -->

**Generated:** 2026-09-28 18:27 UTC
**Generated:** 2026-09-28 20:30 UTC
**Viewport:** 1024×768, device-scale 1
**Timing:** median of N=5 runs after 1 warmup, wall-clock incl. network fetch
**Chrome:** `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`
Expand All @@ -11,16 +11,16 @@
<!-- BEGIN RESULTS TABLE -->
| URL | SSIM | pixdiff % | webengine ms | chrome ms | speed× | region | status |
|-----|-----:|----------:|-------------:|----------:|-------:|:------:|:------:|
| example.com/ | 0.936 | 3.0 | 87.8 | 1294.5 | 14.74 | 1024×768 | ok |
| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2837.5 | 3748.3 | 1.32 | 1024×2500 | ok |
| pkg.go.dev/net/http | 0.716 | 11.3 | 5460.0 | 3663.2 | 0.67 | 1024×2500 | ok |
| go.dev/blog/ | 0.689 | 16.8 | 4393.1 | 1967.8 | 0.45 | 1024×1439 | ok |
| react.dev/ | 0.725 | 33.0 | 2316.0 | 2104.0 | 0.91 | 1024×2500 | ok |
| news.ycombinator.com/ | 0.613 | 14.0 | 1290.9 | 2028.9 | 1.57 | 1024×1077 | ok |
| developer.mozilla.org/en-US/docs/Web/CSS | 0.606 | 18.1 | 554.0 | 1461.1 | 2.64 | 1009×2500 | ok |
| github.com/golang/go | 0.638 | 14.2 | 2768.5 | 1729.5 | 0.62 | 1024×2491 | ok |
| tailwindcss.com/ | 0.727 | 12.1 | 7672.3 | 1848.6 | 0.24 | 1024×2500 | ok |
| caniuse.com/ | 0.657 | 18.3 | 4750.9 | 3291.9 | 0.69 | 1024×1740 | ok |
| example.com/ | 0.936 | 3.0 | 93.2 | 1308.8 | 14.04 | 1024×768 | ok |
| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2685.0 | 3727.8 | 1.39 | 1024×2500 | ok |
| pkg.go.dev/net/http | 0.716 | 11.3 | 5522.9 | 3651.9 | 0.66 | 1024×2500 | ok |
| go.dev/blog/ | 0.689 | 16.8 | 4446.5 | 2495.7 | 0.56 | 1024×1439 | ok |
| react.dev/ | 0.725 | 33.0 | 2302.6 | 2064.3 | 0.90 | 1024×2500 | ok |
| news.ycombinator.com/ | 0.617 | 13.9 | 1286.2 | 2057.9 | 1.60 | 1024×1077 | ok |
| developer.mozilla.org/en-US/docs/Web/CSS | 0.606 | 18.1 | 533.8 | 1455.2 | 2.73 | 1009×2500 | ok |
| github.com/golang/go | 0.638 | 14.2 | 2286.0 | 1792.3 | 0.78 | 1024×2491 | ok |
| tailwindcss.com/ | 0.740 | 11.3 | 7795.4 | 1777.7 | 0.23 | 1024×2500 | ok |
| caniuse.com/ | 0.658 | 18.2 | 4709.1 | 3496.5 | 0.74 | 1024×1740 | ok |
<!-- END RESULTS TABLE -->

Speed× is `chrome_ms / webengine_ms`: >1 means webengine is faster.
Expand Down
Binary file modified bench/out/caniuse.com.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified bench/out/github.com_golang_go.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified bench/out/news.ycombinator.com.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified bench/out/tailwindcss.com.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 38 additions & 38 deletions bench/results.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[
{
"url": "https://example.com/",
"webengine_ms": 87.848,
"chrome_ms": 1294.461,
"speed_ratio": 14.735235861943357,
"webengine_ms": 93.19,
"chrome_ms": 1308.763,
"speed_ratio": 14.044028329219874,
"ssim": 0.936338704059682,
"pixdiff_pct": 2.965037027994792,
"region_w": 1024,
Expand All @@ -14,9 +14,9 @@
},
{
"url": "https://en.wikipedia.org/wiki/Go_(programming_language)",
"webengine_ms": 2837.538,
"chrome_ms": 3748.313,
"speed_ratio": 1.3209736750662018,
"webengine_ms": 2685.035,
"chrome_ms": 3727.761,
"speed_ratio": 1.3883472654918838,
"ssim": 0.4362092534605521,
"pixdiff_pct": 21.2776171875,
"region_w": 1024,
Expand All @@ -27,9 +27,9 @@
},
{
"url": "https://pkg.go.dev/net/http",
"webengine_ms": 5460.049,
"chrome_ms": 3663.194,
"speed_ratio": 0.6709086310397581,
"webengine_ms": 5522.925,
"chrome_ms": 3651.865,
"speed_ratio": 0.6612193719813323,
"ssim": 0.7155686319990039,
"pixdiff_pct": 11.3370703125,
"region_w": 1024,
Expand All @@ -40,9 +40,9 @@
},
{
"url": "https://go.dev/blog/",
"webengine_ms": 4393.122,
"chrome_ms": 1967.827,
"speed_ratio": 0.4479336107670126,
"webengine_ms": 4446.548,
"chrome_ms": 2495.719,
"speed_ratio": 0.5612711253763594,
"ssim": 0.688936244874563,
"pixdiff_pct": 16.781062695448227,
"region_w": 1024,
Expand All @@ -53,9 +53,9 @@
},
{
"url": "https://react.dev/",
"webengine_ms": 2316.042,
"chrome_ms": 2103.956,
"speed_ratio": 0.9084273946672816,
"webengine_ms": 2302.562,
"chrome_ms": 2064.344,
"speed_ratio": 0.8965421995151488,
"ssim": 0.72456508425769,
"pixdiff_pct": 32.97015625,
"region_w": 1024,
Expand All @@ -66,11 +66,11 @@
},
{
"url": "https://news.ycombinator.com/",
"webengine_ms": 1290.928,
"chrome_ms": 2028.876,
"speed_ratio": 1.5716414858148555,
"ssim": 0.6128652701532896,
"pixdiff_pct": 13.972913765088208,
"webengine_ms": 1286.164,
"chrome_ms": 2057.917,
"speed_ratio": 1.6000424518179641,
"ssim": 0.6167584152511546,
"pixdiff_pct": 13.89085349930362,
"region_w": 1024,
"region_h": 1077,
"montage": "out/news.ycombinator.com.png",
Expand All @@ -79,9 +79,9 @@
},
{
"url": "https://developer.mozilla.org/en-US/docs/Web/CSS",
"webengine_ms": 553.981,
"chrome_ms": 1461.122,
"speed_ratio": 2.637494787727377,
"webengine_ms": 533.761,
"chrome_ms": 1455.173,
"speed_ratio": 2.726263252654278,
"ssim": 0.6056768401997266,
"pixdiff_pct": 18.121902874132807,
"region_w": 1009,
Expand All @@ -92,11 +92,11 @@
},
{
"url": "https://github.com/golang/go",
"webengine_ms": 2768.454,
"chrome_ms": 1729.468,
"speed_ratio": 0.6247053409592501,
"ssim": 0.6380718891863798,
"pixdiff_pct": 14.236289705439583,
"webengine_ms": 2286.032,
"chrome_ms": 1792.326,
"speed_ratio": 0.784033644323439,
"ssim": 0.6379022409486612,
"pixdiff_pct": 14.241699806804498,
"region_w": 1024,
"region_h": 2491,
"montage": "out/github.com_golang_go.png",
Expand All @@ -105,11 +105,11 @@
},
{
"url": "https://tailwindcss.com/",
"webengine_ms": 7672.34,
"chrome_ms": 1848.604,
"speed_ratio": 0.2409439623374355,
"ssim": 0.7270546806249579,
"pixdiff_pct": 12.114609375,
"webengine_ms": 7795.445,
"chrome_ms": 1777.714,
"speed_ratio": 0.2280452238454636,
"ssim": 0.740217576113234,
"pixdiff_pct": 11.292421875,
"region_w": 1024,
"region_h": 2500,
"montage": "out/tailwindcss.com.png",
Expand All @@ -118,11 +118,11 @@
},
{
"url": "https://caniuse.com/",
"webengine_ms": 4750.922,
"chrome_ms": 3291.918,
"speed_ratio": 0.692900872714812,
"ssim": 0.6571057315033443,
"pixdiff_pct": 18.266938308189655,
"webengine_ms": 4709.09,
"chrome_ms": 3496.54,
"speed_ratio": 0.74250863754993,
"ssim": 0.6581748370161463,
"pixdiff_pct": 18.162827765804597,
"region_w": 1024,
"region_h": 1740,
"montage": "out/caniuse.com.png",
Expand Down
16 changes: 16 additions & 0 deletions css/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,20 @@ func (s *Style) apply(d Declaration, emRef float64, parent *Style) {
s.RotateDeg = deg
}
}
case "appearance", "-webkit-appearance":
// Only "none" (suppress native chrome) is modelled — see
// AppearanceNone's own doc comment. "auto"/"initial"/"unset" reset to
// the initial value. Every other CSS Basic UI keyword (textfield,
// menulist-button, button, searchfield, ...) is syntactically valid
// but has no confirmed trigger and asks for nothing this engine paints
// differently from auto — left untouched rather than guessed at,
// same as an unsupported `transform` function is left untouched.
switch lv {
case "none":
s.AppearanceNone = true
case "auto", "initial", "unset":
s.AppearanceNone = false
}
case "flex-direction":
switch lv {
case "row", "row-reverse":
Expand Down Expand Up @@ -1448,6 +1462,8 @@ func (s *Style) inheritProperty(prop string, parent *Style) {
s.ColumnCount, s.ColumnWidth = parent.ColumnCount, parent.ColumnWidth
case "rotate":
s.RotateDeg = parent.RotateDeg
case "appearance", "-webkit-appearance":
s.AppearanceNone = parent.AppearanceNone
}
}

Expand Down
47 changes: 47 additions & 0 deletions css/props_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,53 @@ func TestApplyStandaloneRotateProperty(t *testing.T) {
}
}

// TestApplyAppearanceProperty covers `appearance`/`-webkit-appearance`'s only
// modelled value: `none` (see AppearanceNone's own doc comment for the
// confirmed developer.mozilla.org <mdn-switch> trigger this exists for).
func TestApplyAppearanceProperty(t *testing.T) {
s := &Style{}
applyOn(s, "appearance", "none", 16)
if !s.AppearanceNone {
t.Errorf("appearance:none => AppearanceNone=%v, want true", s.AppearanceNone)
}

// auto/initial/unset all reset to false (the initial value) — this
// property is not inherited.
for _, v := range []string{"auto", "initial", "unset"} {
s = &Style{AppearanceNone: true}
applyOn(s, "appearance", v, 16)
if s.AppearanceNone {
t.Errorf("appearance:%s => AppearanceNone=%v, want reset to false", v, s.AppearanceNone)
}
}

// The vendor-prefixed alias sets the same field.
s = &Style{}
applyOn(s, "-webkit-appearance", "none", 16)
if !s.AppearanceNone {
t.Errorf("-webkit-appearance:none => AppearanceNone=%v, want true", s.AppearanceNone)
}

// Any other CSS Basic UI keyword (textfield, menulist-button, ...) has no
// confirmed trigger and is left untouched, matching every other
// property's own "invalid/unsupported value dropped by the cascade"
// convention rather than being guessed at as either none or auto.
s = &Style{AppearanceNone: true}
applyOn(s, "appearance", "menulist-button", 16)
if !s.AppearanceNone {
t.Errorf("appearance:menulist-button (unsupported) => AppearanceNone=%v, want unchanged true", s.AppearanceNone)
}

// Not inherited by default, but the explicit inherit keyword still
// copies the parent's computed value.
parent := Style{AppearanceNone: true}
s = &Style{}
s.apply(Declaration{Property: "appearance", Value: "inherit"}, 16, &parent)
if !s.AppearanceNone {
t.Errorf("appearance:inherit => AppearanceNone=%v, want parent's true", s.AppearanceNone)
}
}

// TestDisplayContentsKeyword: `display: contents` parses to DisplayContents
// (the element generates no box of its own — see the Display doc comment).
func TestDisplayContentsKeyword(t *testing.T) {
Expand Down
Loading
Loading