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
10 changes: 10 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 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: `f<e.scrollTop?e.scrollTop=f:p>e.scrollTop+e.clientHeight&&(e.scrollTop=p-e.c…)`.
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-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`
Expand All @@ -11,16 +11,16 @@
<!-- BEGIN RESULTS TABLE -->
| 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 |
<!-- 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": 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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion css/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
}
Expand Down
38 changes: 38 additions & 0 deletions css/selector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
11 changes: 11 additions & 0 deletions dom/dom.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ type Node struct {
// to centre (see css/ua.go's quirks-mode table rule).
Quirks bool

// Indeterminate is an <input type=checkbox>'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
// <template shadowrootmode> hoisted out at parse time — see
// attachDeclarativeShadowRoots), or nil for a plain element. When set,
Expand Down
11 changes: 11 additions & 0 deletions js/dom.go
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,17 @@ func (b *binder) defineElement(o *goja.Object, n *dom.Node) {
b.removeAttr(n, "checked")
}
})
// indeterminate: unlike checked, this has NO backing content attribute at
// all (HTML Standard §4.10.5.1.19) — pure script-set runtime state, held
// directly on dom.Node (see its own doc comment) rather than reflected
// through setAttr/removeAttr like every other accessor on this element.
// Entirely missing before this fix. Real corpus usage confirmed on
// github.com's own behaviors.js, which sets `checkbox.indeterminate =
// true` on page load for its "select all" bulk-action tri-state
// checkboxes (`[data-indeterminate]`).
b.accessor(o, "indeterminate",
func() goja.Value { return b.vm.ToValue(n.Indeterminate) },
func(v goja.Value) { n.Indeterminate = v.ToBoolean() })
// HTMLDetailsElement.open — a plain boolean reflection (HTML Standard
// §4.11.1), the same presence-based shape as checked/hidden above — was
// entirely missing, so `details.open = true` from script silently
Expand Down
23 changes: 23 additions & 0 deletions js/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,29 @@ func TestScrollTopLeftReadWhatYouWrote(t *testing.T) {
"afterSet top=42 left=7")
}

// TestIndeterminateAccessor confirms `.indeterminate` — entirely missing
// before this fix — round-trips a script-set value. Unlike checked/disabled,
// it has NO backing content attribute (HTML Standard §4.10.5.1.19): setting
// it must NOT touch getAttribute('indeterminate') at all, only the runtime
// state the accessor (and the ":indeterminate" CSS pseudo-class,
// css/selector_test.go) reads. Real corpus usage confirmed on github.com's
// own behaviors.js, which sets it on page load for its "select all"
// bulk-action tri-state checkboxes.
func TestIndeterminateAccessor(t *testing.T) {
_, logs, _ := runJS(t, page(`
var d = document.getElementById('d');
console.log('default='+d.indeterminate);
d.indeterminate = true;
console.log('afterSet='+d.indeterminate+' attr='+d.getAttribute('indeterminate'));
d.indeterminate = false;
console.log('afterUnset='+d.indeterminate);
`))
mustHave(t, logs,
"default=false",
"afterSet=true attr=null",
"afterUnset=false")
}

// TestAddEventListenerOnce confirms addEventListener's `{once: true}` option
// — entirely ignored before this fix (the third argument was never read at
// all) — per the DOM standard's own "inner invoke" algorithm (§2.9): a once
Expand Down
Loading