Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
# 1.2.2

Released on Sunday, October 25 2026.

- Improved the cascade to only match the rules that can apply to an element (grouped by the id, class, or tag name of their subject)
- Improved the render tree to skip computing `::before` and `::after` without a `content` declaration
- Improved the lookup of property declarations
- Fixed stack overflow for `rem` in the `font-size` of the root element (now refers to the initial font size)
- Fixed media queries in the range syntax of Media Queries 4 (`(width >= 600px)`, `(400px < width <= 700px)`), which never matched
- Fixed media queries combined with `or`, nested conditions (`(a) and ((b) or (c))`), and `not` applying to the whole query
- Fixed media queries with math functions as values (`(width < calc(50rem + 2rem))`), which were invalid
- Fixed `var()` substitution separating tokens with comments where they could not merge (e.g. `minmax(/**/15rem/**/,1fr)`), which made grid track lists invalid
- Fixed `gap` with a single value (e.g. `gap: 8px` or `gap: var(--gap)`) not setting `column-gap`
- Fixed flow-relative properties (e.g. `padding-inline`, `margin-inline-end`, `inset-inline-start`, `inline-size`) not applying to the physical sides; they are now mapped according to `writing-mode` and `direction`, sharing one cascade with the physical properties
- Fixed `orientation` media feature to follow the viewport instead of the device
- Fixed `matchMedia` throwing for an invalid query instead of evaluating it as `not all`
- Fixed the pseudo-elements of CSS 2 written with a single colon (e.g. `li:after`) not applying to the pseudo-element
- Fixed pseudo-classes before a pseudo-element (e.g. `li:first-child::after`) being matched against the pseudo-element instead of its originating element
- Fixed the `background` shorthand not accepting `currentcolor` as its color
- Fixed `aspect-ratio` not accepting a single number (e.g. `aspect-ratio: 1.8`, meaning `1.8 / 1`)
- Fixed grid area and line names (e.g. `grid-area: leadingVisual`) and other custom identifiers (`view-transition-name`, `anchor-name`, `position-anchor`, `animation-timeline`, `font-palette`) being lowercased, so they no longer matched case-sensitive names like the ones of `grid-template-areas`
- Fixed `em` and percentage lengths throwing for an element with `font-size: 0` or an empty box (e.g. in `translate()`)
- Added cascade layers (`@layer`) to the cascade, whose rules were ignored before
- Added shadow trees to the render tree: a shadow host renders its shadow tree (styled by the shadow root's style sheets, with the host as scope for `:host`) with slots showing their assigned nodes, unless hidden
- Added `:host` rules of a shadow tree styling the shadow host in the render tree; they lose against the author rules of the host's own tree unless important
- Added the presentational hints of HTML elements to the cascade: dimension attributes (e.g. `<img width=40 height=30>`, `<td width>`) and `bgcolor`/`text`/`color` attributes map to their CSS properties, before all author rules

# 1.2.1

Released on Friday, October 23 2026.
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Css.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/css",
"version": "1.2.1",
"version": "1.2.2",
"preview": true,
"description": "The doclet for the AngleSharp.Css documentation.",
"keywords": [
Expand Down
11 changes: 11 additions & 0 deletions src/AngleSharp.Css.Tests/Declarations/CssBackgroundProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ namespace AngleSharp.Css.Tests.Declarations
[TestFixture]
public class CssBackgroundPropertyTests
{
[TestCase("background: currentcolor", "currentColor")]
[TestCase("background: url(i.svg) currentColor", "url(\"i.svg\") currentColor")]
[TestCase("background: CURRENTCOLOR no-repeat", "no-repeat currentColor")]
public void CssBackgroundWithCurrentColorLegal(string snippet, string expected)
{
var property = ParseDeclaration(snippet);
Assert.AreEqual("background", property.Name);
Assert.IsTrue(property.HasValue);
Assert.AreEqual(expected, property.Value);
}

[Test]
public void CssBackgroundSizeCoverTest()
{
Expand Down
23 changes: 23 additions & 0 deletions src/AngleSharp.Css.Tests/Declarations/CssBoxModelProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ public void AspectRatioNegativeLegal()
Assert.IsTrue(property.HasValue);
}

// a single number n is the ratio n/1 (found on MDN: aspect-ratio: 1.8)
[TestCase("aspect-ratio: 2", "2/1")]
[TestCase("aspect-ratio: 1.8", "1.8/1")]
[TestCase("aspect-ratio: auto 2", "auto 2/1")]
[TestCase("aspect-ratio: 2 auto", "auto 2/1")]
public void AspectRatioNumberLegal(string snippet, string expected)
{
var property = ParseDeclaration(snippet);
Assert.AreEqual("aspect-ratio", property.Name);
Assert.IsTrue(property.HasValue);
Assert.AreEqual(expected, property.Value);
}

[TestCase("aspect-ratio: 2px")]
[TestCase("aspect-ratio: -2")]
[TestCase("aspect-ratio: 2 3")]
public void AspectRatioNumberIllegal(string snippet)
{
var property = ParseDeclaration(snippet);
Assert.AreEqual("aspect-ratio", property.Name);
Assert.IsFalse(property.HasValue);
}

[Test]
public void AspectRatioStringIllegal()
{
Expand Down
13 changes: 13 additions & 0 deletions src/AngleSharp.Css.Tests/Declarations/CssGridProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,19 @@ public void CssGridAreaThreeValuesLegal()
Assert.AreEqual("2 / foobar / auto / foobar", property.Value);
}

// area and line names are case-sensitive, like the ones of grid-template-areas (found on GitHub)
[TestCase("grid-area: leadingVisual", "leadingVisual")]
[TestCase("grid-row-start: Header", "Header")]
[TestCase("grid-column: span Main", "span Main")]
[TestCase("view-transition-name: Card", "Card")]
[TestCase("animation-timeline: --Scroller", "--Scroller")]
public void CssGridLineNamesKeepTheirCase(string snippet, string expected)
{
var property = ParseDeclaration(snippet);
Assert.IsTrue(property.HasValue);
StringAssert.StartsWith(expected, property.Value);
}

[Test]
public void CssGridAreaSingleValueLegal()
{
Expand Down
120 changes: 120 additions & 0 deletions src/AngleSharp.Css.Tests/Extensions/MatchMedia.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
#nullable disable
namespace AngleSharp.Css.Tests.Extensions
{
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using NUnit.Framework;
using System.Linq;

[TestFixture]
public class MatchMediaTests
Expand Down Expand Up @@ -171,6 +173,124 @@ public void MatchMediaKeepsTheProvidedMediaText()
Assert.AreEqual("(min-width: 600px)", window.MatchMedia("(min-width: 600px)").MediaText);
}

// Media Queries 4 range syntax, e.g. used by GitHub for all of its breakpoints
[TestCase("(width >= 1000px)", true)]
[TestCase("(width >= 1001px)", false)]
[TestCase("(width <= 1000px)", true)]
[TestCase("(width <= 999.98px)", false)]
[TestCase("(width > 1000px)", false)]
[TestCase("(width > 999px)", true)]
[TestCase("(width < 1000px)", false)]
[TestCase("(width < 1001px)", true)]
[TestCase("(width = 1000px)", true)]
[TestCase("(width = 999px)", false)]
[TestCase("(width>=1000px)", true)]
[TestCase("(1000px <= width)", true)]
[TestCase("(1001px <= width)", false)]
[TestCase("(1001px > width)", true)]
[TestCase("(1000px > width)", false)]
[TestCase("(400px < width < 1200px)", true)]
[TestCase("(400px < width <= 1000px)", true)]
[TestCase("(400px < width < 1000px)", false)]
[TestCase("(1200px > width > 400px)", true)]
[TestCase("(1200px > width >= 1000px)", true)]
[TestCase("(1200px > width > 1000px)", false)]
[TestCase("(height > 600px)", true)]
[TestCase("(height >= 801px)", false)]
[TestCase("screen and (width >= 1000px)", true)]
[TestCase("(width >= 768px) and (width < 1012px)", true)]
[TestCase("(width >= 1012px) and (height > 600px)", false)]
[TestCase("not (width >= 1012px)", true)]
[TestCase("not (width < 1012px)", false)]
// calc() in a range, e.g. used by MDN for its breakpoints (1rem = 16px here)
[TestCase("(width < calc(1rem * 2 + 15rem + 2rem + 31rem))", false)]
[TestCase("(width > calc(1rem * 2 + 15rem + 2rem + 31rem))", true)]
[TestCase("(min-width: calc(500px + 1rem))", true)]
[TestCase("(min-width: calc(1100px + 1rem))", false)]
[TestCase("(max-width: min(800px, 50rem))", false)]
[TestCase("(max-width: min(1200px, 100rem))", true)]
public void MatchMediaEvaluatesRanges(string query, bool expected)
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800, FontSize = 16 });
Assert.AreEqual(expected, window.MatchMedia(query).IsMatched);
}

// the viewport is 1000x800 here
[TestCase("(min-width: 2000px) or (min-height: 600px)", true)]
[TestCase("(min-width: 2000px) or (min-height: 900px)", false)]
[TestCase("(width >= 2000px) or (width < 1012px)", true)]
[TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: landscape))", true)]
[TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: portrait))", false)]
[TestCase("((min-width: 500px) and (min-height: 900px)) or (orientation: landscape)", true)]
[TestCase("(min-width: 500px) and (not (min-height: 900px))", true)]
[TestCase("not ((min-width: 500px) and (min-height: 900px))", true)]
[TestCase("not ((min-width: 500px) and (min-height: 600px))", false)]
[TestCase("not ((min-width: 2000px) or (min-height: 600px))", false)]
[TestCase("not screen and (min-width: 2000px)", true)]
[TestCase("not screen and (min-width: 500px)", false)]
[TestCase("not print and (min-width: 500px)", true)]
[TestCase("only screen and (min-width: 500px) and (max-width: 1200px)", true)]
[TestCase("tv", false)]
[TestCase("not tv", true)]
[TestCase("not all", false)]
public void MatchMediaCombinesConditions(string query, bool expected)
{
var window = CreateWindow(new DefaultRenderDevice { Category = DeviceCategory.Screen, ViewPortWidth = 1000, ViewPortHeight = 800 });
Assert.AreEqual(expected, window.MatchMedia(query).IsMatched);
}

[TestCase(1000, 800, "landscape")]
[TestCase(800, 1000, "portrait")]
[TestCase(800, 800, "portrait")]
public void MatchMediaOrientationFollowsTheViewport(int width, int height, string orientation)
{
// a landscape device may well show a portrait viewport
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = width, ViewPortHeight = height, DeviceWidth = 1920, DeviceHeight = 1080 });
Assert.IsTrue(window.MatchMedia($"(orientation: {orientation})").IsMatched);
}

[TestCase("(min-width: 500px) and ((min-height: 900px) or (orientation: landscape))")]
[TestCase("(min-width: 500px) and (not (min-height: 900px))")]
[TestCase("not ((min-width: 500px) and (min-height: 900px))")]
public void MatchMediaKeepsNestedConditions(string query)
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
Assert.AreEqual(query, window.MatchMedia(query).Media.MediaText);
}

[TestCase("(400px < width > 1200px)")]
[TestCase("(min-width: )")]
[TestCase("screen and")]
public void MatchMediaOfAnInvalidQueryIsNotAll(string query)
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
var list = window.MatchMedia(query);

Assert.IsFalse(list.IsMatched);
Assert.AreEqual("not all", list.Media.MediaText);
}

[Test]
public void RangesPointingInDifferentDirectionsAreInvalid()
{
var context = BrowsingContext.New(Configuration.Default.WithCss());
var sheet = (ICssStyleSheet)context.GetService<IHtmlParser>()
.ParseDocument("<style>@media (400px < width > 1200px) { p { color: red } }</style>")
.StyleSheets[0];

Assert.AreEqual("not all", sheet.Rules.OfType<ICssMediaRule>().Single().ConditionText);
}

[TestCase("(width >= 1012px)")]
[TestCase("(1012px <= width)")]
[TestCase("(400px < width <= 1200px)")]
[TestCase("(1200px > width > 400px)")]
public void MatchMediaKeepsTheRangeText(string query)
{
var window = CreateWindow(new DefaultRenderDevice { ViewPortWidth = 1000, ViewPortHeight = 800 });
Assert.AreEqual(query, window.MatchMedia(query).Media.MediaText);
}

private static IWindow CreateWindow(IRenderDevice device)
{
var config = Configuration.Default.WithCss().WithRenderDevice(device);
Expand Down
21 changes: 20 additions & 1 deletion src/AngleSharp.Css.Tests/Extensions/PseudoElementRenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,24 @@ public void PseudoElementDefaultsToInlineDisplay()

Assert.AreEqual("inline", before.ComputedStyle.GetDisplay());
}
}

// the pseudo-elements of CSS 2 can be written with a single colon, and the pseudo-classes
// before the pseudo-element refer to the originating element (e.g. MDN's breadcrumbs)
[TestCase("#target:after", true)]
[TestCase("li:after", true)]
[TestCase("#target:not(.x):after", true)]
[TestCase("li:first-child::after", true)]
[TestCase("li:first-child:after", true)]
[TestCase(":is(ul li):not(:last-child):after", true)]
[TestCase("li:last-child::after", false)]
[TestCase("li:not(:last-child)::after", true)]
public void PseudoClassesBeforeAPseudoElementReferToTheOriginatingElement(string selector, bool expected)
{
var root = Render($"<html><head><style>{selector} {{ content: \"X\"; }}</style></head>" +
"<body><ul><li id=target>A</li><li>B</li></ul></body></html>");
var target = FindElement(root, "target");

Assert.AreEqual(expected, target.Children.Any(m => m.Ref is IPseudoElement));
}
}
}
Loading
Loading