From 1db73ebf6da9c201ec180d39769d63abeee9659c Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 11:19:32 +0330 Subject: [PATCH 1/2] add theme tokens for the unreduced motion durations #13557 --- .../Surfaces/Accordion/BitAccordion.scss | 2 +- .../Components/Surfaces/Modal/BitModal.scss | 2 +- .../Styles/Fluent/motion.fluent.scss | 59 ++++++++++---- src/BlazorUI/Bit.BlazorUI/Styles/general.scss | 5 +- .../Bit.BlazorUI/Styles/theme-overlay.scss | 3 + .../Bit.BlazorUI/Styles/theme-variables.scss | 6 ++ src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs | 13 ++++ .../Bit.BlazorUI/Utils/Theme/BitTheme.ts | 2 + .../Utils/Theme/BitTheme/BitThemeMotion.cs | 11 +++ .../Utils/Theme/BitThemeMapper.cs | 19 +++-- src/BlazorUI/CLAUDE.md | 9 ++- .../Pages/Theming/ThemingPage.razor | 18 ++--- .../Surfaces/Modal/BitModalStylesheetTests.cs | 2 +- .../Utils/Theme/BitMotionTokenTests.cs | 76 +++++++++++++++++++ .../BitThemeFamilyAliasReSubstitutionTests.cs | 7 ++ .../Theme/BitThemeMapperContractTests.cs | 34 ++++++++- 16 files changed, 231 insertions(+), 37 deletions(-) create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss index d1e5b122dd8..7d82d5dc7d4 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss @@ -86,7 +86,7 @@ $acd-private-properties: // The default is read from the theme's -full duration rather than from the derived one it feeds: the derived // token is the one the reduced-motion query collapses, so reading it here would leave the .bit-fam opt-out // below nothing to restore. - --bit-acd-dur-full: var(--bit-mot-duration-long-full, #{$mot-duration-long}); + --bit-acd-dur-full: #{$mot-duration-long-full}; --bit-acd-dur: var(--bit-acd-dur-full); // Every rule that reaches from the root into a part of the accordion is chained down through the header line diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss index f8a64771a6a..d71440aa8f7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss @@ -215,7 +215,7 @@ @media (prefers-reduced-motion: reduce) { .bit-mdl-bna:not(.bit-fam *), .bit-mdl-bnb:not(.bit-fam *) { - animation-duration: var(--bit-mot-duration-long-full, #{$mot-duration-long}); + animation-duration: $mot-duration-long-full; animation-timing-function: steps(1, end); } diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss index 83994a91155..cce91104ba1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss @@ -1,12 +1,18 @@ // The -full tokens hold the unreduced values and are never touched by the reduced-motion query // below, so they stay the single place to theme a duration: overriding --bit-mot-duration-full -// changes both the normal value and the one .bit-fam restores. Components always read the plain -// --bit-mot-* tokens; only the reduced-motion opt-out (.bit-fam in general.scss) reads -full. +// changes both the normal value and the one .bit-fam restores. That is why BitTheme.Motion writes +// them, never the plain tokens: a plain token set inline would outrank the reduced-motion query. +// Components read the plain --bit-mot-* tokens; besides the reduced-motion opt-out (.bit-fam in +// general.scss), the only readers of -full are the $mot-duration-*-full aliases of +// theme-variables.scss, for what stays meaningful under the preference (a color flash, not a +// movement). // // The literals live here, on :root alone; the plain tokens that alias them live in the twinned // block below. A preset for another design system (the packaged Fluent 2 / Material / Cupertino // bundles) overrides only the -full literals and the easing curves, both of which are literals it // declares on its own :root[bit-theme="..."] / :root [bit-theme="..."] pair. +@import "../theme-overlay.scss"; + :root { --bit-mot-duration-full: 200ms; --bit-mot-duration-short-full: 100ms; @@ -41,8 +47,7 @@ // query below re-declares the same two selectors and wins on source order, and .bit-fam (0,1,0) - // the ForceAnimation opt-out in general.scss - still outranks both, including on an element that // carries bit-theme and bit-fam at once. -:root, -:where(:root [bit-theme]) { +@mixin motion-aliases { --bit-mot-duration: var(--bit-mot-duration-full); --bit-mot-duration-short: var(--bit-mot-duration-short-full); --bit-mot-duration-long: var(--bit-mot-duration-long-full); @@ -54,20 +59,44 @@ // 0.01ms instead of 0ms: a zero-duration transition/animation never fires its // transitionend/animationend event, which would hang any JS awaiting it. -// Twinned like the block above, and for the same reason in reverse: without the twin the collapse -// would never reach a scoped preset region, whose durations are re-substituted on its own element. +@mixin motion-reduced-aliases { + --bit-mot-duration: 0.01ms; + --bit-mot-duration-short: 0.01ms; + --bit-mot-duration-long: 0.01ms; + + // Calmed rather than collapsed - see above. The spinner also drops its eased timing function, + // whose speeding up and slowing down is the part that draws the eye. Stopping a loader + // outright is never an option: it is the only signal that the work is still in progress. + --bit-mot-duration-spinner: 4s; + --bit-mot-easing-spinner: linear; + --bit-mot-loop-factor: 3; +} + +:root, +:where(:root [bit-theme]) { + @include motion-aliases; +} + +// An inline theme overlay - a BitThemeProvider wrapper, or ApplyBitThemeAsync on an element - that +// re-values a -full token writes it on its own element, below the :root that substituted the aliases, +// so the aliases are declared again there (theme-overlay.scss). Every theme builds them the same way, +// so this one rule serves them all. Its weight (0,2,0) is above the :where() twin, and the +// reduced-motion rule below re-declares the same selector and wins on source order; general.scss +// gives .bit-fam a rule of its own that outranks both on an element that carries the overlay too. +@include theme-overlay("motion", $document: true) { + @include motion-aliases; +} + +// Twinned like the blocks above, and for the same reason in reverse: without the twins the collapse +// would never reach a scoped preset region or an overlay, whose durations are re-substituted on their +// own element. @media (prefers-reduced-motion: reduce) { :root, :where(:root [bit-theme]) { - --bit-mot-duration: 0.01ms; - --bit-mot-duration-short: 0.01ms; - --bit-mot-duration-long: 0.01ms; + @include motion-reduced-aliases; + } - // Calmed rather than collapsed - see above. The spinner also drops its eased timing function, - // whose speeding up and slowing down is the part that draws the eye. Stopping a loader - // outright is never an option: it is the only signal that the work is still in progress. - --bit-mot-duration-spinner: 4s; - --bit-mot-easing-spinner: linear; - --bit-mot-loop-factor: 3; + @include theme-overlay("motion", $document: true) { + @include motion-reduced-aliases; } } diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss index 275b9a99c86..4d0bedd7c38 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss @@ -42,7 +42,10 @@ button { // unreduced values for this element and everything under it, without touching the document root. The // literal fallbacks keep this working for a consumer theme that defines --bit-mot-duration* by hand and // therefore has no -full tokens to fall back on. -.bit-fam { +// The second selector is the same opt-out on an element that also carries an inline theme overlay of the +// motion tokens, whose rule in motion.fluent.scss would otherwise outrank .bit-fam there. +.bit-fam, +:root .bit-fam[bit-theme-overlay~="motion"] { --bit-mot-duration: var(--bit-mot-duration-full, 200ms); --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss index 02fb8f81d52..ec211d4e7e6 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss @@ -22,6 +22,9 @@ // tooltip --bit-clr-bg-pri/-bg-sec/-fg-pri the tooltip fill and text // foreground --bit-clr-fg-pri every role foreground // pri ... err --bit-clr- that role's foreground and tint +// motion --bit-mot-*-full the plain motion tokens, which alias the -full ones +// (motion.fluent.scss: one rule, as every theme builds +// them alike, with a reduced-motion twin) // // This mixin emits the selectors a theme block re-declares a group on, mirroring the selectors of the // block itself: diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss index ab6442635a2..e83137ee278 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss @@ -562,6 +562,12 @@ $box-shadow-focus-ring: var(--bit-shd-focus-ring); $mot-duration: var(--bit-mot-duration); $mot-duration-short: var(--bit-mot-duration-short); $mot-duration-long: var(--bit-mot-duration-long); +//The unreduced durations: never collapsed by prefers-reduced-motion. Only for motion that stays +//meaningful under the preference - a change of color, not a movement (BitModal's refusal ring) - or +//for the default a component restores itself under ForceAnimation (BitAccordion's transition). +$mot-duration-full: var(--bit-mot-duration-full); +$mot-duration-short-full: var(--bit-mot-duration-short-full); +$mot-duration-long-full: var(--bit-mot-duration-long-full); $mot-easing: var(--bit-mot-easing); //Entry (decelerate) and exit (accelerate) curves for popups, callouts and sheets. Standard easing //is for state transitions on an element that stays; these are for elements arriving and leaving. diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs b/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs index d2b82acb1a8..8865080c60f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs @@ -613,17 +613,30 @@ public static class Card } } + /// + /// The motion tokens. The durations, the spinner pair and the loop factor come in two forms: the plain + /// one is what a style reads, and what prefers-reduced-motion collapses (or calms) and + /// ForceAnimation restores; the ...Full one holds the unreduced value the plain one aliases, + /// and is the one to set - setting the plain one, on an element above all, would outrank the + /// reduced-motion query. writes the ...Full ones. + /// public static class Motion { public const string Duration = "--bit-mot-duration"; public const string DurationShort = "--bit-mot-duration-short"; public const string DurationLong = "--bit-mot-duration-long"; + public const string DurationFull = "--bit-mot-duration-full"; + public const string DurationShortFull = "--bit-mot-duration-short-full"; + public const string DurationLongFull = "--bit-mot-duration-long-full"; public const string Easing = "--bit-mot-easing"; public const string EasingDecelerate = "--bit-mot-easing-decelerate"; public const string EasingAccelerate = "--bit-mot-easing-accelerate"; public const string DurationSpinner = "--bit-mot-duration-spinner"; public const string EasingSpinner = "--bit-mot-easing-spinner"; public const string LoopFactor = "--bit-mot-loop-factor"; + public const string DurationSpinnerFull = "--bit-mot-duration-spinner-full"; + public const string EasingSpinnerFull = "--bit-mot-easing-spinner-full"; + public const string LoopFactorFull = "--bit-mot-loop-factor-full"; } public static class Layout diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts index 7342af18113..2cb6a36d4e3 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts @@ -34,6 +34,8 @@ namespace BitBlazorUI { ['wrn', ['--bit-clr-wrn']], ['swr', ['--bit-clr-swr']], ['err', ['--bit-clr-err']], + ['motion', ['--bit-mot-duration-full', '--bit-mot-duration-short-full', '--bit-mot-duration-long-full', + '--bit-mot-duration-spinner-full', '--bit-mot-easing-spinner-full', '--bit-mot-loop-factor-full']], ]; // The page's own background - what a status bar sits above in nearly every app. const THEME_COLOR_VARIABLE = '--bit-clr-bg-pri'; diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs index 60c8f43914e..ff2ef094d6c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs @@ -1,10 +1,21 @@ namespace Bit.BlazorUI; +/// +/// The motion tokens of a theme. The durations, the spinner pair and the loop factor are written to their unreduced +/// -full tokens (--bit-mot-duration-full, ...), as a preset declares them, so a value set here is still +/// collapsed (or calmed) under prefers-reduced-motion and restored by ForceAnimation. +/// public class BitThemeMotion { + /// The standard duration of a transition or a one-shot animation (--bit-mot-duration-full). public string? Duration { get; set; } + + /// The short duration, for small state changes (--bit-mot-duration-short-full). public string? DurationShort { get; set; } + + /// The long duration, for large surfaces entering and leaving (--bit-mot-duration-long-full). public string? DurationLong { get; set; } + public string? EasingStandard { get; set; } /// The entry curve of popups, callouts and sheets (--bit-mot-easing-decelerate). diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs index c9130b77cad..49c21f7590c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs @@ -116,6 +116,8 @@ internal static void AugmentWithFocusRingReSubstitution(Dictionary @@ -657,15 +659,20 @@ internal static Dictionary MapToCssVariables(BitTheme bitTheme) addCssVar(BitCss.Var.Typography.Subtitle2.LineHeight, bitTheme.Typography.Subtitle2.LineHeight); addCssVar(BitCss.Var.Typography.Subtitle2.LetterSpacing, bitTheme.Typography.Subtitle2.LetterSpacing); - addCssVar(BitCss.Var.Motion.Duration, bitTheme.Motion.Duration); - addCssVar(BitCss.Var.Motion.DurationShort, bitTheme.Motion.DurationShort); - addCssVar(BitCss.Var.Motion.DurationLong, bitTheme.Motion.DurationLong); + // The durations, the spinner pair and the loop factor go to their unreduced -full tokens, as a preset's do: + // the plain ones alias them and are what the reduced-motion query collapses and ForceAnimation restores, so + // a plain one written inline would outrank the user's preference and be restored to the default theme's + // value inside a ForceAnimation subtree (the motion group of FamilyAliasOverlayGroups re-substitutes the + // aliases on the overlay's element). + addCssVar(BitCss.Var.Motion.DurationFull, bitTheme.Motion.Duration); + addCssVar(BitCss.Var.Motion.DurationShortFull, bitTheme.Motion.DurationShort); + addCssVar(BitCss.Var.Motion.DurationLongFull, bitTheme.Motion.DurationLong); addCssVar(BitCss.Var.Motion.Easing, bitTheme.Motion.EasingStandard); addCssVar(BitCss.Var.Motion.EasingDecelerate, bitTheme.Motion.EasingDecelerate); addCssVar(BitCss.Var.Motion.EasingAccelerate, bitTheme.Motion.EasingAccelerate); - addCssVar(BitCss.Var.Motion.DurationSpinner, bitTheme.Motion.DurationSpinner); - addCssVar(BitCss.Var.Motion.EasingSpinner, bitTheme.Motion.EasingSpinner); - addCssVar(BitCss.Var.Motion.LoopFactor, bitTheme.Motion.LoopFactor); + addCssVar(BitCss.Var.Motion.DurationSpinnerFull, bitTheme.Motion.DurationSpinner); + addCssVar(BitCss.Var.Motion.EasingSpinnerFull, bitTheme.Motion.EasingSpinner); + addCssVar(BitCss.Var.Motion.LoopFactorFull, bitTheme.Motion.LoopFactor); addCssVar(BitCss.Var.Layout.DensityScale, bitTheme.Layout.DensityScale); addCssVar(BitCss.Var.Layout.DialogActionsDirection, bitTheme.Layout.DialogActionsDirection); diff --git a/src/BlazorUI/CLAUDE.md b/src/BlazorUI/CLAUDE.md index ff20d5da7b9..1cb1f74c5f6 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -352,7 +352,10 @@ the whole library from one `:root[bit-theme="..."]` block. surface family (plus `$box-shadow-card-hover`, the lift of a card under the pointer), never `$box-shadow-callout` directly. - **Motion**: `$mot-easing` for state transitions, `$mot-easing-decelerate` / `-accelerate` for popup - entry / exit; never a literal `ease` or `cubic-bezier` outside a looping loader keyframe. + entry / exit; never a literal `ease` or `cubic-bezier` outside a looping loader keyframe. A duration + that must survive `prefers-reduced-motion` (a color flash rather than a movement, BitModal's refusal ring) + reads `$mot-duration-full` / `-short-full` / `-long-full`, never `--bit-mot-*-full` itself + (`BitMotionTokenTests`). - **Opacity**: a disabled element that keeps its own colors dims with `$opa-dis`; text-bearing controls use the `$clr-*-dis` color tokens instead. - **Density**: a size or inset measured in spacing units is never computed in a theme scope - one @@ -408,7 +411,9 @@ global token tier: add the token, let the component read it, and keep the preset **A family alias a theme declares is declared again for an inline overlay, from the same mixin.** A `BitThemeProvider` / `ApplyBitThemeAsync` writes only the primitives it was given, plus a `bit-theme-overlay` attribute naming the alias groups those feed (`BitThemeMapper.FamilyAliasOverlayGroups`: -`radius`, `radius-control`, `shadow`, `tooltip`, `foreground`, the eight roles); every theme stylesheet +`radius`, `radius-control`, `shadow`, `tooltip`, `foreground`, the eight roles, and `motion` - the plain +`--bit-mot-*` aliases of the `-full` sources `BitTheme.Motion` writes, built alike by every theme and so +re-declared by one rule of `motion.fluent.scss`, with its reduced-motion twin); every theme stylesheet re-declares its OWN aliases of each group on that element through the `theme-overlay($group, $names)` mixin of `Styles/theme-overlay.scss`, so the aliases follow the overlay as the active theme builds them - never the core default under a preset that decided otherwise. So a group's declarations live in one mixin per theme diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor index ca6e92e9a69..2635b32f1d8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor @@ -838,7 +838,7 @@ BitAccentColorPresets.Rose; // #C239B3 - Re-declare your family aliases for an inline overlay. A BitThemeProvider, or ApplyBitThemeAsync on an element, that re-values a primitive marks its element with bit-theme-overlay, naming the groups of family aliases built from what it re-valued: radius (the global radius: every per-family radius), radius-control (the control or full radius: the button, chip and selection radii), shadow (the callout shadow, the tertiary border, the border width or the primary foreground: the per-family elevations), tooltip (the primary or secondary background or the primary foreground: the tooltip's fill and text), foreground and pri ... err (the role foregrounds and tints). The core stylesheet re-declares its defaults for each group there, which is what makes the aliases follow the overlay - so a preset that decides one of them differently re-declares its own value for that group on the same element, or an overlay puts the default back: a callout shadow under a flat tooltip, the field radius on a pill button. Three selectors per group, each one notch above the selector of your block it mirrors, which is how the packaged presets do it: + Re-declare your family aliases for an inline overlay. A BitThemeProvider, or ApplyBitThemeAsync on an element, that re-values a primitive marks its element with bit-theme-overlay, naming the groups of family aliases built from what it re-valued: radius (the global radius: every per-family radius), radius-control (the control or full radius: the button, chip and selection radii), shadow (the callout shadow, the tertiary border, the border width or the primary foreground: the per-family elevations), tooltip (the primary or secondary background or the primary foreground: the tooltip's fill and text), foreground and pri ... err (the role foregrounds and tints), and motion (a -full motion source: the effective durations, which every theme builds alike, so a preset has nothing to re-declare for it). The core stylesheet re-declares its defaults for each group there, which is what makes the aliases follow the overlay - so a preset that decides one of them differently re-declares its own value for that group on the same element, or an overlay puts the default back: a callout shadow under a flat tooltip, the field radius on a pill button. Three selectors per group, each one notch above the selector of your block it mirrors, which is how the packaged presets do it: /* acme-material.css, after the main block: the aliases above, again for an overlay of what they are built from */ @@ -1140,7 +1140,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense); + Description="The motion tokens, how a themed duration still honours reduced motion, and the safety nets the stylesheets carry on their own."> @@ -1152,22 +1152,22 @@ await BitThemeManager.ApplyBitThemeAsync(dense); { Motion = { - Duration = "200ms", // --bit-mot-duration - DurationShort = "100ms", // --bit-mot-duration-short - DurationLong = "300ms", // --bit-mot-duration-long + Duration = "200ms", // --bit-mot-duration-full + DurationShort = "100ms", // --bit-mot-duration-short-full + DurationLong = "300ms", // --bit-mot-duration-long-full EasingStandard = "cubic-bezier(0.33, 0, 0.67, 1)", // --bit-mot-easing // Looping animations (spinners, loaders, indeterminate bars) cannot use the durations // above: those collapse to a near-zero value under reduced motion, which renders as // flicker rather than as stillness. They have their own tokens, which slow down instead. - DurationSpinner = "1.3s", // --bit-mot-duration-spinner - EasingSpinner = "cubic-bezier(0.53, 0.21, 0.29, 0.67)", // --bit-mot-easing-spinner - LoopFactor = "1", // --bit-mot-loop-factor + DurationSpinner = "1.3s", // --bit-mot-duration-spinner-full + EasingSpinner = "cubic-bezier(0.53, 0.21, 0.29, 0.67)", // --bit-mot-easing-spinner-full + LoopFactor = "1", // --bit-mot-loop-factor-full } }; - One caveat when theming durations. BitTheme.Motion.* writes the effective tokens (--bit-mot-duration…), and those are exactly the tokens the reduced-motion media query collapses to 0.01ms - but it does so on :root, while ApplyBitThemeAsync / BitThemeProvider write inline on the body or a subtree, and inline wins. A duration set through the C# theme therefore also applies for users who asked for reduced motion. To theme durations and keep the opt-out working, set the --bit-mot-duration*-full sources in CSS instead - they feed the effective tokens in both states: + A themed duration still honours reduced motion. BitTheme.Motion.* writes the unreduced -full sources (--bit-mot-duration-full…), exactly as a preset declares them, never the effective tokens (--bit-mot-duration…) components read: those alias the sources, and they are what the reduced-motion media query collapses to 0.01ms and ForceAnimation restores. Where ApplyBitThemeAsync / BitThemeProvider write the sources inline on the body or a subtree, they also mark that element with bit-theme-overlay="motion", and the stylesheet re-declares the effective tokens there - collapsed under reduced motion as everywhere else. The same sources are what you set in CSS: :root { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs index c849e105217..e4a6a8a14b9 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs @@ -164,7 +164,7 @@ public void BitModalRefusalShouldStillBeAnsweredUnderReducedMotion() // unless the Modal was asked to animate regardless. StringAssert.Contains(reduced, "animation-name: bit-mdl-refuse-ring-a;"); StringAssert.Contains(reduced, "animation-name: bit-mdl-refuse-ring-b;"); - StringAssert.Contains(reduced, "var(--bit-mot-duration-long-full, #{$mot-duration-long})"); + StringAssert.Contains(reduced, "animation-duration: $mot-duration-long-full;"); Assert.IsFalse(Regex.IsMatch(reduced, @"\d+m?s\b"), "The ring hard-codes a duration instead of reading the motion token."); StringAssert.Contains(reduced, ":not(.bit-fam *)"); } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs new file mode 100644 index 00000000000..e392f3ee010 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Utils.Theme; + +/// +/// Pins how the motion tokens reach a component: the plain --bit-mot-* ones alias the unreduced -full +/// ones, the reduced-motion query collapses the aliases wherever they are declared, and a component reads the +/// unreduced values only through the $mot-duration-*-full aliases of theme-variables.scss. +/// +[TestClass] +public sealed class BitMotionTokenTests +{ + private static readonly Regex FullMotionToken = new(@"--bit-mot-[a-z-]+-full\b", RegexOptions.Compiled); + + [TestMethod] + public void NoComponentStylesheetReadsAnUnreducedMotionTokenDirectly() + { + // The -full tokens are the theme's to set and the motion tier's to alias; a component that needs the unreduced + // duration reads it through $mot-duration-*-full, like every other design-system decision it reads. + var offenders = new List(); + + foreach (var file in EnumerateComponentStylesheets()) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + + offenders.AddRange(FullMotionToken.Matches(stylesheet).Select(m => $"{Path.GetFileName(file)}: {m.Value}")); + } + + CollectionAssert.AreEqual(Array.Empty(), offenders.ToArray(), + $"Read the unreduced durations through $mot-duration-full / -short-full / -long-full: {string.Join(", ", offenders)}"); + } + + [TestMethod] + public void TheUnreducedDurationsHaveThemeAliases() + { + var scss = SourceFiles.ReadThemeStylesheet("theme-variables.scss"); + + StringAssert.Contains(scss, "$mot-duration-full: var(--bit-mot-duration-full);"); + StringAssert.Contains(scss, "$mot-duration-short-full: var(--bit-mot-duration-short-full);"); + StringAssert.Contains(scss, "$mot-duration-long-full: var(--bit-mot-duration-long-full);"); + } + + [TestMethod] + public void AnOverlayOfTheMotionTokensIsCollapsedUnderReducedMotion() + { + // A theme overlay writes the -full tokens on its own element, where the motion rule re-declares the aliases, so + // the reduced-motion query has to collapse them on that element as well - and after it, to win at equal weight. + var css = Regex.Replace(SourceFiles.Read("Bit.BlazorUI", "wwwroot", "styles", "bit.blazorui.css"), @"\s+", " ").Replace("\"", string.Empty); + + var overlay = Regex.Escape(":root [bit-theme-overlay~=motion]"); + var aliasing = Regex.Match(css, overlay + @" ?\{ ?--bit-mot-duration: ?var\(--bit-mot-duration-full\);"); + var reduced = Regex.Match(css, @"@media ?\(prefers-reduced-motion: ?reduce\) ?\{[^@]*?" + overlay + @" ?\{ ?--bit-mot-duration: ?0\.01ms;"); + + Assert.IsTrue(aliasing.Success, "The core stylesheet has no rule re-declaring the motion aliases on a motion overlay."); + Assert.IsTrue(reduced.Success, "The reduced-motion query does not collapse the motion aliases on a motion overlay."); + Assert.IsTrue(reduced.Index > aliasing.Index, "The reduced-motion rule of the overlay must come after the rule it collapses."); + } + + [TestMethod] + public void ForceAnimationOutranksAMotionOverlayOnTheSameElement() + { + // The overlay's rule weighs (0,2,0), above .bit-fam alone, so the opt-out names that element at a weight above it. + var general = SourceFiles.ReadThemeStylesheet("general.scss").Replace("\r\n", "\n"); + + StringAssert.Contains(general, ".bit-fam,\n:root .bit-fam[bit-theme-overlay~=\"motion\"] {"); + } + + private static IEnumerable EnumerateComponentStylesheets() + => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories)); +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs index af2fa3e8108..e3f7b7432a7 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs @@ -53,6 +53,8 @@ private static Dictionary BuildGroupAliases() "--bit-shd-snackbar", "--bit-shd-appbar-top", "--bit-shd-appbar-bottom"], ["tooltip"] = ["--bit-clr-tooltip-bg", "--bit-clr-tooltip-fg"], ["foreground"] = [.. Roles.Select(r => $"--bit-clr-{r}-fg")], + ["motion"] = ["--bit-mot-duration", "--bit-mot-duration-short", "--bit-mot-duration-long", + "--bit-mot-duration-spinner", "--bit-mot-easing-spinner", "--bit-mot-loop-factor"], }; foreach (var role in Roles) @@ -348,6 +350,9 @@ public void AnOverlayNamesTheGroupsItsVariablesFeed() ("the primary foreground", t => t.Color.Foreground.Primary = "#111111", "shadow tooltip foreground"), ("the warning role", t => t.Color.Warning.Main = "#FFB900", "wrn"), ("the primary role", t => t.Color.Primary.Main = "#0F6CBD", "pri"), + ("the duration", t => t.Motion.Duration = "250ms", "motion"), + ("the spinner easing", t => t.Motion.EasingSpinner = "linear", "motion"), + ("the loop factor", t => t.Motion.LoopFactor = "2", "motion"), }; foreach (var (what, set, expected) in cases) @@ -376,6 +381,7 @@ public void AnOverlayOfEveryInputNamesEveryGroupInTableOrder() theme.Color.Warning.Main = "#EDAE12"; theme.Color.SevereWarning.Main = "#CE4207"; theme.Color.Error.Main = "#D2393B"; + theme.Motion.DurationLong = "400ms"; Assert.AreEqual(string.Join(' ', OverlayGroupNames()), RenderOverlay(theme)); } @@ -386,6 +392,7 @@ public void AnOverlayOfNoFamilyInputCarriesNoMarker() var theme = new BitTheme(); theme.Typography.FontFamily = "Georgia, serif"; theme.Shape.Radius.Surface = "2rem"; + theme.Motion.EasingStandard = "linear"; Assert.IsNull(RenderOverlay(theme)); } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs index 4ed4cc9b397..fc0e95b8194 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs @@ -26,10 +26,42 @@ public void ThemeVariablesReferencedTokensAreEmittedByMapperWhenSet() var mapped = BitThemeUtilities.ToCssVariables(theme); - var missing = expectedKeys.Where(k => !mapped.ContainsKey(k)).ToArray(); + // A plain motion token is an alias of its unreduced -full token, which the reduced-motion query collapses and + // ForceAnimation restores, so BitTheme sets it through the -full one it aliases and never itself. + var missing = expectedKeys.Where(k => !mapped.ContainsKey(k) && !(k.StartsWith("--bit-mot-", StringComparison.Ordinal) && mapped.ContainsKey($"{k}-full"))).ToArray(); CollectionAssert.AreEqual(Array.Empty(), missing, $"Mapper missing keys referenced in theme-variables.scss: {string.Join(", ", missing)}"); } + [TestMethod] + public void ThemeMotionIsWrittenToTheUnreducedTokens() + { + // Written inline, a plain duration would outrank the prefers-reduced-motion query that collapses it, and inside + // a ForceAnimation subtree .bit-fam would restore it to the -full token the theme never set. The mapper writes + // the -full tokens, as a preset does, and leaves the plain aliases to the stylesheets. + var theme = new BitTheme(); + theme.Motion.Duration = "250ms"; + theme.Motion.DurationShort = "120ms"; + theme.Motion.DurationLong = "400ms"; + theme.Motion.DurationSpinner = "2s"; + theme.Motion.EasingSpinner = "linear"; + theme.Motion.LoopFactor = "2"; + + var mapped = BitThemeUtilities.ToCssVariables(theme); + + Assert.AreEqual("250ms", mapped[BitCss.Var.Motion.DurationFull]); + Assert.AreEqual("120ms", mapped[BitCss.Var.Motion.DurationShortFull]); + Assert.AreEqual("400ms", mapped[BitCss.Var.Motion.DurationLongFull]); + Assert.AreEqual("2s", mapped[BitCss.Var.Motion.DurationSpinnerFull]); + Assert.AreEqual("linear", mapped[BitCss.Var.Motion.EasingSpinnerFull]); + Assert.AreEqual("2", mapped[BitCss.Var.Motion.LoopFactorFull]); + + foreach (var plain in new[] { BitCss.Var.Motion.Duration, BitCss.Var.Motion.DurationShort, BitCss.Var.Motion.DurationLong, + BitCss.Var.Motion.DurationSpinner, BitCss.Var.Motion.EasingSpinner, BitCss.Var.Motion.LoopFactor }) + { + Assert.IsFalse(mapped.ContainsKey(plain), $"{plain} is an alias the reduced-motion query collapses; the theme must not set it."); + } + } + [TestMethod] public void MapperEmittedTokensAreConsumedByShippedStyles() { From ee43a4a2760df77f7b88e24347c30678605114de Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 11 Oct 2026 13:45:16 +0330 Subject: [PATCH 2/2] code review --- .../Styles/Fluent/motion.fluent.scss | 114 +++++++++--------- src/BlazorUI/Bit.BlazorUI/Styles/general.scss | 20 --- .../Bit.BlazorUI/Styles/theme-overlay.scss | 5 +- .../Bit.BlazorUI/Styles/theme-variables.scss | 14 ++- src/BlazorUI/CLAUDE.md | 7 +- .../Pages/Theming/ThemingPage.razor | 4 +- .../Styles/app.scss | 52 +++++--- .../Utils/Theme/BitMotionTokenTests.cs | 90 ++++++++++---- .../BitThemeFamilyAliasReSubstitutionTests.cs | 31 ++++- .../Utils/Theme/BitThemeScopedPresetTests.cs | 18 +-- 10 files changed, 219 insertions(+), 136 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss index cce91104ba1..ae06d4ba704 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss @@ -2,17 +2,14 @@ // below, so they stay the single place to theme a duration: overriding --bit-mot-duration-full // changes both the normal value and the one .bit-fam restores. That is why BitTheme.Motion writes // them, never the plain tokens: a plain token set inline would outrank the reduced-motion query. -// Components read the plain --bit-mot-* tokens; besides the reduced-motion opt-out (.bit-fam in -// general.scss), the only readers of -full are the $mot-duration-*-full aliases of -// theme-variables.scss, for what stays meaningful under the preference (a color flash, not a -// movement). +// Components read the plain --bit-mot-* tokens; besides the aliases below, the only readers of -full +// are the $mot-*-full aliases of theme-variables.scss, for what stays meaningful under the +// preference (a color flash, not a movement). // // The literals live here, on :root alone; the plain tokens that alias them live in the twinned // block below. A preset for another design system (the packaged Fluent 2 / Material / Cupertino // bundles) overrides only the -full literals and the easing curves, both of which are literals it // declares on its own :root[bit-theme="..."] / :root [bit-theme="..."] pair. -@import "../theme-overlay.scss"; - :root { --bit-mot-duration-full: 200ms; --bit-mot-duration-short-full: 100ms; @@ -37,66 +34,73 @@ --bit-mot-loop-factor-full: 1; } -// The plain tokens every component reads. They are aliases, so - exactly as in semantic-tokens.scss -// and family-tokens.scss - they need a scoped-preset twin: a custom property's var() references are +// The plain tokens every component reads. Each is the reduced value while the reduction is in force, +// and the unreduced -full value otherwise. +// +// The reduction is itself inherited state: the -reduced tokens hold the values it substitutes, and +// are declared only while it is in force - by the reduced-motion query on :root below, or again by a +// consumer re-applying it to a subtree - and reset to `initial` (no value, so the alias falls back to +// -full) by .bit-fam, the ForceAnimation opt-out, for its element and everything under it. So nothing +// ever has to decide again whether motion is reduced where the aliases are re-substituted: every +// place that declares them reads that answer off its ancestors. +// +// They are aliases, so - exactly as in semantic-tokens.scss and family-tokens.scss - they are +// re-substituted wherever one of their inputs can change: a custom property's var() references are // substituted at the element that DEFINES it, and descendants inherit the already-substituted value. // Declared on :root alone, --bit-mot-duration would freeze the DOCUMENT's 200ms inside an element -// carrying bit-theme="material", whose own --bit-mot-duration-full says 250ms. -// -// The twin is wrapped in :where() so it contributes no specificity of its own: the reduced-motion -// query below re-declares the same two selectors and wins on source order, and .bit-fam (0,1,0) - -// the ForceAnimation opt-out in general.scss - still outranks both, including on an element that -// carries bit-theme and bit-fam at once. +// carrying bit-theme="material", whose own --bit-mot-duration-full says 250ms, or inside an inline +// theme overlay - a BitThemeProvider wrapper, or ApplyBitThemeAsync on an element - that re-values a +// -full token on its own element (the motion group of theme-overlay.scss). Every theme builds the +// aliases the same way, so this one rule serves every scoped region and every overlay; and it would +// freeze the reduced 0.01ms inside a .bit-fam subtree, which is why .bit-fam re-substitutes them too. @mixin motion-aliases { - --bit-mot-duration: var(--bit-mot-duration-full); - --bit-mot-duration-short: var(--bit-mot-duration-short-full); - --bit-mot-duration-long: var(--bit-mot-duration-long-full); - - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full); -} + --bit-mot-duration: var(--bit-mot-duration-reduced, var(--bit-mot-duration-full)); + --bit-mot-duration-short: var(--bit-mot-duration-short-reduced, var(--bit-mot-duration-short-full)); + --bit-mot-duration-long: var(--bit-mot-duration-long-reduced, var(--bit-mot-duration-long-full)); -// 0.01ms instead of 0ms: a zero-duration transition/animation never fires its -// transitionend/animationend event, which would hang any JS awaiting it. -@mixin motion-reduced-aliases { - --bit-mot-duration: 0.01ms; - --bit-mot-duration-short: 0.01ms; - --bit-mot-duration-long: 0.01ms; - - // Calmed rather than collapsed - see above. The spinner also drops its eased timing function, - // whose speeding up and slowing down is the part that draws the eye. Stopping a loader - // outright is never an option: it is the only signal that the work is still in progress. - --bit-mot-duration-spinner: 4s; - --bit-mot-easing-spinner: linear; - --bit-mot-loop-factor: 3; + --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-reduced, var(--bit-mot-duration-spinner-full)); + --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-reduced, var(--bit-mot-easing-spinner-full)); + --bit-mot-loop-factor: var(--bit-mot-loop-factor-reduced, var(--bit-mot-loop-factor-full)); } +// The twins are wrapped in :where() so they contribute no specificity of their own: what a +// consumer's own rule declares for a plain token on a scoped region or an overlay element still +// wins there, as it does on any other element. :root, -:where(:root [bit-theme]) { +:where(:root [bit-theme]), +:where(:root [bit-theme-overlay~="motion"]) { @include motion-aliases; } -// An inline theme overlay - a BitThemeProvider wrapper, or ApplyBitThemeAsync on an element - that -// re-values a -full token writes it on its own element, below the :root that substituted the aliases, -// so the aliases are declared again there (theme-overlay.scss). Every theme builds them the same way, -// so this one rule serves them all. Its weight (0,2,0) is above the :where() twin, and the -// reduced-motion rule below re-declares the same selector and wins on source order; general.scss -// gives .bit-fam a rule of its own that outranks both on an element that carries the overlay too. -@include theme-overlay("motion", $document: true) { - @include motion-aliases; -} - -// Twinned like the blocks above, and for the same reason in reverse: without the twins the collapse -// would never reach a scoped preset region or an overlay, whose durations are re-substituted on their -// own element. +// 0.01ms instead of 0ms: a zero-duration transition/animation never fires its +// transitionend/animationend event, which would hang any JS awaiting it. @media (prefers-reduced-motion: reduce) { - :root, - :where(:root [bit-theme]) { - @include motion-reduced-aliases; - } + :root { + --bit-mot-duration-reduced: 0.01ms; + --bit-mot-duration-short-reduced: 0.01ms; + --bit-mot-duration-long-reduced: 0.01ms; - @include theme-overlay("motion", $document: true) { - @include motion-reduced-aliases; + // Calmed rather than collapsed - see above. The spinner also drops its eased timing function, + // whose speeding up and slowing down is the part that draws the eye. Stopping a loader + // outright is never an option: it is the only signal that the work is still in progress. + --bit-mot-duration-spinner-reduced: 4s; + --bit-mot-easing-spinner-reduced: linear; + --bit-mot-loop-factor-reduced: 3; } } + +// Opts a subtree out of the reduced-motion collapse (BitComponentBase.ForceAnimation): the reduction +// is withdrawn for this element and everything under it, and the aliases are substituted again here, +// where they now resolve to the -full values - without touching the document root. A scoped region +// or an overlay inside the subtree inherits the withdrawn reduction and so stays unreduced as well. +// It comes after the query above, so it still wins on source order on the root element itself. +.bit-fam { + --bit-mot-duration-reduced: initial; + --bit-mot-duration-short-reduced: initial; + --bit-mot-duration-long-reduced: initial; + --bit-mot-duration-spinner-reduced: initial; + --bit-mot-easing-spinner-reduced: initial; + --bit-mot-loop-factor-reduced: initial; + + @include motion-aliases; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss index 4d0bedd7c38..0bccd28b1cd 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss @@ -37,26 +37,6 @@ button { } } -// Opts a subtree out of the reduced-motion collapse in motion.fluent.scss (BitComponentBase.ForceAnimation). -// The durations are inherited custom properties, so re-pointing them at the -full tokens here restores the -// unreduced values for this element and everything under it, without touching the document root. The -// literal fallbacks keep this working for a consumer theme that defines --bit-mot-duration* by hand and -// therefore has no -full tokens to fall back on. -// The second selector is the same opt-out on an element that also carries an inline theme overlay of the -// motion tokens, whose rule in motion.fluent.scss would otherwise outrank .bit-fam there. -.bit-fam, -:root .bit-fam[bit-theme-overlay~="motion"] { - --bit-mot-duration: var(--bit-mot-duration-full, 200ms); - --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); - --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); - - // The looping tokens slow down rather than collapse under reduced motion, but they are restored - // here the same way, so a spinner inside a ForceAnimation subtree turns at full speed again. - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full, 1.3s); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full, cubic-bezier(0.53, 0.21, 0.29, 0.67)); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full, 1); -} - // The chain of `display: contents` copies a relocated callout is moved into (Callouts.moveCalloutToBody) carries // the classes of the ancestors it stands for, so that what they declare still inherits into the callout. A // display: contents element still generates its ::before and ::after, which would draw the decorations of diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss index ec211d4e7e6..0fe6afa1b2a 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss @@ -23,8 +23,9 @@ // foreground --bit-clr-fg-pri every role foreground // pri ... err --bit-clr- that role's foreground and tint // motion --bit-mot-*-full the plain motion tokens, which alias the -full ones -// (motion.fluent.scss: one rule, as every theme builds -// them alike, with a reduced-motion twin) +// (motion.fluent.scss, not this mixin: every theme builds +// them alike, so one zero-specificity rule serves all, +// and they read whether motion is reduced off ancestors) // // This mixin emits the selectors a theme block re-declares a group on, mirroring the selectors of the // block itself: diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss index e83137ee278..9d0735fdb84 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss @@ -564,10 +564,11 @@ $mot-duration-short: var(--bit-mot-duration-short); $mot-duration-long: var(--bit-mot-duration-long); //The unreduced durations: never collapsed by prefers-reduced-motion. Only for motion that stays //meaningful under the preference - a change of color, not a movement (BitModal's refusal ring) - or -//for the default a component restores itself under ForceAnimation (BitAccordion's transition). -$mot-duration-full: var(--bit-mot-duration-full); -$mot-duration-short-full: var(--bit-mot-duration-short-full); -$mot-duration-long-full: var(--bit-mot-duration-long-full); +//for the default a component restores itself under ForceAnimation (BitAccordion's transition). Each +//falls back to its plain token, for a consumer theme that declares only the plain ones by hand. +$mot-duration-full: var(--bit-mot-duration-full, var(--bit-mot-duration)); +$mot-duration-short-full: var(--bit-mot-duration-short-full, var(--bit-mot-duration-short)); +$mot-duration-long-full: var(--bit-mot-duration-long-full, var(--bit-mot-duration-long)); $mot-easing: var(--bit-mot-easing); //Entry (decelerate) and exit (accelerate) curves for popups, callouts and sheets. Standard easing //is for state transitions on an element that stays; these are for elements arriving and leaving. @@ -593,6 +594,11 @@ $mot-easing-spinner: var(--bit-mot-easing-spinner); //the loop factor multiplies literal durations inside calc(), so it carries a fallback of 1 to keep //those animations valid for consumer-defined themes that never set the token. $mot-loop-factor: var(--bit-mot-loop-factor, 1); +//The unreduced looping tokens, which the reduced-motion query never calms - for the same narrow uses +//as the unreduced durations above, with the same fallback to the plain token. +$mot-duration-spinner-full: var(--bit-mot-duration-spinner-full, var(--bit-mot-duration-spinner)); +$mot-easing-spinner-full: var(--bit-mot-easing-spinner-full, var(--bit-mot-easing-spinner)); +$mot-loop-factor-full: var(--bit-mot-loop-factor-full, var(--bit-mot-loop-factor, 1)); /*---- Typography ----*/ $tg-font-family: var(--bit-tpg-font-family); diff --git a/src/BlazorUI/CLAUDE.md b/src/BlazorUI/CLAUDE.md index 1cb1f74c5f6..1cb9c796624 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -355,7 +355,9 @@ the whole library from one `:root[bit-theme="..."]` block. entry / exit; never a literal `ease` or `cubic-bezier` outside a looping loader keyframe. A duration that must survive `prefers-reduced-motion` (a color flash rather than a movement, BitModal's refusal ring) reads `$mot-duration-full` / `-short-full` / `-long-full`, never `--bit-mot-*-full` itself - (`BitMotionTokenTests`). + (`BitMotionTokenTests`). Whether motion is reduced is inherited state, the `--bit-mot-*-reduced` tokens: declared on `:root` + under the query, withdrawn (`initial`) by `.bit-fam`, and read by every rule that substitutes the plain + aliases again (`:root`, a scoped preset, a motion overlay, `.bit-fam`), so none of them decides it anew. - **Opacity**: a disabled element that keeps its own colors dims with `$opa-dis`; text-bearing controls use the `$clr-*-dis` color tokens instead. - **Density**: a size or inset measured in spacing units is never computed in a theme scope - one @@ -413,7 +415,8 @@ global token tier: add the token, let the component read it, and keep the preset `bit-theme-overlay` attribute naming the alias groups those feed (`BitThemeMapper.FamilyAliasOverlayGroups`: `radius`, `radius-control`, `shadow`, `tooltip`, `foreground`, the eight roles, and `motion` - the plain `--bit-mot-*` aliases of the `-full` sources `BitTheme.Motion` writes, built alike by every theme and so -re-declared by one rule of `motion.fluent.scss`, with its reduced-motion twin); every theme stylesheet +re-declared by one zero-specificity rule of `motion.fluent.scss`, reading whether motion is reduced off the +inherited `--bit-mot-*-reduced` tokens that `.bit-fam` withdraws); every theme stylesheet re-declares its OWN aliases of each group on that element through the `theme-overlay($group, $names)` mixin of `Styles/theme-overlay.scss`, so the aliases follow the overlay as the active theme builds them - never the core default under a preset that decided otherwise. So a group's declarations live in one mixin per theme diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor index 2635b32f1d8..c430ea7d07f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor @@ -1167,7 +1167,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense); }; - A themed duration still honours reduced motion. BitTheme.Motion.* writes the unreduced -full sources (--bit-mot-duration-full…), exactly as a preset declares them, never the effective tokens (--bit-mot-duration…) components read: those alias the sources, and they are what the reduced-motion media query collapses to 0.01ms and ForceAnimation restores. Where ApplyBitThemeAsync / BitThemeProvider write the sources inline on the body or a subtree, they also mark that element with bit-theme-overlay="motion", and the stylesheet re-declares the effective tokens there - collapsed under reduced motion as everywhere else. The same sources are what you set in CSS: + A themed duration still honours reduced motion. BitTheme.Motion.* writes the unreduced -full sources (--bit-mot-duration-full…), exactly as a preset declares them, never the effective tokens (--bit-mot-duration…) components read: those alias the sources, and they are what the reduced-motion media query collapses to 0.01ms and ForceAnimation restores. Where ApplyBitThemeAsync / BitThemeProvider write the sources inline on the body or a subtree, they also mark that element with bit-theme-overlay="motion", and the stylesheet re-declares the effective tokens there - reduced or not exactly as the content around that element is, so a provider inside a ForceAnimation subtree still animates. The same sources are what you set in CSS: :root { @@ -1187,7 +1187,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense);
  • Reduced motion. Under prefers-reduced-motion: reduce, all --bit-mot-duration* tokens collapse to 0.01ms, so transitions effectively disable. The unreduced values stay available in the matching --bit-mot-duration*-full tokens, which is also where you change a duration since it feeds both states. - A single component can opt out with the ForceAnimation parameter (it renders the bit-fam class, which restores the full durations for that element and its content). + A single component can opt out with the ForceAnimation parameter (it renders the bit-fam class, which restores the full durations for that element and its content). The reduction itself is declared on :root as the --bit-mot-*-reduced tokens (--bit-mot-duration-reduced…), which the effective tokens read while they are set: an app that ignores the preference everywhere resets them to initial on :root in its own stylesheet, after the library's, and one that re-applies it to a subtree declares them there.
  • Looping animations are slowed, not stopped. A spinner or a loader is the only thing telling the user that work is still in progress, so it keeps running under reduced motion - and collapsing it to 0.01ms would render as flicker rather than as stillness. Instead --bit-mot-duration-spinner goes to 4s and --bit-mot-easing-spinner to linear, while --bit-mot-loop-factor rises to 3, stretching every other looping animation (the indeterminate BitProgress bar, the staggered BitLoading variants) as a whole so the phase offsets between their parts survive. All three have -full sources and are restored by ForceAnimation, exactly like the durations above. The exception is a shimmer or skeleton - a placeholder for content that has not arrived rather than a progress signal - which stops outright. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss index 86e9b524b69..2cf3f14c019 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss @@ -54,18 +54,20 @@ // the demonstration. Everywhere else, on the marketing and guide pages whose motion is a handful of // 200ms hovers, the setting is ignored rather than leaving the site feeling broken. // -// The mechanics: the library collapses its --bit-mot-* tokens under the query below, and its own -// .bit-fam class (BitComponentBase.ForceAnimation) points them back at the untouched -full values. -// This restores them at :root, which this stylesheet can do because it is linked after the library's; -// the demo pages then opt back IN to the reduction through .demo-reduced-motion. +// The mechanics: under the query below the library declares its --bit-mot-*-reduced tokens on :root, +// and every plain --bit-mot-* token reads the reduced value from them while they are set and the +// untouched -full one otherwise - on :root and wherever it is substituted again (a scoped preset, a +// theme overlay, and the .bit-fam class of BitComponentBase.ForceAnimation, which withdraws them). +// This withdraws them at :root, which this stylesheet can do because it is linked after the +// library's; the demo pages then opt back IN to the reduction through .demo-reduced-motion. @media (prefers-reduced-motion: reduce) { :root { - --bit-mot-duration: var(--bit-mot-duration-full, 200ms); - --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); - --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full, 1.3s); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full, cubic-bezier(0.53, 0.21, 0.29, 0.67)); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full, 1); + --bit-mot-duration-reduced: initial; + --bit-mot-duration-short-reduced: initial; + --bit-mot-duration-long-reduced: initial; + --bit-mot-duration-spinner-reduced: initial; + --bit-mot-easing-spinner-reduced: initial; + --bit-mot-loop-factor-reduced: initial; // The bar's own height, which the sticky pivot bar under it offsets itself by. It holds a // single BitToggle on a doc surface, so it is that toggle's switch track plus the surface's @@ -81,17 +83,27 @@ --demo-motion-bar-height: calc(max(var(--bit-siz-switch-h-md, calc(var(--bit-spa-scaling-factor, #{rem2(8px)}) * var(--bit-layout-density-scale, 1) * var(--bit-siz-switch-h-md-steps, 3))), #{rem2(24px)}) + #{$doc-space-2xs} * 2 + #{rem2(2px)}); } - // A component demo page with its ForceAnimation toggle off: exactly the library's own reduced - // values, re-applied to this subtree. Mirrors the block in Styles/Fluent/motion.fluent.scss - - // 0.01ms rather than 0ms, because a zero-duration transition never fires transitionend, and the - // looping tokens calmed rather than collapsed, because a near-zero loop renders as flicker. + // A component demo page with its ForceAnimation toggle off: exactly the library's own reduction, + // re-applied to this subtree. Mirrors Styles/Fluent/motion.fluent.scss - 0.01ms rather than 0ms, + // because a zero-duration transition never fires transitionend, and the looping tokens calmed + // rather than collapsed, because a near-zero loop renders as flicker. The plain tokens are + // substituted again here, as the library's .bit-fam does, because what this element inherits was + // already substituted unreduced at :root; a scoped preset or a theme overlay inside the page + // reads the reduction off this element by itself. .demo-reduced-motion { - --bit-mot-duration: 0.01ms; - --bit-mot-duration-short: 0.01ms; - --bit-mot-duration-long: 0.01ms; - --bit-mot-duration-spinner: 4s; - --bit-mot-easing-spinner: linear; - --bit-mot-loop-factor: 3; + --bit-mot-duration-reduced: 0.01ms; + --bit-mot-duration-short-reduced: 0.01ms; + --bit-mot-duration-long-reduced: 0.01ms; + --bit-mot-duration-spinner-reduced: 4s; + --bit-mot-easing-spinner-reduced: linear; + --bit-mot-loop-factor-reduced: 3; + + --bit-mot-duration: var(--bit-mot-duration-reduced, var(--bit-mot-duration-full)); + --bit-mot-duration-short: var(--bit-mot-duration-short-reduced, var(--bit-mot-duration-short-full)); + --bit-mot-duration-long: var(--bit-mot-duration-long-reduced, var(--bit-mot-duration-long-full)); + --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-reduced, var(--bit-mot-duration-spinner-full)); + --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-reduced, var(--bit-mot-easing-spinner-full)); + --bit-mot-loop-factor: var(--bit-mot-loop-factor-reduced, var(--bit-mot-loop-factor-full)); } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs index e392f3ee010..58a90d89992 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs @@ -8,9 +8,9 @@ namespace Bit.BlazorUI.Tests.Utils.Theme; /// -/// Pins how the motion tokens reach a component: the plain --bit-mot-* ones alias the unreduced -full -/// ones, the reduced-motion query collapses the aliases wherever they are declared, and a component reads the -/// unreduced values only through the $mot-duration-*-full aliases of theme-variables.scss. +/// Pins how the motion tokens reach a component: the plain --bit-mot-* ones alias the inherited reduction (the +/// -reduced tokens) or else the unreduced -full ones, wherever they are declared, and a component reads +/// the unreduced values only through the $mot-*-full aliases of theme-variables.scss. /// [TestClass] public sealed class BitMotionTokenTests @@ -36,40 +36,88 @@ public void NoComponentStylesheetReadsAnUnreducedMotionTokenDirectly() } [TestMethod] - public void TheUnreducedDurationsHaveThemeAliases() + public void TheUnreducedTokensHaveThemeAliases() { + // Each falls back to its plain token, for a consumer theme that declares only the plain ones by hand. var scss = SourceFiles.ReadThemeStylesheet("theme-variables.scss"); - StringAssert.Contains(scss, "$mot-duration-full: var(--bit-mot-duration-full);"); - StringAssert.Contains(scss, "$mot-duration-short-full: var(--bit-mot-duration-short-full);"); - StringAssert.Contains(scss, "$mot-duration-long-full: var(--bit-mot-duration-long-full);"); + StringAssert.Contains(scss, "$mot-duration-full: var(--bit-mot-duration-full, var(--bit-mot-duration));"); + StringAssert.Contains(scss, "$mot-duration-short-full: var(--bit-mot-duration-short-full, var(--bit-mot-duration-short));"); + StringAssert.Contains(scss, "$mot-duration-long-full: var(--bit-mot-duration-long-full, var(--bit-mot-duration-long));"); + StringAssert.Contains(scss, "$mot-duration-spinner-full: var(--bit-mot-duration-spinner-full, var(--bit-mot-duration-spinner));"); + StringAssert.Contains(scss, "$mot-easing-spinner-full: var(--bit-mot-easing-spinner-full, var(--bit-mot-easing-spinner));"); + StringAssert.Contains(scss, "$mot-loop-factor-full: var(--bit-mot-loop-factor-full, var(--bit-mot-loop-factor, 1));"); } [TestMethod] - public void AnOverlayOfTheMotionTokensIsCollapsedUnderReducedMotion() + public void EveryPlaceThatSubstitutesTheAliasesReadsTheInheritedReduction() { - // A theme overlay writes the -full tokens on its own element, where the motion rule re-declares the aliases, so - // the reduced-motion query has to collapse them on that element as well - and after it, to win at equal weight. - var css = Regex.Replace(SourceFiles.Read("Bit.BlazorUI", "wwwroot", "styles", "bit.blazorui.css"), @"\s+", " ").Replace("\"", string.Empty); + // Whether motion is reduced is the inherited -reduced tokens, so every rule that substitutes the plain aliases + // again - :root, a scoped preset, a motion overlay (at no weight, so a consumer's own rule on that element still + // wins), and .bit-fam - reads it off its ancestors instead of deciding it anew: an overlay or a scoped preset + // inside a ForceAnimation subtree, or below an app that withdraws the reduction on :root, stays unreduced. + var css = CompiledCoreCss(); - var overlay = Regex.Escape(":root [bit-theme-overlay~=motion]"); - var aliasing = Regex.Match(css, overlay + @" ?\{ ?--bit-mot-duration: ?var\(--bit-mot-duration-full\);"); - var reduced = Regex.Match(css, @"@media ?\(prefers-reduced-motion: ?reduce\) ?\{[^@]*?" + overlay + @" ?\{ ?--bit-mot-duration: ?0\.01ms;"); + var aliasing = Regex.Match(css, Regex.Escape(":root,:where(:root [bit-theme]),:where(:root [bit-theme-overlay~=motion]){") + "[^}]*"); + var forced = Regex.Match(css, @"(? aliasing.Index, "The reduced-motion rule of the overlay must come after the rule it collapses."); + Assert.IsTrue(aliasing.Success, "The motion aliases are not declared on :root, the scoped presets and the motion overlays by one rule."); + Assert.IsTrue(forced.Success, "There is no .bit-fam rule of the motion tokens."); + + foreach (var (plain, reduced, full) in Aliases) + { + StringAssert.Contains(aliasing.Value, $"{plain}: var({reduced},var({full}))"); + StringAssert.Contains(forced.Value, $"{reduced}: initial", ".bit-fam does not withdraw the reduction."); + StringAssert.Contains(forced.Value, $"{plain}: var({reduced},var({full}))", ".bit-fam does not substitute the aliases again."); + } + } + + [TestMethod] + public void TheReducedMotionQueryDeclaresOnlyTheReductionOnTheRoot() + { + // A plain alias declared under the query would be the reduction decided anew on that element, which is what left an + // overlay inside a ForceAnimation subtree collapsed. The query declares the -reduced tokens on :root and nothing + // else, and .bit-fam comes after it, so it still withdraws them on the root element itself. + var css = CompiledCoreCss(); + + var query = Regex.Match(css, @"@media ?\(prefers-reduced-motion: ?reduce\) ?\{:root\{(?--bit-mot-[^}]*)\}\}"); + + Assert.IsTrue(query.Success, "The reduced-motion query of the motion tokens is no longer a :root rule of its own."); + CollectionAssert.AreEquivalent(Aliases.Select(a => a.Reduced).ToArray(), + Regex.Matches(query.Groups["body"].Value, @"(--bit-[a-z-]+):").Select(m => m.Groups[1].Value).ToArray()); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-duration-reduced: 0.01ms"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-duration-spinner-reduced: 4s"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-easing-spinner-reduced: linear"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-loop-factor-reduced: 3"); + + Assert.IsTrue(Regex.Match(css, @"(? query.Index, ".bit-fam must come after the query to win on the root element."); } [TestMethod] - public void ForceAnimationOutranksAMotionOverlayOnTheSameElement() + public void OnlyTheMotionTierDeclaresTheReduction() { - // The overlay's rule weighs (0,2,0), above .bit-fam alone, so the opt-out names that element at a weight above it. - var general = SourceFiles.ReadThemeStylesheet("general.scss").Replace("\r\n", "\n"); + // The -reduced tokens are set only while the reduction is in force; a preset or a component declaring one would + // reduce, or un-reduce, motion regardless of the preference. + var declared = new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project), "*.scss", SearchOption.AllDirectories)) + .Where(file => file.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}", StringComparison.Ordinal) is false) + .Where(file => Path.GetFileName(file) != "motion.fluent.scss") + .SelectMany(file => Regex.Matches(SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)), @"--bit-mot-[a-z-]+-reduced\s*:") + .Select(m => $"{Path.GetFileName(file)}: {m.Value}")) + .ToArray(); - StringAssert.Contains(general, ".bit-fam,\n:root .bit-fam[bit-theme-overlay~=\"motion\"] {"); + CollectionAssert.AreEqual(Array.Empty(), declared, $"Declared outside motion.fluent.scss: {string.Join(", ", declared)}"); } + private static readonly (string Plain, string Reduced, string Full)[] Aliases = + [ + .. new[] { "duration", "duration-short", "duration-long", "duration-spinner", "easing-spinner", "loop-factor" } + .Select(n => ($"--bit-mot-{n}", $"--bit-mot-{n}-reduced", $"--bit-mot-{n}-full")) + ]; + + private static string CompiledCoreCss() + => Regex.Replace(SourceFiles.Read("Bit.BlazorUI", "wwwroot", "styles", "bit.blazorui.css"), @"\s*([{};,])\s*", "$1").Replace("\"", string.Empty); + private static IEnumerable EnumerateComponentStylesheets() => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories)); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs index e3f7b7432a7..4bfbcb364ad 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs @@ -283,13 +283,42 @@ public void TheCoreStylesheetReDeclaresExactlyTheGroupedAliasesForEachGroup() foreach (var (group, aliases) in GroupAliases) { - var rule = rules.FirstOrDefault(r => r.Selectors.Contains($":root [bit-theme-overlay~={group}]", StringComparer.Ordinal)); + // The motion aliases are built alike by every theme, so their one rule re-declares them at no weight. + var rule = rules.FirstOrDefault(r => r.Selectors.Contains($":root [bit-theme-overlay~={group}]", StringComparer.Ordinal) || + r.Selectors.Contains($":where(:root [bit-theme-overlay~={group}])", StringComparer.Ordinal)); Assert.IsNotNull(rule, $"The core stylesheet has no overlay rule for the {group} group."); CollectionAssert.AreEquivalent(aliases, rule.Declarations.Select(d => d.Key).ToArray(), $"The core overlay rule for the {group} group."); } } + [TestMethod] + public void NoThemeDeclaresTheMotionAliasesOutsideTheRuleItsOverlaysShare() + { + // The motion group has no per-theme overlay rule for EveryThemeReDeclaresItsOwnFamilyAliasesForAnOverlay to pin: + // every theme builds the aliases alike, from the -full tokens a preset re-values, so the one core rule declaring + // them on :root, on every scoped region and on every motion overlay is all of them. That holds only while no + // theme declares an alias of its own: one that did would be the core recipe again under an overlay. + string[] shared = [":root", ":where(:root [bit-theme])", ":where(:root [bit-theme-overlay~=motion])"]; + var checkedRules = 0; + + foreach (var (bundle, rules) in Bundles()) + { + foreach (var rule in rules.Where(r => r.Declarations.Any(d => GroupAliases["motion"].Contains(d.Key)))) + { + // .bit-fam (ForceAnimation) substitutes them again too, from the same mixin, for the subtree it opts out. + if (rule.Selectors is [".bit-fam"]) continue; + + CollectionAssert.AreEqual(shared, rule.Selectors, + $"{bundle}: '{string.Join(", ", rule.Selectors)}' declares a motion alias; a theme re-values the -full tokens instead."); + CollectionAssert.AreEquivalent(GroupAliases["motion"], rule.Declarations.Select(d => d.Key).ToArray(), $"{bundle}: the motion alias rule."); + checkedRules++; + } + } + + Assert.AreEqual(1, checkedRules, "The motion aliases are declared by one rule, in the core stylesheet."); + } + [TestMethod] public void NoThemeReDeclaresAnAliasOutsideItsGroupForAnOverlay() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs index ecfcf3a1984..f2dd763a82b 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs @@ -173,20 +173,20 @@ public void MotionAliasesAreReDeclaredOnScopedPresetElements() // The plain --bit-mot-* tokens alias the -full ones, and a preset retunes only the -full // literals. Declared on :root alone the aliases would freeze the DOCUMENT's durations inside // an element carrying bit-theme="material", whose own -full tokens say something else. The - // twin is wrapped in :where() so it adds no specificity: the reduced-motion query must still - // win on source order, and .bit-fam (the ForceAnimation opt-out) must still outrank both. + // twin is wrapped in :where() so it adds no specificity, and it needs no reduced-motion twin: + // the reduction is the inherited -reduced tokens, which the twin reads off its ancestors, so a + // scoped region inside a ForceAnimation subtree stays unreduced and one outside it is reduced. var scss = ReadStylesFile(Path.Combine("Fluent", "motion.fluent.scss")); var twins = Regex.Matches(scss, @":where\(:root \[bit-theme\]\)").Count; - Assert.AreEqual(2, twins, - "motion.fluent.scss must carry the ':where(:root [bit-theme])' scoped twin twice - once on the " + - "block that aliases the -full tokens, and once inside the prefers-reduced-motion query so the " + - "collapse reaches a scoped preset region as well."); + Assert.AreEqual(1, twins, + "motion.fluent.scss must carry the ':where(:root [bit-theme])' scoped twin once, on the block that " + + "aliases the -full tokens."); var reducedMotion = SourceFiles.GetScssBlock(scss, "@media (prefers-reduced-motion: reduce) {"); - Assert.IsTrue(reducedMotion.Contains(":where(:root [bit-theme])", StringComparison.Ordinal), - "The prefers-reduced-motion block must carry the scoped twin too, or a scoped preset region " + - "would keep its unreduced durations."); + Assert.IsFalse(reducedMotion.Contains("[bit-theme]", StringComparison.Ordinal), + "The prefers-reduced-motion block declares the reduction on :root alone; a scoped region reads it by " + + "inheritance, and a twin of its own there would collapse a region inside a ForceAnimation subtree."); } [TestMethod]