From 33bb5e9091656b924944b3b0234070a435bccf42 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:10:15 +0400 Subject: [PATCH 1/9] Ship both colour modes in every fluent-next bundle, selectable by class Each bundle now carries the opposite mode's roles as well as its own, under dx-theme-mode-light / -dark / -inverted. The role layer is generated as a mixin because one bundle needs it under three different selectors and a :root block cannot be re-scoped on load. The overlay container helper reads the mode prefix alongside the swatch one, carries every class it finds rather than the first, and resolves the relative class against the nearest named scope - the container hangs off the viewport, so a relative class on it would be read against the wrong element. --- .../build/tokens/build-tokens.mjs | 31 +++- .../widgets/fluent-next/_design-system.scss | 50 +++++- .../utils/__tests__/swatch_container.test.ts | 157 ++++++++++++++++++ .../__internal/core/utils/swatch_container.ts | 75 ++++++++- 4 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 750f5b0a4d45..7ca19ef659ad 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -3,6 +3,7 @@ import url from 'node:url'; import { createRequire } from 'node:module'; import { readdir, readFile, rm } from 'node:fs/promises'; import StyleDictionary from 'style-dictionary'; +import { fileHeader, formattedVariables } from 'style-dictionary/utils'; import { registerTransforms } from './transforms.mjs'; import { buildAvailableNames, @@ -175,6 +176,9 @@ const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; const THEME_FOLDER = 'fluent-next'; +// Kept in step with the @include in widgets/fluent-next/_design-system.scss. +const MODE_ROLES_MIXIN = 'roles'; + const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); const FLUENT_PALETTES = [ @@ -231,6 +235,31 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); +// The mode role layer is the one generated file every bundle needs twice: once for the mode it was +// built for and once for the opposite one, under the mode classes. A `:root` block cannot be +// re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so +// the roles ship as a mixin the theme places under the selectors it wants. +StyleDictionary.registerFormat({ + name: 'dx/mode-roles-mixin', + format: async ({ dictionary, file, options }) => { + const { + outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, + } = options; + const header = await fileHeader({ file, formatting, options }); + const variables = formattedVariables({ + format: 'css', + dictionary, + outputReferences, + outputReferenceFallbacks, + formatting: { ...formatting, indentation: ' ' }, + usesDtcg, + sort, + }); + + return `${header}@mixin ${MODE_ROLES_MIXIN}() {\n${variables}\n}\n`; + }, +}); + StyleDictionary.registerFormat({ name: 'scssToCss', format: ({ dictionary }) => dictionary.allTokens @@ -338,7 +367,7 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ }, { destination: `${THEME_NAME}/semantic/colors/${mode}.scss`, - format: 'css/variables', + format: 'dx/mode-roles-mixin', filter: (token) => { const filePath = normalizeFilePath(token); diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index e255d380ae46..358640150a23 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -1,5 +1,7 @@ @use "sass:meta"; @use "colors"; +@use "../../_design-system/fluent/semantic/colors/light" as light-roles; +@use "../../_design-system/fluent/semantic/colors/dark" as dark-roles; $accent: colors.$color; @@ -17,4 +19,50 @@ $accent: colors.$color; @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); @include meta.load-css("../../_design-system/fluent/semantic/box-shadow"); -@include meta.load-css("../../_design-system/fluent/semantic/colors/#{colors.$mode}"); + +/* + * The colour roles are the only mode-dependent tier, so both modes ship in every bundle and a class + * picks between them: `dx-theme-mode-light` / `-dark` name a mode outright, `dx-theme-mode-inverted` + * asks for the opposite of its surroundings. Everything downstream reads the roles through custom + * properties, so any element carrying one of these classes repaints itself and its subtree. + * + * Selector weight is one class throughout, `:root` included, so an override still wins by coming + * after the theme - the rule that held before the classes existed. The third block is what makes + * "inverted" relative: without it an island would keep inverting the bundle rather than the page + * whenever the page names its mode by class. `:where()` keeps that block at the same one-class + * weight as the rest. + * + * "Inverted" is not recursive: an inverted island inside an inverted island stays inverted rather + * than flipping back. Name the mode outright for the inner one. + */ +@if colors.$mode == "light" { + :root, + .dx-theme-mode-light { + @include light-roles.roles(); + } + + .dx-theme-mode-dark, + .dx-theme-mode-inverted { + @include dark-roles.roles(); + } + + :where(.dx-theme-mode-dark) .dx-theme-mode-inverted { + @include light-roles.roles(); + } +} @else if colors.$mode == "dark" { + :root, + .dx-theme-mode-dark { + @include dark-roles.roles(); + } + + .dx-theme-mode-light, + .dx-theme-mode-inverted { + @include light-roles.roles(); + } + + :where(.dx-theme-mode-light) .dx-theme-mode-inverted { + @include dark-roles.roles(); + } +} @else { + @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; +} diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts new file mode 100644 index 000000000000..226e6fa23e34 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -0,0 +1,157 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import { value as viewPort } from '@js/core/utils/view_port'; +import swatchContainer from '@ts/core/utils/swatch_container'; + +const { getSwatchContainer } = swatchContainer; + +const classesOf = (element: Element | undefined): string[] => [...(element?.classList ?? [])] + .sort(); + +describe('getSwatchContainer', () => { + let $viewport = document.createElement('div'); + + const render = (markup: string): HTMLElement => { + const host = document.createElement('div'); + + host.innerHTML = markup; + document.body.appendChild(host); + + return host.querySelector('.target') as HTMLElement; + }; + + const containerFor = (markup: string): Element => getSwatchContainer(render(markup)).get(0); + + beforeEach(() => { + $viewport = document.createElement('div'); + $viewport.className = 'dx-viewport'; + document.body.appendChild($viewport); + viewPort($viewport); + }); + + afterEach(() => { + document.body.innerHTML = ''; + viewPort(undefined); + }); + + it('returns the viewport itself when the element is in no swatch and in no named mode', () => { + expect(containerFor('
')).toBe($viewport); + }); + + it('creates a container in the viewport for a swatch', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-swatch-custom']); + expect(container.parentElement).toBe($viewport); + }); + + it('reads the classes off the element itself', () => { + expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); + }); + + it('carries every swatch class, not just the first', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); + }); + + it('carries a named theme mode', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(container.parentElement).toBe($viewport); + }); + + it('carries a swatch and a theme mode declared on different ancestors', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); + }); + + it('takes the nearest declaration of each kind', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-inner', 'dx-theme-mode-dark']); + }); + + it('reuses one container for elements in the same swatch and mode', () => { + const markup = '
'; + + expect(containerFor(markup)).toBe(containerFor(markup)); + expect($viewport.children).toHaveLength(1); + }); + + it('does not reuse a container that carries classes the element is not in', () => { + const inBoth = containerFor('
'); + const inSwatch = containerFor('
'); + + expect(inSwatch).not.toBe(inBoth); + expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); + }); + + describe('inverted mode', () => { + it('is carried as is when no named mode surrounds it', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + }); + + it('resolves to light inside a dark scope', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('resolves to dark inside a light scope', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + }); + + it('resolves against the nearest named scope, not the outermost', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('does not invert again when nested in another inverted block', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + }); + + it('resolves nested inverted blocks against the named scope around them', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index c426d0b9050d..18baa4ddedb7 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -3,27 +3,84 @@ import $ from '@js/core/renderer'; import { value } from '@js/core/utils/view_port'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; +const THEME_MODE_CLASS_PREFIX = 'dx-theme-mode-'; + +const LIGHT_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}light`; +const DARK_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}dark`; +const INVERTED_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}inverted`; + +const closestByClassPrefix = ( + $element: dxElementWrapper, + prefix: string, +): dxElementWrapper => $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); + +const classesByPrefix = ( + element: Element, + prefix: string, +): string[] => [...element.classList].filter((cssClass) => cssClass.startsWith(prefix)); + +const getThemeModeClasses = ($element: dxElementWrapper): string[] => { + const $scope = closestByClassPrefix($element, THEME_MODE_CLASS_PREFIX); + + if (!$scope.length) { + return []; + } + + const classes = classesByPrefix($scope[0], THEME_MODE_CLASS_PREFIX); + + if (!classes.includes(INVERTED_THEME_MODE_CLASS)) { + return classes; + } + + // The container hangs off the viewport, so "the opposite of my surroundings" would be read + // against the viewport rather than against the element the overlay belongs to. Name the mode the + // element resolves to instead. Without a named mode above it that is the mode the stylesheet + // falls back to, which the container inherits too, so the relative class carries over as is. + const $named = $scope.parent().closest(`.${LIGHT_THEME_MODE_CLASS}, .${DARK_THEME_MODE_CLASS}`); + + if (!$named.length) { + return classes; + } + + return [ + $named[0].classList.contains(DARK_THEME_MODE_CLASS) + ? LIGHT_THEME_MODE_CLASS + : DARK_THEME_MODE_CLASS, + ]; +}; + +const getContainerClasses = ($element: dxElementWrapper): string[] => { + const $swatch = closestByClassPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX); + const swatchClasses = $swatch.length + ? classesByPrefix($swatch[0], SWATCH_CONTAINER_CLASS_PREFIX) + : []; + + return [...swatchClasses, ...getThemeModeClasses($element)]; +}; const getSwatchContainer = ( element: Element | dxElementWrapper, ): dxElementWrapper => { - const $element = $(element); - const swatchContainer = $element.closest(`[class^="${SWATCH_CONTAINER_CLASS_PREFIX}"], [class*=" ${SWATCH_CONTAINER_CLASS_PREFIX}"]`); + const containerClasses = getContainerClasses($(element)); const viewport: dxElementWrapper = value(); - if (!swatchContainer.length) { + if (!containerClasses.length) { return viewport; } - const swatchClassRegex = new RegExp(`(\\s|^)(${SWATCH_CONTAINER_CLASS_PREFIX}.*?)(\\s|$)`); - const swatchClass = swatchContainer[0].className.match(swatchClassRegex)[2]; - let viewportSwatchContainer = viewport.children(`.${swatchClass}`); + const selector = containerClasses.map((cssClass) => `.${cssClass}`).join(''); + // A container carrying more classes than asked for would hand the overlay a swatch or a mode the + // element itself is not in. + let viewportContainer = $(viewport + .children(selector) + .toArray() + .filter((node) => node.classList.length === containerClasses.length)); - if (!viewportSwatchContainer.length) { - viewportSwatchContainer = $('
').addClass(swatchClass).appendTo(viewport); + if (!viewportContainer.length) { + viewportContainer = $('
').addClass(containerClasses.join(' ')).appendTo(viewport); } - return viewportSwatchContainer; + return viewportContainer; }; export default { getSwatchContainer }; From 951590e6ef78b8c9f669a4bd52dfc0b80fd150e6 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:59:55 +0400 Subject: [PATCH 2/9] Let the system tier follow the theme mode class, and the diagram icon with it A custom property resolves where it is declared, so a :root-only alias onto a role froze at the bundle's mode and ignored a mode class further down: 12 names over 46 reads, among them the focus ring, the modal backdrop and the overlay surface. The system tier is now declared on the mode classes too - same block, same values, a second resolution point. That also settles the diagram toolbar icon, which took its colour from a literal kept for baking into data-uri images. It reads --dx-global-content now. The component tier would not do: half the rule applies inside the toolbar overflow menu, an overlay that renders outside every diagram root. --- .../scss/widgets/fluent-next/_public-tier.scss | 10 +++++++++- .../scss/widgets/fluent-next/diagram/_index.scss | 2 +- .../tools/naming/derive-registries.mjs | 16 +++++++++++++--- .../devextreme-scss/tools/naming/registries.json | 10 ++++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss index d05a4e37799f..5d2ee56f1b24 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss @@ -76,7 +76,15 @@ @use "validation/public" as validationPublic; @use "widget/public" as widgetPublic; -:root { +/* + * The system tier is declared on the document root and on every element that names a theme mode. + * A custom property resolves where it is declared, so a `:root`-only alias onto a role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { @include commonPublic.publish(); @include typographyPublic.publish(); } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss index 6ba4f37ea880..401e399a20c4 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss @@ -430,7 +430,7 @@ .dx-icon { font-size: $diagram-toolbar-icon-size; - color: $diagram-content; + color: var(--dx-global-content); } } } diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index dc5f1baa5211..2573182113ec 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -31,6 +31,9 @@ const output = join(here, 'registries.json'); // Judgment calls. Everything else in registries.json is derived. // --------------------------------------------------------------------------------------------- +// Public contract of widgets/fluent-next/_design-system.scss: an element naming a theme mode. +const THEME_MODE_SELECTORS = ['.dx-theme-mode-light', '.dx-theme-mode-dark', '.dx-theme-mode-inverted']; + const OVERRIDES = { // folder -> component, only where kebab(folder) is not the component name components: { @@ -198,8 +201,15 @@ const OVERRIDES = { * that component's consumption wave lands. */ rootSelectors: { - // system tier: theme-wide values (system concerns of common/) live on the document root - common: [':root'], + /* + * System tier: theme-wide values live on the document root — plus every element that names a + * theme mode. A custom property is resolved where it is DECLARED, so a `:root`-only alias onto + * a role (`--dx-global-content: var(--dxds-color-content)`) freezes at the bundle's mode and + * ignores a mode class further down. Re-declaring the same text on the mode classes makes it + * resolve again against the roles that class carries. The component tier needs no such entry: + * its roots sit inside the mode scope, so they already re-resolve. + */ + common: [':root', ...THEME_MODE_SELECTORS], /* * The drop-down editor's inner button is a dxButton whose root carries dx-button-normal + * dx-dropdowneditor-button but NOT dx-button (found by the F12 runtime reachability audit: @@ -377,7 +387,7 @@ const OVERRIDES = { // the type scale is cross-component (chat, stepper and toolbar read it), so it lives on // :root like icon — the surface class .dx-theme-fluent-next-typography is opt-in and would // leave the borrowers outside the values they read - typography: [':root'], + typography: [':root', ...THEME_MODE_SELECTORS], }, // System-tier concerns (common/). Each must map to a non-component token family. diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index a90ae0e1b591..43477de92dbe 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -352,7 +352,10 @@ ".dx-gallery" ], "typography": [ - ":root" + ":root", + ".dx-theme-mode-light", + ".dx-theme-mode-dark", + ".dx-theme-mode-inverted" ], "toolbar": [ ".dx-toolbar", @@ -566,7 +569,10 @@ ".dx-cardview-column-chooser-plain" ], "common": [ - ":root" + ":root", + ".dx-theme-mode-light", + ".dx-theme-mode-dark", + ".dx-theme-mode-inverted" ] }, "themeIdentity": [ From e6828d8a82e801459694f1f9dc4383fb2293e02e Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 05:11:32 +0400 Subject: [PATCH 3/9] Drop two colour-scheme branches that picked the same image either way Both fluent-next PNG pairs were byte-identical, so the $mode branch produced no difference in any bundle. One copy each now lives at a path that does not claim a colour scheme, and the duplicates go. All 49 bundles are unchanged byte for byte. --- .../color-schemes/light/grid/text-stub.png | Bin 1230 -> 0 bytes .../fluent-next/color-schemes/light/pulldown.png | Bin 328 -> 0 bytes .../{color-schemes/dark => }/grid/text-stub.png | Bin .../{color-schemes/dark => }/pulldown.png | Bin .../widgets/fluent-next/gridBase/_colors.scss | 10 +--------- .../scss/widgets/fluent-next/icons/_colors.scss | 12 +----------- 6 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png delete mode 100644 packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/pulldown.png rename packages/devextreme-scss/images/widgets/fluent-next/{color-schemes/dark => }/grid/text-stub.png (100%) rename packages/devextreme-scss/images/widgets/fluent-next/{color-schemes/dark => }/pulldown.png (100%) diff --git a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png b/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png deleted file mode 100644 index 77bf05a6864773b085005a5376d4d4f73ed5887a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1230 zcmaJ=O>7fK6dpc#4O+ez30}+TrRfISo4k+pgwQ51CR8CRV!mRCeqN?snyYpuB zec$`uytkJo#t()2KI#JigtLd$JY5a?z76%#uXFC+4|Lf<#)@PTmq=A}5s(c$g<#gw zrcoYg#@v}7(Fgz=KQ#+QQp_EZbZoJjk6~+;L)id~9IQE-K7$CHLer+5X8u@uz(CVT zGshD-KIbT?Y#y$=XtF+D(CagL%3uy2fFm`D3Rs9}ux8ELo>WUSOorNOF9V6Jk+bkoX}fmV)hvq1jxcB<0mi zI~U!hnKB`c#BtSXm955D>`rq+DwX1RkrTxzMMS+hn`pJD?F|G9D)MyKbcl&<=qqYd zxI)qlb=oSyayqiM*S;otVO&jfIDzH;k^*2Z_y17K>YzQ6M{9onQ`jraIf%<64_92B zHm)?_r*b64MH<0w0pr==7AMM>V6TiFs3>sHanm+%)!W;I=WJ(4wRF>&dt}pLcj;9By0G^f z05&LDRW8(CzFAoeKjQnxUW6eU!VviBvrqVs zlfA<8O=}!V@A>WF-9(%FZ1m0bM=x$}IdyBHclW>ePUF?a(*DQ04#kvpNqvD0ZFYU> z&r@HDCkKO^&GexLSXlXf^OOEc$8O7YVE2A`Zuz@!uZ5>xx5@~_uf2p^V1As%A0e-Kw~Cy$vmS aUGD{By6(+O*Zar-0000 Date: Wed, 2 Sep 2026 14:11:51 +0400 Subject: [PATCH 4/9] Follow the mode class wherever a value depends on the mode Declaring the system tier on the mode classes covered the names the theme's own rules read. It missed everything else that aliases a role from the document root, and those freeze the same way: 39 custom properties over five blocks. Three are hand-written and get the same selector list as the system tier: the legacy --dx-color-* contract and --dx-component-color-bg (14 names, which the theme does not read but demos and customer code do - 575 reads of --dx-color-options-panel-bg alone), --dx-texteditor-color-text / -label, and --dx-datagrid-row-alternation-bg. Two are generated, so the pipeline had to change. The box-shadow composites are geometry over color.shadow-*, whose alpha differs by mode (0.14 against 0.28), and eleven components read them through ds.$box-shadow-sm/md/lg - a dark island kept the light shadows. The figma-utils shadow layers and the global focus aliases sit in the same position. All three sources now build one mixin, fluent/mode-aliases.scss, which the theme includes in every mode scope: the text is mode-independent, only the resolution point is not. The format that emitted the role mixin serves both files and is named dx/mode-scoped-mixin. Every mode scope also names its outcome in --dx-theme-mode. No amount of class-reading tells you which mode an element ended up in, because "inverted" means "the opposite of my surroundings" - only the cascade knows, and the overlay container has to be given the mode its owner resolved to. The three scopes are one mixin over one pair of mode names now, so they cannot drift apart, and the two limits of the relative block are written down: it reads any ancestor rather than the nearest one, and it does not recurse. Cost: 11.5K raw and 0.85K gzipped per bundle. --- .../build/tokens/build-tokens.mjs | 65 ++++++++++----- .../scss/widgets/fluent-next/_colors.scss | 5 +- .../widgets/fluent-next/_design-system.scss | 82 +++++++++++++------ .../widgets/fluent-next/gridBase/_colors.scss | 10 ++- .../fluent-next/textEditor/_colors.scss | 10 ++- .../tests/fluent-next-naming.baseline.json | 5 +- 6 files changed, 127 insertions(+), 50 deletions(-) diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 7ca19ef659ad..49f5daf3edc1 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -176,8 +176,9 @@ const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; const THEME_FOLDER = 'fluent-next'; -// Kept in step with the @include in widgets/fluent-next/_design-system.scss. +// Kept in step with the @includes in widgets/fluent-next/_design-system.scss. const MODE_ROLES_MIXIN = 'roles'; +const MODE_ALIASES_MIXIN = 'aliases'; const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); @@ -235,17 +236,32 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); -// The mode role layer is the one generated file every bundle needs twice: once for the mode it was -// built for and once for the opposite one, under the mode classes. A `:root` block cannot be -// re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so -// the roles ship as a mixin the theme places under the selectors it wants. +/* + * Every bundle needs the mode-dependent declarations more than once: under the mode it was built + * for, under the opposite one, and under the relative "inverted" scope. A `:root` block cannot be + * re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so + * these layers ship as mixins the theme places under the selectors it wants. + * + * Two files use it. The roles carry the mode's own values, one file per mode. The aliases carry the + * layers whose TEXT is mode-independent but whose values read a role (`box-shadow.md` is geometry + * over `color.shadow-key`): a custom property resolves where it is declared, so leaving them on + * `:root` would freeze them at the bundle's mode no matter what class sits below. Same text in + * every scope, resolved anew in each. + * + * Otherwise identical to Style Dictionary's own `css/variables` (lib/common/formats.js) minus the + * selector nesting; keep the two in step. + */ +// `prefix` belongs to the declaration lines, not to the header comment — upstream drops it before +// building the header (getFormattingCloneWithoutPrefix), and so must we. +const headerFormatting = ({ prefix, ...formatting } = {}) => formatting; + StyleDictionary.registerFormat({ - name: 'dx/mode-roles-mixin', + name: 'dx/mode-scoped-mixin', format: async ({ dictionary, file, options }) => { const { - outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, + outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, mixin, } = options; - const header = await fileHeader({ file, formatting, options }); + const header = await fileHeader({ file, formatting: headerFormatting(formatting), options }); const variables = formattedVariables({ format: 'css', dictionary, @@ -256,7 +272,7 @@ StyleDictionary.registerFormat({ sort, }); - return `${header}@mixin ${MODE_ROLES_MIXIN}() {\n${variables}\n}\n`; + return `${header}@mixin ${mixin}() {\n${variables}\n}\n`; }, }); @@ -344,8 +360,6 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ const filePath = normalizeFilePath(token); return filePath.includes(`base/colors/utility/${THEME_NAME}.json`) - || filePath.includes(`global/${THEME_NAME}.json`) - || filePath.includes(`figma-utils/box-shadow/semantic/${THEME_NAME}.json`) || filePath.includes(`figma-utils/icon/set/${THEME_NAME}.json`); }, options: FILE_OPTIONS, @@ -359,22 +373,35 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ filter: (token) => normalizeFilePath(token).includes(`semantic/typography/${THEME_NAME}`), options: FILE_OPTIONS, }, - { - destination: `${THEME_NAME}/semantic/box-shadow.scss`, - format: 'css/variables', - filter: (token) => normalizeFilePath(token).includes(`semantic/box-shadow/${THEME_NAME}.json`), - options: FILE_OPTIONS, - }, { destination: `${THEME_NAME}/semantic/colors/${mode}.scss`, - format: 'dx/mode-roles-mixin', + format: 'dx/mode-scoped-mixin', filter: (token) => { const filePath = normalizeFilePath(token); return filePath.includes(`semantic/colors/${THEME_NAME}/${mode}.json`) || filePath.includes(`icons/${THEME_NAME}/${mode}.json`); }, - options: FILE_OPTIONS, + options: { ...FILE_OPTIONS, mixin: MODE_ROLES_MIXIN }, + }, + /* + * The three layers that read a colour role without being one: the box-shadow composites and + * their Figma layer parts (geometry over `color.shadow-*`) and the global aliases (focus rings + * over `color.border-focus*`). Written once, included in every mode scope — see the + * dx/mode-scoped-mixin comment for why they cannot stay on `:root`. Both mode configs emit this + * file; the sources are mode-independent, so the two writes are byte-identical. + */ + { + destination: `${THEME_NAME}/mode-aliases.scss`, + format: 'dx/mode-scoped-mixin', + filter: (token) => { + const filePath = normalizeFilePath(token); + + return filePath.includes(`semantic/box-shadow/${THEME_NAME}.json`) + || filePath.includes(`global/${THEME_NAME}.json`) + || filePath.includes(`figma-utils/box-shadow/semantic/${THEME_NAME}.json`); + }, + options: { ...FILE_OPTIONS, mixin: MODE_ALIASES_MIXIN }, }, ]); diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss index 80a4dfc34aa4..69e97ca69776 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss @@ -22,7 +22,10 @@ $theme-marker-mode: null !default; * --dx-color-shadow carries alpha (the DS ships no solid-black token; the shadow roles are * rgba over black) — unlike the legacy solid #000 of the other themes. */ -:root { +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-component-color-bg: #{ds.$color-bg}; --dx-color-main-bg: #{ds.$color-bg-canvas}; --dx-color-primary: #{ds.$color-content-primary}; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index 358640150a23..65e648d24b67 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -2,6 +2,7 @@ @use "colors"; @use "../../_design-system/fluent/semantic/colors/light" as light-roles; @use "../../_design-system/fluent/semantic/colors/dark" as dark-roles; +@use "../../_design-system/fluent/mode-aliases" as mode-aliases; $accent: colors.$color; @@ -13,18 +14,44 @@ $accent: colors.$color; * to every stylesheet. Component size tokens are absent for the same reason plus one more: * fluent-next maps sizes onto the base scales (spacing, font-size, border-radius, border-width), * so no widget would read the layout names either. + * + * What is loaded here is what does NOT depend on the colour mode. The rest goes through + * `mode-values` below, because a custom property resolves where it is DECLARED: an alias onto a + * mode-dependent role, left on `:root`, freezes at the bundle's mode and ignores every mode class + * under it. That is why `mode-aliases` exists rather than a plain `:root` box-shadow layer. */ @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); -@include meta.load-css("../../_design-system/fluent/semantic/box-shadow"); /* - * The colour roles are the only mode-dependent tier, so both modes ship in every bundle and a class - * picks between them: `dx-theme-mode-light` / `-dark` name a mode outright, `dx-theme-mode-inverted` - * asks for the opposite of its surroundings. Everything downstream reads the roles through custom - * properties, so any element carrying one of these classes repaints itself and its subtree. + * Everything a colour mode decides, in one place so the three scopes below cannot drift apart: + * the roles for that mode, the aliases that read them, and `--dx-theme-mode` naming the outcome. + * + * The marker is what the JS reads. `dx-theme-mode-inverted` means "the opposite of my + * surroundings", so no amount of class-reading tells you which mode an element ended up in - only + * the cascade knows. Overlays are reparented to the viewport and have to be given the mode their + * owner resolved to, so `core/utils/swatch_container.ts` asks the browser for this property + * instead of walking up the ancestor classes. + */ +@mixin mode-values($mode) { + --dx-theme-mode: #{$mode}; + + @if $mode == "light" { + @include light-roles.roles(); + } @else { + @include dark-roles.roles(); + } + + @include mode-aliases.aliases(); +} + +/* + * Both modes ship in every bundle and a class picks between them: `dx-theme-mode-light` / `-dark` + * name a mode outright, `dx-theme-mode-inverted` asks for the opposite of its surroundings. + * Everything downstream reads these values through custom properties, so any element carrying one + * of the classes repaints itself and its subtree. * * Selector weight is one class throughout, `:root` included, so an override still wins by coming * after the theme - the rule that held before the classes existed. The third block is what makes @@ -32,37 +59,38 @@ $accent: colors.$color; * whenever the page names its mode by class. `:where()` keeps that block at the same one-class * weight as the rest. * - * "Inverted" is not recursive: an inverted island inside an inverted island stays inverted rather - * than flipping back. Name the mode outright for the inner one. + * Two limits of that third block, both inherent to descendant selectors - CSS cannot ask for the + * NEAREST matching ancestor: + * + * - "inverted" flips the bundle's mode unless it sits anywhere inside a scope naming the + * opposite mode, at any distance. `dark > light > inverted` therefore resolves against the + * dark, not against the light next to it. Name the mode outright when that matters. + * - it is not recursive: an inverted island inside an inverted island stays inverted rather than + * flipping back. + * + * `--dx-theme-mode` keeps the JS honest about both: whatever these rules resolve to is what the + * overlay container is given. */ -@if colors.$mode == "light" { +@mixin mode-scopes($own, $other) { :root, - .dx-theme-mode-light { - @include light-roles.roles(); + .dx-theme-mode-#{$own} { + @include mode-values($own); } - .dx-theme-mode-dark, + .dx-theme-mode-#{$other}, .dx-theme-mode-inverted { - @include dark-roles.roles(); + @include mode-values($other); } - :where(.dx-theme-mode-dark) .dx-theme-mode-inverted { - @include light-roles.roles(); - } -} @else if colors.$mode == "dark" { - :root, - .dx-theme-mode-dark { - @include dark-roles.roles(); - } - - .dx-theme-mode-light, - .dx-theme-mode-inverted { - @include light-roles.roles(); + :where(.dx-theme-mode-#{$other}) .dx-theme-mode-inverted { + @include mode-values($own); } +} - :where(.dx-theme-mode-light) .dx-theme-mode-inverted { - @include dark-roles.roles(); - } +@if colors.$mode == "light" { + @include mode-scopes("light", "dark"); +} @else if colors.$mode == "dark" { + @include mode-scopes("dark", "light"); } @else { @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss index e10ea5d08fba..80248bd75410 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -59,7 +59,15 @@ $grid-text-stub-bg: data-uri("images/widgets/fluent-next/grid/text-stub.png") !d $grid-filter-panel-content: ds.$color-content-primary !default; $grid-draggable-column-content: ds.$color-content !default; -:root { +/* + * Declared on the document root and on every element that names a theme mode. A custom property + * resolves where it is DECLARED, so a `:root`-only alias onto a mode-dependent role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-datagrid-row-alternation-bg: #{$grid-row-alternation-bg}; } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss index c9b8d3668877..35883481ae00 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss @@ -32,7 +32,15 @@ $text-editor-content-disabled: ds.$color-content-disabled !default; $text-editor-label-content-focused: ds.$color-content-primary; -:root { +/* + * Declared on the document root and on every element that names a theme mode. A custom property + * resolves where it is DECLARED, so a `:root`-only alias onto a mode-dependent role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-texteditor-color-text: #{$text-editor-content}; --dx-texteditor-color-label: #{$text-editor-placeholder}; } diff --git a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json index a014a0aa37b2..9235ea21996e 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json +++ b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json @@ -511,7 +511,9 @@ "--dx-toolbar-height" ], "publicSurfaceUndeclared": [], - "publicSurfaceDifferences": [], + "publicSurfaceDifferences": [ + "--dx-theme-mode: only in fluent-next" + ], "publicTierManualDeclarations": [ "fluent-next/_colors.scss: --dx-color-border", "fluent-next/_colors.scss: --dx-color-danger", @@ -527,6 +529,7 @@ "fluent-next/_colors.scss: --dx-color-text", "fluent-next/_colors.scss: --dx-color-warning", "fluent-next/_colors.scss: --dx-component-color-bg", + "fluent-next/_design-system.scss: --dx-theme-mode", "fluent-next/_sizes.scss: --dx-border-radius", "fluent-next/_sizes.scss: --dx-border-width", "fluent-next/_sizes.scss: --dx-component-height", From 74b4609da2500ba052af0752abd3627400d700b1 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:03 +0400 Subject: [PATCH 5/9] Gate the mode invariant against the built bundles A frozen alias breaks the promise silently: the declaration stays valid, the colour is merely the one from the other mode, and none of the usual checks see it. A rule-by-rule diff of the light and dark bundles cannot - the line --dx-color-text: var(--dxds-color-content) is byte-identical in both, since what differs is the resolution point, not the text. The reachability audit only sees what a page materialises, in the mode it was opened in, and the demos set no mode classes at all. Following the references does see it. The gate takes the names declared under the mode classes out of the built bundle and reports anything that reads them - through a chain as well, --dxds-box-shadow-md over --dxds-color-shadow-key - from a rule whose subject is the document element. A declaration on a component root is not a finding: that element may sit inside a mode scope, and then the read resolves there. It also pins the two things the mechanism needs: the three scopes declare the same set of names, and each names its mode in --dx-theme-mode. Everything is derived from the bundle, so there is no list here to keep in step. On the bundles from before the previous commit the last check reports 39 names. --- .../tests/theme-mode-scope.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/devextreme-scss/tests/theme-mode-scope.test.ts diff --git a/packages/devextreme-scss/tests/theme-mode-scope.test.ts b/packages/devextreme-scss/tests/theme-mode-scope.test.ts new file mode 100644 index 000000000000..f1f01c408cd9 --- /dev/null +++ b/packages/devextreme-scss/tests/theme-mode-scope.test.ts @@ -0,0 +1,143 @@ +/* + * Gate for the fluent-next theme-mode invariant: an element carrying `dx-theme-mode-light`, + * `-dark` or `-inverted` repaints itself and its subtree. + * + * The invariant is easy to break silently, because a custom property is substituted where it is + * DECLARED, not where it is read. `:root { --dx-color-text: var(--dxds-color-content) }` computes + * on , freezes at the bundle's mode, and every element below inherits that frozen value no + * matter which mode class sits between - the declaration is still valid, the colour is simply the + * wrong one, so nothing fails and only a screenshot would notice. That is what happened to 39 + * properties (the legacy `--dx-color-*` surface, the box-shadow composites and their Figma layer + * colours, the global focus aliases) before this gate existed. + * + * Two things are checked, both derived from the built bundle rather than from a list here: + * + * 1. the three mode scopes declare exactly the same names, so none of them can go missing; + * 2. nothing whose value reads a mode-scoped name is declared where a mode class cannot reach + * it - i.e. on the document element. + * + * A declaration on a component root (`.dx-button { --dx-button-bg: var(--dxds-color-bg) }`) is + * fine and deliberately not flagged: that element may sit inside a mode scope, and then the read + * resolves there. + * + * The bundles come from packages/devextreme/artifacts/css - the `test` target depends on + * `build:themes`, so they are fresh here; a missing bundle fails the suite loudly instead of + * passing silently. + */ + +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import postcss from 'postcss'; + +const packageRoot = process.cwd(); +const artifactsCss = join(packageRoot, '..', 'devextreme', 'artifacts', 'css'); + +const MODE_PROPERTY = '--dx-theme-mode'; +const MODE_SCOPES = ['light', 'dark', 'inverted']; +const MODE_CLASS_PREFIX = '.dx-theme-mode-'; + +const bundleNames = existsSync(artifactsCss) + ? readdirSync(artifactsCss).filter((name) => /^dx\.fluent-next\.[a-z0-9.]+\.css$/.test(name)).sort() + : []; + +if (!bundleNames.length) { + throw new Error(`no dx.fluent-next.*.css bundles found in ${artifactsCss} — the gate needs the ` + + 'built theme; run `pnpm nx run devextreme-scss:build:themes` (the `test` target normally ' + + 'does it for you)'); +} + +/** The compound a selector actually targets: `:where(.a) .b` -> `.b`, `:root` -> `:root`. */ +const subjectOf = (selector: string): string => selector.trim().split(/[\s>+~]+/).filter(Boolean).pop() ?? ''; + +const modeScopesOf = (selector: string): string[] => MODE_SCOPES + .filter((scope) => subjectOf(selector) === `${MODE_CLASS_PREFIX}${scope}`); + +// A rule lands on the document element - the one place a mode class below it cannot reach. +const isDocumentRoot = (selector: string): boolean => [':root', 'html'].includes(subjectOf(selector)); + +interface BundleFacts { + scopeNames: Record>; + rootDeclarations: { property: string; reads: string[]; selector: string }[]; + modeScopedNames: Set; +} + +const readBundle = (name: string): BundleFacts => { + const root = postcss.parse(readFileSync(join(artifactsCss, name), 'utf8'), { from: name }); + const scopeNames: Record> = Object.fromEntries( + MODE_SCOPES.map((scope) => [scope, new Set()]), + ); + const rootDeclarations: BundleFacts['rootDeclarations'] = []; + const modeScopedNames = new Set(); + + root.walkRules((rule) => { + const scopes = new Set(rule.selectors.flatMap(modeScopesOf)); + const onDocumentRoot = rule.selectors.every(isDocumentRoot); + + rule.each((node) => { + if (node.type !== 'decl' || !node.prop.startsWith('--')) { + return; + } + + scopes.forEach((scope) => scopeNames[scope].add(node.prop)); + + if (scopes.size) { + modeScopedNames.add(node.prop); + } + + if (onDocumentRoot) { + rootDeclarations.push({ + property: node.prop, + reads: [...node.value.matchAll(/var\(\s*(--[\w-]+)/g)].map((match) => match[1]), + selector: rule.selector, + }); + } + }); + }); + + return { scopeNames, rootDeclarations, modeScopedNames }; +}; + +/* + * Frozen = declared on the document element and reading, directly or through another such + * declaration, something a mode class redefines. `--dxds-box-shadow-md` reads + * `--dxds-color-shadow-key` (mode-scoped) and is itself read by every popup, so the chain has to + * be followed rather than only the first hop. + */ +const frozenProperties = ({ rootDeclarations, modeScopedNames }: BundleFacts): string[] => { + const frozen = new Map(); + const tainted = new Set(modeScopedNames); + + for (;;) { + const found = rootDeclarations.filter(({ property, reads }) => !tainted.has(property) + && reads.some((name) => tainted.has(name))); + + if (!found.length) { + return [...frozen.keys()].sort(); + } + + found.forEach(({ property, selector, reads }) => { + tainted.add(property); + frozen.set(property, `${selector} { ${property}: … ${reads.find((name) => tainted.has(name)) ?? ''} … }`); + }); + } +}; + +describe.each(bundleNames)('%s', (name) => { + const facts = readBundle(name); + + test('the three mode scopes declare the same names', () => { + const [light, dark, inverted] = MODE_SCOPES.map((scope) => [...facts.scopeNames[scope]].sort()); + + expect(light.length).toBeGreaterThan(0); + expect(dark).toEqual(light); + expect(inverted).toEqual(light); + }); + + test(`every mode scope names its mode in ${MODE_PROPERTY}`, () => { + expect(MODE_SCOPES.filter((scope) => !facts.scopeNames[scope].has(MODE_PROPERTY))).toEqual([]); + }); + + test('nothing reading a mode-scoped value is declared on the document element', () => { + expect(frozenProperties(facts)).toEqual([]); + }); +}); From 4bc9e243c1e9d44a781ef50cf329888dcd995dab Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:19 +0400 Subject: [PATCH 6/9] Give the overlay container the mode its owner resolved to The container is reparented to the viewport, so reading the owner's ancestor classes answers the wrong question twice. "Inverted" means "the opposite of my surroundings" and the container's surroundings are different ones; and the class does not determine the mode anyway, because the relative rule reads any ancestor rather than the nearest. Measured in the browser on the built theme, the ancestor walk disagreed with the cascade in 7 of 46 shapes - dark > light > inverted and its mirrors, plus a bare inverted island whenever the viewport itself named a mode, where the container landed inside that class and inverted it instead. Reading --dx-theme-mode agrees by construction: 46 of 46. Three more things came out of it. The viewport is not always set. Before documentReady value() returns undefined, and the old code returned it for any element outside a swatch - which speed_dial_action relies on to defer to ready() (T713615, T1143527). An element inside a mode scope no longer took that path and dereferenced undefined instead. The signature says | undefined now, so the two call sites that append into the container had to say what they do when there is none. A scope the viewport already resolves to needs no container. It repainted nothing, and popup drag and resize takes the container as its boundary area (popup_position_controller._getDragResizeContainer), so a dxPopup inside an app that names its mode on the viewport was clamped to a div of zero height. Reuse compares the swatch and mode classes rather than counting all of them. A class with neither prefix says nothing about the scope, and disqualifying a container over one grew the viewport by a wrapper per overlay shown. --- .../utils/__tests__/swatch_container.test.ts | 213 +++++++++++------- .../__internal/core/utils/swatch_container.ts | 119 ++++++---- .../speed_dial_action/speed_dial_main_item.ts | 4 +- 3 files changed, 199 insertions(+), 137 deletions(-) diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts index 226e6fa23e34..57e492176eb5 100644 --- a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -1,13 +1,29 @@ import { - afterEach, beforeEach, describe, expect, it, + afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; +import $ from '@js/core/renderer'; import { value as viewPort } from '@js/core/utils/view_port'; import swatchContainer from '@ts/core/utils/swatch_container'; +/* + * The viewport is mocked rather than assigned: `value(x)` falls back to for anything empty, + * so the state before documentReady - `value()` returning undefined - is otherwise unreachable, + * and that is the state overlays created too early run into (T713615, T1143527). + */ +jest.mock('@js/core/utils/view_port'); + +const viewPortMock = viewPort as unknown as jest.Mock<() => unknown>; + const { getSwatchContainer } = swatchContainer; -const classesOf = (element: Element | undefined): string[] => [...(element?.classList ?? [])] - .sort(); +// jsdom resolves a custom property declared ON an element but does not inherit it, so the tests +// name the resolved mode at the elements the code reads it from. +const MODE_STYLES = ` + .mode-light { --dx-theme-mode: light; } + .mode-dark { --dx-theme-mode: dark; } +`; + +const classesOf = (element: Element): string[] => [...element.classList].sort(); describe('getSwatchContainer', () => { let $viewport = document.createElement('div'); @@ -21,137 +37,162 @@ describe('getSwatchContainer', () => { return host.querySelector('.target') as HTMLElement; }; - const containerFor = (markup: string): Element => getSwatchContainer(render(markup)).get(0); + const containerFor = ( + markup: string, + ): Element => getSwatchContainer(render(markup))?.get(0) as Element; beforeEach(() => { + document.head.innerHTML = ``; $viewport = document.createElement('div'); $viewport.className = 'dx-viewport'; document.body.appendChild($viewport); - viewPort($viewport); + viewPortMock.mockReturnValue($($viewport)); }); afterEach(() => { + document.head.innerHTML = ''; document.body.innerHTML = ''; - viewPort(undefined); + viewPortMock.mockReset(); }); - it('returns the viewport itself when the element is in no swatch and in no named mode', () => { + it('returns the viewport itself when the element is in no swatch and in no mode', () => { expect(containerFor('
')).toBe($viewport); }); - it('creates a container in the viewport for a swatch', () => { - const container = containerFor('
'); + describe('swatches', () => { + it('creates a container in the viewport for a swatch', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-custom']); - expect(container.parentElement).toBe($viewport); - }); + expect(classesOf(container)).toEqual(['dx-swatch-custom']); + expect(container.parentElement).toBe($viewport); + }); - it('reads the classes off the element itself', () => { - expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); - }); + it('reads the classes off the element itself', () => { + expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); + }); - it('carries every swatch class, not just the first', () => { - const container = containerFor('
'); + it('carries every swatch class, not just the first', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); - }); + expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); + }); - it('carries a named theme mode', () => { - const container = containerFor('
'); + it('takes the nearest swatch', () => { + const container = containerFor(` +
+
+
`); - expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); - expect(container.parentElement).toBe($viewport); + expect(classesOf(container)).toEqual(['dx-swatch-inner']); + }); }); - it('carries a swatch and a theme mode declared on different ancestors', () => { - const container = containerFor(` -
-
-
`); + describe('theme mode', () => { + it('carries the mode the element resolved to', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); - }); + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(container.parentElement).toBe($viewport); + }); - it('takes the nearest declaration of each kind', () => { - const container = containerFor(` -
-
-
-
-
`); + /* + * `dx-theme-mode-inverted` means "the opposite of my surroundings" and the container is + * reparented to the viewport, where the surroundings are different ones - so the mode comes + * from what the cascade resolved, never from the class the element wears. + */ + it('names the resolved mode, not the class the element carries', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-inner', 'dx-theme-mode-dark']); - }); + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('carries no mode when the theme declares none', () => { + expect(containerFor('
')).toBe($viewport); + }); - it('reuses one container for elements in the same swatch and mode', () => { - const markup = '
'; + it('carries a swatch and a mode together', () => { + const container = containerFor(` +
+
+
`); - expect(containerFor(markup)).toBe(containerFor(markup)); - expect($viewport.children).toHaveLength(1); + expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); + }); }); - it('does not reuse a container that carries classes the element is not in', () => { - const inBoth = containerFor('
'); - const inSwatch = containerFor('
'); + describe('scopes the viewport already resolves to', () => { + it('returns the viewport when it resolves to the same mode', () => { + $viewport.classList.add('mode-dark'); - expect(inSwatch).not.toBe(inBoth); - expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); - }); + expect(containerFor('
')).toBe($viewport); + expect($viewport.children).toHaveLength(0); + }); - describe('inverted mode', () => { - it('is carried as is when no named mode surrounds it', () => { - const container = containerFor('
'); + it('returns the viewport when it sits in the same swatch', () => { + const $swatch = document.createElement('div'); - expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + $swatch.className = 'dx-swatch-custom'; + document.body.appendChild($swatch); + $swatch.appendChild($viewport); + + expect(containerFor('
')).toBe($viewport); }); - it('resolves to light inside a dark scope', () => { - const container = containerFor(` -
-
-
`); + it('creates a container when the modes differ', () => { + $viewport.classList.add('mode-dark'); + + const container = containerFor('
'); expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + expect(container.parentElement).toBe($viewport); }); + }); - it('resolves to dark inside a light scope', () => { - const container = containerFor(` -
-
-
`); + describe('reuse', () => { + it('reuses one container for elements in the same swatch and mode', () => { + const markup = '
'; - expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(containerFor(markup)).toBe(containerFor(markup)); + expect($viewport.children).toHaveLength(1); }); - it('resolves against the nearest named scope, not the outermost', () => { - const container = containerFor(` -
-
-
-
-
`); + it('does not reuse a container carrying a scope the element is not in', () => { + const inBoth = containerFor('
'); + const inSwatch = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + expect(inSwatch).not.toBe(inBoth); + expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); }); - it('does not invert again when nested in another inverted block', () => { - const container = containerFor(` -
-
-
`); + // Only swatch and mode classes describe the scope; anything else on the page may have tagged + // the container, and re-creating it on every call would grow the viewport without bound. + it('reuses a container that picked up an unrelated class', () => { + const first = containerFor('
'); + + first.classList.add('some-app-class'); - expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + expect(containerFor('
')).toBe(first); + expect($viewport.children).toHaveLength(1); }); + }); - it('resolves nested inverted blocks against the named scope around them', () => { - const container = containerFor(` -
-
-
-
-
`); + describe('before the viewport is set', () => { + beforeEach(() => { + viewPortMock.mockReturnValue(undefined); + }); - expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + it('reports no container for an element in no scope', () => { + expect(getSwatchContainer(render('
'))).toBeUndefined(); + }); + + it('reports no container for an element in a mode', () => { + expect(getSwatchContainer(render('
'))).toBeUndefined(); + }); + + it('reports no container for an element in a swatch', () => { + const element = render('
'); + + expect(getSwatchContainer(element)).toBeUndefined(); }); }); }); diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index 18baa4ddedb7..ec43f7081f9d 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -1,86 +1,107 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { value } from '@js/core/utils/view_port'; +import { getWindow, hasWindow } from '@js/core/utils/window'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; const THEME_MODE_CLASS_PREFIX = 'dx-theme-mode-'; - -const LIGHT_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}light`; -const DARK_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}dark`; -const INVERTED_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}inverted`; - -const closestByClassPrefix = ( - $element: dxElementWrapper, - prefix: string, -): dxElementWrapper => $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); +const THEME_MODE_PROPERTY = '--dx-theme-mode'; const classesByPrefix = ( element: Element, prefix: string, ): string[] => [...element.classList].filter((cssClass) => cssClass.startsWith(prefix)); -const getThemeModeClasses = ($element: dxElementWrapper): string[] => { - const $scope = closestByClassPrefix($element, THEME_MODE_CLASS_PREFIX); - - if (!$scope.length) { - return []; - } +const closestClassesByPrefix = ( + $element: dxElementWrapper, + prefix: string, +): string[] => { + const $scope = $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); - const classes = classesByPrefix($scope[0], THEME_MODE_CLASS_PREFIX); + return $scope.length ? classesByPrefix($scope.get(0), prefix) : []; +}; - if (!classes.includes(INVERTED_THEME_MODE_CLASS)) { - return classes; +/* + * The mode an element ended up in is what the cascade decided, not what its ancestor classes + * spell: `dx-theme-mode-inverted` asks for the opposite of its surroundings, and the container is + * reparented to the viewport, whose surroundings are different ones. The theme names the outcome + * in `--dx-theme-mode` (widgets/fluent-next/_design-system.scss), so ask the browser for it. + * Themes that ship one mode per bundle declare nothing and get no class, as before. + */ +const themeModeClasses = ($element: dxElementWrapper): string[] => { + const element = $element.get(0); + const window = hasWindow() ? getWindow() : undefined; + + if (!element || !window?.getComputedStyle) { + return []; } - // The container hangs off the viewport, so "the opposite of my surroundings" would be read - // against the viewport rather than against the element the overlay belongs to. Name the mode the - // element resolves to instead. Without a named mode above it that is the mode the stylesheet - // falls back to, which the container inherits too, so the relative class carries over as is. - const $named = $scope.parent().closest(`.${LIGHT_THEME_MODE_CLASS}, .${DARK_THEME_MODE_CLASS}`); - - if (!$named.length) { - return classes; - } + const mode = window.getComputedStyle(element).getPropertyValue(THEME_MODE_PROPERTY).trim(); - return [ - $named[0].classList.contains(DARK_THEME_MODE_CLASS) - ? LIGHT_THEME_MODE_CLASS - : DARK_THEME_MODE_CLASS, - ]; + return mode ? [`${THEME_MODE_CLASS_PREFIX}${mode}`] : []; }; -const getContainerClasses = ($element: dxElementWrapper): string[] => { - const $swatch = closestByClassPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX); - const swatchClasses = $swatch.length - ? classesByPrefix($swatch[0], SWATCH_CONTAINER_CLASS_PREFIX) - : []; +const scopeClasses = ($element: dxElementWrapper): string[] => [ + ...closestClassesByPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX), + ...themeModeClasses($element), +]; - return [...swatchClasses, ...getThemeModeClasses($element)]; +const getContainerClasses = ( + $element: dxElementWrapper, + $viewport: dxElementWrapper, +): string[] => { + const classes = scopeClasses($element); + // A scope the viewport already resolves to needs no container of its own: it would be a wrapper + // that repaints nothing, and one that measures nothing - callers reading the container as a + // geometric area (popup drag and resize) would be clamped to its zero height. + const sorted = (cssClasses: string[]): string => [...cssClasses].sort().join(' '); + + return sorted(classes) === sorted(scopeClasses($viewport)) ? [] : classes; }; +// A container carrying a swatch or a mode class beyond the ones asked for belongs to a scope the +// element itself is not in. A class with neither prefix says nothing about the scope, so it does +// not disqualify a container - anything on the page may have tagged it. +const isExactScope = ( + node: Element, + containerClasses: string[], +): boolean => [SWATCH_CONTAINER_CLASS_PREFIX, THEME_MODE_CLASS_PREFIX] + .every((prefix) => classesByPrefix(node, prefix) + .every((cssClass) => containerClasses.includes(cssClass))); + +/* + * Where an overlay belonging to `element` should be rendered: the viewport itself, or a child of it + * repeating the swatch and the theme mode the element resolved to. + * + * Undefined while the viewport is unset - before documentReady - which callers read as "not ready + * yet" (speed_dial_action defers to ready(); T713615, T1143527). + */ const getSwatchContainer = ( element: Element | dxElementWrapper, -): dxElementWrapper => { - const containerClasses = getContainerClasses($(element)); - const viewport: dxElementWrapper = value(); +): dxElementWrapper | undefined => { + const $viewport = value() as dxElementWrapper | undefined; + + if (!$viewport?.length) { + return $viewport; + } + + const containerClasses = getContainerClasses($(element), $viewport); if (!containerClasses.length) { - return viewport; + return $viewport; } const selector = containerClasses.map((cssClass) => `.${cssClass}`).join(''); - // A container carrying more classes than asked for would hand the overlay a swatch or a mode the - // element itself is not in. - let viewportContainer = $(viewport + let $container = $($viewport .children(selector) .toArray() - .filter((node) => node.classList.length === containerClasses.length)); + .filter((node) => isExactScope(node, containerClasses))); - if (!viewportContainer.length) { - viewportContainer = $('
').addClass(containerClasses.join(' ')).appendTo(viewport); + if (!$container.length) { + $container = $('
').addClass(containerClasses.join(' ')).appendTo($viewport); } - return viewportContainer; + return $container; }; export default { getSwatchContainer }; diff --git a/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts b/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts index 8fe2f4a5ed6f..409f80dbbe3f 100644 --- a/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts +++ b/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts @@ -314,7 +314,7 @@ class SpeedDialMainItem extends SpeedDialItem { for (const action of actions) { const $actionElement = $('
') - .appendTo(getSwatchContainer(action.$element())); + .appendTo(getSwatchContainer(action.$element()) ?? $()); eventsEngine.off($actionElement, 'click'); eventsEngine.on($actionElement, 'click', () => { @@ -483,7 +483,7 @@ export function initAction(newAction: SpeedDialAction): void { if (!speedDialMainItem) { const $fabMainElement = $('
') - .appendTo(getSwatchContainer(newAction.$element())); + .appendTo(getSwatchContainer(newAction.$element()) ?? $()); speedDialMainItem = newAction._createComponent( $fabMainElement, From bed65f6a50dc0fdd9ed6448a7c07b2ace8aeb82f Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 3 Sep 2026 11:14:19 +0400 Subject: [PATCH 7/9] Make "inverted" mean the nearest enclosing mode A descendant selector cannot ask for the nearest matching ancestor, only for any of them, and the relative block was built out of one: `:where(.dx-theme-mode-dark) .dx-theme-mode-inverted`. So `dark > light > inverted` inverted the dark two levels up instead of the light next to it, and nesting did not compose - an inverted island inside another one stayed as it was rather than flipping. A style query asks the question the contract actually poses. It is evaluated against the nearest ancestor, `--dx-theme-mode` inherits, so the value read is the one the enclosing scope resolved to - at any depth, and whether that scope named its mode or was itself inverted. Both blocks are identical in either bundle, because flipping the enclosing mode says nothing about the mode the bundle was built for; that is what turns the semantics from approximate into exact. Judged against an oracle written from the contract - "the opposite of the nearest enclosing mode", as a recursion over ancestors - on the built bundle in a browser, over 28 nesting shapes: the old rule matched 17, this matches 28, in both bundles, with the marker agreeing with the roles actually applied in every one of them. The inverted blocks come first now. A named class on the same element states the mode outright and has to win, and since every rule here weighs one class, source order is what decides; emitted last they took `.dx-theme-mode-dark .dx-theme-mode-inverted` down to 26 of 28. Where style queries are unsupported the blocks are dropped and an inverted island renders as its surroundings instead of the opposite of them. Nothing breaks: it is still a correctly painted scope, --dx-theme-mode still describes it, and the JS keeps agreeing with the screen. The theme's browserslist is the last two versions of every engine, all far above the feature. Cost: 21K raw and 0.3-1.0K gzipped per bundle, which the shared/mode-scoped split of the generated mixins pays back twice over. The naming gate needed one correction to see this: `--dx-theme-mode: dark` inside a style query is a condition, so counting it as a hand-written declaration was wrong. It is a read, and reads are now checked in the case that already checks var() - a typo there is quieter than a typo in var(), since the whole block silently stops matching instead of one value going missing. --- .../widgets/fluent-next/_design-system.scss | 70 +++++++++++-------- .../tests/fluent-next-naming.test.ts | 34 +++++++-- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index 65e648d24b67..e90f53282564 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -49,48 +49,60 @@ $accent: colors.$color; /* * Both modes ship in every bundle and a class picks between them: `dx-theme-mode-light` / `-dark` - * name a mode outright, `dx-theme-mode-inverted` asks for the opposite of its surroundings. - * Everything downstream reads these values through custom properties, so any element carrying one - * of the classes repaints itself and its subtree. + * name a mode outright, `dx-theme-mode-inverted` asks for the opposite of the nearest enclosing + * mode. Everything downstream reads these values through custom properties, so any element + * carrying one of the classes repaints itself and its subtree. * * Selector weight is one class throughout, `:root` included, so an override still wins by coming - * after the theme - the rule that held before the classes existed. The third block is what makes - * "inverted" relative: without it an island would keep inverting the bundle rather than the page - * whenever the page names its mode by class. `:where()` keeps that block at the same one-class - * weight as the rest. - * - * Two limits of that third block, both inherent to descendant selectors - CSS cannot ask for the - * NEAREST matching ancestor: - * - * - "inverted" flips the bundle's mode unless it sits anywhere inside a scope naming the - * opposite mode, at any distance. `dark > light > inverted` therefore resolves against the - * dark, not against the light next to it. Name the mode outright when that matters. - * - it is not recursive: an inverted island inside an inverted island stays inverted rather than - * flipping back. - * - * `--dx-theme-mode` keeps the JS honest about both: whatever these rules resolve to is what the - * overlay container is given. + * after the theme - the rule that held before the classes existed. */ -@mixin mode-scopes($own, $other) { +@mixin named-scopes($own, $other) { :root, .dx-theme-mode-#{$own} { @include mode-values($own); } - .dx-theme-mode-#{$other}, - .dx-theme-mode-inverted { + .dx-theme-mode-#{$other} { @include mode-values($other); } +} - :where(.dx-theme-mode-#{$other}) .dx-theme-mode-inverted { - @include mode-values($own); +/* + * "The nearest enclosing mode" is what a style query answers: it is evaluated against the nearest + * ancestor, and `--dx-theme-mode` inherits, so the value read here is the one the enclosing scope + * resolved to - at any depth, and whether that scope named its mode or was itself inverted. A + * descendant selector cannot ask for the NEAREST matching ancestor, only for ANY of them, so the + * rule this replaces resolved `dark > light > inverted` against the dark rather than against the + * light next to it, and nesting did not compose. + * + * Both blocks are the same in either bundle: flipping the enclosing mode says nothing about the + * mode the bundle was built for. That is what makes the semantics exact rather than approximate. + * + * Where style queries are unsupported these blocks are dropped and an inverted island renders as + * its surroundings instead of the opposite of them. Nothing breaks: it is still a correctly + * painted scope, `--dx-theme-mode` still describes it, and the JS keeps agreeing with the screen. + */ +@mixin inverted-scope() { + @container style(--dx-theme-mode: light) { + .dx-theme-mode-inverted { + @include mode-values("dark"); + } + } + + @container style(--dx-theme-mode: dark) { + .dx-theme-mode-inverted { + @include mode-values("light"); + } } } -@if colors.$mode == "light" { - @include mode-scopes("light", "dark"); -} @else if colors.$mode == "dark" { - @include mode-scopes("dark", "light"); -} @else { +@if colors.$mode != "light" and colors.$mode != "dark" { @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; } + +/* + * Inverted first: a named class on the SAME element states the mode outright and has to win, and + * since every rule here weighs one class, source order is what decides. + */ +@include inverted-scope(); +@include named-scopes(colors.$mode, if(colors.$mode == "light", "dark", "light")); diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 03df125e97bc..d023945ff73e 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -33,6 +33,15 @@ const themeRoot = join(widgetsRoot, 'fluent-next'); // Labels a stylesheet for error messages: `fluent-next/common/_mixins.scss`. const sourceLabel = (file: string): string => file.slice(widgetsRoot.length + 1); +/* + * `name: value` inside an at-rule prelude is a condition, not a declaration - a style query reads + * a custom property (`@container style(--dx-theme-mode: dark)`) and looks exactly like one to a + * `--dx-…:` match. Preludes carry no declarations, so dropping them is safe; the reads themselves + * are covered by the "every var(--dx-…) read resolves" case below. + */ +const declarationBody = (content: string, label: string): string => stripScssComments(content, label) + .replace(/@[a-z-]+[^;{]*\{/g, '{'); + // The hand-maintained wave-F component tier (see the "wave F" test block and NAMING.md). const isPublicTierFile = (file: string): boolean => file.endsWith('_public.scss') || file.endsWith('_public-tier.scss'); @@ -635,7 +644,7 @@ const findings = { */ publicTierManualDeclarations: walk(themeRoot, '.scss') .filter((file) => !isPublicTierFile(file)) - .flatMap((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) + .flatMap((file) => [...declarationBody(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .map((match) => `${sourceLabel(file)}: ${match[1]}`)) .sort(), @@ -1164,23 +1173,34 @@ test('component tier: the collector matches registries.rootSelectors exactly', ( }).toEqual({ offenders: [], includedTwice: [], notIncluded: [], unknownNamespace: [] }); }); -test('component tier: every var(--dx-…) read in the theme resolves to a declared name', () => { +test('component tier: every --dx-… read in the theme resolves to a declared name', () => { /* * stylelint does not ban the FORM (the tier is consumed through it) — this is the check that * took over: a read anywhere in fluent-next must hit the tier, the legacy surface, or the JS * runtime contract. A typo'd custom property compiles and dies silently at computed-value time; * this fails the build instead. + * + * `var()` is not the only way to read one: a style query names the property in its condition + * (`@container style(--dx-theme-mode: dark)`), and a typo there is even quieter — the block + * simply never matches, so the rules inside it go missing rather than losing one value. */ const declared = new Set([ ...[...tierDeclared.keys()].map((variable) => `--dx-${variable.slice(1)}`), ...RUNTIME_CONTRACT, ...findings.publicTierManualDeclarations.map((entry) => entry.slice(entry.indexOf(': ') + 2)), ]); - const offenders = walk(themeRoot, '.scss').flatMap((file) => [ - ...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)).matchAll(/var\(\s*(--dx-[a-z0-9-]+)/g), - ].map((match) => match[1]) - .filter((name) => !declared.has(name)) - .map((name) => `${sourceLabel(file)}: var(${name}) resolves to no declared --dx name`)); + const READS = [ + { pattern: /var\(\s*(--dx-[a-z0-9-]+)/g, form: (name: string): string => `var(${name})` }, + { pattern: /style\(\s*(--dx-[a-z0-9-]+)/g, form: (name: string): string => `style(${name}: …)` }, + ]; + const offenders = walk(themeRoot, '.scss').flatMap((file) => { + const content = stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)); + + return READS.flatMap(({ pattern, form }) => [...content.matchAll(pattern)] + .map((match) => match[1]) + .filter((name) => !declared.has(name)) + .map((name) => `${sourceLabel(file)}: ${form(name)} resolves to no declared --dx name`)); + }); expect(offenders).toEqual([]); }); From e9287c0124c2923d2dae560bc98252fd61a18905 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 3 Sep 2026 11:24:53 +0400 Subject: [PATCH 8/9] Scope only what the mode actually decides The mode-scoped layers were selected by source file, and a source file is a coarse answer. Of the 300 colour roles only 209 differ between the modes, and of the 86 alias declarations only 20 read one - the rest are shadow geometry, the icon set and non-colour globals, which resolve to the same value wherever they are declared. Repeating them is pure weight, and there are four mode scopes in a bundle. The split is now derived from the generated text rather than declared by a filter: a name whose two mode values differ depends on the mode, and so does anything reading such a name, through a chain as well - box-shadow-md is geometry over color-shadow-key. The remainder goes to fluent/mode-shared.scss as a plain :root block, written once. Nothing here lists names, so a token that starts or stops depending on the mode moves by itself at the next package bump. 229 declarations stay mode-scoped, 157 move to :root. That takes 23.7K raw off every bundle - more than the container queries of the previous commit cost, so the two together land 2.5K below where the exact semantics started. The two halves check each other: were a mode-dependent name to end up in the shared block, it would be a value read from the document element that a mode class redefines, which is exactly what the theme-mode-scope gate fails on. Verified by breaking the split on purpose - the gate reports the name in all four bundles. Against the state before the review, on dx.fluent-next.blue.light.css through the production pipeline: +39.9K raw (+3.54%) and +2.1K gzipped (+1.51%); the dark bundle is +40.0K and +1.4K (+1.02%). --- .../build/tokens/build-tokens.mjs | 94 ++++++++++++++++++- .../widgets/fluent-next/_design-system.scss | 5 + 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 49f5daf3edc1..650e3dc80f84 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -1,7 +1,9 @@ import path from 'node:path'; import url from 'node:url'; import { createRequire } from 'node:module'; -import { readdir, readFile, rm } from 'node:fs/promises'; +import { + readdir, readFile, rm, writeFile, +} from 'node:fs/promises'; import StyleDictionary from 'style-dictionary'; import { fileHeader, formattedVariables } from 'style-dictionary/utils'; import { registerTransforms } from './transforms.mjs'; @@ -179,6 +181,8 @@ const THEME_FOLDER = 'fluent-next'; // Kept in step with the @includes in widgets/fluent-next/_design-system.scss. const MODE_ROLES_MIXIN = 'roles'; const MODE_ALIASES_MIXIN = 'aliases'; +const MODE_ALIASES_FILE = 'mode-aliases'; +const MODE_SHARED_FILE = 'mode-shared'; const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); @@ -392,7 +396,7 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ * file; the sources are mode-independent, so the two writes are byte-identical. */ { - destination: `${THEME_NAME}/mode-aliases.scss`, + destination: `${THEME_NAME}/${MODE_ALIASES_FILE}.scss`, format: 'dx/mode-scoped-mixin', filter: (token) => { const filePath = normalizeFilePath(token); @@ -469,6 +473,90 @@ async function collectThemeStyleSheets() { .map((entry) => path.join(entry.parentPath, entry.name)); } +/* + * The mode-scoped layers are emitted by source file, and a source file is a coarse answer: of the + * 300 colour roles only 209 actually differ between the modes, and of the alias layers only a + * fifth read one. A declaration that does not depend on the mode does not need re-resolving, so + * repeating it in every scope is pure weight - and there are four of them per bundle. + * + * Which is which is derived here rather than declared, from the generated text: a name whose two + * mode values differ is mode-dependent, and so is anything that reads such a name, through a chain + * as well (`box-shadow-md` is geometry over `color-shadow-key`). The remainder is moved to a plain + * `:root` block, written once. Deriving it means a token that starts or stops depending on the + * mode moves on its own at the next bump; the theme-mode-scope gate is the judge either way. + */ +const DECLARATION = /^(\s*)(--[\w-]+)\s*:\s*([^;]+);\s*$/; + +const parseDeclarations = (content) => content.split('\n').reduce((declarations, line) => { + const match = DECLARATION.exec(line); + + return match ? declarations.set(match[2], match[3].trim()) : declarations; +}, new Map()); + +const readsOf = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name]) => name); + +const modeDependentNames = (light, dark, aliases) => { + const tainted = new Set([...light.keys()].filter((name) => light.get(name) !== dark.get(name))); + + for (let grew = true; grew;) { + grew = false; + + for (const source of [light, aliases]) { + for (const [name, value] of source) { + if (!tainted.has(name) && readsOf(value).some((read) => tainted.has(read))) { + tainted.add(name); + grew = true; + } + } + } + } + + return tainted; +}; + +const withBody = (content, keep) => content.replace( + /(\{\n)([\s\S]*)(\n\})/, + (whole, open, body, close) => { + const lines = body.split('\n').filter((line) => { + const match = DECLARATION.exec(line); + + return !match || keep(match[2]); + }); + + return `${open}${lines.join('\n')}${close}`; + }, +); + +async function splitModeScopedLayers() { + const modeFile = (mode) => path.join(buildPath, THEME_NAME, 'semantic', 'colors', `${mode}.scss`); + const aliasesFile = path.join(buildPath, THEME_NAME, `${MODE_ALIASES_FILE}.scss`); + const sharedFile = path.join(buildPath, THEME_NAME, `${MODE_SHARED_FILE}.scss`); + + const sources = Object.fromEntries(await Promise.all( + [['light', modeFile('light')], ['dark', modeFile('dark')], ['aliases', aliasesFile]] + .map(async ([key, file]) => [key, { file, content: await readFile(file, 'utf-8') }]), + )); + const parsed = Object.fromEntries( + Object.entries(sources).map(([key, { content }]) => [key, parseDeclarations(content)]), + ); + const dependent = modeDependentNames(parsed.light, parsed.dark, parsed.aliases); + + await Promise.all(Object.values(sources).map(({ file, content }) => writeFile( + file, + withBody(content, (name) => dependent.has(name)), + 'utf-8', + ))); + + // The light file carries the shared roles: for those two, light and dark agree by definition. + const shared = [...parsed.light, ...parsed.aliases].filter(([name]) => !dependent.has(name)); + const header = sources.light.content.slice(0, sources.light.content.indexOf('@mixin')); + const body = shared.map(([name, value]) => ` ${name}: ${value};`).join('\n'); + + await writeFile(sharedFile, `${header}:root {\n${body}\n}\n`, 'utf-8'); + + return { dependent: dependent.size, shared: shared.length }; +} + // Every token a widget reads must still exist in the package. Without this a deleted token surfaces // much later as a Sass "Undefined variable", one name per rebuild, with no hint that a bump caused // it. Read from the flat index, not the bridge: it carries the version for the message. @@ -525,10 +613,12 @@ async function build() { await sd.buildAllPlatforms(); } + const split = await splitModeScopedLayers(); const fileCount = await validateReferences(); const consumedCount = await validateConsumedTokens(); console.log(`Design tokens generated: ${fileCount} files in ${buildPath}`); + console.log(`Mode-scoped declarations: ${split.dependent} depend on the mode, ${split.shared} moved to :root`); console.log(`Design tokens consumed by ${THEME_FOLDER}: ${consumedCount} verified against the package`); } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index e90f53282564..42d218e58f6e 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -19,11 +19,16 @@ $accent: colors.$color; * `mode-values` below, because a custom property resolves where it is DECLARED: an alias onto a * mode-dependent role, left on `:root`, freezes at the bundle's mode and ignores every mode class * under it. That is why `mode-aliases` exists rather than a plain `:root` box-shadow layer. + * + * `mode-shared` is the other side of that split: the roles and aliases whose values turn out not + * to depend on the mode after all. The build derives the two sets from the generated text rather + * than from source files, so this file carries no list - see build/tokens/build-tokens.mjs. */ @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); +@include meta.load-css("../../_design-system/fluent/mode-shared"); /* * Everything a colour mode decides, in one place so the three scopes below cannot drift apart: From 0b6d3180d1f73d9836e48d79cc1f0da265d3f731 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Sun, 6 Sep 2026 02:15:47 +0400 Subject: [PATCH 9/9] Say what the container getter does and does not cover The note said "swatch classes can be updated runtime", which was true when the swatch prefix was the only thing read here. The theme mode is read the same way now, and the getter resolves both on every read. What it does not cover was easy to read into it and is worth stating: an overlay that is already open keeps the container it was appended to, because the wrapper moves in _moveToContainer, which runs when the overlay becomes visible or re-renders its content - a bare class flip does neither. --- .../__internal/ui/overlay/overlay_position_controller.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts b/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts index cc3bef9acf77..dc80ec02a411 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts @@ -173,7 +173,12 @@ export class OverlayPositionController< } get $container(): dxElementWrapper | undefined { - // NOTE: swatch classes can be updated runtime + /* + * Resolved on every read: the swatch and the theme mode an element sits in can both change at + * runtime, and an overlay shown afterwards has to land in the scope that holds at that moment. + * An overlay that is already open keeps the container it was appended to - the wrapper moves + * in `_moveToContainer`, which runs when the overlay becomes visible or re-renders its content. + */ this.updateContainer(); return this._$markupContainer;