diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 199c766..f180e0c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,8 +9,8 @@ env:
DOCS_BRANCH: ${{ secrets.DOCS_BRANCH }}
# Snapshot baselines are per-platform and committed; a missing one is a gap, never a pass.
ANGLESHARP_SNAPSHOT_STRICT: 1
- ANGLESHARP_VERSION: 1.8.0
- ANGLESHARP_CSS_VERSION: 1.1.0
+ ANGLESHARP_VERSION: 1.8.1
+ ANGLESHARP_CSS_VERSION: 1.1.1
jobs:
can_document:
diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml
index 5bce9e1..985c05d 100644
--- a/.github/workflows/update-snapshots.yml
+++ b/.github/workflows/update-snapshots.yml
@@ -15,8 +15,8 @@ permissions:
contents: write
env:
- ANGLESHARP_VERSION: 1.8.0
- ANGLESHARP_CSS_VERSION: 1.1.0
+ ANGLESHARP_VERSION: 1.8.1
+ ANGLESHARP_CSS_VERSION: 1.1.1
jobs:
# Keep this matrix identical to the `test` matrix in ci.yml, images included - a baseline
diff --git a/AGENTS.md b/AGENTS.md
index 94cae8d..eee098a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,7 +52,99 @@ Font handling resolves each entry of a `font-family` list in order, and the firs
Table spans: a cell covers the columns and rows it spans, and a spanning cell's height is shared across the rows it covers rather than imposed on each of them. With `border-collapse: collapse` each cell paints only its top and left edge and the table adds the frame, so shared edges are drawn once and no rule is painted across a spanning cell. Cell content honours `vertical-align` (`top`, `middle`, `bottom`), defaulting to the `middle` that AngleSharp.Css resolves for cells; `baseline` is treated as `top`, since baselines are not aligned across a row. Note that on a cell `vertical-align` positions the content box, which is a different meaning from the inline shift `ParseVerticalAlign` applies to `super`, `sub` and friends.
-Current behavior includes block layout, margins, padding, borders, floats, inline-block, relative/fixed/absolute positioning, z-index ordering, outlines, text styling, text alignment, line-height, letter-spacing, text-indent, vertical-align, and generic font-family handling.
+`border-radius` (rounded corners): the shorthand and all four longhands (`border-top-left-radius` etc.) are supported, each independently elliptical (`border-top-left-radius: 20px 10px`, or the whole-box `border-radius: 40px / 20px` slash shorthand) and represented backend-agnostically as `RenderCornerRadii` (`Rendering/RenderCornerRadii.cs`) - eight floats, X/Y per corner. `RenderCornerRadii.ClampToBox` implements the CSS corner-overlap-prevention algorithm (https://www.w3.org/TR/css-backgrounds-3/#corner-overlap): a single global scale factor, not a per-axis one, is applied to every radius so adjacent corners along the same edge never overlap - this is what turns a `border-radius` larger than half a box's height into a pill/stadium shape rather than a self-intersecting curve. A background fills with `SKRoundRect`/`DrawRoundRect` (`SkiaRenderBackend.DrawFillRect`) instead of `DrawRect` whenever `Radii` is non-zero. A border with rounded corners is painted differently depending on whether every edge shares the same width: a uniform-width border becomes a single `StrokeRoundedRectCommand` (one stroked ring, inset by half the border width, with the radius reduced by that same half-width since the stroke sits on the border's centerline); a mixed-width border (e.g. `border-right-width` wider than the others) has no single stroke width to give that ring, so it falls back to the existing four-straight-rectangle border path and the corners render square - a deliberate, documented limitation rather than an attempt at four independently-rounded quads. `outline` never rounds regardless of `border-radius`, matching the CSS spec, because `PaintOutline`'s internal `PaintBorder` call never passes the element's radii. Percentage radii resolve to pixels through AngleSharp.Css's computed-style engine before this renderer ever sees them. A confirmed AngleSharp.Css 1.1.0 bug - both the horizontal and vertical component of a percentage radius resolving against the *containing block's width* instead of per-axis against the element's own width/height, confirmed by rendering the same `%` radius against different viewport widths and observing the resulting pixel value track the viewport, not the element's own box - was reported with a reproducing test in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs`) and is now **fixed upstream**, confirmed by `BuildDisplayList_ResolvesPercentageBorderRadiusAgainstBox` flipping from the old (wrong) 30px/30px to the correct per-axis 20px/8px with no renderer-side code change of its own - this renderer never carried a workaround for it, since there was nothing to intercept short of re-deriving the radius itself.
+
+`box-shadow` and `text-shadow`: both are parsed from AngleSharp.Css's `GetBoxShadow()`/`GetTextShadow()` computed-style accessors, which return every comma-separated layer already normalized with the color as `rgba(r, g, b, a)` regardless of how it was authored (`red`, `#f00`, ...) - `ExtractColorToken` (`HtmlRenderer.cs`) relies on that normalization to split a layer's color from its lengths by locating the `rgba(...)` call rather than naively splitting on whitespace, which the spaces after that function's commas would otherwise break. `text-shadow` is an inherited property and AngleSharp.Css already resolves that inheritance before this renderer sees it (a child with no `text-shadow` of its own reports its ancestor's value verbatim, and an explicit `text-shadow: none` reports the literal string `"none"`, not an empty one) - `ParseTextShadows` still takes an `inherited` fallback for defense in depth, mirroring how `color` and the other inherited text properties are already handled in `ResolveTextStyle`. `box-shadow` is not inherited, matching spec, so `ResolveBoxStyle` parses it directly with no inherited fallback. Multiple shadows paint back-to-front in reverse authoring order (`PaintBoxShadows`/`PaintTextShadows`), because CSS defines the first-listed shadow as topmost. A `box-shadow` layer is `RenderBoxShadow` (`Rendering/RenderShadow.cs`) - offset, blur, spread, color, and `inset` - painted via `DrawBoxShadowCommand`/`SkiaRenderBackend.DrawBoxShadow`, which reuses the same `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established so a shadow follows the box's own corner rounding. An outset shadow is the border box's `SKRoundRect` grown by `SpreadRadius` (via `SKRoundRect.Inflate`/`Deflate` - verified functionally to adjust the rect *and* clamp each radius down to zero together, exactly matching the CSS spread semantics for free) and offset, blurred with `SKMaskFilter.CreateBlur`, then clipped with `SKClipOperation.Difference` against the *un-inflated* border box so the shadow never bleeds into the box's own interior even when its background is transparent - this clip-out is required by spec, not an approximation. An inset shadow is the mirror image: the border box shrunk by spread and offset, painted as the even-odd "ring" between that shrunk shape and a rect well outside the box, clipped with `SKClipOperation.Intersect` to the border box so it only shows inside. Both cases derive the Gaussian blur sigma as half the CSS blur radius (`shadow.BlurRadius / 2f`), the same approximation browsers commonly use; there is no dedicated conformance test pinning that constant, so treat it as adjustable if a real blurred-shadow comparison ever disagrees with it. `text-shadow` has no spread or inset, and paints as a second, blurred copy of the same text run (`DrawTextShadowCommand`/`SkiaRenderBackend.DrawTextShadow`) offset behind the real glyphs, shaped through the same `SkiaTextShaping` path as ordinary text so a shadow never drifts out of alignment with what it is shadowing. Painting order for a box is background, then `box-shadow` (all layers), then border, then outline - box-shadow must sit on top of the background (not behind it) even though it is visually "behind" the box's own content, because an inset shadow paints inside the box and would otherwise be hidden by an opaque background; this was a real bug caught by rendering the inset-shadow visual test and seeing no shadow at all, fixed by reordering `PaintBoxShadows` after `PaintBackground` at all three box-painting call sites.
+
+A box's own background/border/`box-shadow`/outline must paint *behind* its children, but an auto-sized box's height is only known once its children have been laid out (and their commands already appended to the display list) - so `LayoutElement`, `LayoutFlexContainer`, and `LayoutGridContainer` each capture `displayList.Commands.Count` as `boxPaintInsertIndex` before laying out their children/items, build the box's own paint commands into a scratch `DisplayList` buffer, and splice that buffer in at the captured index via `DisplayList.InsertRange` once the box's final size is known - rather than simply appending, which used to paint the box's own background *after*, and therefore on top of, its children. This was a real, confirmed bug (not hypothetical): a `
` with both a `background-color` and direct text content rendered the text completely invisible, and an absolutely-positioned or negative-`z-index` child was fully hidden behind its own containing block's background. Fixed by the splice approach and locked in by `BuildDisplayList_PaintsOwnBackgroundBehindOwnDirectTextContent`/`RenderToPng_PaintsOwnBackgroundBehindOwnDirectTextContent`; two pre-existing tests (`negative-z-index-behind-in-flow`/`absolute-positioned-out-of-flow` and their structural counterpart) had encoded the old, incorrect order as "expected" and were corrected to match CSS 2.1 Appendix E's actual stacking order (a stacking context's own background paints first, *then* its negative-z-index descendants, on top of it).
+
+`display: list-item` (``/`
`/`
` and any element explicitly given this display value) renders a marker without any dedicated layout mode of its own - AngleSharp.Css's `ShouldRenderAsBlock` already treats `list-item` as block-level (the `_ => true` default case), so an `
` flows through the ordinary block-flow path in `LayoutElement`; `PaintListItemMarker` is called once box geometry is known (before children are laid out - it doesn't depend on their size) and paints a bullet shape or number/letter text just like any other box content. `list-style-type` and `list-style-position` are read via `style.GetPropertyValue(...)` rather than a typed accessor (AngleSharp.Css has none for either). A confirmed AngleSharp.Css gap - unlike `border-radius` or `box-shadow`, an *unset* `list-style-type` on a `
` (as opposed to ``, whose UA rule explicitly sets `decimal`) used to report an empty string, not `disc` - was reported with a reproducing test in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs`) and is now **fixed upstream**, via a targeted UA-stylesheet addition (`ol, ul, dir, menu, dd { ...; list-style-type: disc; list-style-position: outside }`) rather than a general "resolve every unset property to its own initial value" mechanism - `ResolveListStyleType`'s own `"disc"` default in this renderer is therefore *not* fully redundant: it still fires, correctly, for an element given `display: list-item` explicitly on a tag the UA rule does not cover (confirmed empirically that this case still computes an empty `list-style-type`), so it remains in place as a narrower defensive fallback than it used to be. `disc`/`circle`/`square` are shape markers reusing the exact `RenderCornerRadii`/`SKRoundRect` machinery `border-radius` already established - a `disc` is a `FillRect` with full corner radii (a circle), a `circle` is the `StrokeRoundedRect` equivalent (a ring), and a `square` is a plain zero-radius `FillRect` - so no new rasterization code was needed for markers at all. Every other keyword (`decimal`, `decimal-leading-zero`, `lower-alpha`/`upper-alpha`/`lower-latin`/`upper-latin`, `lower-roman`/`upper-roman`) is a text marker drawn with the ordinary `DrawText` command, formatted by `FormatListMarkerText`/`FormatAlphaListMarker`/`FormatRomanListMarker`; an unrecognized keyword falls back to `decimal`, and Roman numerals outside 1-3999 (which have no standard representation) fall back to a plain decimal number - both matching typical browser behavior for out-of-range/unsupported values. `list-style-type: none` suppresses the marker entirely.
+
+An ordered item's ordinal comes from `ResolveListItemOrdinal`, which walks the `
`'s actual DOM parent's direct `
` children in document order (via plain `IElement.ParentElement`/`Children`, not the render tree, which can reorder for z-index/absolute positioning) - honoring ``, ``, and a per-`
` override exactly as browsers do (each subsequent item continues counting from an overridden value, in either direction). This is DOM-position counting, not a general CSS counter implementation (`counter-reset`/`counter-increment` are not implemented) - it happens to match the spec's built-in `list-item` counter behavior for the overwhelmingly common case (an ``/`
` with direct `
` children) without needing one. A nested list's own `
` children are scoped to their own immediate parent list, so nesting numbers independently without any extra bookkeeping. Since this DOM walk is O(children) and re-run once per `
`, a list is O(n²) in child count - negligible for realistic list sizes, and avoided the alternative (threading a precomputed ordinal down through every `LayoutElement`/`LayoutNode` call signature, or an `AsyncLocal` ambient dictionary in the style of `s_layoutCapture`) for a simpler, self-contained implementation.
+
+Marker placement approximates rather than exactly replicates browser metrics, since AngleSharp.Css's UA stylesheet gives `
`/`` their default 40px indent via `margin-left` rather than `padding-left` as real browsers do (verified empirically) - there is no distinct padding "gutter" region to place an outside marker inside. For `list-style-position: outside` (the default), the marker's right edge sits a fixed 6px gutter gap before the `
`'s own border-box edge, vertically aligned to the first line's baseline - this fixed gap is a visual approximation, not derived from any spec metric, and works within AngleSharp.Css's 40px default margin for any font size in normal ranges. For `list-style-position: inside`, the marker is placed at the `
`'s own content edge and the *first line only* is indented to make room for it (reusing the exact mechanism `text-indent` already uses - `textIndentConsumed`/`RenderTextStyle.TextIndent` - so wrapped continuation lines fall back to the unindented content edge, not a hanging indent under the marker; this matches how `text-indent` itself behaves and is a defensible, common interpretation of the property, verified visually rather than assumed). Boosting `TextIndent` locally for the "inside" case is safe against leaking into nested descendants' own indent because `ResolveTextStyle` never inherits `TextIndent` - every element re-derives its own from its own style map, defaulting to 0 rather than falling back to the parent's value. A `
` whose content starts with a nested block rather than inline text is a known, accepted simplification: the marker still paints at an approximated first-line baseline, but the "inside" indent boost has no effect on that nested block's own independent layout.
+
+`overflow`/`overflow-x`/`overflow-y` clip a box's content at the padding edge when the resolved value is `hidden`, `scroll`, or `auto` (`visible`, the default, does not clip). `scroll` and `auto` are treated identically to `hidden` - a rendered PNG has no scrollbars or interactivity, so anything a browser would let the user scroll to reveal is simply clipped away here, same as `hidden`. Two AngleSharp.Css gaps here - `overflow: clip` not being recognized at all (the whole declaration silently dropped), and the `overflow` shorthand not decomposing into `overflow-x`/`overflow-y` in computed style the way `border-color` and other shorthands do - were reported with reproducing tests in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs`) and are now **fixed upstream**, via a new general `PropertyFlags.PreserveShorthand` mechanism (the shorthand's own declaration is retained and its longhand values derived from it on demand, rather than the shorthand being expanded away at set-time). `ResolveOverflowAxis` no longer needs its old "prefer the longhand, fall back to reading the shorthand itself" logic - `overflow-x`/`overflow-y` can now always be read directly.
+
+That same upstream fix has one remaining, confirmed gap of its own, also reported with a reproducing test in the same file: when both the `overflow` shorthand and an explicit `overflow-x`/`overflow-y` longhand override are authored on the same element, the shorthand's own component always wins - regardless of declaration order - instead of the more specific, later-or-not longhand override. This renderer has no way to work around it locally (AngleSharp.Css's own computed `overflow-y` is simply wrong in that case, indistinguishable from a genuinely-intended `hidden`), so `overflow: ; overflow-y: ;` on the same element currently clips using the shorthand's value for both axes rather than honoring the explicit per-axis override - a known, currently-open upstream limitation, not a gap in this renderer's own parsing.
+
+Clipping is a single rectangle covering both axes together whenever *either* axis requests it - there is no independent per-axis clip (e.g. `overflow-x: hidden; overflow-y: visible` still clips vertically too), a deliberate simplification.
+
+Because `DisplayList` is a flat, backend-agnostic command sequence with no grouping/nesting structure, clipping an arbitrary range of already-emitted commands (a box's entire subtree) needed a new primitive rather than reusing anything from `border-radius`/`box-shadow`: `PushClipCommand`/`PopClipCommand` (`DisplayList.PushClip`/`PopClip`) bracket a scope the way `SKCanvas.Save()`/`ClipRect`-or-`ClipRoundRect`/`Restore()` naturally already work in `SkiaRenderBackend` - `PushClip` pushes and clips, `PopClip` simply restores. `LayoutElement`, `LayoutFlexContainer`, and `LayoutGridContainer` each compute whether their own element clips overflow, then reuse the exact same `boxPaintInsertIndex`/scratch-`DisplayList`-buffer splice mechanism `border-radius`/`box-shadow` background-ordering already established: `PushClip` is appended to the scratch buffer *after* that buffer's border/outline (so border and outline themselves are never clipped, matching spec) and the whole buffer is spliced in before the children's already-appended commands; `PopClip` is appended for real (not spliced) once children - and, for a replaced element, its image - have all been emitted, so the clip scope closes after everything that should be inside it. The clip shape reuses the box's own (unmodified) `RenderCornerRadii`, so `overflow: hidden` on a box with `border-radius` clips to that same rounded shape for free - the classic "rounded image container" pattern - via the identical `SKRoundRect` clipping `border-radius`/`box-shadow` already rely on.
+
+Vertical overflow clipping cannot be demonstrated with ordinary normal-flow content (text or a plain nested block) in this renderer: an auto-sized box's content height is computed as `Math.Max(specifiedHeight, autoContentHeight)` (see `LayoutElement`/`LayoutFlexContainer`/`LayoutGridContainer`), so a normal-flow child taller than a box's specified `height` simply grows that box to fit it rather than overflowing it - `height` behaves like a floor, not a cap, for in-flow content. Genuine overflow that clipping can act on comes from content whose size or position is *not* folded into that auto-height growth: an absolutely/fixed-positioned descendant (the common case - decorative or cropped imagery positioned outside its container's normal flow), or a normal-flow child that is simply *wider* than its container (block width is not grown to fit children the way height is, so horizontal overflow is real and gets clipped). The visual tests deliberately cover both.
+
+Page-level scrolling: `HtmlRenderer.RenderToPng`/`BuildDisplayList` (the public overloads) honor `document.documentElement`'s scroll position when an interactive `IDomHarness` already exists for the document's browsing context (created via `IBrowsingContext.GetDomHarness()` - typically through `IDomHarness.PaintToPng()` or by calling any of the CSSOM-view scroll APIs `ElementCssomViewExtensions.cs` already exposed for other purposes: `scrollTop`, `scrollTo`, `scrollIntoView`, ...). This was previously a real, confirmed gap, not a hypothetical one: `scrollTop`/`scrollTo` were fully implemented and tested for CSSOM-view purposes (`getBoundingClientRect`, `scrollHeight`, ...), but nothing in the rendering path ever consulted them, so scrolling an element had zero effect on `RenderToPng` output. `ResolveRootScrollOffsetY` (`HtmlRenderer.cs`) looks up a harness *without* creating one (`IBrowsingContext.TryGetDomHarness`, added alongside the existing `GetDomHarness`) - `GetDomHarness` auto-creates on first access but throws when no `IRenderDevice` service is registered, which the vast majority of `RenderToPng` callers (including nearly this entire test suite) never do, so unconditionally calling it would break plain, non-interactive rendering entirely. Finding no harness (the common case) resolves to a scroll offset of 0, leaving rendering exactly as before. The offset is applied as a single float shift to the top-level layout's starting Y position in `BuildDisplayList` - a scrolled page is laid out exactly like an unscrolled one, just starting from a (possibly negative) Y - and needs no explicit clip, since Skia's surface is a fixed `viewport.Width x viewport.Height` raster that simply never has pixels to write for content positioned outside those bounds.
+
+Critically, `HtmlRenderer.CaptureLayoutMetrics` (backing `getBoundingClientRect`/`GetScrollExtents`/hover-hit-testing) deliberately never applies this offset (it does not call `ResolveRootScrollOffsetY` at all) - `scrollHeight` and max-scroll clamping are scroll-*independent* intrinsic measurements, and letting them see a scrolled layout would create a self-referential bug where setting a scroll position depends on measurements that were themselves computed at the very scroll position being set. This is also why `document.documentElement`/`document.body` needed a second, narrower change: neither is ever laid out as a box of its own in this renderer (the top-level loop in `BuildDisplayList` only ever lays out *their children* directly onto the page canvas), so neither previously appeared in the layout-metrics map `GetScrollExtents` reads at all, making `document.documentElement.scrollHeight` (and therefore max-scroll) permanently 0 regardless of content height - confirmed by a failing test before the fix, not assumed. `BuildDisplayList` now synthesizes one `RecordLayoutMetrics` call for `document.Body`/`document.documentElement` sized to the *viewport's client area* (not the total content height - the existing `GetScrollExtents` descendant-walk already computes true scrollable height correctly from each individual child's own, normally-recorded metrics, exactly as it already does for any other scrollable element; recording the full content height here directly instead would make the synthesized element's own client and scroll heights identical, so nothing would ever look scrollable). This recording is a no-op outside of `CaptureLayoutMetrics`, since `RecordLayoutMetrics` itself only does anything while that capture's `AsyncLocal` is active. A related, pre-existing performance optimization (layout stops early once content runs past the visible viewport bottom) had to become conditional too: normal painting still wants that early exit, but metrics capture needs the *full* page laid out regardless of viewport height to measure a true scrollable extent, so `BuildDisplayList` takes a `measureFullExtent` parameter that widens its cutoff to `float.MaxValue` only for that call path.
+
+Real `:hover` matching and CSS `transition`: this was previously a hard, confirmed gap all the way down in AngleSharp itself, not something this renderer could work around on its own - `element.Matches(":hover")` and the cascade backing `ComputeCurrentStyle()` both hard-coded `:hover` to never match (AngleSharp is a headless DOM engine with no real pointer device), and neither `ComputeCurrentStyle`/`ComputeDeclarations` accepted any way to override pseudo-class state for a specific element. Reported and reproduced with failing tests in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs`), per this project's "get real upstream gaps fixed there, do not reinvent them locally" policy (see the `transform`/`filter` sections above) - AngleSharp.Css was then fixed upstream with a general pseudo-class forcing API, `AngleSharp.Dom.ElementExtensions.SetPseudoClass`/`GetPseudoClass`/`RemovePseudoClass`/`ClearPseudoClasses` (analogous to what browser devtools expose via the Chrome DevTools Protocol's `CSS.forcePseudoState`), confirmed working end-to-end against `AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs`'s own test suite before this renderer built anything on top of it.
+
+`InteractiveHtmlRendererState.UpdateForcedHoverChain` (called whenever `MousePosition` changes the hit-tested `HoveredElement`) forces `:hover` via that new API - but `SetPseudoClass` is deliberately per-element only and does not propagate to ancestors (confirmed by AngleSharp.Css's own `PseudoClassForcingTests.ForcingIsExplicitAndDoesNotPropagateToAncestors`), unlike a real pointer device, which makes every ancestor of the physically-hovered element match `:hover` too (`.card:hover .title` relies on this). So this method walks `IElement.ParentElement` itself and forces `:hover` on the *whole* ancestor chain, and - since there is no document-wide "clear every forced pseudo-class" API to lean on either - tracks exactly which elements it forced in `_forcedHoverChain` so it can precisely un-force the old chain when the hovered element changes, rather than leaving stale forced state behind on elements the pointer has moved away from.
+
+CSS `transition` (`transition`/`-property`/`-duration`/`-delay`/`-timing-function`) turns a hover-driven style change into an animated one instead of an instant jump, entirely inside `CssTransitionTracker` (owned by `InteractiveHtmlRendererState`, one per interactive document) - there is no real-time timer anywhere in this renderer; a caller pumps a virtual clock explicitly via `IDomHarness.AdvanceTime(TimeSpan)` before each repaint, the same way it already drives `MousePosition`/scroll offsets. `transition-property`/`-duration`/`-delay`/`-timing-function` are read from an element's own `ComputeCurrentStyle()` (the shorthand's own computed value is empty - the same shorthand-decomposition quirk already documented for `overflow` - but the four longhands decompose correctly even when only the shorthand was authored, confirmed empirically, so `ParseTransitionSpecs` reads the longhands directly and needs no `data-render-*` extraction workaround the way `transform` does); the duration/delay/timing-function lists are consumed cyclically against a longer `transition-property` list, per spec. `transition-timing-function` is parsed via AngleSharp.Css's own public `TimingFunctionParser`/`CssCubicBezierValue`/`CssStepsValue` (mirroring the `TransformParser` precedent for `transform`, not the from-scratch `filter` precedent) - AngleSharp.Css supplies the parsed control points/step count, but not an "evaluate the curve at t" function, so `CssTimingFunction.Evaluate` implements the standard bisection-search "UnitBezier" technique (and the standard `steps()` floor/ceil formula) itself, the same "spec formula, independently implemented" pattern already used for `filter`'s color matrices.
+
+Only a fixed, explicit whitelist of properties is interpolated - colors (`background-color`/`color`/`border-*-color`, channel-wise lerp) and numeric lengths/`opacity` (`width`/`height`/`font-size`/`margin-*`/`padding-*`/`border-*-width`/`top`/`left`/`right`/`bottom`, plain numeric lerp) - covering the overwhelming majority of real `:hover` transitions without needing a general CSS-value interpolation engine that understands every property's own grammar (including ones this renderer does not otherwise resolve component-wise, like `transform`/`filter` - a deliberate, documented scope cut, not an oversight). `transition-property: all` expands to this same whitelist, not literally every CSS property, for the same reason. Interpolated values are re-serialized as ordinary CSS text (`rgba(r, g, b, a)` / `Npx`) and injected back into the element's own style map by `HtmlRenderer.ApplyActiveTransitionAndAnimationOverrides` (called at the end of `CreateStyleMap`, for every element, gated on a single cheap `TryGetDomHarness` lookup that is a no-op for the overwhelming majority of documents never wired up for interactive use) - so every other property-parsing call site (`ParseLength`, `ParseColor`, ...) never has to know a transition or animation is even happening; it just sees whatever value the map would otherwise have held, silently substituted for the eased in-between one. This override loop walks the full interpolatable-property whitelist itself, not just whatever keys the map already happens to contain - a real bug caught while building `animation`: a property like `opacity` that is *only* ever declared inside a `@keyframes` block (never as a base/inherited declaration) never gets a map entry from the ordinary `AddIfPresent` calls above it, so overriding only existing keys silently dropped every such animation.
+
+`CssTransitionTracker.NotifyElementsMayHaveChanged` is what actually starts/updates/clears a transition run, called after `:hover` is forced/removed for the union of the old and new forced-hover chains (an element losing `:hover` needs its own "fade back to resting" transition considered too, not just the newly-hovered chain's "fade in"). A transition's "from" value has to be captured *before* the pseudo-class mutation happens - once `:hover` changes, there is no way to ask AngleSharp.Css what the value used to be - so `CaptureNaturalValuesBeforeChange` snapshots each candidate element's current natural value first (skipping any property that already has a live run, since that run's own current interpolated value, not the stale snapshot, is the correct "from" for a reversal); `UpdateForcedHoverChain` calls it before mutating pseudo-class state and `NotifyElementsMayHaveChanged` after. A transition that reverses mid-flight (the pointer moves away before the hover-in transition finished) restarts from whatever value is currently on screen with the *full* declared duration, not a remaining fraction of it - confirmed with a structural test that reverses a 1s linear transition at its 50% midpoint and checks it takes a full additional second to reach the resting value, not 500ms - matching the common, simple interpretation most CSS transition implementations use rather than attempting duration-proportional interruption smoothing.
+
+CSS `animation`/`@keyframes` reuses `transition`'s virtual-clock foundation (`IDomHarness.AdvanceTime`) and value-interpolation machinery, but has a fundamentally different trigger model, which is why it is a separate class, `CssAnimationTracker`, rather than a mode of `CssTransitionTracker`: a `transition` only ever starts because some *other* state change (`:hover`) makes a property's natural value differ from what it just was, whereas an `animation` needs no trigger at all - its own `@keyframes` rule already supplies both (all) endpoints directly, so it simply starts running the first moment this tracker observes the element declaring that `animation-name`, and keeps looping per `animation-iteration-count` for as long as that declaration stays in effect, entirely independent of `:hover`. That "first observed" moment is deliberately pinned to virtual-clock *zero*, not to whatever the clock happens to read at that instant - confirmed as a real, caught bug: initializing it to "whatever the clock currently is" made an animation appear to start later than it should whenever a caller called `AdvanceTime` one or more times before ever painting for the first time, since nothing observes the animation (and therefore records a start time) until the first `CreateStyleMap` call for that element actually runs. Pinning to zero always matches how an animation begins playing at document-ready in a real browser, regardless of when a caller first happens to look at it.
+
+`@keyframes` lookup goes through AngleSharp.Css's own public `ICssKeyframesRule`/`ICssKeyframeRule`/`.Name`/`.Rules`/`.Style`/`.KeyText` (found by walking `document.StyleSheets`), not a hand-rolled parser - unlike `filter`, AngleSharp.Css does have real (if `internal`-heavy) support for `@keyframes` parsing, it just does not expose an "evaluate at time t" API, mirroring `transition-timing-function`'s own `TimingFunctionParser`/`CssCubicBezierValue`/`CssStepsValue` situation. `CssAnimationTracker.FindKeyframeStops` reads every keyframe's own declared values for the same fixed, explicit property whitelist `transition` uses (`CssValueInterpolation.InterpolatableProperties`) and expands `KeyText` (`"0%"`, `"50%, 75%"`, `"from"`/`"to"`) into one or more sorted stop positions; `InterpolateAtKeyframes` brackets the current eased position between two stops and interpolates - or, if only one of the two bracketing keyframes declares the requested property, uses that one's value directly rather than searching further afield for the nearest declaring keyframe on either side (the spec's own rule), a deliberate simplification. `animation-name`/`-duration`/`-delay`/`-timing-function`/`-iteration-count`/`-direction`/`-fill-mode` support the same comma-list cycling `transition-property` does, letting several simultaneous animations run on one element (the last-listed animation wins for any property more than one of them targets, per spec). A confirmed AngleSharp.Css gap - `animation-direction`/`-fill-mode`/`-delay`/`-play-state` reporting the literal string `"initial"` (not each property's own actual initial value, and not empty) for any longhand the `animation` shorthand did not explicitly set, unlike `transition`'s equivalent longhands (which report empty) - was reported with a reproducing test in AngleSharp.Css's own suite (`AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs`) and is now **fixed upstream**: each of these longhands resolves to its real initial value (`normal`/`none`/`0s`/`running`) directly. `ResolveListValue` no longer maps a literal `"initial"` through a fallback - it only handles the "list shorter than `animation-name`" cycling case an absent property list represents, per spec.
+
+`animation-iteration-count`/`-direction`/`-fill-mode` are implemented as one small, unified piece of math rather than special-cased branches: `GetAnimatedValueForSpec` first computes an *effective* elapsed time - clamped to `0` (start of iteration 0) if still within the initial delay and `fill-mode` includes `backwards`, or to a hair before the final iteration's own end if the animation has already finished and `fill-mode` includes `forwards` - and only then runs the ordinary iteration-index/direction/easing computation against that effective elapsed time, exactly as it would mid-animation. This reuses a single code path for "mid-flight", "held at the start during a backwards-fill delay", and "frozen at the end under a forwards fill" instead of three separate ones, and gets the direction-aware end state right for free (e.g. `alternate` with an even total iteration count correctly freezes at the *start* keyframe, not the end one, because the shared math evaluates whichever iteration/direction the final instant actually falls in). `animation-play-state` (pausing) is not implemented - a deliberate, documented scope cut; every animation this renderer tracks is always running.
+
+Form controls (``, `