From bbb965c58b2a700cdbb1db78a4f1a4bab8c23f05 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 28 Sep 2026 22:33:42 +0200 Subject: [PATCH] css+paint: honour appearance:none on checkbox/radio controls paintCheckboxLike had no *css.Style parameter at all, so a checkbox with appearance:none (the standard custom-toggle-switch pattern, confirmed live on developer.mozilla.org's own component) always painted this engine's generic hardcoded square instead of the author's own cascaded background/background-image/border/border-radius. Adds css.Style.AppearanceNone (backing appearance/-webkit-appearance: none) and, when set, routes the checkbox through the same background-layer/ border-radius/border painting a real element's box already uses instead of the generic square. Without appearance:none, nothing changes. Co-Authored-By: Claude Sonnet 5 --- FIDELITY.md | 21 ++++++ bench/REPORT.md | 22 +++--- bench/out/caniuse.com.png | Bin 971146 -> 970409 bytes bench/out/github.com_golang_go.png | Bin 1704570 -> 1705550 bytes bench/out/news.ycombinator.com.png | Bin 833919 -> 833873 bytes bench/out/tailwindcss.com.png | Bin 1035722 -> 1045520 bytes bench/results.json | 76 ++++++++++---------- css/parse.go | 16 +++++ css/props_test.go | 47 +++++++++++++ css/ua.go | 17 +++-- css/value.go | 18 +++++ paint/formcontrol_test.go | 108 +++++++++++++++++++++++++++++ paint/paint.go | 59 ++++++++++++---- 13 files changed, 315 insertions(+), 69 deletions(-) diff --git a/FIDELITY.md b/FIDELITY.md index bd1edcd..95975bc 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 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 `` custom element's declarative-shadow-DOM CSS sets `appearance:none;background-color:...;background-image:radial-gradient(...);border-radius:9999px` on a real `` 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 `` 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` `
` 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 `` (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) ``-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 `` 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. @@ -5295,6 +5305,17 @@ columns. pixel-perfect UA chrome (close enough to read as intentional, not exact), and no actual popup/listbox surface for ` gets // (attribute selectors outrank the bare-tag rule they override, // same specificity ordering as any real stylesheet). Checkbox/ - // radio get no generic background/border at all — paint's own - // checkbox-square renderer never consults Style.Background/Border - // for them, so a leftover white field-coloured value would only - // ever be a trap for some future consumer (e.g. a JS - // getComputedStyle) reading a colour that is never actually drawn. + // radio get no generic background/border at all: paint's own + // checkbox-square renderer (paintCheckboxLike) only ever consults + // Style.Background/Border/BackgroundImages when the author also + // sets `appearance:none` (see AppearanceNone's own doc comment), + // in which case this transparent/0 default is exactly a real + // browser's own reset for a native-chrome-suppressed checkbox — + // invisible until the author's OWN rule (like this one) paints + // something, never a leftover white field-coloured square. In the + // far more common case of no appearance:none at all, this value + // is still never drawn (the generic square path ignores Style + // entirely), so it remains only a trap-avoider for some future + // consumer (e.g. a JS getComputedStyle) reading it. "input[type=button], input[type=submit], input[type=reset] { background-color: #efefef }\n" + "input[type=checkbox], input[type=radio] { background-color: transparent; border: 0 }\n") diff --git a/css/value.go b/css/value.go index 3deb8be..e9c3ec5 100644 --- a/css/value.go +++ b/css/value.go @@ -901,6 +901,24 @@ type Style struct { TranslateY Length RotateDeg float64 + // AppearanceNone is `appearance: none` (or `-webkit-appearance: none`), + // which suppresses a form control's native OS chrome so the author's own + // background/background-image/border/border-radius paint instead. Not + // inherited; the zero value (false) is the initial `auto`, matching every + // other form control this engine already renders with its own hardcoded + // UA-default look (see paintFormControl/paintCheckboxLike). Confirmed + // load-bearing live: developer.mozilla.org's own `` custom + // toggle-switch component styles a real `` with + // exactly `appearance:none;background-color:...;background-image:radial- + // gradient(...);border-radius:9999px` UNCONDITIONALLY (not gated behind + // `:checked` — only the knob's position/colour REFINES on `:checked`), so + // a checkbox that sets this must stop taking this engine's own generic + // checkbox square and instead paint like any other styled box. Only the + // bare `none`/`auto` keywords are modelled; the CSS Basic UI vendor- + // specific appearance keywords (`textfield`, `menulist-button`, etc.) have + // no confirmed trigger and are left unsupported (equivalent to `auto`). + AppearanceNone bool + // Flex container properties (meaningful when Display == DisplayFlex). FlexDirection FlexDirection FlexWrap FlexWrap diff --git a/paint/formcontrol_test.go b/paint/formcontrol_test.go index 3d18509..e40b782 100644 --- a/paint/formcontrol_test.go +++ b/paint/formcontrol_test.go @@ -187,6 +187,114 @@ func TestPaintCheckboxCheckedVsUnchecked(t *testing.T) { } } +// TestPaintCheckboxIgnoresAuthorStyleWithoutAppearanceNone is a regression +// guard for the dispatch condition itself: WITHOUT `appearance:none`, a +// checkbox's own cascaded Background/Border must still be completely +// ignored in favour of the generic square, exactly as before this round — +// only AppearanceNone opts a checkbox out of that generic look. A custom +// magenta background here would otherwise leak through if the dispatch +// check were ever accidentally dropped or inverted. +func TestPaintCheckboxIgnoresAuthorStyleWithoutAppearanceNone(t *testing.T) { + n := elem("input", map[string]string{"type": "checkbox"}) + style := &css.Style{Background: css.Color{R: 255, B: 255, A: 255}} // magenta, AppearanceNone: false + dst := paintControlStyled(t, n, 13, 13, style) + c := dst.RGBAAt(9, 9) + if got, want := (css.Color{R: c.R, G: c.G, B: c.B, A: 255}), formFieldBg; got != want { + t.Errorf("interior = %+v, want the generic square's %+v (author background must be ignored)", got, want) + } +} + +// TestPaintCheckboxAppearanceNoneHonoursBackground covers the confirmed real +// trigger's central mechanism (developer.mozilla.org's , see +// css.Style.AppearanceNone's own doc comment): `appearance:none` on a +// checkbox switches it from this engine's generic square to a plain styled +// box that paints the author's own cascaded background colour, matching +// paintFormControl's own non-checkbox path. +func TestPaintCheckboxAppearanceNoneHonoursBackground(t *testing.T) { + n := elem("input", map[string]string{"type": "checkbox"}) + magenta := css.Color{R: 255, B: 255, A: 255} + style := &css.Style{AppearanceNone: true, Background: magenta} + dst := paintControlStyled(t, n, 13, 13, style) + c := dst.RGBAAt(9, 9) + if got := (css.Color{R: c.R, G: c.G, B: c.B, A: 255}); got != magenta { + t.Errorf("interior = %+v, want author background %+v", got, magenta) + } +} + +// TestPaintCheckboxAppearanceNoneHonoursBackgroundImage covers the OTHER +// half of the real trigger: MDN's own switch knob is a `background-image: +// radial-gradient(...)`, not a solid colour — reusing paintBackgroundLayers +// (the same helper a real layout.Box's own gradient background already +// uses) must paint it here too. +func TestPaintCheckboxAppearanceNoneHonoursBackgroundImage(t *testing.T) { + n := elem("input", map[string]string{"type": "checkbox"}) + style := linearGradStyle(90, stop(css.Color{R: 0, G: 0, B: 0, A: 255}), stop(css.Color{R: 255, G: 255, B: 255, A: 255})) + style.AppearanceNone = true + dst := paintControlStyled(t, n, 40, 13, style) + left := dst.RGBAAt(5, 9) + right := dst.RGBAAt(44, 9) // item spans X:[5,45) — 44 is its rightmost pixel + if left.R > 8 { + t.Errorf("left = %+v, want ~black (gradient start)", left) + } + if right.R < 247 { + t.Errorf("right = %+v, want ~white (gradient end)", right) + } +} + +// TestPaintCheckboxAppearanceNoneHonoursBorder covers the author's own +// border (paintEdges, the SAME per-side helper a real element's border +// uses) replacing the generic square's hardcoded outline — and, with no +// border declared at all (css/ua.go's own `border:0` UA default for +// checkbox/radio, left standing since `appearance:none` does not itself +// paint a native fallback border), that NOTHING paints at the edge, mirroring +// a real browser's own bare, unstyled appearance:none checkbox. +func TestPaintCheckboxAppearanceNoneHonoursBorder(t *testing.T) { + n := elem("input", map[string]string{"type": "checkbox"}) + green := css.Color{G: 128, A: 255} + styled := &css.Style{AppearanceNone: true, Border: css.Borders{ + Top: css.BorderSide{Width: 2, Style: css.BorderSolid, Color: green}, + }} + dst := paintControlStyled(t, n, 13, 13, styled) + edge := dst.RGBAAt(5, 5) + if got := (css.Color{R: edge.R, G: edge.G, B: edge.B, A: 255}); got != green { + t.Errorf("top edge = %+v, want author border %+v", got, green) + } + + bare := &css.Style{AppearanceNone: true} // no Background, no Border at all + dstBare := paintControlStyled(t, n, 13, 13, bare) + // The item spans X:[5,18) Y:[5,18) on an 33x33 canvas — check every pixel + // inside that box (not hasNonBackgroundPixel, whose fixed 100px-wide scan + // range assumes the larger controls elsewhere in this file and would read + // past this small canvas's own bounds) stayed untouched white. + for y := 5; y < 18; y++ { + for x := 5; x < 18; x++ { + if c := dstBare.RGBAAt(x, y); c.R != 255 || c.G != 255 || c.B != 255 { + t.Fatalf("pixel (%d,%d) = %+v, want untouched white (no author background/border)", x, y, c) + } + } + } +} + +// TestPaintCheckboxAppearanceNoneHonoursBorderRadius covers styleRadius +// integration: a pill-shaped (border-radius: 50%) appearance:none checkbox +// must leave its own corners unpainted (outside the rounded shape) while its +// centre still fills — MDN's own switch relies on exactly this for its pill +// track. +func TestPaintCheckboxAppearanceNoneHonoursBorderRadius(t *testing.T) { + n := elem("input", map[string]string{"type": "checkbox"}) + blue := css.Color{B: 255, A: 255} + style := &css.Style{AppearanceNone: true, Background: blue, BorderRadius: css.Length{IsPercent: true, Percent: 0.5}} + dst := paintControlStyled(t, n, 20, 20, style) + center := dst.RGBAAt(15, 15) + if got := (css.Color{R: center.R, G: center.G, B: center.B, A: 255}); got != blue { + t.Errorf("centre = %+v, want fill %+v", got, blue) + } + corner := dst.RGBAAt(5, 5) // the box's own extreme top-left corner pixel + if got := (css.Color{R: corner.R, G: corner.G, B: corner.B, A: 255}); got == blue { + t.Errorf("corner = %+v, want untouched white (outside the rounded/circular shape)", got) + } +} + // TestPaintFormControlDrawsSomeText covers that a control WITH a value // actually draws glyphs (some non-background pixel inside), and one with // none does not — the difference proves text painting is actually wired, diff --git a/paint/paint.go b/paint/paint.go index 63c563f..f2b9505 100644 --- a/paint/paint.go +++ b/paint/paint.go @@ -171,7 +171,7 @@ func paintBoxContent(dst *image.RGBA, pp *painter.PixelPainter, box *layout.Box, } // 3. Background-image layers (last-listed painted first; first on top). if drawable && len(box.Style.BackgroundImages) > 0 { - paintBackgroundLayers(dst, box, rad, bgImgs, clip) + paintBackgroundLayers(dst, box.Style, rectOf(box), rad, bgImgs, clip) } // 4. Inset box-shadows paint over the background, under the border/content. if drawable { @@ -379,11 +379,11 @@ func styleRadius(st *css.Style, w, h float64) int { return int(r + 0.5) } -// paintBackgroundLayers paints the box's background-image layers into its border -// box, clipped to the rounded-rect shape. -func paintBackgroundLayers(dst *image.RGBA, box *layout.Box, rad int, bgImgs map[string]image.Image, clip image.Rectangle) { - st := box.Style - bx := rectOf(box) +// paintBackgroundLayers paints st's background-image layers into bx (a +// border box — the caller's, be it a real layout.Box's own rectOf(box) or a +// form control's own painter.Rect-derived bounds), clipped to the rounded- +// rect shape. +func paintBackgroundLayers(dst *image.RGBA, st *css.Style, bx image.Rectangle, rad int, bgImgs map[string]image.Image, clip image.Rectangle) { for i := len(st.BackgroundImages) - 1; i >= 0; i-- { layer := st.BackgroundImages[i] switch layer.Kind { @@ -929,7 +929,7 @@ func paintItem(dst *image.RGBA, pp *painter.PixelPainter, it *layout.InlineItem, return } if it.FormControl != nil { - paintFormControl(dst, pp, it, f, imgs, clip) + paintFormControl(dst, pp, it, f, imgs, bgImgs, clip) return } if it.Text == "" || it.Style == nil { @@ -1009,13 +1009,13 @@ const buttonIconGap = 4 // text — the visible, clickable rendering isReplacedTag-style items never // got before (see layout.go's isFormControlTag branch, which is what gives // it.FormControl a non-nil node and a real box size to paint here). -func paintFormControl(dst *image.RGBA, pp *painter.PixelPainter, it *layout.InlineItem, f *Fonts, imgs map[*dom.Node]image.Image, clip image.Rectangle) { +func paintFormControl(dst *image.RGBA, pp *painter.PixelPainter, it *layout.InlineItem, f *Fonts, imgs map[*dom.Node]image.Image, bgImgs map[string]image.Image, clip image.Rectangle) { n := it.FormControl r := painter.Rect{X: int(it.X), Y: int(it.Y), W: int(it.Width), H: int(it.LineHeight)} kind := formControlKind(n) if kind == controlCheckbox || kind == controlRadio { - paintCheckboxLike(pp, r, n, clip) + paintCheckboxLike(dst, pp, r, n, it.Style, bgImgs, clip) return } @@ -1134,12 +1134,41 @@ func strokeRect1px(pp *painter.PixelPainter, r painter.Rect, c css.Color, clip i fillRectClipped(pp, painter.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, c, clip) } -// paintCheckboxLike draws a checkbox/radio as a small square: filled accent -// when checked, outlined white otherwise. Checkbox vs. radio (square vs. -// circular) is not distinguished — clickability and checked-state visibility -// are what matter for this engine's driving use case (a login form), not -// exact native chrome fidelity. -func paintCheckboxLike(pp *painter.PixelPainter, r painter.Rect, n *dom.Node, clip image.Rectangle) { +// paintCheckboxLike draws a checkbox/radio. By default (no `appearance:none`) +// it draws this engine's own generic small square — filled accent when +// checked, outlined otherwise. Checkbox vs. radio (square vs. circular) is +// not distinguished, and neither is this engine's own real native OS chrome +// (never modelled at all) — clickability and checked-state visibility are +// what matter for this engine's driving use case (a login form), not exact +// native fidelity. +// +// But when the author has set `appearance:none` (st.AppearanceNone — see its +// own doc comment for the confirmed developer.mozilla.org +// trigger, a custom toggle-switch built from a plain checkbox), the element +// is no longer a native-look control at all: it becomes a plain styled box +// like any other, and the SAME author style paintFormControl's own +// non-checkbox path already honours (round 65's caniuse.com precedent) must +// paint here too — background colour, background-image layers (gradients +// and url() bitmaps, reusing paintBackgroundLayers exactly as a real +// layout.Box's own background does), border-radius, and the per-side border. +// A checked-state colour swap some custom-checkbox patterns apply via a +// `:checked` rule (MDN's own switch does, for its knob's position and +// accent colour) is not specially modelled here: it works automatically, +// the same as any other `:checked`-matched rule already recascades this +// element's Style before paint ever sees it. +func paintCheckboxLike(dst *image.RGBA, pp *painter.PixelPainter, r painter.Rect, n *dom.Node, st *css.Style, bgImgs map[string]image.Image, clip image.Rectangle) { + if st != nil && st.AppearanceNone { + rad := styleRadius(st, float64(r.W), float64(r.H)) + if st.Background.A > 0 { + fillRoundRectClipped(dst, pp, r, rad, st.Background, clip) + } + if len(st.BackgroundImages) > 0 { + bx := image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) + paintBackgroundLayers(dst, st, bx, rad, bgImgs, clip) + } + paintEdges(pp, st.Border, r.X, r.Y, r.W, r.H, rad, true, true, clip) + return + } _, checked := n.Attribute("checked") bg, border := formFieldBg, formBorder if checked {