Skip to content

Fidelity fixes for captured styles, plus pre-parsed style rules - #40

Open
LuLaValva wants to merge 6 commits into
mainfrom
llavalva-M451495HWX-percy-748y5g
Open

Fidelity fixes for captured styles, plus pre-parsed style rules#40
LuLaValva wants to merge 6 commits into
mainfrom
llavalva-M451495HWX-percy-748y5g

Conversation

@LuLaValva

@LuLaValva LuLaValva commented Aug 18, 2026

Copy link
Copy Markdown
Member

Five commits, each independent and each passing on its own. The four fixes were all found the same way: rendering a captured snapshot back in a browser next to the page it was captured from and diffing computed styles element by element. Two are covered by new tests in the existing jsdom suite; the other two are browser-only behaviours — see the note at the end.

fix: settle declarations by the property they write

Declarations were kept or dropped by name, but a property can be written under several. padding writes padding-left, and so does padding-inline-start; a shorthand written with a var() can't be split at all, so CSSOM enumerates its longhands with no value and only the shorthand carries one:

.a { border-radius: var(--r, 20px); border: 1px solid; }
  border-top-left-radius = ""
  border-radius          = "var(--r, 20px)"

Snapshots therefore held both the winner and the loser of one property, and since serialization sorts by name it was often the loser that took effect when the markup was rendered — a textbox padded by padding-inline-start came out with the padding: 0 it overrides. Reading an empty longhand fared worse: the name was chopped at its last dash until something answered, which walks to a different property (border-top-left-radiusborder-top), so the radius was dropped and the unrelated border: 1px solid was captured as border-top: 1px solid.

Declarations are now settled per property rather than per name, against the properties each one writes — shorthands expanded, logical names resolved to physical for the element's writing mode. Both mappings are read from the browser at runtime rather than kept as a list to maintain.

Two smaller corrections belong to the same reading. Declaring the default value of an inherited property was treated as a no-op; it isn't one — line-height: normal inside a body with a line height is what stops it inheriting. And an initial is resolved rather than skipped: skipping kept shorthand noise out but also dropped background-color: initial, which is what makes a button transparent instead of UA grey.

fix: compute default styles per element rather than per tag name

Defaults were cached under the tag name, so the first <input> of a run decided the defaults for every later one:

input[type=checkbox]  border-top-style=none   background=rgba(0,0,0,0)  padding-top=0px
input[type=text]      border-top-style=inset  background=rgb(255,255,255) padding-top=1px

Capture a checkbox first and a text input's border: none, padding: 0 and background all look redundant and get dropped; its margin: 0 gets kept for the same reason inverted. The cached entry now covers attributes, which is what UA styles select on.

The same probe ran in quirks mode — a blank frame has no doctype — while the page being captured does not:

as visual-html does it : compatMode=BackCompat  box-sizing=border-box
with a doctype written : compatMode=CSS1Compat  box-sizing=content-box
page under test        : compatMode=CSS1Compat  box-sizing=border-box

so box-sizing: border-box was dropped from every input on every standards-mode page.

fix: quote attribute values so a snapshot can be parsed back

A style attribute was written with double quotes around values that often contain them — url("…"), a quoted font name — so the attribute ended at the first inner quote and every declaration after it was lost to anything parsing the snapshot. Values now take a quote they don't themselves use.

fix: capture the href that makes an anchor a link

An <a> without an href isn't a link: no underline, no link colour, no pointer cursor.

fix: read derived attributes from their content attribute

currentSrc, img.width and img.height reflect resolved and loaded state, so captures depended on network timing and on where the page was served from; poster, data and background resolve against the document's base URL. All are read from their content attributes now. srcset joins the whitelist, since currentSrc had been the only thing capturing a responsive image's source.

feat: accept pre-parsed style rules

Reading and specificity sorting the document's rules is the bulk of a capture's work and repeats per call even when nothing changed. Options.styleRules lets a suite parse once via the now-exported getDocumentStyleRules; omitting it keeps current behaviour. In a ~1,300-story suite this took a run from ~5 minutes to ~105 seconds.

Verification, and a request

After these, a captured snapshot re-rendered with only the design tokens matches the live page on every visual property for buttons, selects, textareas and switches. One known gap remains, not addressed here: when a rule set declares both a logical and a physical longhand (padding-inline-start and padding-left), both are captured and then serialized alphabetically, so the physical one wins on re-render regardless of which won in the cascade. Fixing that means resolving logical properties against the element's direction and writing mode — happy to open it separately if you want it.

Two of the four now have regression tests in the existing jsdom suite, and both fail against the code they were written for: jsdom's user agent styles distinguish a checkbox (border-box) from a text input (content-box), which catches defaults leaking between elements of the same tag, and its declarations expose an authored initial exactly as a browser does.

The other two are not expressible in jsdom — it keeps a var() shorthand under the shorthand name rather than enumerating pending-substitution longhands, and its user agent styles don't vary with quirks mode. Rather than reach for a browser I checked the alternatives: happy-dom has no user agent stylesheet at all (every computed value comes back "") and doesn't implement compatMode; linkedom has no getComputedStyle. No jsdom option changes this either. Those two are verified by rendering captures back in Chromium and diffing computed styles, and whether that belongs in CI is your call — either as a browser job, or by driving the reading logic from declaration fixtures recorded off Chrome, which needs no new runtime dependency. Happy to build whichever you prefer.

🤖 Generated with Claude Code

@LuLaValva
LuLaValva force-pushed the llavalva-M451495HWX-percy-748y5g branch from c55d16c to 98f9958 Compare August 18, 2026 22:27
@LuLaValva LuLaValva changed the title feat: pre-parsed style rules; fix: attribute-sourced src/width/height fix: read derived attributes from their content attribute; feat: pre-parsed style rules Aug 18, 2026
@LuLaValva LuLaValva changed the title fix: read derived attributes from their content attribute; feat: pre-parsed style rules Fidelity fixes for captured styles, plus pre-parsed style rules Aug 19, 2026
LuLaValva and others added 6 commits August 19, 2026 20:22
Reading and specificity sorting every rule in the document is the bulk of a
capture's work, and it repeats per call even when nothing about the document
has changed. Options.styleRules lets a suite parse once, via the now exported
getDocumentStyleRules, and reuse the result. Omitting it keeps the existing
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several of the whitelisted properties report a value the author never wrote.
URL properties (src, poster, data, background) resolve against the document's
base URL, so the same markup serialises differently depending on where it was
served from — under a dev server on a random port, differently between runs.
An <img>'s width and height report its natural size once the image has
loaded, and currentSrc is empty until then, so output also depended on
whether a network fetch had finished.

These now read their content attribute, recording what the author wrote. An
absent attribute contributes nothing: the rendered size of an image is
already covered by its styles. The currentSrc entry, which existed only to
capture <img src>, is merged into the src entry, and srcset joins the
whitelist so a responsive image still reports its candidates.

Snapshots holding any of these attributes will need regenerating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An <a> without an href is not a link, and the user agent styles it as plain
text: no underline, no link colour, no pointer cursor. Dropping the attribute
changed how every anchor in a snapshot renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A style attribute was written with double quotes around a value that often
contains them — url("…") in a background, a quoted font name — so the
attribute ended at the first inner quote and every declaration after it was
lost to anything that parsed the snapshot. Attribute values now take a quote
the value doesn't use, and ampersands are escaped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The defaults an author declaration is compared against were cached under the
element's tag name, so the first <input> captured in a run decided the
defaults for every later one. A checkbox has no border, padding or background
where a text input has all three, so after a checkbox was captured, a text
input's `border: none`, `padding: 0` and `background` declarations all looked
redundant and were dropped, while its `margin: 0` was kept for the same
reason in reverse. The cached entry now covers the attributes as well, since
user agent styles select on them.

The probe those defaults are read from was also left in quirks mode: a blank
frame has no doctype, and quirks changes user agent styles — an <input> is
border-box there and content-box in standards mode, so `box-sizing:
border-box` was dropped from every input on a standards mode page. The probe
now matches the mode of the document being captured.

Snapshots of form controls will need regenerating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declarations were kept or dropped by name, but a property can be written
under several. `padding` writes `padding-left`, and so does
`padding-inline-start` in a left to right flow; a shorthand written with a
var() cannot be split at all, so its longhands enumerate with no value and
only the shorthand carries one. Rule sets mix these freely.

The result was that a snapshot held both the winner and the loser of the same
property, and since serialization sorts by name it was often the loser that
took effect when the markup was rendered: a textbox padded by
`padding-inline-start` came out with the `padding: 0` it overrides. Reading a
longhand with no value fared worse — the name was chopped at its last dash
until something answered, which walks to a different property, so
`border-radius: var(--radius)` was dropped and an unrelated `border: 1px
solid` in the same rule was captured as `border-top: 1px solid`.

Declarations are now settled per property rather than per name, against the
properties each one writes: shorthands expanded, logical names resolved to
physical ones for the element's writing mode. Whatever wins a property is
what the snapshot records.

Two smaller corrections belong to the same reading. Declaring the default
value of an inherited property was treated as a no-op and dropped; it isn't
one — `line-height: normal` on an element inside a body with a line height is
what stops it inheriting. And an `initial` is now resolved to the value it
stands for rather than skipped: skipping kept the noise a shorthand leaves on
the longhands it doesn't set out of snapshots, but it also dropped
`background-color: initial`, which is what makes a button transparent rather
than the grey the user agent paints. Comparing the resolved value against the
element's default keeps the noise out and the meaningful reset in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LuLaValva
LuLaValva force-pushed the llavalva-M451495HWX-percy-748y5g branch from 24499a8 to 1e91eca Compare August 20, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant