diff --git a/CHANGELOG.md b/CHANGELOG.md
index e009b69b..b7e7ac58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+# 1.1.1
+
+Released on Wednesday, September 9 2026
+
+- Fixed unresolved converter re-entry (#243) @sebastienros
+- Fixed issue with computation of `transform` functions
+- Fixed computed `animation` longhands retaining unresolved `initial` values
+- Fixed percentage `border-radius` components resolving against the wrong axis
+- Fixed default list styling for unordered lists
+- Added shorthand decomposition and `clip` support for `overflow`
+- Added explicit pseudo class handling via `SetPseudoClass`
+- Added support for parsing `filter` declarations
+
# 1.1.0
Released on Saturday, September 5 2026
diff --git a/docs/general/04-Core-Interfaces.md b/docs/general/04-Core-Interfaces.md
index b569ccaa..aa0264c1 100644
--- a/docs/general/04-Core-Interfaces.md
+++ b/docs/general/04-Core-Interfaces.md
@@ -77,6 +77,33 @@ var config = Configuration.Default
These are central for extensibility and custom property handling.
+## Pseudo-Class Forcing
+
+AngleSharp has no notion of pointer/keyboard interaction state, so pseudo-classes such as `:hover` and `:active` never match by default. `WithCss()` adds extension methods on `IElement` that let a caller force a pseudo-class to match (or not match) for a specific element, both in `Element.Matches(...)` and in computed style (`ComputeCurrentStyle()`/`ComputeDeclarations(...)`):
+
+- `SetPseudoClass(pseudoClass, value = true)`
+: Forces the given pseudo-class on (or off, with `value: false`).
+- `GetPseudoClass(pseudoClass)`
+: Returns the forced value, or `null` if nothing was forced for that element.
+- `RemovePseudoClass(pseudoClass)`
+: Clears a single forced pseudo-class, reverting to normal matching.
+- `ClearPseudoClasses()`
+: Clears every forced pseudo-class for the element.
+
+```cs
+using AngleSharp.Dom;
+
+var target = document.QuerySelector("#target");
+target.SetPseudoClass("hover");
+
+Console.WriteLine(target.Matches(":hover")); // True
+Console.WriteLine(target.ComputeCurrentStyle().GetPropertyValue("background-color"));
+
+target.RemovePseudoClass("hover");
+```
+
+This applies to any pseudo-class the selector engine recognizes (`:hover`, `:active`, `:visited`, ...), works per element only (forcing a child does not propagate to its ancestors), and is not the mechanism for `:focus`: that pseudo-class already has a real, settable state in AngleSharp core, so `SetPseudoClass("focus", ...)` delegates to `IHtmlElement.DoFocus()`/`DoBlur()` instead of a separate forced flag.
+
## Rule Of Thumb
- Use parser and CSSOM interfaces for analysis/transforms.
diff --git a/docs/general/06-Provided-Services.md b/docs/general/06-Provided-Services.md
index 73dc5a4a..d1ba3e60 100644
--- a/docs/general/06-Provided-Services.md
+++ b/docs/general/06-Provided-Services.md
@@ -18,6 +18,7 @@ From the default configuration extension:
- `ICssParser` -> parser instance using optional `CssParserOptions`,
- `IStylingService` -> `CssStylingService` for CSS MIME handling,
- CSS observer service (`Factory.Observer`) for style mutation integration.
+- `IPseudoClassSelectorFactory` -> wrapped so any recognized pseudo-class can be forced per element via `SetPseudoClass(...)` (see [Core Interfaces](04-Core-Interfaces.md)).
## Quick Service Retrieval
diff --git a/docs/tutorials/02-Examples.md b/docs/tutorials/02-Examples.md
index 5edffa79..69a0fca6 100644
--- a/docs/tutorials/02-Examples.md
+++ b/docs/tutorials/02-Examples.md
@@ -197,7 +197,33 @@ Console.WriteLine(serialized);
This preserves comments, but not their exact original positions in every case. Depending on where a comment was placed, it may be moved when the stylesheet is serialized again.
-## 11. Where To Go Next
+## 11. Preview A `:hover` State Without Real Interaction
+
+Useful for headless rendering/screenshot tools that need to show interactive states.
+
+```cs
+using AngleSharp;
+using AngleSharp.Dom;
+
+var html = @"
+
+";
+
+var context = BrowsingContext.New(Configuration.Default.WithCss());
+var document = await context.OpenAsync(req => req.Content(html));
+
+var button = document.QuerySelector("#btn");
+button.SetPseudoClass("hover");
+
+Console.WriteLine(button.ComputeCurrentStyle().GetPropertyValue("background-color")); // rgba(255, 0, 0, 1)
+
+button.RemovePseudoClass("hover");
+```
+
+## 12. Where To Go Next
- Read [API Documentation](01-API.md) for deeper CSSOM details.
- Read [Render Tree Examples](03-Render-Tree.md) for style-aware tree traversal and resource download workflows.
diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json
index 9e402773..32de862e 100644
--- a/src/AngleSharp.Css.Docs/package.json
+++ b/src/AngleSharp.Css.Docs/package.json
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
- "version": "1.1.0",
+ "version": "1.1.1",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
diff --git a/src/AngleSharp.Css.Tests/Extensions/Elements.cs b/src/AngleSharp.Css.Tests/Extensions/Elements.cs
index f34bc68d..3c563f93 100644
--- a/src/AngleSharp.Css.Tests/Extensions/Elements.cs
+++ b/src/AngleSharp.Css.Tests/Extensions/Elements.cs
@@ -39,7 +39,11 @@ public async Task DownloadResources()
};
var config = Configuration.Default
.WithDefaultLoader(loaderOptions)
- .WithRenderDevice()
+ .WithRenderDevice(new DefaultRenderDevice
+ {
+ ViewPortWidth = 800,
+ ViewPortHeight = 600,
+ })
.WithCss();
var document = "
".ToHtmlDocument(config);
var tree = document.DefaultView!.Render();
diff --git a/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
new file mode 100644
index 00000000..b07987ec
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/AnimationComputedStyleTests.cs
@@ -0,0 +1,59 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Per the CSS cascade, a computed style is supposed to fully resolve every longhand to a
+ /// concrete value - the CSS-wide keyword `initial` is itself resolved away during computation,
+ /// never present verbatim in the computed declaration. Confirmed empirically while building
+ /// CSS `animation` support in a downstream renderer (AngleSharp.Renderer): any `animation`
+ /// longhand the `animation` shorthand does not explicitly set reports the literal string
+ /// `"initial"` instead of that property's own real initial value (`normal` for
+ /// `animation-direction`, `none` for `animation-fill-mode`, `0s` for `animation-delay`,
+ /// `running` for `animation-play-state`) - unlike `transition`'s equivalent longhands, which
+ /// report an empty string instead in the same situation (see BasicStyling.cs's sibling
+ /// investigation for `transition`, not affected by this).
+ ///
+ [TestFixture]
+ public class AnimationComputedStyleTests
+ {
+ [Test]
+ public void UnsetAnimationDirectionResolvesToNormal()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("normal", target.ComputeCurrentStyle().GetPropertyValue("animation-direction"));
+ }
+
+ [Test]
+ public void UnsetAnimationFillModeResolvesToNone()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("none", target.ComputeCurrentStyle().GetPropertyValue("animation-fill-mode"));
+ }
+
+ [Test]
+ public void UnsetAnimationDelayResolvesToZeroSeconds()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("0s", target.ComputeCurrentStyle().GetPropertyValue("animation-delay"));
+ }
+
+ [Test]
+ public void UnsetAnimationPlayStateResolvesToRunning()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("running", target.ComputeCurrentStyle().GetPropertyValue("animation-play-state"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs
new file mode 100644
index 00000000..cdeceba8
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/BorderRadiusPercentageResolutionTests.cs
@@ -0,0 +1,63 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Html.Parser;
+ using NUnit.Framework;
+
+ ///
+ /// Per https://www.w3.org/TR/css-backgrounds-3/#corner-overlap, a percentage `border-radius`
+ /// resolves its horizontal component against the border box's own *width* and its vertical
+ /// component against the border box's own *height* - independently. Confirmed empirically
+ /// while building `border-radius` support in a downstream renderer (AngleSharp.Renderer) that
+ /// this does not happen: both components resolve against the containing block's/element's
+ /// *width* alone, so on a box whose width and height differ, the vertical radius comes out
+ /// wrong (tracking the wrong axis's dimension entirely, not merely imprecise).
+ ///
+ [TestFixture]
+ public class BorderRadiusPercentageResolutionTests
+ {
+ private static IDocument ParseWithRenderDevice(string html, int viewPortWidth = 1000)
+ {
+ var config = Configuration.Default
+ .WithCss()
+ .WithRenderDevice(new DefaultRenderDevice { ViewPortWidth = viewPortWidth });
+ var browsingContext = BrowsingContext.New(config);
+ var htmlParser = browsingContext.GetService();
+ return htmlParser.ParseDocument(html);
+ }
+
+ [Test]
+ public void VerticalPercentageComponentResolvesAgainstTheElementsOwnHeight()
+ {
+ // 200x100 box, `border-radius: 10% / 30%` - the horizontal component (10%) should
+ // resolve against the 200px width (20px); the vertical component (30%) should resolve
+ // against the 100px height (30px), not against the 200px width (which would give the
+ // wrong value, 60px).
+ var document = ParseWithRenderDevice("");
+ var target = document.GetElementById("target");
+ var style = target.ComputeCurrentStyle();
+
+ var horizontal = style.GetPropertyValue("border-top-left-radius");
+ Assert.IsTrue(horizontal.Contains("30px"), $"expected the vertical 30% component to resolve to 30px (30% of the 100px height); got '{horizontal}'");
+ }
+
+ [Test]
+ public void VerticalPercentageComponentTracksHeightAcrossDifferentElementHeights()
+ {
+ // The same vertical percentage against two different heights (but the same width)
+ // must resolve to two different pixel values if it is genuinely tracking height - if
+ // it were (incorrectly) tracking width instead, both would resolve identically despite
+ // the different heights.
+ var shortDocument = ParseWithRenderDevice("");
+ var shortTarget = shortDocument.GetElementById("target");
+ var shortRadius = shortTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");
+
+ var tallDocument = ParseWithRenderDevice("");
+ var tallTarget = tallDocument.GetElementById("target");
+ var tallRadius = tallTarget.ComputeCurrentStyle().GetPropertyValue("border-top-left-radius");
+
+ Assert.AreNotEqual(shortRadius, tallRadius, "a 30% vertical radius against a 100px-tall box and a 200px-tall box must resolve to different pixel values.");
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
new file mode 100644
index 00000000..ad3d7cb7
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/FilterPropertyTests.cs
@@ -0,0 +1,54 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Dom;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Html.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Verifies that filter function lists are parsed into structured values and preserved by
+ /// computed style, while the original inline style remains available through the DOM.
+ ///
+ [TestFixture]
+ public class FilterPropertyTests
+ {
+ [Test]
+ public void FilterComputedValuePreservesAuthoredFunctions()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("grayscale(0.9) blur(2px)", target.ComputeCurrentStyle().GetPropertyValue("filter"));
+ }
+
+ [Test]
+ public void FilterFunctionsExposeNamesAndArguments()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+ var value = target.GetStyle().GetProperty("filter").RawValue as CssFilterValue;
+
+ Assert.IsNotNull(value);
+ Assert.AreEqual(2, value.Functions.Length);
+ Assert.AreEqual("grayscale", value.Functions[0].Name);
+ Assert.AreEqual("0.9", value.Functions[0].Arguments[0].CssText);
+ Assert.AreEqual("blur", value.Functions[1].Name);
+ Assert.AreEqual("2px", value.Functions[1].Arguments[0].CssText);
+ }
+
+ [Test]
+ public void RawFilterTextIsStillReadableFromTheInlineStyleAttributeItself()
+ {
+ // Confirms the gap is specifically in AngleSharp.Css's own computed-style/cascade
+ // pipeline, not in the HTML/attribute layer - the text is right there, just never
+ // parsed into a structured value or even echoed back through computed style.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("filter: grayscale(0.9) blur(2px);", target.GetAttribute("style"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs b/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
new file mode 100644
index 00000000..53a327be
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/HoverPseudoClass.cs
@@ -0,0 +1,57 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// AngleSharp has no notion of pointer/interaction state, so `:hover` is unconditionally
+ /// non-matching unless explicitly forced via
+ /// (see PseudoClassForcing.cs for the general mechanism this relies on). These tests pin down
+ /// the specific `:hover` case that first surfaced the gap while building CSS `transition`
+ /// support in a downstream renderer (AngleSharp.Renderer).
+ ///
+ [TestFixture]
+ public class HoverPseudoClassTests
+ {
+ [Test]
+ public void HoverDoesNotMatchByDefault()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.IsFalse(target.Matches(":hover"));
+ Assert.IsTrue(target.Matches(":not(:hover)"));
+ }
+
+ [Test]
+ public void ForcingHoverMakesItMatchInTheSelectorEngine()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+
+ Assert.IsTrue(target.Matches(":hover"));
+ Assert.IsFalse(target.Matches(":not(:hover)"));
+ }
+
+ [Test]
+ public void ForcingHoverLetsTheMoreSpecificHoverRuleWinTheCascade()
+ {
+ // #target:hover is more specific than #target and declared after it, so once :hover
+ // is forced to match, the cascade should resolve to red instead of blue.
+ var document = ParseDocument(@"");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ var backgroundColor = target.ComputeCurrentStyle().GetPropertyValue("background-color");
+
+ Assert.AreEqual("rgba(255, 0, 0, 1)", backgroundColor);
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs b/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs
new file mode 100644
index 00000000..0c4a6319
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/IndividualTransformComputation.cs
@@ -0,0 +1,124 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.RenderTree;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using System;
+ using System.Threading.Tasks;
+ using static CssConstructionFunctions;
+
+ [TestFixture]
+ public class IndividualTransformComputationTests
+ {
+ [Test]
+ public async Task OriginalReproductionDoesNotOverflow()
+ {
+ using var context = BrowsingContext.New(Configuration.Default.WithCss());
+ using var document = await context.OpenAsync(request => request.Content(
+ ""));
+
+ Assert.AreEqual("1px", document.QuerySelector("div").ComputeCurrentStyle().GetPropertyValue("translate"));
+ }
+
+ [TestCase("translate", "1px")]
+ [TestCase("translate", "1px 2px")]
+ [TestCase("translate", "1px 2px 3px")]
+ [TestCase("translate", "50% 25%")]
+ [TestCase("translate", "0")]
+ [TestCase("translate", "0 -50%")]
+ [TestCase("translate", "none")]
+ [TestCase("rotate", "1deg")]
+ [TestCase("rotate", "45deg")]
+ [TestCase("rotate", "x 45deg")]
+ [TestCase("rotate", "1 0 0 45deg")]
+ [TestCase("rotate", "none")]
+ [TestCase("scale", "1")]
+ [TestCase("scale", "1.5 2")]
+ [TestCase("scale", "1 1.5 2")]
+ [TestCase("scale", "none")]
+ public void IndividualTransformsComputeWithoutChangingSpecifiedStyles(String name, String value)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var styles = document.DefaultView.GetStyleCollection(new DefaultRenderDevice());
+ var specified = styles.GetDeclarations(element);
+ var source = specified.CssText;
+ var rendered = RenderTreeBuilder.GetInstance(document.DefaultView).RenderElement(element, styles.Device);
+
+ Assert.AreEqual(value, element.ComputeCurrentStyle().GetPropertyValue(name));
+ Assert.AreEqual(value, rendered.ComputedStyle.GetPropertyValue(name));
+ Assert.AreEqual(value, rendered.SpecifiedStyle.GetPropertyValue(name));
+ Assert.AreEqual(source, specified.CssText);
+ Assert.AreEqual(value, element.ComputeCurrentStyle().GetPropertyValue(name));
+ }
+
+ [TestCase("translate", "1px", "2px", "1px 2px")]
+ [TestCase("rotate", "x", "45deg", "x 45deg")]
+ [TestCase("scale", "1", "2", "1 2")]
+ public void SubstitutionPreservesAllComponentsAndInheritedAliases(String name, String first, String second, String expected)
+ {
+ using var document = ParseDocument("
" +
+ "
");
+ var element = document.QuerySelector("span");
+ var computed = element.ComputeCurrentStyle();
+
+ Assert.AreEqual(expected, computed.GetPropertyValue(name));
+ Assert.AreEqual(first, computed.GetPropertyValue("--alias"));
+ Assert.AreEqual("var(--alias) var(--b)", element.GetStyle().GetPropertyValue(name));
+ }
+
+ [TestCase("translate", "1px")]
+ [TestCase("rotate", "45deg")]
+ [TestCase("scale", "2")]
+ public void MissingAndCyclicVariablesStillUseFallbacksOrInitialValues(String name, String fallback)
+ {
+ using var document = ParseDocument("");
+ var element = document.QuerySelector("div");
+ var computed = element.ComputeCurrentStyle();
+
+ Assert.AreEqual(fallback, computed.GetPropertyValue(name));
+ Assert.AreEqual("rgba(255, 0, 0, 1)", computed.GetPropertyValue("color"));
+ Assert.IsInstanceOf(computed.GetProperty("--a").RawValue);
+ Assert.IsInstanceOf(computed.GetProperty("--b").RawValue);
+
+ foreach (var value in new[] { "var(--a)", "var(--missing)", "var(--missing,none trailing)" })
+ {
+ element.GetStyle().SetProperty(name, value);
+ Assert.AreEqual("none", element.ComputeCurrentStyle().GetPropertyValue(name), value);
+ }
+ }
+
+ [TestCase("translate", "1px")]
+ [TestCase("rotate", "45deg")]
+ [TestCase("scale", "2")]
+ public void InheritanceAndSubstitutedCssWideKeywordsRetainTheirBehavior(String name, String parentValue)
+ {
+ using var document = ParseDocument("
");
+ var element = document.QuerySelector("span");
+ element.SetAttribute("style", name + ":inherit");
+ Assert.AreEqual(parentValue, element.ComputeCurrentStyle().GetPropertyValue(name));
+
+ foreach (var keyword in new[] { "inherit", "initial", "unset" })
+ {
+ element.SetAttribute("style", "--keyword:var(--missing," + keyword + ");" + name + ":var(--keyword)");
+ Assert.AreEqual(keyword == "inherit" ? parentValue : "none",
+ element.ComputeCurrentStyle().GetPropertyValue(name), keyword);
+ }
+ }
+
+ [Test]
+ public void InvalidConcreteValuesStillDefaultInsteadOfExposingEarlierDeclarations()
+ {
+ using var document = ParseDocument("
");
+ var computed = document.QuerySelector("span").ComputeCurrentStyle();
+
+ Assert.AreEqual("auto", computed.GetPropertyValue("width"));
+ Assert.AreEqual("hidden", computed.GetPropertyValue("visibility"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs b/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs
new file mode 100644
index 00000000..d0846468
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/ListStyleComputedValueTests.cs
@@ -0,0 +1,49 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// A computed style should always resolve to a property's own initial value when nothing in
+ /// the cascade sets it explicitly - confirmed this does not happen for `list-style-type`/
+ /// `list-style-position` on a `<ul>` while building `display: list-item` support in a
+ /// downstream renderer (AngleSharp.Renderer): both come back as an empty string instead of
+ /// their real initial values (`disc`, `outside`). `<ol>` is included as a control case -
+ /// its UA-stylesheet rule explicitly sets `list-style-type: decimal`, and that value *does*
+ /// show up correctly, confirming this is specifically a missing initial-value fallback (for a
+ /// property nothing in the cascade ever set), not a blanket "these properties are never
+ /// computed" issue.
+ ///
+ [TestFixture]
+ public class ListStyleComputedValueTests
+ {
+ [Test]
+ public void UnsetListStyleTypeOnUnorderedListResolvesToDisc()
+ {
+ var document = ParseDocument("
Item
");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("disc", target.ComputeCurrentStyle().GetPropertyValue("list-style-type"));
+ }
+
+ [Test]
+ public void UnsetListStylePositionResolvesToOutside()
+ {
+ var document = ParseDocument("
Item
");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("outside", target.ComputeCurrentStyle().GetPropertyValue("list-style-position"));
+ }
+
+ [Test]
+ public void OrderedListsUaRuleAlreadyResolvesListStyleTypeCorrectly()
+ {
+ var document = ParseDocument("
Item
");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("decimal", target.ComputeCurrentStyle().GetPropertyValue("list-style-type"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs
new file mode 100644
index 00000000..2919b9c1
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/OverflowComputedStyleTests.cs
@@ -0,0 +1,77 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Two `overflow` computed-style gaps confirmed while building overflow clipping support in a
+ /// downstream renderer (AngleSharp.Renderer):
+ /// (1) the `overflow` shorthand does not decompose into `overflow-x`/`overflow-y` in the
+ /// computed style the way other shorthand/longhand pairs do, so a value authored only via the
+ /// shorthand is never visible through either longhand accessor;
+ /// (2) `overflow: clip` (a real, shipped CSS Overflow Module value) is not recognized by the
+ /// parser at all - the whole declaration is silently dropped rather than being computed or
+ /// even reported as an unsupported/unresolved value.
+ ///
+ [TestFixture]
+ public class OverflowComputedStyleTests
+ {
+ [Test]
+ public void OverflowShorthandDecomposesIntoOverflowXLonghand()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-x"));
+ }
+
+ [Test]
+ public void OverflowShorthandDecomposesIntoOverflowYLonghand()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-y"));
+ }
+
+ [Test]
+ public void OverflowClipIsRecognizedAndComputed()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("clip", target.ComputeCurrentStyle().GetPropertyValue("overflow"));
+ }
+
+ [Test]
+ public void ExplicitLonghandAuthoredAfterTheShorthandOverridesItsComponent()
+ {
+ // A newly confirmed gap, found once the two gaps above were fixed: `overflow-y`
+ // written *after* the `overflow` shorthand in the same declaration block should win
+ // for that axis (the ordinary "later declaration of the same effective property wins"
+ // cascade rule), but the shorthand's own component always wins instead, regardless of
+ // declaration order (confirmed with both orderings below) - the shorthand's expansion
+ // into longhands appears to be applied unconditionally rather than only when that
+ // longhand was not otherwise explicitly set.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("visible", target.ComputeCurrentStyle().GetPropertyValue("overflow-y"));
+ }
+
+ [Test]
+ public void ExplicitLonghandAuthoredBeforeTheShorthandStillWinsForThatAxis()
+ {
+ // The shorthand comes textually *after* the longhand here - if the shorthand's
+ // expansion is unconditionally overwriting rather than declaration-order-aware, this
+ // ordering fails identically to the reverse ordering above (confirmed: it does).
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.AreEqual("hidden", target.ComputeCurrentStyle().GetPropertyValue("overflow-x"), "the horizontal axis is untouched by the explicit overflow-y override and should still pick up hidden from the shorthand.");
+ Assert.AreEqual("visible", target.ComputeCurrentStyle().GetPropertyValue("overflow-y"));
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs
new file mode 100644
index 00000000..0aa2d58e
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs
@@ -0,0 +1,155 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Styling
+{
+ using AngleSharp.Dom;
+ using AngleSharp.Html.Dom;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Tests the generic pseudo-class forcing API (SetPseudoClass/GetPseudoClass/RemovePseudoClass/
+ /// ClearPseudoClasses), which lets a caller simulate interaction states such as `:hover` and
+ /// `:active` that AngleSharp otherwise never reports as matching - analogous to what browser
+ /// devtools expose via the Chrome DevTools Protocol's `CSS.forcePseudoState`.
+ ///
+ [TestFixture]
+ public class PseudoClassForcingTests
+ {
+ [Test]
+ public void GetPseudoClassReturnsNullWhenNothingHasBeenForced()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ Assert.IsNull(target.GetPseudoClass("hover"));
+ }
+
+ [Test]
+ public void SetPseudoClassForcesTheGivenStateAndGetPseudoClassReportsIt()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+
+ Assert.AreEqual(true, target.GetPseudoClass("hover"));
+ Assert.IsTrue(target.Matches(":hover"));
+ }
+
+ [Test]
+ public void SetPseudoClassAcceptsALeadingColon()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass(":active");
+
+ Assert.IsTrue(target.Matches(":active"));
+ }
+
+ [Test]
+ public void RemovePseudoClassRevertsToTheNaturalState()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ target.RemovePseudoClass("hover");
+
+ Assert.IsNull(target.GetPseudoClass("hover"));
+ Assert.IsFalse(target.Matches(":hover"));
+ }
+
+ [Test]
+ public void ClearPseudoClassesRemovesEveryForcedStateForTheElement()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("hover");
+ target.SetPseudoClass("active");
+ target.ClearPseudoClasses();
+
+ Assert.IsFalse(target.Matches(":hover"));
+ Assert.IsFalse(target.Matches(":active"));
+ }
+
+ [Test]
+ public void ForcingIsExplicitAndDoesNotPropagateToAncestors()
+ {
+ var document = ParseDocument("
");
+ var child = document.GetElementById("child");
+ var parent = document.GetElementById("parent");
+
+ child.SetPseudoClass("hover");
+
+ Assert.IsTrue(child.Matches(":hover"));
+ Assert.IsFalse(parent.Matches(":hover"), "forcing a pseudo-class on an element must not implicitly affect its ancestors.");
+ }
+
+ [Test]
+ public void ForcingCanBeExplicitlySetToFalseToOverrideTheNaturalState()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("active", false);
+
+ Assert.AreEqual(false, target.GetPseudoClass("active"));
+ Assert.IsFalse(target.Matches(":active"));
+ }
+
+ [Test]
+ public void ForcingGeneralizesToOtherHardcodedPseudoClassesLikeVisited()
+ {
+ var document = ParseDocument("");
+ var target = document.GetElementById("target");
+
+ target.SetPseudoClass("visited");
+
+ Assert.IsTrue(target.Matches(":visited"));
+ }
+
+ [Test]
+ public void SettingFocusDelegatesToTheRealFocusStateInsteadOfAForcedOverride()
+ {
+ // Only elements whose DoFocus() implementation actually sets focus (e.g. anchors with
+ // an href) can be focused today - this is a real, separate AngleSharp core limitation.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target") as IHtmlElement;
+
+ target.SetPseudoClass("focus");
+
+ Assert.IsTrue(target.Matches(":focus"));
+ Assert.AreEqual(true, target.GetPseudoClass("focus"));
+ Assert.AreSame(target, document.ActiveElement);
+ }
+
+ [Test]
+ public void SettingFocusOnAnElementMakesFocusWithinMatchOnItsAncestorsNaturally()
+ {
+ var document = ParseDocument("
");
+ var target = document.GetElementById("target") as IHtmlElement;
+ var parent = document.GetElementById("parent");
+
+ target.SetPseudoClass("focus");
+
+ Assert.IsTrue(parent.Matches(":focus-within"), "focus-within is derived from real focus state, so it works without any forcing.");
+ }
+
+ [Test]
+ public void RemovingFocusAttemptsToBlurTheElement()
+ {
+ // AngleSharp core does not currently implement DoBlur() for any element (it is a
+ // no-op everywhere), so this only calls into that hook - it cannot itself force focus
+ // to be cleared. Documented here since it is a separate, existing core limitation.
+ var document = ParseDocument("");
+ var target = document.GetElementById("target") as IHtmlElement;
+
+ target.SetPseudoClass("focus");
+ target.RemovePseudoClass("focus");
+
+ Assert.IsTrue(target.Matches(":focus"), "DoBlur() is a no-op in AngleSharp core today, so focus is not actually cleared.");
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs b/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs
new file mode 100644
index 00000000..d41b6ee1
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Values/AnyValueComputation.cs
@@ -0,0 +1,127 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Values
+{
+ using AngleSharp.Css.Converters;
+ using AngleSharp.Css.Dom;
+ using AngleSharp.Css.Values;
+ using AngleSharp.Text;
+ using NUnit.Framework;
+ using System;
+ using static ValueConverters;
+
+ [TestFixture]
+ public class AnyValueComputationTests
+ {
+ [TestCase(false)]
+ [TestCase(true)]
+ public void AnyResultIsNotRecomputedThroughNestedConverters(Boolean isResolved)
+ {
+ var converter = new SingleUseConverter(Or(None, Or(Auto, Any)));
+ var context = new TestComputeContext { Converter = converter };
+ ICssValue value = new CssAnyValue("opaque tokens", isResolved);
+
+ Assert.AreEqual("opaque tokens", value.Compute(context).CssText);
+ Assert.AreEqual(1, converter.Calls);
+ }
+
+ [TestCase("none", "none")]
+ [TestCase("2em", "32px")]
+ [TestCase("calc(1px + 2px)", "3px")]
+ [TestCase(" /*before*/ 2em /*after*/ ", "32px")]
+ [TestCase("invalid", null)]
+ [TestCase("2px trailing", null)]
+ [TestCase("none trailing", null)]
+ public void ConcreteCompositeResultsStillComputeAndValidate(String text, String expected)
+ {
+ var context = new TestComputeContext { Converter = Or(None, LengthConverter) };
+
+ foreach (var isResolved in new[] { false, true })
+ {
+ ICssValue value = new CssAnyValue(text, isResolved);
+ Assert.AreEqual(expected, value.Compute(context)?.CssText);
+ }
+ }
+
+ [TestCase("none", "none")]
+ [TestCase("2em", "32px")]
+ [TestCase("calc(1px + 2px)", "3px")]
+ public void ConcreteBranchesBeforeAnyStillCompute(String text, String expected)
+ {
+ var context = new TestComputeContext { Converter = Or(None, LengthConverter, Any) };
+ ICssValue value = new CssAnyValue(text);
+
+ Assert.AreEqual(expected, value.Compute(context).CssText);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void AnyResultStillRequiresCompleteInputConsumption(Boolean isResolved)
+ {
+ var context = new TestComputeContext
+ {
+ Converter = new ClassValueConverter(source =>
+ {
+ source.Next();
+ return new CssAnyValue("accepted", isResolved);
+ }),
+ };
+ ICssValue value = new CssAnyValue("xy", isResolved);
+
+ Assert.IsNull(value.Compute(context));
+ }
+
+ [TestCase(false, false)]
+ [TestCase(false, true)]
+ [TestCase(true, false)]
+ [TestCase(true, true)]
+ public void DirectAnyAndMissingConvertersRetainResolutionSemantics(Boolean isResolved, Boolean hasConverter)
+ {
+ var context = new TestComputeContext { Converter = hasConverter ? Any : null };
+ ICssValue value = new CssAnyValue("opaque tokens", isResolved);
+
+ Assert.AreSame(isResolved ? value : null, value.Compute(context));
+ }
+
+ [Test]
+ public void ConverterExceptionsAreNotSuppressed()
+ {
+ var context = new TestComputeContext
+ {
+ Converter = new ClassValueConverter(_ => throw new InvalidOperationException("Test exception")),
+ };
+ ICssValue value = new CssAnyValue("opaque tokens");
+
+ Assert.Throws(() => value.Compute(context));
+ }
+
+ private sealed class SingleUseConverter : IValueConverter
+ {
+ private readonly IValueConverter _converter;
+
+ public SingleUseConverter(IValueConverter converter)
+ {
+ _converter = converter;
+ }
+
+ public Int32 Calls { get; private set; }
+
+ public ICssValue Convert(StringSource source)
+ {
+ if (++Calls > 1)
+ {
+ throw new InvalidOperationException("The converter was re-entered for its own any result.");
+ }
+
+ return _converter.Convert(source);
+ }
+ }
+
+ private sealed class TestComputeContext : ICssComputeContext
+ {
+ public IRenderDevice Device { get; } = new DefaultRenderDevice { FontSize = 16 };
+ public IBrowsingContext Context => null;
+ public IValueConverter Converter { get; set; }
+ public ICssValue Resolve(String name) => null;
+ }
+ }
+}
diff --git a/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
new file mode 100644
index 00000000..c65c5c8f
--- /dev/null
+++ b/src/AngleSharp.Css.Tests/Values/TransformFunctions.cs
@@ -0,0 +1,153 @@
+#nullable disable
+namespace AngleSharp.Css.Tests.Values
+{
+ using AngleSharp.Css.Parser;
+ using AngleSharp.Css.Tests.Mocks;
+ using AngleSharp.Dom;
+ using AngleSharp.Text;
+ using NUnit.Framework;
+ using static CssConstructionFunctions;
+
+ ///
+ /// Regression tests for three confirmed bugs in the CSS `transform` function value pipeline
+ /// (`ICssTransformFunctionValue`/`TransformMatrix`), found while integrating this library's
+ /// transform support into a downstream renderer. All three currently fail (that is the point -
+ /// they pin down the exact defect for a fix); see each test's own comments for the root cause
+ /// traced in the corresponding source file.
+ ///
+ [TestFixture]
+ public class TransformFunctionsTests
+ {
+ [Test]
+ public void TranslateWithTwoArgumentsDoesNotThrowWhenComputed()
+ {
+ // Root cause: CssTranslateValue.Compute() (Values/Functions/CssTranslateValue.cs)
+ // unconditionally calls _x.Compute(context), _y.Compute(context), _z.Compute(context)
+ // with no null check on any of the three. For the 2-argument `translate(x, y)` form,
+ // the constructor is only ever given a non-null z when parsing translate3d - here _z is
+ // null, so _z.Compute(context) throws NullReferenceException. ComputeMatrix (a few
+ // lines below Compute in the same file) does not have this bug, because it reads each
+ // component through the null-tolerant AsPx(...) extension method instead of calling an
+ // interface method directly on a value that may be null.
+ //
+ // This is not a synthetic edge case: it reproduces via the most ordinary possible
+ // route - building a render tree for a document with a plain inline
+ // `style="transform: translate(10px, 5px)"` declaration. RenderTreeBuilder computes an
+ // element's entire style declaration eagerly while constructing the tree, so the crash
+ // happens even if nothing downstream ever reads the `transform` property specifically.
+ //
+ // Written as "does not throw" (the correct, expected behavior) rather than "throws
+ // NullReferenceException" (today's actual, buggy behavior) so this test fails now and
+ // starts passing automatically once the bug is fixed, instead of needing to be flipped.
+ var document = "".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss());
+ var window = document.DefaultView;
+
+ Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice()));
+ }
+
+ [Test]
+ public void TranslateXWithOneArgumentDoesNotThrowWhenComputed()
+ {
+ // Same root cause as TranslateWithTwoArgumentsDoesNotThrowWhenComputed, via
+ // translateX() instead of the 2-argument translate() - here both _y and _z are null,
+ // so Compute() throws on the first of the two (_y.Compute(context)).
+ var document = "".ToHtmlDocument(Configuration.Default.WithRenderDevice().WithCss());
+ var window = document.DefaultView;
+
+ Assert.DoesNotThrow(() => window.Render(new PlainRenderDevice()));
+ }
+
+ [Test]
+ public void RotateComputeMatrixReturnsNaNForThePlain2DForm()
+ {
+ // Root cause: CssRotateValue.ComputeMatrix() (Values/Functions/CssRotateValue.cs) has
+ // two distinct bugs that compound here:
+ //
+ // 1. A copy-paste typo - the `y` and `z` locals are both assigned `_x.AsDouble()`
+ // instead of `_y.AsDouble()`/`_z.AsDouble()` respectively. This alone would corrupt
+ // rotate3d(x, y, z, angle) results, but does not explain this test's NaN, since for
+ // plain 2D rotate() _x/_y/_z are all null anyway.
+ //
+ // 2. The real cause of the NaN: for the plain 2D `rotate(angle)` form, _x/_y/_z are all
+ // null (per this class's own Name property, which treats "all three null" as the
+ // signal for plain `rotate`, distinct from rotateX/Y/Z/rotate3d). Per the CSS
+ // Transforms spec, `rotate(angle)` is shorthand for `rotate3d(0, 0, 1, angle)` - the
+ // rotation axis defaults to the Z axis, not the zero vector. ComputeMatrix does not
+ // special-case this: it reads x/y/z as 0 (via AsDouble() on a null value) and
+ // proceeds to normalize (x, y, z) as if it were a real axis vector - normalizing a
+ // zero-length vector divides by Math.Sqrt(0) = 0, producing Infinity, and
+ // 0 * Infinity is NaN in IEEE 754, propagating through every matrix entry.
+ var source = new StringSource("rotate(45deg)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ var matrix = value.ComputeMatrix(new PlainRenderDevice());
+
+ Assert.IsFalse(double.IsNaN(matrix.M11), "M11 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M12), "M12 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M21), "M21 should not be NaN");
+ Assert.IsFalse(double.IsNaN(matrix.M22), "M22 should not be NaN");
+ }
+
+ [Test]
+ public void PlainSixValueMatrixFunctionDoesNotThrowWhenComputed()
+ {
+ // Original root cause (now fixed): CssMatrixValue.ComputeMatrix() used to pad the
+ // ordinary 6-value 2D matrix(a, b, c, d, e, f) form up to a 4x4 matrix by appending
+ // only 8 more values before constructing a TransformMatrix from the flat array - but
+ // TransformMatrix's array constructor requires exactly 16 values (4x4) and 6 + 8 = 14,
+ // not 16, so it threw "You need to provide 16 (4x4) values." See
+ // PlainSixValueMatrixFunctionPreservesAllSixComponents below for a second, distinct bug
+ // the fix for this one introduced.
+ var source = new StringSource("matrix(1, 0, 0, 1, 5, 9)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ Assert.DoesNotThrow(() => value.ComputeMatrix(new PlainRenderDevice()));
+ }
+
+ [Test]
+ public void PlainSixValueMatrixFunctionPreservesAllSixComponents()
+ {
+ // Root cause: TransformMatrix's array constructor (Values/TransformMatrix.cs) is
+ // column-major - `for (i = 0..4) for (j = 0..4, k++) _matrix[j, i] = values[k];` with
+ // `i` as the outer/column index and `j` as the inner/row index, so array indices
+ // 0-3 fill *column* 0 (not row 0), indices 4-7 fill column 1, and so on; Tx/Ty/Tz live
+ // at indices 12/13/14 (the start of column 3), not scattered through the middle of the
+ // array.
+ //
+ // CssMatrixValue.ComputeMatrix()'s current 6-to-16 padding
+ // (Values/Functions/CssMatrixValue.cs) builds
+ // [a, c, 0, e, b, d, 0, f, 0, 0, 1, 0, 0, 0, 0, 1] - grouping by CSS *row*
+ // (matching how a reader would naturally transcribe matrix(a,b,c,d,e,f) into the 2D
+ // homogeneous matrix [[a,c,0,e],[b,d,0,f],[0,0,1,0],[0,0,0,1]]), which would be correct
+ // for a *row-major* array constructor but is wrong for this one, which is column-major.
+ // Reading that array back through the constructor's actual column-major loop lands
+ // `e` and `f` in column 0/1's *last* row (an unused perspective cell, values[3] and
+ // values[7]) rather than in column 3 (Tx/Ty) - so the translation is silently dropped
+ // to 0 - and it also transposes b/c into the wrong of M12/M21.
+ //
+ // Confirmed with distinguishable, non-symmetric coefficients (a=2, b=3, c=4, d=5, e=6,
+ // f=7) specifically so a coincidental symmetric identity (e.g. a=d=1, b=c=0) cannot mask
+ // the corruption - which is exactly what happened with the identical-looking
+ // PlainSixValueMatrixFunctionDoesNotThrowWhenComputed test above: for matrix(1,0,0,1,5,9)
+ // this bug happens to zero everything down to the identity matrix, silently discarding
+ // the translation without a symptom that "does not throw" alone would ever catch.
+ //
+ // The correct column-major 16-value embedding of matrix(a, b, c, d, e, f) is
+ // [a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1].
+ var source = new StringSource("matrix(2, 3, 4, 5, 6, 7)");
+ var value = TransformParser.ParseTransform(source);
+ Assert.IsNotNull(value);
+
+ var matrix = value.ComputeMatrix(new PlainRenderDevice());
+
+ Assert.AreEqual(2.0, matrix.M11, "M11 should be a (2)");
+ Assert.AreEqual(4.0, matrix.M12, "M12 should be c (4)");
+ Assert.AreEqual(3.0, matrix.M21, "M21 should be b (3)");
+ Assert.AreEqual(5.0, matrix.M22, "M22 should be d (5)");
+ Assert.AreEqual(6.0, matrix.Tx, "Tx should be e (6)");
+ Assert.AreEqual(7.0, matrix.Ty, "Ty should be f (7)");
+ }
+ }
+}
diff --git a/src/AngleSharp.Css/Constants/InitialValues.cs b/src/AngleSharp.Css/Constants/InitialValues.cs
index eb808595..a6d9b590 100644
--- a/src/AngleSharp.Css/Constants/InitialValues.cs
+++ b/src/AngleSharp.Css/Constants/InitialValues.cs
@@ -59,6 +59,7 @@ static class InitialValues
public static readonly ICssValue ForcedColorAdjustDecl = new CssConstantValue