Fidelity fixes for captured styles, plus pre-parsed style rules - #40
Open
LuLaValva wants to merge 6 commits into
Open
Fidelity fixes for captured styles, plus pre-parsed style rules#40LuLaValva wants to merge 6 commits into
LuLaValva wants to merge 6 commits into
Conversation
LuLaValva
force-pushed
the
llavalva-M451495HWX-percy-748y5g
branch
from
August 18, 2026 22:27
c55d16c to
98f9958
Compare
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
force-pushed
the
llavalva-M451495HWX-percy-748y5g
branch
from
August 20, 2026 03:25
24499a8 to
1e91eca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 writeDeclarations were kept or dropped by name, but a property can be written under several.
paddingwritespadding-left, and so doespadding-inline-start; a shorthand written with avar()can't be split at all, so CSSOM enumerates its longhands with no value and only the shorthand carries one: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-startcame out with thepadding: 0it 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-radius→border-top), so the radius was dropped and the unrelatedborder: 1px solidwas captured asborder-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: normalinside a body with a line height is what stops it inheriting. And aninitialis resolved rather than skipped: skipping kept shorthand noise out but also droppedbackground-color: initial, which is what makes a button transparent instead of UA grey.fix: compute default styles per element rather than per tag nameDefaults were cached under the tag name, so the first
<input>of a run decided the defaults for every later one:Capture a checkbox first and a text input's
border: none,padding: 0andbackgroundall look redundant and get dropped; itsmargin: 0gets 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:
so
box-sizing: border-boxwas dropped from every input on every standards-mode page.fix: quote attribute values so a snapshot can be parsed backA 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 linkAn
<a>without an href isn't a link: no underline, no link colour, no pointer cursor.fix: read derived attributes from their content attributecurrentSrc,img.widthandimg.heightreflect resolved and loaded state, so captures depended on network timing and on where the page was served from;poster,dataandbackgroundresolve against the document's base URL. All are read from their content attributes now.srcsetjoins the whitelist, sincecurrentSrchad been the only thing capturing a responsive image's source.feat: accept pre-parsed style rulesReading and specificity sorting the document's rules is the bulk of a capture's work and repeats per call even when nothing changed.
Options.styleRuleslets a suite parse once via the now-exportedgetDocumentStyleRules; 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-startandpadding-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 authoredinitialexactly 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 implementcompatMode; linkedom has nogetComputedStyle. 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