From 16a92f4733da418e423e81ee6de27ee78e92d531 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 3 Sep 2026 10:25:38 +0300 Subject: [PATCH 1/2] Answer the user-preference media features from the render device Adds IRenderDevicePreferences, a small interface with a single IReadOnlyDictionary Preferences member that DefaultRenderDevice implements, so a host can say which user preferences its device carries without every existing IRenderDevice implementation having to change. A generic PreferenceFeatureValidator is registered for prefers-color-scheme, prefers-reduced-motion, prefers-reduced-transparency, prefers-contrast, prefers-reduced-data, forced-colors and display-mode, and hover/any-hover and pointer/any-pointer now read the dictionary when it carries them, while keeping their previous answer when it does not. A key that is not set leaves its feature unknown, i.e., the query does not match. Fixes #234 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- README.md | 31 +++ docs/general/05-Extensibility.md | 33 +++ .../CssConstructionFunctions.cs | 7 + .../Extensions/MediaPreferences.cs | 163 +++++++++++ .../Mocks/PlainRenderDevice.cs | 43 +++ .../Mocks/PreferringRenderDevice.cs | 55 ++++ .../Rules/CssMediaPreferenceFeatures.cs | 258 ++++++++++++++++++ src/AngleSharp.Css/Constants/CssKeywords.cs | 30 ++ src/AngleSharp.Css/Constants/FeatureNames.cs | 45 +++ src/AngleSharp.Css/DefaultRenderDevice.cs | 10 +- .../Extensions/RenderDeviceExtensions.cs | 26 ++ .../DefaultFeatureValidatorFactory.cs | 13 +- .../HoverFeatureValidator.cs | 14 + .../PointerFeatureValidator.cs | 14 + .../PreferenceFeatureValidator.cs | 54 ++++ .../IRenderDevicePreferences.cs | 20 ++ 16 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs create mode 100644 src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs create mode 100644 src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs create mode 100644 src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs create mode 100644 src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs create mode 100644 src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs create mode 100644 src/AngleSharp.Css/IRenderDevicePreferences.cs diff --git a/README.md b/README.md index dcc5e464..ee29e537 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,37 @@ var config = Configuration.Default If no specific `IRenderDevice` (e.g., via creating an `DefaultRenderDevice` object) instance is created a default implementation will be set. +The render device also carries the *user preferences* of the Media Queries Level 5 (and Level 4) user-preference features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that, and any custom `IRenderDevice` can implement it as well. The dictionary is keyed by the media feature name and holds the keyword that the feature should answer with: + +```cs +var config = Configuration.Default + .WithCss() + .WithRenderDevice(new DefaultRenderDevice + { + Preferences = new Dictionary + { + { "prefers-color-scheme", "dark" }, + { "prefers-reduced-motion", "reduce" }, + }, + }); +``` + +With this device `@media (prefers-color-scheme: dark)` applies in the cascade and `window.MatchMedia("(prefers-color-scheme: dark)").IsMatched` is `true`. A key that is not set leaves its media feature unknown, i.e., a query using it never matches. The keys a browser would set are: + +| Key | Keywords | +| --- | --- | +| `prefers-color-scheme` | `light`, `dark` | +| `prefers-reduced-motion` | `no-preference`, `reduce` | +| `prefers-reduced-transparency` | `no-preference`, `reduce` | +| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` | +| `prefers-reduced-data` | `no-preference`, `reduce` | +| `forced-colors` | `none`, `active` | +| `hover`, `any-hover` | `none`, `hover` | +| `pointer`, `any-pointer` | `none`, `coarse`, `fine` | +| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` | + +The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism. + Going a bit further it is possible to `Render` the current document. This render tree information can then be used to retrieve or other information, e.g., ```cs diff --git a/docs/general/05-Extensibility.md b/docs/general/05-Extensibility.md index 5b17a698..bd0d64b5 100644 --- a/docs/general/05-Extensibility.md +++ b/docs/general/05-Extensibility.md @@ -20,6 +20,8 @@ AngleSharp.Css is designed to be composed through services in the AngleSharp con : Add pseudo-element behavior. - `IRenderDevice` : Provide device characteristics for style computation. +- `IRenderDevicePreferences` +: Provide the user preferences answering the user-preference media features. ## Override The Default Stylesheet @@ -56,6 +58,37 @@ var config = Configuration.Default .WithRenderDevice(renderDevice); ``` +## Provide The User Preferences + +Beside the dimensions a render device carries the user preferences, which answer the user-preference media features. `DefaultRenderDevice` implements `IRenderDevicePreferences` for that; a custom `IRenderDevice` can implement it as well and is picked up the same way. + +```cs +var renderDevice = new DefaultRenderDevice +{ + Preferences = new Dictionary + { + { "prefers-color-scheme", "dark" }, + { "prefers-reduced-motion", "reduce" }, + }, +}; +``` + +The dictionary is keyed by the media feature name and holds the keyword the feature answers with. A key that is not set leaves its media feature unknown, i.e., a query using it never matches. + +| Key | Keywords | +| --- | --- | +| `prefers-color-scheme` | `light`, `dark` | +| `prefers-reduced-motion` | `no-preference`, `reduce` | +| `prefers-reduced-transparency` | `no-preference`, `reduce` | +| `prefers-contrast` | `no-preference`, `more`, `less`, `custom` | +| `prefers-reduced-data` | `no-preference`, `reduce` | +| `forced-colors` | `none`, `active` | +| `hover`, `any-hover` | `none`, `hover` | +| `pointer`, `any-pointer` | `none`, `coarse`, `fine` | +| `display-mode` | `fullscreen`, `standalone`, `minimal-ui`, `browser` | + +The value is compared to the queried keyword case insensitively, so a keyword that is newer than this library works as well. Used without a value, e.g., `@media (prefers-reduced-motion)`, the feature evaluates in a boolean context, where `no-preference` (and `none` for `forced-colors`, `hover`, `any-hover`, `pointer` and `any-pointer`) is `false`. Without a preference `hover` and `pointer` keep answering as they did before, i.e., as a device with no input mechanism. + ## Composition Pattern Start from the default registrations and replace only what you need: diff --git a/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs b/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs index 5c3d377b..5d74129f 100644 --- a/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs +++ b/src/AngleSharp.Css.Tests/CssConstructionFunctions.cs @@ -98,6 +98,13 @@ internal static Predicate CreateValidator(String name, String val return device => validator.Validate(feature, device); } + internal static Predicate CreateBooleanValidator(String name) + { + var validator = CreateMediaFeatureValidator(name); + var feature = new MediaFeature(name); + return device => validator.Validate(feature, device); + } + internal static CssFontFeatureValuesRule ParseFontFeatureValuesRule(String source) { ICssParser parser = new CssParser(); diff --git a/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs b/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs new file mode 100644 index 00000000..a9ebe0ec --- /dev/null +++ b/src/AngleSharp.Css.Tests/Extensions/MediaPreferences.cs @@ -0,0 +1,163 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Extensions +{ + using AngleSharp.Css.Dom; + using AngleSharp.Css.Tests.Mocks; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using NUnit.Framework; + using System; + using System.Collections.Generic; + + [TestFixture] + public class MediaPreferencesTests + { + [Test] + public void MatchMediaPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWhenLightIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light)); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedWithoutAnyPreference() + { + var window = CreateWindow(new DefaultRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaNotPrefersColorSchemeDarkIsMatchedWhenLightIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light)); + Assert.IsTrue(window.MatchMedia("not (prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsMatchedForAThirdPartyDevice() + { + var window = CreateWindow(new PreferringRenderDevice(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeDarkIsNotMatchedForADeviceWithoutPreferences() + { + var window = CreateWindow(new PlainRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaScreenAndPrefersColorSchemeDarkIsMatchedWhenDarkIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("screen and (prefers-color-scheme: dark)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeInBooleanContextIsMatchedWhenSet() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark)); + Assert.IsTrue(window.MatchMedia("(prefers-color-scheme)").IsMatched); + } + + [Test] + public void MatchMediaPrefersColorSchemeInBooleanContextIsNotMatchedWithoutAnyPreference() + { + var window = CreateWindow(new DefaultRenderDevice()); + Assert.IsFalse(window.MatchMedia("(prefers-color-scheme)").IsMatched); + } + + [Test] + public void MatchMediaPrefersReducedMotionIsMatchedWhenReduceIsPreferred() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)); + Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched); + Assert.IsTrue(window.MatchMedia("(prefers-reduced-motion)").IsMatched); + } + + [Test] + public void MatchMediaPrefersReducedMotionIsNotMatchedWhenNoPreferenceIsSet() + { + var window = CreateWindow(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference)); + Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion: reduce)").IsMatched); + Assert.IsFalse(window.MatchMedia("(prefers-reduced-motion)").IsMatched); + } + + [Test] + public void MatchMediaForcedColorsIsMatchedWhenActive() + { + var window = CreateWindow(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active)); + Assert.IsTrue(window.MatchMedia("(forced-colors: active)").IsMatched); + Assert.IsTrue(window.MatchMedia("(forced-colors)").IsMatched); + } + + [Test] + public void MatchMediaHoverIsMatchedFromThePreference() + { + var window = CreateWindow(DeviceWith(FeatureNames.Hover, CssKeywords.Hover)); + Assert.IsTrue(window.MatchMedia("(hover: hover)").IsMatched); + Assert.IsFalse(window.MatchMedia("(hover: none)").IsMatched); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsAppliedInTheCascade() + { + var document = CreateDocument(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(0, 128, 0, 1)", style.GetColor()); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsSkippedWithoutThePreference() + { + var document = CreateDocument(new DefaultRenderDevice()); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); + } + + [Test] + public void PrefersReducedMotionMediaRuleIsSkippedForADeviceWithoutPreferences() + { + var document = CreateDocument(new PlainRenderDevice()); + var style = document.QuerySelector("div").ComputeCurrentStyle(); + Assert.AreEqual("rgba(255, 0, 0, 1)", style.GetColor()); + } + + private static DefaultRenderDevice DeviceWith(String name, String value) => new DefaultRenderDevice + { + Preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }, + }; + + private static IDocument CreateDocument(IRenderDevice device) + { + var source = @"
"; + var config = Configuration.Default.WithCss().WithRenderDevice(device); + var context = BrowsingContext.New(config); + var parser = context.GetService(); + return parser.ParseDocument(source); + } + + private static IWindow CreateWindow(IRenderDevice device) + { + var config = Configuration.Default.WithCss().WithRenderDevice(device); + var context = BrowsingContext.New(config); + var parser = context.GetService(); + var document = parser.ParseDocument("Example"); + return document.DefaultView; + } + } +} diff --git a/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs b/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs new file mode 100644 index 00000000..348cf430 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Mocks/PlainRenderDevice.cs @@ -0,0 +1,43 @@ +namespace AngleSharp.Css.Tests.Mocks +{ + using AngleSharp.Css; + using System; + + /// + /// A render device that deliberately does not implement + /// , i.e., what an existing + /// third-party implementation of looks like. + /// + sealed class PlainRenderDevice : IRenderDevice + { + public DeviceCategory Category => DeviceCategory.Screen; + + public Int32 ColorBits => 32; + + public Int32 DeviceHeight => 800; + + public Int32 DeviceWidth => 1000; + + public Int32 Frequency => 60; + + public Boolean IsGrid => false; + + public Boolean IsInterlaced => false; + + public Boolean IsScripting => true; + + public Int32 MonochromeBits => 16; + + public Int32 Resolution => 96; + + public Int32 ViewPortHeight => 800; + + public Int32 ViewPortWidth => 1000; + + public Double RenderWidth => ViewPortWidth; + + public Double RenderHeight => ViewPortHeight; + + public Double FontSize => 16; + } +} diff --git a/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs b/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs new file mode 100644 index 00000000..994f6487 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Mocks/PreferringRenderDevice.cs @@ -0,0 +1,55 @@ +namespace AngleSharp.Css.Tests.Mocks +{ + using AngleSharp.Css; + using System; + using System.Collections.Generic; + + /// + /// A third-party render device that opts into the user preferences + /// without deriving from . + /// + sealed class PreferringRenderDevice : IRenderDevice, IRenderDevicePreferences + { + private readonly Dictionary _preferences; + + public PreferringRenderDevice(String name, String value) + { + _preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }; + } + + public IReadOnlyDictionary Preferences => _preferences; + + public DeviceCategory Category => DeviceCategory.Screen; + + public Int32 ColorBits => 32; + + public Int32 DeviceHeight => 800; + + public Int32 DeviceWidth => 1000; + + public Int32 Frequency => 60; + + public Boolean IsGrid => false; + + public Boolean IsInterlaced => false; + + public Boolean IsScripting => true; + + public Int32 MonochromeBits => 16; + + public Int32 Resolution => 96; + + public Int32 ViewPortHeight => 800; + + public Int32 ViewPortWidth => 1000; + + public Double RenderWidth => ViewPortWidth; + + public Double RenderHeight => ViewPortHeight; + + public Double FontSize => 16; + } +} diff --git a/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs b/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs new file mode 100644 index 00000000..491a67e6 --- /dev/null +++ b/src/AngleSharp.Css.Tests/Rules/CssMediaPreferenceFeatures.cs @@ -0,0 +1,258 @@ +#nullable disable +namespace AngleSharp.Css.Tests.Rules +{ + using AngleSharp.Css; + using AngleSharp.Css.FeatureValidators; + using AngleSharp.Css.Tests.Mocks; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using static CssConstructionFunctions; + + [TestFixture] + public class CssMediaPreferenceFeaturesTests + { + [Test] + public void CssMediaPreferenceFeatureValidatorFactory() + { + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersColorScheme)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedMotion)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedTransparency)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersReducedData)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.PrefersContrast)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.ForcedColors)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.DisplayMode)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.Hover)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.AnyHover)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.Pointer)); + Assert.IsInstanceOf(CreateMediaFeatureValidator(FeatureNames.AnyPointer)); + } + + [Test] + public void CssMediaPrefersColorSchemeValidation() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersColorSchemeIsComparedCaseInsensitively() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, "DARK"))); + } + + [Test] + public void CssMediaPrefersColorSchemeIsFoundForAnUppercaseKey() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsTrue(validate(DeviceWith("Prefers-Color-Scheme", CssKeywords.Dark))); + } + + [Test] + public void CssMediaPrefersColorSchemeInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersColorScheme); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Dark))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.Light))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersColorScheme, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedMotionValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedMotionInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedMotion); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedTransparencyValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedTransparencyInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedTransparency); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaPrefersReducedDataValidation() + { + var validate = CreateValidator(FeatureNames.PrefersReducedData, CssKeywords.Reduce); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.NoPreference))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersReducedDataInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersReducedData); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.Reduce))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedData, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaPrefersContrastValidation() + { + var validate = CreateValidator(FeatureNames.PrefersContrast, CssKeywords.More); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.More))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Less))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Custom))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPrefersContrastInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.PrefersContrast); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.More))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Less))); + Assert.IsTrue(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.Custom))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersContrast, CssKeywords.NoPreference))); + } + + [Test] + public void CssMediaForcedColorsValidation() + { + var validate = CreateValidator(FeatureNames.ForcedColors, CssKeywords.Active); + Assert.IsTrue(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.None))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaForcedColorsInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.ForcedColors); + Assert.IsTrue(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.Active))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.ForcedColors, CssKeywords.None))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaDisplayModeValidation() + { + var validate = CreateValidator(FeatureNames.DisplayMode, "standalone"); + Assert.IsTrue(validate(DeviceWith(FeatureNames.DisplayMode, "standalone"))); + Assert.IsFalse(validate(DeviceWith(FeatureNames.DisplayMode, "browser"))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaDisplayModeInBooleanContext() + { + var validate = CreateBooleanValidator(FeatureNames.DisplayMode); + Assert.IsTrue(validate(DeviceWith(FeatureNames.DisplayMode, "browser"))); + Assert.IsFalse(validate(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPreferencesAreNotReadFromADeviceWithoutTheInterface() + { + var device = new PlainRenderDevice(); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedMotion, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersReducedData, CssKeywords.Reduce)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.PrefersContrast, CssKeywords.More)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.ForcedColors, CssKeywords.Active)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.DisplayMode, "browser")(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.PrefersColorScheme)(device)); + } + + [Test] + public void CssMediaHoverKeepsItsAnswerWithoutAPreference() + { + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.None)(new DefaultRenderDevice())); + Assert.IsFalse(CreateValidator(FeatureNames.Hover, CssKeywords.Hover)(new DefaultRenderDevice())); + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.None)(new PlainRenderDevice())); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Hover)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaHoverIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.Hover, CssKeywords.Hover); + Assert.IsTrue(CreateValidator(FeatureNames.Hover, CssKeywords.Hover)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.Hover, CssKeywords.None)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.Hover)(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Hover)(DeviceWith(FeatureNames.Hover, CssKeywords.None))); + } + + [Test] + public void CssMediaAnyHoverIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.AnyHover, CssKeywords.Hover); + Assert.IsTrue(CreateValidator(FeatureNames.AnyHover, CssKeywords.Hover)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.AnyHover, CssKeywords.None)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.AnyHover)(device)); + Assert.IsTrue(CreateValidator(FeatureNames.AnyHover, CssKeywords.None)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPointerKeepsItsAnswerWithoutAPreference() + { + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.None)(new DefaultRenderDevice())); + Assert.IsFalse(CreateValidator(FeatureNames.Pointer, CssKeywords.Fine)(new DefaultRenderDevice())); + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.None)(new PlainRenderDevice())); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Pointer)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPointerIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.Pointer, CssKeywords.Fine); + Assert.IsTrue(CreateValidator(FeatureNames.Pointer, CssKeywords.Fine)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.Pointer, CssKeywords.Coarse)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.Pointer)(device)); + Assert.IsFalse(CreateBooleanValidator(FeatureNames.Pointer)(DeviceWith(FeatureNames.Pointer, CssKeywords.None))); + } + + [Test] + public void CssMediaAnyPointerIsTakenFromThePreference() + { + var device = DeviceWith(FeatureNames.AnyPointer, CssKeywords.Coarse); + Assert.IsTrue(CreateValidator(FeatureNames.AnyPointer, CssKeywords.Coarse)(device)); + Assert.IsFalse(CreateValidator(FeatureNames.AnyPointer, CssKeywords.Fine)(device)); + Assert.IsTrue(CreateBooleanValidator(FeatureNames.AnyPointer)(device)); + Assert.IsTrue(CreateValidator(FeatureNames.AnyPointer, CssKeywords.None)(new DefaultRenderDevice())); + } + + [Test] + public void CssMediaPreferenceOfAnotherFeatureIsNotUsed() + { + var validate = CreateValidator(FeatureNames.PrefersColorScheme, CssKeywords.Dark); + Assert.IsFalse(validate(DeviceWith(FeatureNames.PrefersReducedMotion, CssKeywords.Dark))); + } + + private static DefaultRenderDevice DeviceWith(String name, String value) => new DefaultRenderDevice + { + Preferences = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { name, value }, + }, + }; + } +} diff --git a/src/AngleSharp.Css/Constants/CssKeywords.cs b/src/AngleSharp.Css/Constants/CssKeywords.cs index 4d36e61b..7b03468b 100644 --- a/src/AngleSharp.Css/Constants/CssKeywords.cs +++ b/src/AngleSharp.Css/Constants/CssKeywords.cs @@ -2126,5 +2126,35 @@ public static class CssKeywords /// The manipulation keyword for touch-action property. /// public static readonly String Manipulation = "manipulation"; + + /// + /// The no-preference keyword for the user preference media features. + /// + public static readonly String NoPreference = "no-preference"; + + /// + /// The reduce keyword for the user preference media features. + /// + public static readonly String Reduce = "reduce"; + + /// + /// The more keyword for the prefers-contrast media feature. + /// + public static readonly String More = "more"; + + /// + /// The less keyword for the prefers-contrast media feature. + /// + public static readonly String Less = "less"; + + /// + /// The custom keyword for the prefers-contrast media feature. + /// + public static readonly String Custom = "custom"; + + /// + /// The active keyword for the forced-colors media feature. + /// + public static readonly String Active = "active"; } } diff --git a/src/AngleSharp.Css/Constants/FeatureNames.cs b/src/AngleSharp.Css/Constants/FeatureNames.cs index 21a8b198..f75c5ea1 100644 --- a/src/AngleSharp.Css/Constants/FeatureNames.cs +++ b/src/AngleSharp.Css/Constants/FeatureNames.cs @@ -206,5 +206,50 @@ public static class FeatureNames /// Gets the name of the hover feature. /// public readonly static String Hover = "hover"; + + /// + /// Gets the name of the any-pointer feature. + /// + public readonly static String AnyPointer = "any-pointer"; + + /// + /// Gets the name of the any-hover feature. + /// + public readonly static String AnyHover = "any-hover"; + + /// + /// Gets the name of the prefers-color-scheme feature. + /// + public readonly static String PrefersColorScheme = "prefers-color-scheme"; + + /// + /// Gets the name of the prefers-reduced-motion feature. + /// + public readonly static String PrefersReducedMotion = "prefers-reduced-motion"; + + /// + /// Gets the name of the prefers-reduced-transparency feature. + /// + public readonly static String PrefersReducedTransparency = "prefers-reduced-transparency"; + + /// + /// Gets the name of the prefers-reduced-data feature. + /// + public readonly static String PrefersReducedData = "prefers-reduced-data"; + + /// + /// Gets the name of the prefers-contrast feature. + /// + public readonly static String PrefersContrast = "prefers-contrast"; + + /// + /// Gets the name of the forced-colors feature. + /// + public readonly static String ForcedColors = "forced-colors"; + + /// + /// Gets the name of the display-mode feature. + /// + public readonly static String DisplayMode = "display-mode"; } } diff --git a/src/AngleSharp.Css/DefaultRenderDevice.cs b/src/AngleSharp.Css/DefaultRenderDevice.cs index e2e5a7fe..3ddf2b3f 100644 --- a/src/AngleSharp.Css/DefaultRenderDevice.cs +++ b/src/AngleSharp.Css/DefaultRenderDevice.cs @@ -1,11 +1,12 @@ namespace AngleSharp.Css { using System; + using System.Collections.Generic; /// /// Represents the default render device. /// - public class DefaultRenderDevice : IRenderDevice + public class DefaultRenderDevice : IRenderDevice, IRenderDevicePreferences { /// public DeviceCategory Category @@ -70,6 +71,13 @@ public Int32 MonochromeBits set; } = 16; + /// + public IReadOnlyDictionary Preferences + { + get; + set; + } = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// public Int32 Resolution { diff --git a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs new file mode 100644 index 00000000..309cd01b --- /dev/null +++ b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs @@ -0,0 +1,26 @@ +namespace AngleSharp.Css +{ + using System; + + static class RenderDeviceExtensions + { + /// + /// Gets the value of the given user preference, or null if the device + /// carries no preferences at all, or none for the given media feature. + /// + public static String? GetPreference(this IRenderDevice? device, String name) + { + if (device is IRenderDevicePreferences source) + { + var preferences = source.Preferences; + + if (preferences is not null && preferences.TryGetValue(name, out var value) && !String.IsNullOrEmpty(value)) + { + return value; + } + } + + return null; + } + } +} diff --git a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs index 6779b61f..ca64b711 100644 --- a/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs +++ b/src/AngleSharp.Css/Factories/DefaultFeatureValidatorFactory.cs @@ -56,8 +56,17 @@ public class DefaultFeatureValidatorFactory : IFeatureValidatorFactory { FeatureNames.Scan, () => new ScanFeatureValidator() }, { FeatureNames.UpdateFrequency, () => new UpdateFrequencyFeatureValidator() }, { FeatureNames.Scripting, () => new ScriptingFeatureValidator() }, - { FeatureNames.Pointer, () => new PointerFeatureValidator() }, - { FeatureNames.Hover, () => new HoverFeatureValidator() }, + { FeatureNames.Pointer, () => new PointerFeatureValidator(FeatureNames.Pointer) }, + { FeatureNames.AnyPointer, () => new PointerFeatureValidator(FeatureNames.AnyPointer) }, + { FeatureNames.Hover, () => new HoverFeatureValidator(FeatureNames.Hover) }, + { FeatureNames.AnyHover, () => new HoverFeatureValidator(FeatureNames.AnyHover) }, + { FeatureNames.PrefersColorScheme, () => new PreferenceFeatureValidator(FeatureNames.PrefersColorScheme, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedMotion, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedMotion, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedTransparency, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedTransparency, CssKeywords.NoPreference) }, + { FeatureNames.PrefersReducedData, () => new PreferenceFeatureValidator(FeatureNames.PrefersReducedData, CssKeywords.NoPreference) }, + { FeatureNames.PrefersContrast, () => new PreferenceFeatureValidator(FeatureNames.PrefersContrast, CssKeywords.NoPreference) }, + { FeatureNames.ForcedColors, () => new PreferenceFeatureValidator(FeatureNames.ForcedColors, CssKeywords.None) }, + { FeatureNames.DisplayMode, () => new PreferenceFeatureValidator(FeatureNames.DisplayMode, null) }, }; /// diff --git a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs index 0f0e3b16..82a5998d 100644 --- a/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/HoverFeatureValidator.cs @@ -7,8 +7,22 @@ namespace AngleSharp.Css.FeatureValidators sealed class HoverFeatureValidator : IFeatureValidator { + private readonly String _name; + + public HoverFeatureValidator(String name) + { + _name = name; + } + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { + var preference = renderDevice.GetPreference(_name); + + if (preference is not null) + { + return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); + } + var hover = HoverAbilityConverter.Convert(feature.Value); if (hover != null) diff --git a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs index 9627f025..81d18671 100644 --- a/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs +++ b/src/AngleSharp.Css/FeatureValidators/PointerFeatureValidator.cs @@ -7,8 +7,22 @@ namespace AngleSharp.Css.FeatureValidators sealed class PointerFeatureValidator : IFeatureValidator { + private readonly String _name; + + public PointerFeatureValidator(String name) + { + _name = name; + } + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) { + var preference = renderDevice.GetPreference(_name); + + if (preference is not null) + { + return PreferenceFeatureValidator.Matches(feature, preference, CssKeywords.None); + } + var accuracy = PointerAccuracyConverter.Convert(feature.Value); if (accuracy != null) diff --git a/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs b/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs new file mode 100644 index 00000000..da1a0cda --- /dev/null +++ b/src/AngleSharp.Css/FeatureValidators/PreferenceFeatureValidator.cs @@ -0,0 +1,54 @@ +namespace AngleSharp.Css.FeatureValidators +{ + using AngleSharp.Css.Dom; + using AngleSharp.Text; + using System; + + /// + /// Validates a user preference media feature, e.g., prefers-color-scheme, + /// against the preferences carried by the render device. + /// https://drafts.csswg.org/mediaqueries-5/#mf-user-preferences + /// + sealed class PreferenceFeatureValidator : IFeatureValidator + { + private readonly String _name; + private readonly String? _noPreference; + + /// + /// Creates a validator for the given media feature. + /// + /// The name of the media feature, which is also the key of the preference. + /// The keyword that evaluates to false in a boolean context, if any. + public PreferenceFeatureValidator(String name, String? noPreference) + { + _name = name; + _noPreference = noPreference; + } + + public Boolean Validate(IMediaFeature feature, IRenderDevice renderDevice) + { + var preference = renderDevice.GetPreference(_name); + return preference is not null && Matches(feature, preference, _noPreference); + } + + /// + /// Compares the queried keyword against the preference of the device. + /// A feature used without a value is evaluated in a boolean context, + /// where the keyword standing for "no preference" yields false. + /// https://drafts.csswg.org/mediaqueries-5/#mq-boolean-context + /// + /// The feature to examine. + /// The preference carried by the device. + /// The keyword that evaluates to false in a boolean context, if any. + /// True if the feature is present, otherwise false. + public static Boolean Matches(IMediaFeature feature, String preference, String? noPreference) + { + if (!feature.HasValue) + { + return noPreference is null || !preference.Isi(noPreference); + } + + return preference.Isi(feature.Value); + } + } +} diff --git a/src/AngleSharp.Css/IRenderDevicePreferences.cs b/src/AngleSharp.Css/IRenderDevicePreferences.cs new file mode 100644 index 00000000..0466e6c0 --- /dev/null +++ b/src/AngleSharp.Css/IRenderDevicePreferences.cs @@ -0,0 +1,20 @@ +namespace AngleSharp.Css +{ + using System; + using System.Collections.Generic; + + /// + /// Represents a render device that also carries the user preferences, + /// e.g., the preferred color scheme. + /// + public interface IRenderDevicePreferences + { + /// + /// Gets the user preferences, keyed by the name of the media feature + /// they answer, e.g., "prefers-color-scheme" mapped to "dark". A name + /// that is not contained remains an unknown media feature, i.e., a + /// query using it never matches. + /// + IReadOnlyDictionary Preferences { get; } + } +} From c5c01becd709acd8c05220012207f204e34a404a Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 3 Sep 2026 16:29:33 +0300 Subject: [PATCH 2/2] Make the render-device preference helper public Review feedback on #235: the convenience layer over IRenderDevice is useful to hosts that configure a DefaultRenderDevice, so the class is public and documented. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NqCcJrL3MJecCPRBMQsZyC --- src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs index 309cd01b..7e4c93eb 100644 --- a/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs +++ b/src/AngleSharp.Css/Extensions/RenderDeviceExtensions.cs @@ -2,12 +2,19 @@ namespace AngleSharp.Css { using System; - static class RenderDeviceExtensions + /// + /// Convenience methods for reading a render device's user preferences, + /// such as the ones a DefaultRenderDevice is configured with. + /// + public static class RenderDeviceExtensions { /// /// Gets the value of the given user preference, or null if the device /// carries no preferences at all, or none for the given media feature. /// + /// The render device to read, which may be null. + /// The media feature name, e.g., prefers-color-scheme. + /// The preference's keyword, or null if there is none. public static String? GetPreference(this IRenderDevice? device, String name) { if (device is IRenderDevicePreferences source)