diff --git a/FIDELITY.md b/FIDELITY.md index 4bcf795..af68505 100644 --- a/FIDELITY.md +++ b/FIDELITY.md @@ -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 138) — `HTMLInputElement.indeterminate` and the `:indeterminate` CSS pseudo-class were entirely missing — CONFIRMED real corpus usage on github.com's own "select all" bulk-action checkboxes (engine#238) + +Broadened the corpus sweep to a genuinely fresh page (github.com's own homepage and its `behaviors.js`/`element-registry.js`/`environment.js` bundles, not previously in this session's ten-page set), after the existing corpus stopped turning up new evidenced gaps. `.indeterminate=` was the one new real hit: `(0,v.l)("[data-indeterminate]",{constructor:HTMLInputElement,initialize(e){e.indeterminate=!0}})` — GitHub's own tri-state "select all" checkbox pattern. + +- **Read the bibliography first, not assumed symmetric with `checked`**: HTML Standard §4.10.5.1.19 — `indeterminate` has NO backing content attribute at all, unlike `checked`. It is pure script-set runtime state with no HTML markup equivalent. +- **Fixed across three layers, matching that architectural difference**: a new `dom.Node.Indeterminate bool` field (the same shape as the existing `Quirks`/`Shadow` runtime-only fields, not `Attr`) backs both a new `js/dom.go` `.indeterminate` accessor (reads/writes the field directly, NOT via `setAttr`/`removeAttr`) and a new `:indeterminate` CSS pseudo-class in `css/selector.go` (`compound.Indeterminate`, wired into `Matches`/the parser switch/the specificity count/the bare-compound-is-non-empty check — the same four sites `:disabled` already uses). +- **Deliberately left out of scope, disclosed honestly**: this engine's own checkbox painter (`paint.paintCheckboxLike`) already approximates `checked` as a plain accent-coloured fill with no real checkmark glyph — adding a visually distinct indeterminate mark (a real browser's usual dash) would mean inventing new iconography beyond what `checked` itself has, not extending an existing one. This round only makes `.indeterminate`/`:indeterminate` correct for script logic and AUTHOR CSS (`input:indeterminate { ... }` rules), not for this engine's own default checkbox appearance. +- **Two new tests**, git-stash-confirmed (a `go vet` compile failure, the strongest class): `css/selector_test.go`'s `TestIndeterminatePseudo` (mirroring `TestCheckedPseudo`/`TestDisabledEnabledPseudo`'s exact structure) and `js/js_test.go`'s `TestIndeterminateAccessor` (confirms setting it does NOT touch `getAttribute('indeterminate')`, unlike every other boolean accessor on this element). +- **Bench**: exactly flat/within already-documented noise across all ten pages, including github.com/golang/go itself (0.638, unchanged) — expected, since this round's fix is a pure JS/CSS-correctness change with no paint-visible effect by design (see the scoping note above). All coverage floors held. + ## 2026-09-27 (round 137) — `scrollTop`/`scrollLeft` were hardcoded to always read 0 with no setter at all, so a script-set value silently did nothing — CONFIRMED real corpus usage on pkg.go.dev's own scroll-into-view logic, which both sets AND reads the value back to decide what to do next (engine#237) Continued sweeping this session's own two hottest bundles for `.propertyName =` assignments across the FULL corpus this time (not just the two hottest files), broadened past boolean properties to numeric/string ones too. `.scrollTop=` stood out on pkg.go.dev's own `frontend.js`, in three separate call sites of a "keep the active item visible in a scrollable list" routine: `fe.scrollTop+e.clientHeight&&(e.scrollTop=p-e.c…)`. diff --git a/bench/REPORT.md b/bench/REPORT.md index 16257b9..20b8b56 100644 --- a/bench/REPORT.md +++ b/bench/REPORT.md @@ -2,7 +2,7 @@ -**Generated:** 2026-09-27 19:44 UTC +**Generated:** 2026-09-28 06:24 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` @@ -11,16 +11,16 @@ | URL | SSIM | pixdiff % | webengine ms | chrome ms | speed× | region | status | |-----|-----:|----------:|-------------:|----------:|-------:|:------:|:------:| -| example.com/ | 0.954 | 1.5 | 51.2 | 1283.7 | 25.09 | 1024×768 | ok | -| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2711.9 | 3739.4 | 1.38 | 1024×2500 | ok | -| pkg.go.dev/net/http | 0.716 | 11.3 | 5170.6 | 3565.4 | 0.69 | 1024×2500 | ok | -| go.dev/blog/ | 0.690 | 16.7 | 4259.3 | 1917.9 | 0.45 | 1024×1439 | ok | -| react.dev/ | 0.725 | 33.0 | 3248.1 | 2174.9 | 0.67 | 1024×2500 | ok | -| news.ycombinator.com/ | 0.602 | 14.4 | 1258.2 | 2159.4 | 1.72 | 1024×1077 | ok | -| developer.mozilla.org/en-US/docs/Web/CSS | 0.606 | 18.1 | 771.5 | 1469.1 | 1.90 | 1009×2500 | ok | -| github.com/golang/go | 0.638 | 14.2 | 2603.9 | 2124.3 | 0.82 | 1024×2491 | ok | -| tailwindcss.com/ | 0.725 | 12.2 | 7412.4 | 1920.9 | 0.26 | 1024×2500 | ok | -| caniuse.com/ | 0.657 | 18.3 | 4691.5 | 3343.0 | 0.71 | 1024×1740 | ok | +| example.com/ | 0.954 | 1.5 | 52.3 | 1283.0 | 24.52 | 1024×768 | ok | +| en.wikipedia.org/wiki/Go_(programming_language) | 0.436 | 21.3 | 2699.3 | 3880.0 | 1.44 | 1024×2500 | ok | +| pkg.go.dev/net/http | 0.716 | 11.3 | 5461.5 | 3951.6 | 0.72 | 1024×2500 | ok | +| go.dev/blog/ | 0.690 | 16.7 | 4432.9 | 1995.8 | 0.45 | 1024×1439 | ok | +| react.dev/ | 0.725 | 33.0 | 3436.6 | 2266.9 | 0.66 | 1024×2500 | ok | +| news.ycombinator.com/ | 0.603 | 14.4 | 1233.1 | 2098.7 | 1.70 | 1024×1077 | ok | +| developer.mozilla.org/en-US/docs/Web/CSS | 0.606 | 18.1 | 540.9 | 1522.4 | 2.81 | 1009×2500 | ok | +| github.com/golang/go | 0.638 | 14.2 | 2607.3 | 1733.7 | 0.66 | 1024×2491 | ok | +| tailwindcss.com/ | 0.740 | 11.3 | 7475.3 | 1868.8 | 0.25 | 1024×2500 | ok | +| caniuse.com/ | 0.657 | 18.2 | 4899.5 | 3228.2 | 0.66 | 1024×1740 | ok | Speed× is `chrome_ms / webengine_ms`: >1 means webengine is faster. diff --git a/bench/out/caniuse.com.png b/bench/out/caniuse.com.png index 214f62d..f2cabfb 100644 Binary files a/bench/out/caniuse.com.png and b/bench/out/caniuse.com.png differ diff --git a/bench/out/github.com_golang_go.png b/bench/out/github.com_golang_go.png index 8aed090..c317369 100644 Binary files a/bench/out/github.com_golang_go.png and b/bench/out/github.com_golang_go.png differ diff --git a/bench/out/news.ycombinator.com.png b/bench/out/news.ycombinator.com.png index 2286254..f3b2b97 100644 Binary files a/bench/out/news.ycombinator.com.png and b/bench/out/news.ycombinator.com.png differ diff --git a/bench/out/tailwindcss.com.png b/bench/out/tailwindcss.com.png index 7ab16b0..b42f233 100644 Binary files a/bench/out/tailwindcss.com.png and b/bench/out/tailwindcss.com.png differ diff --git a/bench/results.json b/bench/results.json index bc0c77e..1d769d3 100644 --- a/bench/results.json +++ b/bench/results.json @@ -1,9 +1,9 @@ [ { "url": "https://example.com/", - "webengine_ms": 51.159, - "chrome_ms": 1283.69, - "speed_ratio": 25.092163646670187, + "webengine_ms": 52.324, + "chrome_ms": 1282.957, + "speed_ratio": 24.519474810794286, "ssim": 0.9541451179596087, "pixdiff_pct": 1.53350830078125, "region_w": 1024, @@ -14,9 +14,9 @@ }, { "url": "https://en.wikipedia.org/wiki/Go_(programming_language)", - "webengine_ms": 2711.893, - "chrome_ms": 3739.355, - "speed_ratio": 1.3788726177618364, + "webengine_ms": 2699.322, + "chrome_ms": 3879.953, + "speed_ratio": 1.4373805718621193, "ssim": 0.4361758822140753, "pixdiff_pct": 21.279570312500002, "region_w": 1024, @@ -27,9 +27,9 @@ }, { "url": "https://pkg.go.dev/net/http", - "webengine_ms": 5170.566, - "chrome_ms": 3565.42, - "speed_ratio": 0.6895608720592679, + "webengine_ms": 5461.531, + "chrome_ms": 3951.595, + "speed_ratio": 0.7235324673612582, "ssim": 0.7155686319990039, "pixdiff_pct": 11.3370703125, "region_w": 1024, @@ -40,9 +40,9 @@ }, { "url": "https://go.dev/blog/", - "webengine_ms": 4259.27, - "chrome_ms": 1917.945, - "speed_ratio": 0.4502989948981867, + "webengine_ms": 4432.901, + "chrome_ms": 1995.772, + "speed_ratio": 0.4502180400599968, "ssim": 0.6901862268272003, "pixdiff_pct": 16.726839384989574, "region_w": 1024, @@ -53,9 +53,9 @@ }, { "url": "https://react.dev/", - "webengine_ms": 3248.095, - "chrome_ms": 2174.916, - "speed_ratio": 0.6695974101742714, + "webengine_ms": 3436.649, + "chrome_ms": 2266.886, + "speed_ratio": 0.6596210436387306, "ssim": 0.72456508425769, "pixdiff_pct": 32.97015625, "region_w": 1024, @@ -66,11 +66,11 @@ }, { "url": "https://news.ycombinator.com/", - "webengine_ms": 1258.212, - "chrome_ms": 2159.361, - "speed_ratio": 1.7162139607633689, - "ssim": 0.602218626514641, - "pixdiff_pct": 14.379044074976788, + "webengine_ms": 1233.055, + "chrome_ms": 2098.676, + "speed_ratio": 1.7020132921889128, + "ssim": 0.6033293622898777, + "pixdiff_pct": 14.418306058495823, "region_w": 1024, "region_h": 1077, "montage": "out/news.ycombinator.com.png", @@ -79,9 +79,9 @@ }, { "url": "https://developer.mozilla.org/en-US/docs/Web/CSS", - "webengine_ms": 771.542, - "chrome_ms": 1469.097, - "speed_ratio": 1.9041050260387637, + "webengine_ms": 540.939, + "chrome_ms": 1522.391, + "speed_ratio": 2.8143487528168616, "ssim": 0.6056768401997266, "pixdiff_pct": 18.121902874132807, "region_w": 1009, @@ -92,11 +92,11 @@ }, { "url": "https://github.com/golang/go", - "webengine_ms": 2603.943, - "chrome_ms": 2124.291, - "speed_ratio": 0.815797811242412, - "ssim": 0.6384297325703495, - "pixdiff_pct": 14.22907623695303, + "webengine_ms": 2607.312, + "chrome_ms": 1733.675, + "speed_ratio": 0.6649280945280043, + "ssim": 0.63813074167239, + "pixdiff_pct": 14.235309614612603, "region_w": 1024, "region_h": 2491, "montage": "out/github.com_golang_go.png", @@ -105,11 +105,11 @@ }, { "url": "https://tailwindcss.com/", - "webengine_ms": 7412.4, - "chrome_ms": 1920.871, - "speed_ratio": 0.25914292267012035, - "ssim": 0.7254186601435486, - "pixdiff_pct": 12.207265625, + "webengine_ms": 7475.3, + "chrome_ms": 1868.777, + "speed_ratio": 0.24999357885302262, + "ssim": 0.7396458938927728, + "pixdiff_pct": 11.30765625, "region_w": 1024, "region_h": 2500, "montage": "out/tailwindcss.com.png", @@ -118,11 +118,11 @@ }, { "url": "https://caniuse.com/", - "webengine_ms": 4691.492, - "chrome_ms": 3342.994, - "speed_ratio": 0.7125652137955261, - "ssim": 0.657124719073104, - "pixdiff_pct": 18.255208333333332, + "webengine_ms": 4899.46, + "chrome_ms": 3228.213, + "speed_ratio": 0.6588915921346434, + "ssim": 0.6569742927777596, + "pixdiff_pct": 18.19190014367816, "region_w": 1024, "region_h": 1740, "montage": "out/caniuse.com.png", diff --git a/css/selector.go b/css/selector.go index a073d2e..1f5f3f2 100644 --- a/css/selector.go +++ b/css/selector.go @@ -54,6 +54,14 @@ type compound struct { // real-world need — only the element's own attribute is. Disabled bool Enabled bool + // Indeterminate is set by the ":indeterminate" pseudo-class. Unlike + // Checked/Disabled above, there is no content attribute to read at + // static render time — "indeterminate" is a pure script-set runtime + // state (see dom.Node.Indeterminate's own doc comment) — so this simply + // mirrors that field directly, with no "at static render time" caveat: + // a script that sets it before this cascade runs is exactly the real + // case (see round 138's own corpus trigger). + Indeterminate bool // FirstChild is set by the ":first-child" structural pseudo-class — the // element has no preceding element sibling. Unlike ":nth-child(...)" this // is cheap and common enough (and, critically, common as a ":not()" @@ -372,6 +380,9 @@ func (c compound) matches(n *dom.Node) bool { if c.Enabled && isDisabled(n) { return false } + if c.Indeterminate && !n.Indeterminate { + return false + } if c.FirstChild && prevElementSibling(n) != nil { return false } @@ -519,6 +530,9 @@ func (c compound) specificity() (idCount, classCount, tagCount int) { if c.Enabled { classCount++ // ":enabled" is a pseudo-class (class-level weight) } + if c.Indeterminate { + classCount++ // ":indeterminate" is a pseudo-class (class-level weight) + } if c.Host { classCount++ // ":host" is itself a pseudo-class (class-level weight) } @@ -1263,6 +1277,8 @@ func parseSimple(s string) (compound, bool) { c.Disabled = true case "enabled": c.Enabled = true + case "indeterminate": + c.Indeterminate = true case "first-child": c.FirstChild = true case "last-child": @@ -1394,7 +1410,7 @@ func parseSimple(s string) (compound, bool) { // ":checked"/":first-child"/":not(...)"/attribute/":host" selectors carry a // real constraint on their own. if c.Tag == "" && c.ID == "" && len(c.Classes) == 0 && - !c.Root && !c.Dynamic && !c.Checked && !c.Disabled && !c.Enabled && !c.FirstChild && !c.LastChild && !c.Empty && !c.Host && c.Part == "" && len(c.Not) == 0 && len(c.Attrs) == 0 && !c.HasPresent && !c.NthChildSet && + !c.Root && !c.Dynamic && !c.Checked && !c.Disabled && !c.Enabled && !c.Indeterminate && !c.FirstChild && !c.LastChild && !c.Empty && !c.Host && c.Part == "" && len(c.Not) == 0 && len(c.Attrs) == 0 && !c.HasPresent && !c.NthChildSet && !c.OnlyChild && !c.FirstOfType && !c.LastOfType && !c.OnlyOfType && !c.NthOfTypeSet && !c.NthLastChildSet && !c.NthLastOfTypeSet { return compound{}, false } diff --git a/css/selector_test.go b/css/selector_test.go index 67b7d3a..af23b6a 100644 --- a/css/selector_test.go +++ b/css/selector_test.go @@ -260,6 +260,44 @@ func TestDisabledEnabledPseudo(t *testing.T) { } } +// TestIndeterminatePseudo covers ":indeterminate" — entirely missing before +// this fix, unlike ":checked"/":disabled" above it has NO backing content +// attribute at all (dom.Node.Indeterminate is pure script-set runtime state, +// see its own doc comment), so a real checkbox is matched by setting the +// Node field directly rather than via an Attr map, mirroring how a script +// would set `.indeterminate = true` at runtime. Real corpus trigger: +// github.com's own behaviors.js sets this on its "select all" bulk-action +// checkboxes. +func TestIndeterminatePseudo(t *testing.T) { + on := &dom.Node{Type: dom.Element, Tag: "input", Attr: map[string]string{"type": "checkbox"}, Indeterminate: true} + off := &dom.Node{Type: dom.Element, Tag: "input", Attr: map[string]string{"type": "checkbox"}} + + sel, ok := parseComplex("input:indeterminate") + if !ok { + t.Fatal("input:indeterminate should parse") + } + if !sel.Matches(on) { + t.Error(":indeterminate should match a checkbox with Indeterminate set") + } + if sel.Matches(off) { + t.Error(":indeterminate should NOT match a plain checkbox") + } + + // A bare ":indeterminate" is a valid, real constraint on its own. + bare, ok := parseComplex(":indeterminate") + if !ok || bare.parts[0].Indeterminate != true { + t.Fatalf("bare :indeterminate = %+v ok=%v", bare, ok) + } + if !bare.Matches(on) || bare.Matches(off) { + t.Error("bare :indeterminate match wrong") + } + + // ":indeterminate" contributes class-level specificity, same as ":checked". + if got := bare.Specificity(); got != 100 { + t.Errorf(":indeterminate specificity = %d, want 100", got) + } +} + func TestNotPseudo(t *testing.T) { box := el("div", "", "box") other := el("div", "", "other") diff --git a/dom/dom.go b/dom/dom.go index 94613d6..3e8fa5b 100644 --- a/dom/dom.go +++ b/dom/dom.go @@ -63,6 +63,17 @@ type Node struct { // to centre (see css/ua.go's quirks-mode table rule). Quirks bool + // Indeterminate is an 's own "indeterminate" IDL + // state (HTML Standard §4.10.5.1.19): unlike Checked, it has NO backing + // content attribute at all — script is the only way to set it, so it + // lives here as a plain runtime field, the same shape as Quirks/Shadow + // above, rather than in Attr. Confirmed real usage: github.com's own + // behaviors.js sets `checkbox.indeterminate = true` on page load for any + // `[data-indeterminate]`-marked checkbox (its "select all" bulk-action + // tri-state pattern). Backs the JS `.indeterminate` accessor and the + // `:indeterminate` CSS pseudo-class (css/selector.go). + Indeterminate bool + // Shadow is the shadow root attached to this element (a declarative //