From 965b2040462d355d7c4563e18476da4f837bfc44 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:10:15 +0400 Subject: [PATCH 01/25] 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 +++++- .../tests/data-uri-static-markers.test.ts | 34 +++- .../utils/__tests__/swatch_container.test.ts | 157 ++++++++++++++++++ .../__internal/core/utils/swatch_container.ts | 75 ++++++++- 5 files changed, 328 insertions(+), 19 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 64e7058da8d3..650fb5832f12 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 !default; @@ -17,4 +19,50 @@ $accent: colors.$color !default; @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-scss/tests/data-uri-static-markers.test.ts b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts index 808eecb1ff2d..fa048171b07c 100644 --- a/packages/devextreme-scss/tests/data-uri-static-markers.test.ts +++ b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts @@ -6,7 +6,9 @@ * scss/widgets/fluent-next/DIVERGENCES.md for why the literals exist at all. * * The token side is read from the built bundles — the `test` target depends on `build:themes`, so - * they are fresh here, and a missing bundle fails loudly rather than passing on an empty scan. + * they are fresh here, and a missing bundle fails loudly rather than passing on an empty scan. Only + * the `:root` scope of a bundle counts: the mode classes carry the opposite mode's values, and a + * baked literal answers for a page that names no mode. * * There is nothing to regenerate: a failure means either the literal or the marker is wrong, and * which one it is has to be decided by looking at the token. @@ -67,18 +69,34 @@ const expand = (hex: string): string => { : value; }; -function resolve(css: string, property: string, depth = 0): string | null { - const found = new RegExp(`${property.replace(/-/g, '\\-')}:([^;}]*)`).exec(css); - if (!found) return null; - const value = found[1].trim(); +/* + * A bundle declares each role more than once: the mode it was built for sits on `:root`, and the + * opposite mode sits on the `dx-theme-mode-*` classes (see THEME_MODES.html). A literal baked into + * a data-uri is what a page with no mode class shows, so only the `:root` scope may answer here — + * scanning the whole text would hand back whichever block happens to come first. + */ +const rootDeclarations = (css: string): Map => { + const declarations = new Map(); + for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!selector.split(',').some((one) => one.trim() === ':root')) continue; + for (const [, property, value] of body.matchAll(/(--[a-z0-9-]+):([^;]*)/g)) { + declarations.set(property, value.trim()); + } + } + return declarations; +}; + +function resolve(declarations: Map, property: string, depth = 0): string | null { + const value = declarations.get(property); + if (value === undefined) return null; const indirect = /^var\((--[a-z0-9-]+)\)$/.exec(value); - return indirect && depth < 8 ? resolve(css, indirect[1], depth + 1) : value; + return indirect && depth < 8 ? resolve(declarations, indirect[1], depth + 1) : value; } -const bundles: Record = {}; +const bundles: Record> = {}; for (const mode of ['light', 'dark']) { const path = join(artifactsCss, `dx.fluent-next.blue.${mode}.css`); - if (existsSync(path)) bundles[mode] = readFileSync(path, 'utf8'); + if (existsSync(path)) bundles[mode] = rootDeclarations(readFileSync(path, 'utf8')); } test('every dx-data-uri-static literal still equals the token it names', () => { 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 b09b47cee91025a7f6a438665a6e1224016117a0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:59:55 +0400 Subject: [PATCH 02/25] 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 | 5 ++++- .../scss/widgets/fluent-next/diagram/_index.scss | 2 +- .../tools/naming/derive-registries.mjs | 16 +++++++++++++--- .../devextreme-scss/tools/naming/registries.json | 10 ++++++++-- 4 files changed, 26 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 a9d74d264c2b..daeca41ada26 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss @@ -77,7 +77,10 @@ @use "validation/public" as validationPublic; @use "widget/public" as widgetPublic; -:root { +: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 d3fba875ecf7..e42c04b0113e 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss @@ -435,7 +435,7 @@ .dx-dropdowneditor-field-before-template-wrapper { .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 38f8bea15214..5acb28274120 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 590c82bfbe2b1d3e4c6d821cfd3a2e77f1a6f48f Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:11:51 +0400 Subject: [PATCH 03/25] 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 650fb5832f12..4bfdc96c3eef 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 !default; @@ -13,18 +14,44 @@ $accent: colors.$color !default; * 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 !default; * 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 dda5d4cf38e4..11e13ab9b436 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -66,7 +66,15 @@ $grid-text-stub-bg: rgb(from #{ds.$color-bg-inverted} r g b / 0.1) !default; // $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 d8410e8df25f..99b0c5175c9b 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 !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-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 ccd35e5d208c..f24a79b21b2b 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 ee120d480df07ddca683fa0c4676dddcb3f1a7ac Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:03 +0400 Subject: [PATCH 04/25] 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 a12efde0093afed8ecf3882220a8ebc2b172e0be Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:19 +0400 Subject: [PATCH 05/25] 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 6a2cdd9a8af2433bb91fdd26be45586f12a21914 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 3 Sep 2026 11:14:19 +0400 Subject: [PATCH 06/25] 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 4bfdc96c3eef..c335cb0074ae 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 !default; /* * 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 441e07867f05..27ac3328a4ca 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -38,6 +38,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 --dx-* component tier (see the "wave F" test block and NAMING.md): generated projections in // _public.scss, hand-written links in _public-links.scss, and the collector that mounts them. const isPublicManifestFile = (file: string): boolean => file.endsWith('_public.scss') @@ -477,7 +486,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(), @@ -940,23 +949,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 ef00f666421abd4b4b9165e8eacd89e0ec8936a3 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 3 Sep 2026 11:24:53 +0400 Subject: [PATCH 07/25] 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 c335cb0074ae..78f03b9517a0 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 !default; * `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 c6571164dd492b5e17a9afa20a91f9655e0270b0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Sun, 6 Sep 2026 02:15:47 +0400 Subject: [PATCH 08/25] 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; From 43baa1e2c786ea862e07b5847a33d067fde994cb Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Mon, 7 Sep 2026 23:24:11 +0400 Subject: [PATCH 09/25] Cover the mode classes in a browser, where jsdom cannot follow The unit tests around the container helper name the resolved mode at the element the code reads it from, because jsdom resolves a custom property declared ON an element but does not inherit it - and inheritance is the whole mechanism. Nothing in CI opened a page with a mode class until now. Four cases in the common folder, which the matrix already runs a second time as 'common - fluent-next': a named class re-resolves the roles, inverted answers the nearest enclosing scope at depth three, the :root-published system tier does not freeze at the bundle, and an overlay is painted in the mode of its owner. Every assertion is relative, so a token bump moves the values without touching it. --- .../tests/common/themeModes.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 e2e/testcafe-devextreme/tests/common/themeModes.ts diff --git a/e2e/testcafe-devextreme/tests/common/themeModes.ts b/e2e/testcafe-devextreme/tests/common/themeModes.ts new file mode 100644 index 000000000000..5c286f629006 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/themeModes.ts @@ -0,0 +1,120 @@ +import { ClientFunction, Selector } from 'testcafe'; +import url from '../../helpers/getPageUrl'; +import { createWidget } from '../../helpers/createWidget'; +import { getComputedPropertyValue } from '../../helpers/domUtils'; +import { clearTestPage } from '../../helpers/testPageUtils'; +import { getFullThemeName, getThemeName } from '../../helpers/themeUtils'; + +/* + * The mode classes are a fluent-next contract, and this is the only place that exercises them in a + * real browser. The unit tests around `core/utils/swatch_container.ts` cannot: jsdom resolves a + * custom property declared ON an element but does not inherit it, while the whole mechanism is a + * scope declaring `--dx-theme-mode` and descendants reading it back through the cascade. + * + * Every assertion is relative - "this scope differs from that one", never a hex literal - so a + * token bump moves the values without touching the test. + */ +if (getThemeName() === 'fluent-next') { + fixture`Theme modes` + .page(url(__dirname, '../container.html')) + .afterEach(async (t) => { await clearTestPage(t); }); + + const buildMode = getFullThemeName().includes('.dark') ? 'dark' : 'light'; + const oppositeMode = buildMode === 'dark' ? 'light' : 'dark'; + + // Roles the mode decides, one per family, plus the two system-tier names that used to freeze. + const MODE_DEPENDENT = ['--dxds-color-bg', '--dxds-color-content']; + const SYSTEM_TIER = ['--dx-global-content', '--dx-surface-overlay', '--dx-focus-rect-outline']; + + const render = ClientFunction((markup: string) => { + const container = document.querySelector('#container'); + + if (container) container.innerHTML = markup; + }); + + const valueAt = async (selector: string, property: string): Promise => ( + await getComputedPropertyValue(selector, property) + ).trim(); + + test('a named mode class re-resolves the roles under it', async (t) => { + await render(` +
+
+
+ `); + + await t.expect(await valueAt('#plain', '--dx-theme-mode')).eql(buildMode); + await t.expect(await valueAt('#light', '--dx-theme-mode')).eql('light'); + await t.expect(await valueAt('#dark', '--dx-theme-mode')).eql('dark'); + + for (const role of MODE_DEPENDENT) { + const [light, dark, plain] = [ + await valueAt('#light', role), + await valueAt('#dark', role), + await valueAt('#plain', role), + ]; + + await t.expect(light) + .notEql(dark, `${role} must differ between the two named modes`); + await t.expect(plain) + .eql(buildMode === 'dark' ? dark : light, `${role} without a class is the bundle's mode`); + } + }); + + test('inverted flips against the nearest named scope', async (t) => { + await render(` +
+
+
+
+
+
+
+
+
+
+
+ `); + + await t.expect(await valueAt('#bare', '--dx-theme-mode')) + .eql(oppositeMode, 'with no named scope above it, inverted opposes the bundle'); + await t.expect(await valueAt('#in-dark', '--dx-theme-mode')).eql('light'); + await t.expect(await valueAt('#in-light', '--dx-theme-mode')).eql('dark'); + /* + * Recursive by construction: the style query asks the NEAREST enclosing scope, and the outcome + * of an inverted block is itself a named mode, so the inner one flips back. The depth-3 case + * pins that it is the nearest scope being read and not the bundle - inside a dark block the + * pair resolves dark -> light -> dark, not light -> dark. + */ + await t.expect(await valueAt('#nested', '--dx-theme-mode')) + .eql(buildMode, 'inverted inside inverted flips back'); + await t.expect(await valueAt('#nested-in-dark', '--dx-theme-mode')) + .eql('dark', 'the pair resolves against the dark scope around it'); + }); + + test('the system tier follows the mode instead of freezing at the bundle', async (t) => { + await render(`
`); + + for (const name of SYSTEM_TIER) { + // These are declared on the document root; a custom property resolves where it is declared, + // so without the mode classes on that same rule the value would stay the bundle's. + await t.expect(await valueAt('#probe', name)) + .notEql(await valueAt('html', name), `${name} must re-resolve inside a mode scope`); + } + }); + + test('an overlay is painted in the mode of the element that owns it', async (t) => { + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const wrapper = Selector('.dx-popup-wrapper'); + + await t.expect(wrapper.exists).ok(); + await t.expect(await valueAt('.dx-popup-wrapper', '--dx-theme-mode')).eql(oppositeMode); + await t.expect(await valueAt('.dx-popup-wrapper', '--dxds-color-bg')) + .eql(await valueAt('#owner', '--dxds-color-bg'), 'the overlay resolves the same roles as its owner'); + }); +} From d26d29c847c7cb4b1852b27cddbfed7862c2bfce Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 02:06:25 +0400 Subject: [PATCH 10/25] Let a place on the page answer for its mode, and tell an open overlay when it moved Two of the three questions the mode classes left open; the third - mirroring the mode onto from the runtime - is declined and written up in THEME_MODES.html. themes.mode(element) is the missing question. current() and isDark() answer for the loaded stylesheet and that stays right; what had no API was 'which mode is THIS element in', though the mechanism existed and swatch_container already used it. A separate name rather than an argument on isDark(), so one function does not mean global sometimes and local other times. themes.refreshMode() is the other half. An overlay renders in the viewport, in a container holding a copy of the mode its owner resolved to when it opened, and nothing re-picks that container afterwards. A theme switch now refreshes it; a class the application moves itself is invisible to us, so it says so with one call. No observer: watching ancestor classes would catch every hover and focus in a grid to serve an event that happens a few times per session. Verified by removing the subscription and watching the e2e case fail - the first version of that test passed either way, because it put the scope where the container logic reuses it. --- .../tests/common/themeModes.ts | 80 ++++++++++++++ .../js/__internal/ui/__tests__/themes.test.ts | 103 ++++++++++++++++++ .../js/__internal/ui/m_themes_callback.ts | 9 ++ .../js/__internal/ui/overlay/overlay.ts | 36 ++++++ .../devextreme/js/__internal/ui/themes.ts | 57 ++++++++-- packages/devextreme/js/ui/themes.d.ts | 15 +++ packages/devextreme/js/ui/themes.js | 2 + .../DevExpress.ui.widgets/overlay.tests.js | 72 ++++++++++++ 8 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 packages/devextreme/js/__internal/ui/__tests__/themes.test.ts diff --git a/e2e/testcafe-devextreme/tests/common/themeModes.ts b/e2e/testcafe-devextreme/tests/common/themeModes.ts index 5c286f629006..f3888d820730 100644 --- a/e2e/testcafe-devextreme/tests/common/themeModes.ts +++ b/e2e/testcafe-devextreme/tests/common/themeModes.ts @@ -36,6 +36,20 @@ if (getThemeName() === 'fluent-next') { await getComputedPropertyValue(selector, property) ).trim(); + const reportedMode = ClientFunction((selector: string) => (window as any).DevExpress.ui.themes + .mode(document.querySelector(selector))); + + const setScopeMode = ClientFunction((selector: string, mode: string, tell: boolean) => { + const scope = document.querySelector(selector) as HTMLElement; + + scope.classList.remove('dx-theme-mode-light', 'dx-theme-mode-dark'); + scope.classList.add(`dx-theme-mode-${mode}`); + + if (tell) { + (window as any).DevExpress.ui.themes.refreshMode(); + } + }); + test('a named mode class re-resolves the roles under it', async (t) => { await render(`
@@ -103,6 +117,28 @@ if (getThemeName() === 'fluent-next') { } }); + test('themes.mode answers for the element, not for the loaded file', async (t) => { + await render(` +
+
+
+ `); + + /* + * The reason the API exists: the element inherits the property from a scope above it, so only + * the cascade knows the answer. jsdom cannot inherit a custom property, which is why the unit + * tests next to themes.ts name the mode at the element and this case lives here. + */ + await t.expect(await reportedMode('#plain')).eql(buildMode, 'no scope above it - the loaded theme answers'); + await t.expect(await reportedMode('#scoped')).eql(oppositeMode, 'the mode is inherited from the scope, not declared here'); + await t.expect(await reportedMode('#back')).eql(buildMode, 'and inverted inside it flips back'); + + // The public answer and the property the theme publishes must not drift apart. + for (const id of ['#plain', '#scoped', '#back']) { + await t.expect(await reportedMode(id)).eql(await valueAt(id, '--dx-theme-mode')); + } + }); + test('an overlay is painted in the mode of the element that owns it', async (t) => { await render(`
`); @@ -117,4 +153,48 @@ if (getThemeName() === 'fluent-next') { await t.expect(await valueAt('.dx-popup-wrapper', '--dxds-color-bg')) .eql(await valueAt('#owner', '--dxds-color-bg'), 'the overlay resolves the same roles as its owner'); }); + + test('an open overlay follows its scope once the application says the mode changed', async (t) => { + /* + * A page-level switch needs none of this: the container hangs off the viewport, the viewport is + * inside , so the cascade carries it. What goes stale is a LOCAL scope - the container + * was given a copy of the mode its owner resolved to when the overlay was shown, and nothing + * re-picks it. Verified by removing the subscription: this case fails, the page-level one does + * not, which is why it is written this way. + */ + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const painted = async (): Promise => valueAt('.dx-popup-wrapper', '--dxds-color-bg'); + const asOpened = await painted(); + + await t.expect(asOpened).eql(await valueAt('#owner', '--dxds-color-bg'), 'opens in the mode of its scope'); + + await setScopeMode('#scope', buildMode, true); + + await t.expect(await valueAt('#owner', '--dx-theme-mode')).eql(buildMode, 'the scope did switch'); + await t.expect(await painted()).notEql(asOpened, 'the open overlay repainted'); + await t.expect(await painted()) + .eql(await valueAt('#owner', '--dxds-color-bg'), 'and matches its owner again'); + }); + + test('an open overlay keeps its mode until the application says so', async (t) => { + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const asOpened = await valueAt('.dx-popup-wrapper', '--dxds-color-bg'); + + // A class moved by the application is invisible to us; this pins that we do not pretend + // otherwise - the overlay waits to be told. + await setScopeMode('#scope', buildMode, false); + + await t.expect(await valueAt('.dx-popup-wrapper', '--dxds-color-bg')) + .eql(asOpened, 'still painted in the mode it was opened in'); + }); } diff --git a/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts new file mode 100644 index 000000000000..aa2b652cf3b2 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts @@ -0,0 +1,103 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { themeModeChangedCallback } from '@ts/ui/m_themes_callback'; +import { mode, refreshMode, resetTheme } from '@ts/ui/themes'; + +/* + * jsdom resolves a custom property declared ON an element but does not inherit it, so these cases + * name the mode at the element `mode()` reads it from. Inheritance - a scope declaring the property + * and a descendant resolving it through the cascade, which is how the mechanism actually works - is + * covered in a real browser by e2e/testcafe-devextreme/tests/common/themeModes.ts. + */ +const style = (css: string): void => { document.head.innerHTML = ``; }; + +const withThemeMarker = (themeName: string): string => `.dx-theme-marker { font-family: "dx.${themeName}"; }`; + +describe('themes.mode', () => { + let element: HTMLElement; + + beforeEach(() => { + element = document.createElement('div'); + element.className = 'probe'; + document.body.appendChild(element); + }); + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + resetTheme(); + }); + + it('reads the mode the element resolves to', () => { + style('.probe { --dx-theme-mode: dark; }'); + + expect(mode(element)).toBe('dark'); + }); + + it('accepts a renderer wrapper as well as an element', () => { + style('.probe { --dx-theme-mode: dark; }'); + + expect(mode($(element))).toBe('dark'); + }); + + it('falls back to the loaded theme when nothing declares a mode', () => { + style(withThemeMarker('generic.dark')); + + expect(mode(element)).toBe('dark'); + }); + + it('answers light for a loaded theme that is not dark', () => { + style(withThemeMarker('generic.light')); + + expect(mode(element)).toBe('light'); + }); + + it('lets the element outrank the loaded theme', () => { + style(`${withThemeMarker('generic.dark')} .probe { --dx-theme-mode: light; }`); + + expect(mode(element)).toBe('light'); + }); + + it('ignores a value that names no mode', () => { + style(`${withThemeMarker('generic.dark')} .probe { --dx-theme-mode: sepia; }`); + + expect(mode(element)).toBe('dark'); + }); + + it('answers for a detached element instead of throwing', () => { + style(withThemeMarker('generic.dark')); + + expect(mode(document.createElement('div'))).toBe('dark'); + }); +}); + +describe('themes.refreshMode', () => { + it('announces that a resolved mode may have changed', () => { + const told: number[] = []; + const subscriber = (): void => { told.push(1); }; + + themeModeChangedCallback.add(subscriber); + + try { + refreshMode(); + refreshMode(); + + expect(told).toHaveLength(2); + } finally { + themeModeChangedCallback.remove(subscriber); + } + }); + + it('stops telling a subscriber that unsubscribed', () => { + const told: number[] = []; + const subscriber = (): void => { told.push(1); }; + + themeModeChangedCallback.add(subscriber); + themeModeChangedCallback.remove(subscriber); + refreshMode(); + + expect(told).toHaveLength(0); + }); +}); diff --git a/packages/devextreme/js/__internal/ui/m_themes_callback.ts b/packages/devextreme/js/__internal/ui/m_themes_callback.ts index 8a5897072ef2..30922522543d 100644 --- a/packages/devextreme/js/__internal/ui/m_themes_callback.ts +++ b/packages/devextreme/js/__internal/ui/m_themes_callback.ts @@ -1,3 +1,12 @@ import Callbacks from '@js/core/utils/callbacks'; export const themeReadyCallback = Callbacks(); + +/* + * Fires when the colour mode an element resolves to may have changed: a theme switch, or an + * application telling us it moved a `dx-theme-mode-*` class itself. Anything that was detached + * from the scope it belongs to - today that is open overlays, which render in the viewport - + * re-reads its mode here. Lives beside themeReadyCallback so that neither themes.ts nor the + * overlay has to import the other. + */ +export const themeModeChangedCallback = Callbacks(); diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index e1a18de96da5..d1c320b497a1 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -46,6 +46,7 @@ import windowUtils from '@ts/core/utils/m_window'; import type { OptionChanged } from '@ts/core/widget/types'; import type { SupportedKeys } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; +import { themeModeChangedCallback } from '@ts/ui/m_themes_callback'; import type { BaseControllerProperties, ControllerOverlayElements, @@ -273,6 +274,8 @@ class Overlay< _viewPortChangeHandle?: () => void; + _themeModeChangeHandle?: () => void; + _proxiedDocumentDownHandler?: EventHandler; _supportedKeys(): SupportedKeys { @@ -414,6 +417,7 @@ class Overlay< this._$wrapper.attr('data-bind', 'dxControlsDescendantBindings: true'); this._toggleViewPortSubscription(true); + this._toggleThemeModeSubscription(true); const { hideTopOverlayHandler } = this.option(); @@ -631,6 +635,37 @@ class Overlay< this._refresh(); } + _toggleThemeModeSubscription(toggle: boolean): void { + if (this._themeModeChangeHandle) { + themeModeChangedCallback.remove(this._themeModeChangeHandle); + } + + if (toggle) { + this._themeModeChangeHandle = (): void => { + this._themeModeChangeHandler(); + }; + + themeModeChangedCallback.add(this._themeModeChangeHandle); + } + } + + /* + * The wrapper lives in the viewport, inside a container that carries the mode its owner resolved + * to when the overlay was shown. Nothing re-picks that container afterwards: `_moveToContainer` + * runs on becoming visible and on a content re-render, and a class moving somewhere up the tree + * is neither. An overlay that is already open would keep painting in the previous mode. + */ + _themeModeChangeHandler(): void { + if (!this._isVisible()) { + return; + } + + const { container } = this.option(); + + this._positionController.updateContainer(container); + this._moveToContainer(); + } + _renderWrapperAttributes(): void { const { wrapperAttr } = this.option(); @@ -1583,6 +1618,7 @@ class Overlay< } this._toggleViewPortSubscription(false); + this._toggleThemeModeSubscription(false); this._toggleSubscriptions(false); this._updateZIndexStackPosition(false); diff --git a/packages/devextreme/js/__internal/ui/themes.ts b/packages/devextreme/js/__internal/ui/themes.ts index 537b6e7f29a9..e7e03854dbca 100644 --- a/packages/devextreme/js/__internal/ui/themes.ts +++ b/packages/devextreme/js/__internal/ui/themes.ts @@ -11,7 +11,7 @@ import { changeCallback, originalViewPort, value as viewPortValue } from '@js/co import { getWindow, hasWindow } from '@js/core/utils/window'; import errors from '@js/ui/widget/ui.errors'; import { uiLayerInitialized } from '@ts/core/utils/m_common'; -import { themeReadyCallback } from '@ts/ui/m_themes_callback'; +import { themeModeChangedCallback, themeReadyCallback } from '@ts/ui/m_themes_callback'; const window = getWindow(); const ready = readyCallbacks.add; @@ -108,6 +108,9 @@ export function waitForThemeLoad(themeName: string): void { themeReadyCallback.fire(); themeReadyCallback.empty(); + // a different stylesheet can mean a different mode + themeModeChangedCallback.fire(); + initDeferred.resolve(); } @@ -331,6 +334,9 @@ export function current(options) { themeReadyCallback.fire(); themeReadyCallback.empty(); + + // a different stylesheet can mean a different mode + themeModeChangedCallback.fire(); } else { throw errors.Error('E0021', currentThemeName); } @@ -350,13 +356,11 @@ export function init(options): void { current(options); } -function isTheme(themeRegExp: string, themeName: string): boolean { - if (!themeName) { - // eslint-disable-next-line no-param-reassign - themeName = currentThemeName || readThemeMarker(); - } +function isTheme(themeRegExp: string, themeName?: string): boolean { + // Omitted on purpose by callers that ask about the loaded theme rather than about a given name. + const name: string | null = themeName || currentThemeName || readThemeMarker(); - return new RegExp(themeRegExp).test(themeName); + return !!name && new RegExp(themeRegExp).test(name); } export function isMaterial(themeName: string): boolean { @@ -375,7 +379,7 @@ export function isGeneric(themeName: string): boolean { return isTheme('generic', themeName); } -export function isDark(themeName: string): boolean { +export function isDark(themeName?: string): boolean { return isTheme('dark', themeName); } @@ -383,6 +387,41 @@ export function isCompact(themeName: string): boolean { return isTheme('compact', themeName); } +const THEME_MODE_PROPERTY = '--dx-theme-mode'; + +/** + * The colour mode an element is rendered in. + * + * `current()` and `isDark()` answer for the stylesheet that is loaded, and that stays the right + * answer to the question they ask. It is no longer the whole story: a theme can ship both modes in + * one bundle and let a class pick between them per element, so "which mode" has an answer per place + * rather than per page. Such a theme publishes the outcome in `--dx-theme-mode` on every scope it + * declares, and the element is the only thing that knows - the cascade decides it, not the classes + * on the way up. A theme that does not scope modes declares nothing, and the loaded theme answers. + */ +/** + * Re-reads the colour mode for widgets that render outside the element they belong to - today that + * is open overlays, whose markup lives in the viewport and therefore outside the scope that decides + * their mode. Switching the theme through `current()` calls this; call it yourself after moving a + * `dx-theme-mode-*` class by hand, since that change is invisible to us. + */ +export function refreshMode(): void { + themeModeChangedCallback.fire(); +} + +export function mode(element: Element | dxElementWrapper): 'light' | 'dark' { + const node = $(element).get(0); + const declared = node && hasWindow() + ? window.getComputedStyle(node).getPropertyValue(THEME_MODE_PROPERTY).trim() + : ''; + + if (declared === 'light' || declared === 'dark') { + return declared; + } + + return isDark() ? 'dark' : 'light'; +} + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types function themeReady(callback): void { themeReadyCallback.add(callback); @@ -511,6 +550,8 @@ export default { isMaterial, isFluent, isMaterialBased, + mode, + refreshMode, detachCssClasses, attachCssClasses, current, diff --git a/packages/devextreme/js/ui/themes.d.ts b/packages/devextreme/js/ui/themes.d.ts index c4cf17bed007..03e98bf3f93f 100644 --- a/packages/devextreme/js/ui/themes.d.ts +++ b/packages/devextreme/js/ui/themes.d.ts @@ -41,3 +41,18 @@ export function isFluent(theme: string): boolean; export function isMaterial(theme: string): boolean; export function isGeneric(theme: string): boolean; export function isCompact(theme: string): boolean; + +/** + * The colour mode an element is rendered in: 'light' or 'dark'. + * + * Unlike `current()` and `isDark()`, which answer for the loaded stylesheet, this answers for a + * place on the page - a theme may ship both modes in one bundle and let a class pick between them. + */ +export function mode(element: Element): 'light' | 'dark'; + +/** + * Re-reads the colour mode for widgets that render outside the element they belong to, such as an + * open popup. Switching the theme through `current()` does this for you; call it after moving a + * `dx-theme-mode-*` class by hand. + */ +export function refreshMode(): void; diff --git a/packages/devextreme/js/ui/themes.js b/packages/devextreme/js/ui/themes.js index 7ba25bcc98a7..98dbc2a4fd5b 100644 --- a/packages/devextreme/js/ui/themes.js +++ b/packages/devextreme/js/ui/themes.js @@ -14,6 +14,8 @@ export const { isMaterial, isFluent, isMaterialBased, + mode, + refreshMode, detachCssClasses, attachCssClasses, current, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index 44d0d3583082..eba4a4281e97 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -20,6 +20,7 @@ import * as zIndex from '__internal/ui/overlay/z_index'; import 'ui/scroll_view/ui.scrollable'; import selectors from '__internal/core/utils/m_selectors'; import swatch from '__internal/core/utils/swatch_container'; +import themes from 'ui/themes'; import documentSizeCallbacks from '__internal/core/utils/document_size_callbacks'; import keyboardMock from '../../helpers/keyboardMock.js'; import pointerMock from '../../helpers/pointerMock.js'; @@ -325,6 +326,77 @@ testModule('render', moduleConfig, () => { assert.ok(overlayContainer.parent().hasClass(VIEWPORT_CLASS), 'overlay\'s container is the viewport\'s child'); }); + /* + * The mode a theme scope resolves to is published in --dx-theme-mode and read back through the + * cascade, so these declare it with a stylesheet rather than with the theme: the generic bundle + * this suite loads does not scope modes at all. + */ + const withModeStyles = (callback) => { + const style = $(' + + + +
+ Bundle: + + + Page mode (class on <html>): + + + + theme switches: 1 +
+ +
+

Theme modes: every supported use

+

Live page on the real bundle and real widgets - nothing here is a mock-up. Each section states what it demonstrates; the report at the bottom checks the same cases mechanically, so a broken one is named rather than merely looking wrong.

+ +

1. Naming a mode on any element

+

The mode is a property of a place on the page. Put dx-theme-mode-light or -dark on any element and it, along with everything inside it, repaints. Nothing is loaded or swapped.

+
+
no class - the bundle decidesBody text on a surface
+
dx-theme-mode-lightAlways light
+
dx-theme-mode-darkAlways dark
+
+ +

2. Inverting against the surroundings

+

dx-theme-mode-inverted means "the opposite of the nearest enclosing mode". It is recursive: an inverted block inside an inverted block flips back, at any depth. That is what makes a contrasting panel work without the application knowing which mode it is in.

+
+
inverted, nothing named aboveOpposite of the bundle
+
inside dark +
invertedLight again
+
+
inside dark +
inverted +
inverted againBack to dark
+
+
+
+ +

3. Real widgets inside a scope

+

Components read the same roles, so they follow a scope with no extra work. The same three widgets are built twice, in two scopes.

+
+
dx-theme-mode-light +
+
+
dx-theme-mode-dark +
+
+
+ +

4. Overlays follow the element that owns them

+

A popup or a drop-down renders in the viewport, not where the widget sits, so it cannot inherit the scope. The theme publishes the resolved mode in --dx-theme-mode and the core reads it back, giving the overlay's container the mode its owner resolved to. Open both and compare.

+
+
owner in a light scope
+
owner in a dark scope
+
owner in an inverted scope
+
+ +

5. Switching a scope while an overlay is open

+

Anything that changes what an element resolves to - a class the application moves, or the whole theme - is announced with themes.refreshMode(). Open the drop-down below, then press 1 and watch the report disagree - the list keeps the mode it opened in. Press 2 and it catches up. Everything that stayed inside the scope followed the class immediately; only what was moved out to the viewport needed telling.

+
+
scope: dark
+
+ + + +
+
+ +

6. Together with a colour swatch

+

Swatches and modes are independent scopes and combine: the overlay container repeats both.

+
+
dx-swatch-demo + dx-theme-mode-dark
+
+ +

7. Customising roles

+

A role overridden in a stylesheet loaded after the theme wins, as it always did - all theme rules weigh one class, :root included. Inside a mode scope the theme re-declares the roles, so an override meant for a scope belongs on the element carrying the class.

+
+
page-level override of a custom propertyBorder painted from --dx-demo-brand declared on :root
+
role overridden on the scope element +
+
+
+ +

8. Asking what mode an element is in

+

themes.mode(element) answers for a place on the page. themes.current() and themes.isDark() keep answering for the loaded stylesheet - a different question, still a valid one. Both are reported below.

+ +

9. What does not follow a class

+

Icons whose colour is baked into a data-uri at build time (diagram, gantt, fileManager, list, timeView) and the theme marker keep the bundle's mode. Charts take their theme from scripts, so they follow the loaded file too. This is by construction, not a defect - a var() does not resolve inside a data-uri.

+
+
a data-uri icon inside a dark scope +
+
Stays as the bundle drew it
+
+
+ +

Mechanical report

+

Every case above, checked against the values the browser resolved. Re-run after any switch in the bar.

+
running…
+
+ + + + From 7d0541687b5ce0cd1447b6d1a628a27baba0a75b Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 04:40:01 +0400 Subject: [PATCH 12/25] Stop the demo popups from covering the page they are demonstrating They were modal by default, so the shader dimmed everything below and swallowed the buttons in the live-switching section - the one place on the page you have to click. Not modal now, and anchored under the block that opened each one, so all three can be open at once and compared, which is what the section is for. --- packages/devextreme/playground/theme-modes.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/devextreme/playground/theme-modes.html b/packages/devextreme/playground/theme-modes.html index e2916c6fe87e..7135f1aaa75f 100644 --- a/packages/devextreme/playground/theme-modes.html +++ b/packages/devextreme/playground/theme-modes.html @@ -179,9 +179,15 @@

Mechanical report

$('
').appendTo(host).dxButton({ text: 'Open a popup', onClick: function () { + /* + * Not modal, and anchored to the block that opened it: the point of this section is to + * have all three open at once and compare them, and a shader would both hide the others + * and block the buttons further down the page. + */ var instance = $('
').appendTo(host).dxPopup({ title: 'Painted in the owner’s mode', - width: 320, height: 180, visible: true, showCloseButton: true, + width: 300, height: 150, visible: true, showCloseButton: true, shading: false, + position: { my: 'top left', at: 'bottom left', of: host, offset: '0 6', collision: 'fit' }, contentTemplate: function () { return $('

').text('This popup lives in the viewport, yet it resolved the mode of the block that opened it.'); } }).dxPopup('instance'); From 4ace537646cec8f5e27ed648db06774acc24404d Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 05:43:50 +0400 Subject: [PATCH 13/25] Move an open overlay only when its scope actually moved refreshMode re-appended the wrapper and the content whether or not the container had changed, and appending a child that is already in place still detaches it: the focus left the overlay, its animations restarted and any iframe in its content reloaded. Every visible overlay on the page paid that, including ones in no mode scope at all. --- .../js/__internal/ui/overlay/overlay.ts | 10 +++-- .../DevExpress.ui.widgets/overlay.tests.js | 43 ++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index d1c320b497a1..f3d82d91da81 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -660,10 +660,14 @@ class Overlay< return; } - const { container } = this.option(); + // Reading `$container` re-resolves the scope. Move only when it named a different node: + // appending is not a no-op for a child already in place, and detaching the wrapper takes the + // focus out of the overlay, restarts its animations and reloads any iframe inside it. + const { $container } = this._positionController; - this._positionController.updateContainer(container); - this._moveToContainer(); + if ($container && $container.get(0) !== this._$wrapper?.parent().get(0)) { + this._moveToContainer(); + } } _renderWrapperAttributes(): void { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index eba4a4281e97..bca84b4ed566 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -382,17 +382,58 @@ testModule('render', moduleConfig, () => { }); }); + test('Theme mode - refreshMode leaves an overlay whose scope did not change where it is', function(assert) { + withModeStyles(() => { + const $scope = $('

').addClass('mode-light').appendTo('#container'); + const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance'); + const $wrapper = overlay.$wrapper(); + const container = $wrapper.parent().get(0); + + /* + * Re-appending a child that is already in place is not a no-op: the node is detached + * and re-inserted, which takes the focus out of the overlay, restarts its animations + * and reloads any iframe in its content. Watching for the detach is steadier than + * asserting on document.activeElement, which needs the window to be focused. + */ + const containerWatch = new MutationObserver(() => {}); + const wrapperWatch = new MutationObserver(() => {}); + + containerWatch.observe(container, { childList: true }); + wrapperWatch.observe($wrapper.get(0), { childList: true }); + + themes.refreshMode(); + + const moves = containerWatch.takeRecords().length + wrapperWatch.takeRecords().length; + + containerWatch.disconnect(); + wrapperWatch.disconnect(); + + assert.strictEqual(overlay.$wrapper().parent().get(0), container, 'still in the same container'); + assert.strictEqual(moves, 0, 'and nothing was detached to put it back where it already was'); + + overlay.dispose(); + $scope.remove(); + }); + }); + test('Theme mode - a disposed overlay stops listening', function(assert) { withModeStyles(() => { const $scope = $('
').addClass('mode-light').appendTo('#container'); const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance'); + let told = 0; + + overlay._themeModeChangeHandler = () => { told += 1; }; + + themes.refreshMode(); + + assert.strictEqual(told, 1, 'a live overlay is subscribed - without this the case below passes for the wrong reason'); overlay.dispose(); $scope.removeClass('mode-light').addClass('mode-dark'); themes.refreshMode(); - assert.expect(0); + assert.strictEqual(told, 1, 'and a disposed one is not told again'); $scope.remove(); }); }); From 7d05d96df42e0558b0d56f133a71a9801bac1fc0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 05:43:59 +0400 Subject: [PATCH 14/25] Read the resolved theme mode in one place The property name and the read were spelled twice, and the two spellings disagreed: themes.mode() answered only light or dark, while the container took any value and could hand back a class no rule matches. The docstring for mode() had also come to rest above refreshMode, leaving the function it describes undocumented. The public element type now says what the function has always accepted. --- .../utils/__tests__/swatch_container.test.ts | 7 ++++ .../__internal/core/utils/swatch_container.ts | 19 ++++------- .../js/__internal/core/utils/theme_mode.ts | 32 +++++++++++++++++++ .../devextreme/js/__internal/ui/themes.ts | 20 +++--------- packages/devextreme/js/ui/themes.d.ts | 4 ++- 5 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/utils/theme_mode.ts 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 57e492176eb5..f34102aa58ea 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 @@ -21,6 +21,7 @@ const { getSwatchContainer } = swatchContainer; const MODE_STYLES = ` .mode-light { --dx-theme-mode: light; } .mode-dark { --dx-theme-mode: dark; } + .mode-sepia { --dx-theme-mode: sepia; } `; const classesOf = (element: Element): string[] => [...element.classList].sort(); @@ -110,6 +111,12 @@ describe('getSwatchContainer', () => { expect(containerFor('
')).toBe($viewport); }); + // The contract is light or dark; anything else names no scope the theme can paint, and a + // container carrying it would be a wrapper no rule matches. + it('carries no mode when the value names none', () => { + expect(containerFor('
')).toBe($viewport); + }); + it('carries a swatch and a mode together', () => { const container = containerFor(`
diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index ec43f7081f9d..880cd246713d 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -1,11 +1,10 @@ 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'; +import { resolvedThemeMode } from '@ts/core/utils/theme_mode'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; const THEME_MODE_CLASS_PREFIX = 'dx-theme-mode-'; -const THEME_MODE_PROPERTY = '--dx-theme-mode'; const classesByPrefix = ( element: Element, @@ -24,19 +23,13 @@ const closestClassesByPrefix = ( /* * 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. + * reparented to the viewport, whose surroundings are different ones. `resolvedThemeMode` reads the + * outcome the theme published, the same value `themes.mode()` reports, so the container and the + * public answer cannot drift apart. 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 []; - } - - const mode = window.getComputedStyle(element).getPropertyValue(THEME_MODE_PROPERTY).trim(); + const mode = resolvedThemeMode($element); return mode ? [`${THEME_MODE_CLASS_PREFIX}${mode}`] : []; }; diff --git a/packages/devextreme/js/__internal/core/utils/theme_mode.ts b/packages/devextreme/js/__internal/core/utils/theme_mode.ts new file mode 100644 index 000000000000..e147e752a310 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/theme_mode.ts @@ -0,0 +1,32 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { getWindow, hasWindow } from '@js/core/utils/window'; + +export type ThemeMode = 'light' | 'dark'; + +/* + * What a theme that ships more than one colour mode publishes on every scope it declares + * (widgets/fluent-next/_design-system.scss). `dx-theme-mode-inverted` asks for the opposite of its + * surroundings, so reading the classes on the way up never answers which mode an element ended up + * in - only the cascade does, and this property is where it says so. + */ +export const THEME_MODE_PROPERTY = '--dx-theme-mode'; + +/** + * The mode an element resolves to, or null when the theme scopes no modes and declares nothing. + * A value naming no mode is treated the same way: the contract is `light` or `dark`. + */ +export function resolvedThemeMode( + element: Element | dxElementWrapper, +): ThemeMode | null { + const node = $(element).get(0); + const window = hasWindow() ? getWindow() : undefined; + + if (!node || !window?.getComputedStyle) { + return null; + } + + const declared = window.getComputedStyle(node).getPropertyValue(THEME_MODE_PROPERTY).trim(); + + return declared === 'light' || declared === 'dark' ? declared : null; +} diff --git a/packages/devextreme/js/__internal/ui/themes.ts b/packages/devextreme/js/__internal/ui/themes.ts index 0a28426034d1..aa913921be9b 100644 --- a/packages/devextreme/js/__internal/ui/themes.ts +++ b/packages/devextreme/js/__internal/ui/themes.ts @@ -11,6 +11,7 @@ import { changeCallback, originalViewPort, value as viewPortValue } from '@js/co import { getWindow, hasWindow } from '@js/core/utils/window'; import errors from '@js/ui/widget/ui.errors'; import { uiLayerInitialized } from '@ts/core/utils/m_common'; +import { resolvedThemeMode } from '@ts/core/utils/theme_mode'; import { themeModeChangedCallback, themeReadyCallback } from '@ts/ui/m_themes_callback'; const window = getWindow(); @@ -381,8 +382,6 @@ export function isCompact(themeName: string): boolean { return isTheme('compact', themeName); } -const THEME_MODE_PROPERTY = '--dx-theme-mode'; - /** * The colour mode an element is rendered in. * @@ -393,6 +392,10 @@ const THEME_MODE_PROPERTY = '--dx-theme-mode'; * declares, and the element is the only thing that knows - the cascade decides it, not the classes * on the way up. A theme that does not scope modes declares nothing, and the loaded theme answers. */ +export function mode(element: Element | dxElementWrapper): 'light' | 'dark' { + return resolvedThemeMode(element) ?? (isDark() ? 'dark' : 'light'); +} + /** * Re-reads the colour mode for widgets that render outside the element they belong to - today that * is open overlays, whose markup lives in the viewport and therefore outside the scope that decides @@ -408,19 +411,6 @@ export function refreshMode(): void { themeModeChangedCallback.fire(); } -export function mode(element: Element | dxElementWrapper): 'light' | 'dark' { - const node = $(element).get(0); - const declared = node && hasWindow() - ? window.getComputedStyle(node).getPropertyValue(THEME_MODE_PROPERTY).trim() - : ''; - - if (declared === 'light' || declared === 'dark') { - return declared; - } - - return isDark() ? 'dark' : 'light'; -} - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types function themeReady(callback): void { themeReadyCallback.add(callback); diff --git a/packages/devextreme/js/ui/themes.d.ts b/packages/devextreme/js/ui/themes.d.ts index 2569155d7ec2..3480db75310d 100644 --- a/packages/devextreme/js/ui/themes.d.ts +++ b/packages/devextreme/js/ui/themes.d.ts @@ -1,3 +1,5 @@ +import { UserDefinedElement } from '../core/element'; + /** * @docid ui.themes * @namespace DevExpress.ui @@ -48,7 +50,7 @@ export function isCompact(theme: string): boolean; * Unlike `current()` and `isDark()`, which answer for the loaded stylesheet, this answers for a * place on the page - a theme may ship both modes in one bundle and let a class pick between them. */ -export function mode(element: Element): 'light' | 'dark'; +export function mode(element: UserDefinedElement): 'light' | 'dark'; /** * Re-reads the colour mode for widgets that render outside the element they belong to, such as an From 760c644be0ada08d7a34cf01e61b77fad0433792 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 05:44:07 +0400 Subject: [PATCH 15/25] Catch a frozen value that shares its rule with a selector the mode class can reach The gate asked whether EVERY selector of a rule is the document root, so a rule like :root, .dx-button declared on for everything outside a button and went unreported. What matters is that the rule reaches the root with no mode scope in the same list to re-resolve it. --- .../devextreme-scss/tests/theme-mode-scope.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/devextreme-scss/tests/theme-mode-scope.test.ts b/packages/devextreme-scss/tests/theme-mode-scope.test.ts index f1f01c408cd9..6c70a65807c0 100644 --- a/packages/devextreme-scss/tests/theme-mode-scope.test.ts +++ b/packages/devextreme-scss/tests/theme-mode-scope.test.ts @@ -55,6 +55,15 @@ const modeScopesOf = (selector: string): string[] => MODE_SCOPES // 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)); +/* + * Reaching the document element is what freezes a value, and a rule can do that while also + * matching something else: `:root, .dx-button { … }` still declares on for every button + * that is not inside one. So the question is not whether EVERY selector is the root - it is + * whether ANY is, with no mode scope in the same list to re-resolve it. + */ +const freezesOnDocumentRoot = (selectors: string[]): boolean => selectors.some(isDocumentRoot) + && !selectors.some((selector) => modeScopesOf(selector).length); + interface BundleFacts { scopeNames: Record>; rootDeclarations: { property: string; reads: string[]; selector: string }[]; @@ -71,7 +80,7 @@ const readBundle = (name: string): BundleFacts => { root.walkRules((rule) => { const scopes = new Set(rule.selectors.flatMap(modeScopesOf)); - const onDocumentRoot = rule.selectors.every(isDocumentRoot); + const onDocumentRoot = freezesOnDocumentRoot(rule.selectors); rule.each((node) => { if (node.type !== 'decl' || !node.prop.startsWith('--')) { From 29f2607a4537abfea87e592c88931797472cf8e1 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 05:44:07 +0400 Subject: [PATCH 16/25] Seed the mode split from both mode files, not just the light one A name only one of the two modes declares differs by definition, but seeding from the light keys alone would leave it unmarked: dropped from the dark scope and never moved to :root. The key sets agree today, so the generated output is unchanged. --- packages/devextreme-scss/build/tokens/build-tokens.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 650e3dc80f84..030634059e22 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -496,7 +496,10 @@ const parseDeclarations = (content) => content.split('\n').reduce((declarations, 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))); + // Both key sets, not just the light one: a name only one mode declares differs by definition, + // and seeding from one side would drop it from the other scope without moving it to :root. + const tainted = new Set([...light.keys(), ...dark.keys()] + .filter((name) => light.get(name) !== dark.get(name))); for (let grew = true; grew;) { grew = false; @@ -547,7 +550,7 @@ async function splitModeScopedLayers() { 'utf-8', ))); - // The light file carries the shared roles: for those two, light and dark agree by definition. + // Either mode file carries the shared roles: for those, 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'); From 25a055d45bb17b46a10342ccf28d9cc0a952b117 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 12:05:15 +0400 Subject: [PATCH 17/25] Declare the test mode on the scope element, so the shadow-DOM run sees it too The cases put --dx-theme-mode in a document stylesheet, which cannot reach a fixture inside a shadow root: under ?shadowDom nothing declared the property, no mode class reached the container, and three assertions failed. Declaring it on the scope element inherits down in both configurations, which is the mechanism these cases are about anyway. --- .../DevExpress.ui.widgets/overlay.tests.js | 133 ++++++++---------- 1 file changed, 61 insertions(+), 72 deletions(-) diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index bca84b4ed566..efdaf0fa680b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -327,115 +327,104 @@ testModule('render', moduleConfig, () => { }); /* - * The mode a theme scope resolves to is published in --dx-theme-mode and read back through the - * cascade, so these declare it with a stylesheet rather than with the theme: the generic bundle - * this suite loads does not scope modes at all. + * The generic bundle this suite loads scopes no modes, so these declare --dx-theme-mode + * themselves. It goes on the scope element rather than into a document stylesheet: under + * ?shadowDom the fixture lives in a shadow root, which a rule in cannot reach, while a + * declaration on the element inherits down either way - and inheriting is the whole mechanism. */ - const withModeStyles = (callback) => { - const style = $(' - - - -
- Bundle: - - - Page mode (class on <html>): - - - - theme switches: 1 -
- -
-

Theme modes: every supported use

-

Live page on the real bundle and real widgets - nothing here is a mock-up. Each section states what it demonstrates; the report at the bottom checks the same cases mechanically, so a broken one is named rather than merely looking wrong.

- -

1. Naming a mode on any element

-

The mode is a property of a place on the page. Put dx-theme-mode-light or -dark on any element and it, along with everything inside it, repaints. Nothing is loaded or swapped.

-
-
no class - the bundle decidesBody text on a surface
-
dx-theme-mode-lightAlways light
-
dx-theme-mode-darkAlways dark
-
- -

2. Inverting against the surroundings

-

dx-theme-mode-inverted means "the opposite of the nearest enclosing mode". It is recursive: an inverted block inside an inverted block flips back, at any depth. That is what makes a contrasting panel work without the application knowing which mode it is in.

-
-
inverted, nothing named aboveOpposite of the bundle
-
inside dark -
invertedLight again
-
-
inside dark -
inverted -
inverted againBack to dark
-
-
-
- -

3. Real widgets inside a scope

-

Components read the same roles, so they follow a scope with no extra work. The same three widgets are built twice, in two scopes.

-
-
dx-theme-mode-light -
-
-
dx-theme-mode-dark -
-
-
- -

4. Overlays follow the element that owns them

-

A popup or a drop-down renders in the viewport, not where the widget sits, so it cannot inherit the scope. The theme publishes the resolved mode in --dx-theme-mode and the core reads it back, giving the overlay's container the mode its owner resolved to. Open both and compare.

-
-
owner in a light scope
-
owner in a dark scope
-
owner in an inverted scope
-
- -

5. Switching a scope while an overlay is open

-

Anything that changes what an element resolves to - a class the application moves, or the whole theme - is announced with themes.refreshMode(). Open the drop-down below, then press 1 and watch the report disagree - the list keeps the mode it opened in. Press 2 and it catches up. Everything that stayed inside the scope followed the class immediately; only what was moved out to the viewport needed telling.

-
-
scope: dark
-
- - - -
-
- -

6. Together with a colour swatch

-

Swatches and modes are independent scopes and combine: the overlay container repeats both.

-
-
dx-swatch-demo + dx-theme-mode-dark
-
- -

7. Customising roles

-

A role overridden in a stylesheet loaded after the theme wins, as it always did - all theme rules weigh one class, :root included. Inside a mode scope the theme re-declares the roles, so an override meant for a scope belongs on the element carrying the class.

-
-
page-level override of a custom propertyBorder painted from --dx-demo-brand declared on :root
-
role overridden on the scope element -
-
-
- -

8. Asking what mode an element is in

-

themes.mode(element) answers for a place on the page. themes.current() and themes.isDark() keep answering for the loaded stylesheet - a different question, still a valid one. Both are reported below.

- -

9. What does not follow a class

-

Icons whose colour is baked into a data-uri at build time (diagram, gantt, fileManager, list, timeView) and the theme marker keep the bundle's mode. Charts take their theme from scripts, so they follow the loaded file too. This is by construction, not a defect - a var() does not resolve inside a data-uri.

-
-
a data-uri icon inside a dark scope -
-
Stays as the bundle drew it
-
-
- -

Mechanical report

-

Every case above, checked against the values the browser resolved. Re-run after any switch in the bar.

-
running…
-
- - - - From b26ce8152c50cb58dd9acf0064a488446376b78e Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 12:16:35 +0400 Subject: [PATCH 19/25] Cut the comments back to what the code cannot say itself Restatement, duplicated blocks and accounts of what was tried before, plus two counts in prose that the build already prints. The invariants that are invisible from the code stay. --- .../tests/common/themeModes.ts | 32 +- .../build/tokens/build-tokens.mjs | 38 +-- .../widgets/fluent-next/_design-system.scss | 49 +-- .../widgets/fluent-next/gridBase/_colors.scss | 5 - .../fluent-next/textEditor/_colors.scss | 5 - .../tests/fluent-next-naming.test.ts | 7 +- .../tests/theme-mode-scope.test.ts | 44 +-- .../tools/naming/derive-registries.mjs | 8 +- .../__internal/core/utils/swatch_container.ts | 10 +- .../js/__internal/core/utils/theme_mode.ts | 10 +- .../js/__internal/ui/m_themes_callback.ts | 8 +- .../js/__internal/ui/overlay/overlay.ts | 12 +- .../ui/overlay/overlay_position_controller.ts | 8 +- .../devextreme/js/__internal/ui/themes.ts | 25 +- .../devextreme/playground/theme-modes.html | 323 ++++++++++++++++++ 15 files changed, 400 insertions(+), 184 deletions(-) create mode 100644 packages/devextreme/playground/theme-modes.html diff --git a/e2e/testcafe-devextreme/tests/common/themeModes.ts b/e2e/testcafe-devextreme/tests/common/themeModes.ts index f3888d820730..da4996fa0f41 100644 --- a/e2e/testcafe-devextreme/tests/common/themeModes.ts +++ b/e2e/testcafe-devextreme/tests/common/themeModes.ts @@ -6,13 +6,9 @@ import { clearTestPage } from '../../helpers/testPageUtils'; import { getFullThemeName, getThemeName } from '../../helpers/themeUtils'; /* - * The mode classes are a fluent-next contract, and this is the only place that exercises them in a - * real browser. The unit tests around `core/utils/swatch_container.ts` cannot: jsdom resolves a - * custom property declared ON an element but does not inherit it, while the whole mechanism is a - * scope declaring `--dx-theme-mode` and descendants reading it back through the cascade. - * - * Every assertion is relative - "this scope differs from that one", never a hex literal - so a - * token bump moves the values without touching the test. + * The only place the mode classes are exercised in a real browser: jsdom does not inherit a custom + * property, and inheritance is the whole mechanism. Every assertion is relative - "this scope + * differs from that one", never a hex literal - so a token bump does not touch the test. */ if (getThemeName() === 'fluent-next') { fixture`Theme modes` @@ -94,12 +90,8 @@ if (getThemeName() === 'fluent-next') { .eql(oppositeMode, 'with no named scope above it, inverted opposes the bundle'); await t.expect(await valueAt('#in-dark', '--dx-theme-mode')).eql('light'); await t.expect(await valueAt('#in-light', '--dx-theme-mode')).eql('dark'); - /* - * Recursive by construction: the style query asks the NEAREST enclosing scope, and the outcome - * of an inverted block is itself a named mode, so the inner one flips back. The depth-3 case - * pins that it is the nearest scope being read and not the bundle - inside a dark block the - * pair resolves dark -> light -> dark, not light -> dark. - */ + // The depth-3 case pins that the NEAREST scope is what is read, not the bundle: inside a dark + // block the pair resolves dark -> light -> dark, not light -> dark. await t.expect(await valueAt('#nested', '--dx-theme-mode')) .eql(buildMode, 'inverted inside inverted flips back'); await t.expect(await valueAt('#nested-in-dark', '--dx-theme-mode')) @@ -124,11 +116,8 @@ if (getThemeName() === 'fluent-next') {
`); - /* - * The reason the API exists: the element inherits the property from a scope above it, so only - * the cascade knows the answer. jsdom cannot inherit a custom property, which is why the unit - * tests next to themes.ts name the mode at the element and this case lives here. - */ + // The element inherits the property from a scope above it, so only the cascade knows - which + // is why this case lives here and not next to themes.ts. await t.expect(await reportedMode('#plain')).eql(buildMode, 'no scope above it - the loaded theme answers'); await t.expect(await reportedMode('#scoped')).eql(oppositeMode, 'the mode is inherited from the scope, not declared here'); await t.expect(await reportedMode('#back')).eql(buildMode, 'and inverted inside it flips back'); @@ -156,11 +145,8 @@ if (getThemeName() === 'fluent-next') { test('an open overlay follows its scope once the application says the mode changed', async (t) => { /* - * A page-level switch needs none of this: the container hangs off the viewport, the viewport is - * inside , so the cascade carries it. What goes stale is a LOCAL scope - the container - * was given a copy of the mode its owner resolved to when the overlay was shown, and nothing - * re-picks it. Verified by removing the subscription: this case fails, the page-level one does - * not, which is why it is written this way. + * The scope has to be LOCAL: a page-level switch reaches the container through the cascade on + * its own, so that version of this case passes with the subscription removed. */ await render(`
`); diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 030634059e22..cab61187cd6d 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -241,16 +241,9 @@ const getModeFiles = (mode) => [ const getBridgeFiles = () => getModeFiles('light'); /* - * 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. + * A bundle needs the mode-dependent declarations under three selectors, and 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 where it wants. * * Otherwise identical to Style Dictionary's own `css/variables` (lib/common/formats.js) minus the * selector nesting; keep the two in step. @@ -389,11 +382,10 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ 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. + * The layers that read a colour role without being one: box-shadow composites and the global + * focus aliases. Their text is mode-independent, but a custom property resolves where it is + * declared, so on `:root` they would freeze at the bundle's mode. Both mode configs emit this + * file; the sources are the same, so the two writes are byte-identical. */ { destination: `${THEME_NAME}/${MODE_ALIASES_FILE}.scss`, @@ -474,16 +466,12 @@ async function collectThemeStyleSheets() { } /* - * 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. + * Emitting by source file is a coarse answer: many declarations in those files do not depend on + * the mode, and repeating them in four scopes per bundle is pure weight. Which is which is derived + * from the generated text - a name whose two mode values differ, plus anything reading such a name + * through a chain - so a token that starts or stops depending on the mode moves on its own at the + * next bump. The remainder goes to a plain `:root` block; the theme-mode-scope gate is the judge. + * The counts are printed at the end of the build. */ const DECLARATION = /^(\s*)(--[\w-]+)\s*:\s*([^;]+);\s*$/; 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 78f03b9517a0..19ebd5b0a257 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -15,14 +15,8 @@ $accent: colors.$color !default; * 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. - * - * `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. + * Loaded here is what does not depend on the colour mode; the rest goes through `mode-values`. + * Which is which is derived by build/tokens/build-tokens.mjs, so this file carries no list. */ @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @@ -31,14 +25,10 @@ $accent: colors.$color !default; @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: - * 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. + * `--dx-theme-mode` is here for the JS: `dx-theme-mode-inverted` means "the opposite of my + * surroundings", so no reading of ancestor classes tells you which mode an element ended up in. + * Overlays are reparented to the viewport and have to be given the mode their owner resolved to, + * so `core/utils/theme_mode.ts` asks the browser for this property instead. */ @mixin mode-values($mode) { --dx-theme-mode: #{$mode}; @@ -53,11 +43,6 @@ $accent: colors.$color !default; } /* - * 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 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. */ @@ -73,19 +58,13 @@ $accent: colors.$color !default; } /* - * "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. + * A style query, not a descendant selector: it is evaluated against the NEAREST ancestor, which is + * what "inverted" means. A selector can only ask for ANY ancestor, so `dark > light > inverted` + * resolved against the dark rather than the light next to it, and nesting did not compose. * * 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. + * its surroundings instead of the opposite of them - still a correctly painted scope that + * `--dx-theme-mode` describes, so the JS keeps agreeing with the screen. */ @mixin inverted-scope() { @container style(--dx-theme-mode: light) { @@ -105,9 +84,7 @@ $accent: colors.$color !default; @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. - */ +// Inverted first: a named class on the SAME element has to win, and at equal weight 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/scss/widgets/fluent-next/gridBase/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss index 11e13ab9b436..710f46e6ba04 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -66,11 +66,6 @@ $grid-text-stub-bg: rgb(from #{ds.$color-bg-inverted} r g b / 0.1) !default; // $grid-filter-panel-content: ds.$color-content-primary !default; $grid-draggable-column-content: ds.$color-content !default; -/* - * 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, 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 99b0c5175c9b..7564f5afaa29 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss @@ -32,11 +32,6 @@ $text-editor-content-disabled: ds.$color-content-disabled !default; $text-editor-label-content-focused: ds.$color-content-primary !default; -/* - * 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, diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 27ac3328a4ca..76fa000e89f5 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -39,10 +39,9 @@ const themeRoot = join(widgetsRoot, 'fluent-next'); 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. + * A style query names a custom property in its condition (`@container style(--dx-theme-mode: dark)`) + * and looks exactly like a declaration to a `--dx-…:` match. Preludes hold none, so dropping them + * is safe; the reads themselves are covered by the resolve case below. */ const declarationBody = (content: string, label: string): string => stripScssComments(content, label) .replace(/@[a-z-]+[^;{]*\{/g, '{'); diff --git a/packages/devextreme-scss/tests/theme-mode-scope.test.ts b/packages/devextreme-scss/tests/theme-mode-scope.test.ts index 6c70a65807c0..d12e940d2bf5 100644 --- a/packages/devextreme-scss/tests/theme-mode-scope.test.ts +++ b/packages/devextreme-scss/tests/theme-mode-scope.test.ts @@ -2,27 +2,14 @@ * 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. + * It breaks 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 and freezes + * at the bundle's mode, whatever class sits below. The declaration stays valid and only the colour + * is wrong, so nothing fails - 39 properties were in that state 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. + * Checked against the built bundle, not a list here: the three scopes declare the same names, and + * nothing reading one of those names is declared where a mode class cannot reach it. A declaration + * on a component root is fine and not flagged - that element may itself sit inside a scope. */ import { existsSync, readdirSync, readFileSync } from 'fs'; @@ -55,12 +42,9 @@ const modeScopesOf = (selector: string): string[] => MODE_SCOPES // 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)); -/* - * Reaching the document element is what freezes a value, and a rule can do that while also - * matching something else: `:root, .dx-button { … }` still declares on for every button - * that is not inside one. So the question is not whether EVERY selector is the root - it is - * whether ANY is, with no mode scope in the same list to re-resolve it. - */ +// A rule can reach the root while also matching something else: `:root, .dx-button { … }` still +// declares on . So the question is whether ANY selector is the root, with no mode scope in +// the same list to re-resolve it. const freezesOnDocumentRoot = (selectors: string[]): boolean => selectors.some(isDocumentRoot) && !selectors.some((selector) => modeScopesOf(selector).length); @@ -106,12 +90,8 @@ const readBundle = (name: string): BundleFacts => { 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. - */ +// Frozen = declared on the document element and reading something a mode class redefines, whether +// directly or through another such declaration - `box-shadow-md` over `color-shadow-key`. const frozenProperties = ({ rootDeclarations, modeScopedNames }: BundleFacts): string[] => { const frozen = new Map(); const tainted = new Set(modeScopedNames); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index 5acb28274120..fc6d0a203732 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -203,11 +203,9 @@ const OVERRIDES = { rootSelectors: { /* * 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. + * theme mode, because a `:root`-only alias onto a role resolves once, on , and would + * freeze at the bundle's mode. The component tier needs no entry: its roots sit inside the + * mode scope already. */ common: [':root', ...THEME_MODE_SELECTORS], /* diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index 880cd246713d..53685c4f44fd 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -20,14 +20,8 @@ const closestClassesByPrefix = ( return $scope.length ? classesByPrefix($scope.get(0), prefix) : []; }; -/* - * 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. `resolvedThemeMode` reads the - * outcome the theme published, the same value `themes.mode()` reports, so the container and the - * public answer cannot drift apart. Themes that ship one mode per bundle declare nothing and get - * no class, as before. - */ +// The container is reparented to the viewport, where the surroundings are different ones, so the +// class has to name the mode the cascade resolved rather than the one the element wears. const themeModeClasses = ($element: dxElementWrapper): string[] => { const mode = resolvedThemeMode($element); diff --git a/packages/devextreme/js/__internal/core/utils/theme_mode.ts b/packages/devextreme/js/__internal/core/utils/theme_mode.ts index e147e752a310..5a74dca286c0 100644 --- a/packages/devextreme/js/__internal/core/utils/theme_mode.ts +++ b/packages/devextreme/js/__internal/core/utils/theme_mode.ts @@ -5,17 +5,13 @@ import { getWindow, hasWindow } from '@js/core/utils/window'; export type ThemeMode = 'light' | 'dark'; /* - * What a theme that ships more than one colour mode publishes on every scope it declares - * (widgets/fluent-next/_design-system.scss). `dx-theme-mode-inverted` asks for the opposite of its - * surroundings, so reading the classes on the way up never answers which mode an element ended up + * Published by a theme on every mode scope it declares. `dx-theme-mode-inverted` asks for the + * opposite of its surroundings, so ancestor classes never answer which mode an element ended up * in - only the cascade does, and this property is where it says so. */ export const THEME_MODE_PROPERTY = '--dx-theme-mode'; -/** - * The mode an element resolves to, or null when the theme scopes no modes and declares nothing. - * A value naming no mode is treated the same way: the contract is `light` or `dark`. - */ +/** The mode an element resolves to; null when nothing declared one, or named no mode. */ export function resolvedThemeMode( element: Element | dxElementWrapper, ): ThemeMode | null { diff --git a/packages/devextreme/js/__internal/ui/m_themes_callback.ts b/packages/devextreme/js/__internal/ui/m_themes_callback.ts index b91b7ae03444..08f9f243d539 100644 --- a/packages/devextreme/js/__internal/ui/m_themes_callback.ts +++ b/packages/devextreme/js/__internal/ui/m_themes_callback.ts @@ -3,10 +3,8 @@ import Callbacks from '@js/core/utils/callbacks'; export const themeReadyCallback = Callbacks(); /* - * Fires when the application says the colour mode an element resolves to may have changed - it - * moved a `dx-theme-mode-*` class, or switched the theme. Anything that was detached from the - * scope it belongs to - today that is open overlays, which render in the viewport - re-reads its - * mode here. Lives beside themeReadyCallback so that neither themes.ts nor the overlay has to - * import the other. + * Fired by `themes.refreshMode()`. Anything rendered outside the scope it belongs to - today that + * is open overlays, which live in the viewport - re-reads its mode here. It sits in this module so + * that neither themes.ts nor the overlay has to import the other. */ export const themeModeChangedCallback = Callbacks(); diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index f3d82d91da81..cdbcc0bb46e2 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -650,19 +650,17 @@ class Overlay< } /* - * The wrapper lives in the viewport, inside a container that carries the mode its owner resolved - * to when the overlay was shown. Nothing re-picks that container afterwards: `_moveToContainer` - * runs on becoming visible and on a content re-render, and a class moving somewhere up the tree - * is neither. An overlay that is already open would keep painting in the previous mode. + * The container carries the mode its owner resolved to when the overlay was shown, and nothing + * re-picks it afterwards: `_moveToContainer` runs on becoming visible and on a content + * re-render, and a class moving up the tree is neither. */ _themeModeChangeHandler(): void { if (!this._isVisible()) { return; } - // Reading `$container` re-resolves the scope. Move only when it named a different node: - // appending is not a no-op for a child already in place, and detaching the wrapper takes the - // focus out of the overlay, restarts its animations and reloads any iframe inside it. + // Move only if the scope named a different node: appending is not a no-op for a child already + // in place, and the detach takes the focus out, restarts animations and reloads any iframe. const { $container } = this._positionController; if ($container && $container.get(0) !== this._$wrapper?.parent().get(0)) { 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 dc80ec02a411..a08290530ceb 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts @@ -173,12 +173,8 @@ export class OverlayPositionController< } get $container(): dxElementWrapper | undefined { - /* - * 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. - */ + // 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 then. this.updateContainer(); return this._$markupContainer; diff --git a/packages/devextreme/js/__internal/ui/themes.ts b/packages/devextreme/js/__internal/ui/themes.ts index aa913921be9b..ff49c99cf5b8 100644 --- a/packages/devextreme/js/__internal/ui/themes.ts +++ b/packages/devextreme/js/__internal/ui/themes.ts @@ -383,29 +383,22 @@ export function isCompact(themeName: string): boolean { } /** - * The colour mode an element is rendered in. - * - * `current()` and `isDark()` answer for the stylesheet that is loaded, and that stays the right - * answer to the question they ask. It is no longer the whole story: a theme can ship both modes in - * one bundle and let a class pick between them per element, so "which mode" has an answer per place - * rather than per page. Such a theme publishes the outcome in `--dx-theme-mode` on every scope it - * declares, and the element is the only thing that knows - the cascade decides it, not the classes - * on the way up. A theme that does not scope modes declares nothing, and the loaded theme answers. + * The colour mode an element is rendered in. `current()` and `isDark()` answer for the loaded + * stylesheet; a theme that ships both modes in one bundle has an answer per place instead, and + * publishes it in `--dx-theme-mode`. A theme that scopes no modes declares nothing, and the + * loaded theme answers. */ export function mode(element: Element | dxElementWrapper): 'light' | 'dark' { return resolvedThemeMode(element) ?? (isDark() ? 'dark' : 'light'); } /** - * Re-reads the colour mode for widgets that render outside the element they belong to - today that - * is open overlays, whose markup lives in the viewport and therefore outside the scope that decides - * their mode. Call it after changing what an element resolves to: moving a `dx-theme-mode-*` class, - * or switching the whole theme. + * Re-reads the colour mode for widgets rendered outside the element they belong to - today that is + * open overlays, which live in the viewport. Call it after moving a `dx-theme-mode-*` class or + * switching the theme. * - * Not called from `current()` on purpose. A theme switch reuses one `` and swaps its `href`, - * so the new stylesheet lands some time after the call returns; every point inside the switch that - * was tried fired while the old values were still live in at least one direction, which would make - * this work by luck. Doing it from the outside, once, is predictable. + * Deliberately not called from `current()`: a theme switch swaps one ``'s href, so the new + * stylesheet lands after the call returns and any firing from inside would read stale values. */ export function refreshMode(): void { themeModeChangedCallback.fire(); diff --git a/packages/devextreme/playground/theme-modes.html b/packages/devextreme/playground/theme-modes.html new file mode 100644 index 000000000000..7135f1aaa75f --- /dev/null +++ b/packages/devextreme/playground/theme-modes.html @@ -0,0 +1,323 @@ + + + + fluent-next: every supported use of the theme mode classes + + + + + + + + + +
+ Bundle: + + + Page mode (class on <html>): + + + + theme switches: 1 +
+ +
+

Theme modes: every supported use

+

Live page on the real bundle and real widgets - nothing here is a mock-up. Each section states what it demonstrates; the report at the bottom checks the same cases mechanically, so a broken one is named rather than merely looking wrong.

+ +

1. Naming a mode on any element

+

The mode is a property of a place on the page. Put dx-theme-mode-light or -dark on any element and it, along with everything inside it, repaints. Nothing is loaded or swapped.

+
+
no class - the bundle decidesBody text on a surface
+
dx-theme-mode-lightAlways light
+
dx-theme-mode-darkAlways dark
+
+ +

2. Inverting against the surroundings

+

dx-theme-mode-inverted means "the opposite of the nearest enclosing mode". It is recursive: an inverted block inside an inverted block flips back, at any depth. That is what makes a contrasting panel work without the application knowing which mode it is in.

+
+
inverted, nothing named aboveOpposite of the bundle
+
inside dark +
invertedLight again
+
+
inside dark +
inverted +
inverted againBack to dark
+
+
+
+ +

3. Real widgets inside a scope

+

Components read the same roles, so they follow a scope with no extra work. The same three widgets are built twice, in two scopes.

+
+
dx-theme-mode-light +
+
+
dx-theme-mode-dark +
+
+
+ +

4. Overlays follow the element that owns them

+

A popup or a drop-down renders in the viewport, not where the widget sits, so it cannot inherit the scope. The theme publishes the resolved mode in --dx-theme-mode and the core reads it back, giving the overlay's container the mode its owner resolved to. Open both and compare.

+
+
owner in a light scope
+
owner in a dark scope
+
owner in an inverted scope
+
+ +

5. Switching a scope while an overlay is open

+

Anything that changes what an element resolves to - a class the application moves, or the whole theme - is announced with themes.refreshMode(). Open the drop-down below, then press 1 and watch the report disagree - the list keeps the mode it opened in. Press 2 and it catches up. Everything that stayed inside the scope followed the class immediately; only what was moved out to the viewport needed telling.

+
+
scope: dark
+
+ + + +
+
+ +

6. Together with a colour swatch

+

Swatches and modes are independent scopes and combine: the overlay container repeats both.

+
+
dx-swatch-demo + dx-theme-mode-dark
+
+ +

7. Customising roles

+

A role overridden in a stylesheet loaded after the theme wins, as it always did - all theme rules weigh one class, :root included. Inside a mode scope the theme re-declares the roles, so an override meant for a scope belongs on the element carrying the class.

+
+
page-level override of a custom propertyBorder painted from --dx-demo-brand declared on :root
+
role overridden on the scope element +
+
+
+ +

8. Asking what mode an element is in

+

themes.mode(element) answers for a place on the page. themes.current() and themes.isDark() keep answering for the loaded stylesheet - a different question, still a valid one. Both are reported below.

+ +

9. What does not follow a class

+

Icons whose colour is baked into a data-uri at build time (diagram, gantt, fileManager, list, timeView) and the theme marker keep the bundle's mode. Charts take their theme from scripts, so they follow the loaded file too. This is by construction, not a defect - a var() does not resolve inside a data-uri.

+
+
a data-uri icon inside a dark scope +
+
Stays as the bundle drew it
+
+
+ +

Mechanical report

+

Every case above, checked against the values the browser resolved. Re-run after any switch in the bar.

+
running…
+
+ + + + From 5ed683878c164782e3151d9f96586499c380c0cb Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 12:16:50 +0400 Subject: [PATCH 20/25] Keep the theme-modes playground page out of the PR --- .../devextreme/playground/theme-modes.html | 323 ------------------ 1 file changed, 323 deletions(-) delete mode 100644 packages/devextreme/playground/theme-modes.html diff --git a/packages/devextreme/playground/theme-modes.html b/packages/devextreme/playground/theme-modes.html deleted file mode 100644 index 7135f1aaa75f..000000000000 --- a/packages/devextreme/playground/theme-modes.html +++ /dev/null @@ -1,323 +0,0 @@ - - - - fluent-next: every supported use of the theme mode classes - - - - - - - - - -
- Bundle: - - - Page mode (class on <html>): - - - - theme switches: 1 -
- -
-

Theme modes: every supported use

-

Live page on the real bundle and real widgets - nothing here is a mock-up. Each section states what it demonstrates; the report at the bottom checks the same cases mechanically, so a broken one is named rather than merely looking wrong.

- -

1. Naming a mode on any element

-

The mode is a property of a place on the page. Put dx-theme-mode-light or -dark on any element and it, along with everything inside it, repaints. Nothing is loaded or swapped.

-
-
no class - the bundle decidesBody text on a surface
-
dx-theme-mode-lightAlways light
-
dx-theme-mode-darkAlways dark
-
- -

2. Inverting against the surroundings

-

dx-theme-mode-inverted means "the opposite of the nearest enclosing mode". It is recursive: an inverted block inside an inverted block flips back, at any depth. That is what makes a contrasting panel work without the application knowing which mode it is in.

-
-
inverted, nothing named aboveOpposite of the bundle
-
inside dark -
invertedLight again
-
-
inside dark -
inverted -
inverted againBack to dark
-
-
-
- -

3. Real widgets inside a scope

-

Components read the same roles, so they follow a scope with no extra work. The same three widgets are built twice, in two scopes.

-
-
dx-theme-mode-light -
-
-
dx-theme-mode-dark -
-
-
- -

4. Overlays follow the element that owns them

-

A popup or a drop-down renders in the viewport, not where the widget sits, so it cannot inherit the scope. The theme publishes the resolved mode in --dx-theme-mode and the core reads it back, giving the overlay's container the mode its owner resolved to. Open both and compare.

-
-
owner in a light scope
-
owner in a dark scope
-
owner in an inverted scope
-
- -

5. Switching a scope while an overlay is open

-

Anything that changes what an element resolves to - a class the application moves, or the whole theme - is announced with themes.refreshMode(). Open the drop-down below, then press 1 and watch the report disagree - the list keeps the mode it opened in. Press 2 and it catches up. Everything that stayed inside the scope followed the class immediately; only what was moved out to the viewport needed telling.

-
-
scope: dark
-
- - - -
-
- -

6. Together with a colour swatch

-

Swatches and modes are independent scopes and combine: the overlay container repeats both.

-
-
dx-swatch-demo + dx-theme-mode-dark
-
- -

7. Customising roles

-

A role overridden in a stylesheet loaded after the theme wins, as it always did - all theme rules weigh one class, :root included. Inside a mode scope the theme re-declares the roles, so an override meant for a scope belongs on the element carrying the class.

-
-
page-level override of a custom propertyBorder painted from --dx-demo-brand declared on :root
-
role overridden on the scope element -
-
-
- -

8. Asking what mode an element is in

-

themes.mode(element) answers for a place on the page. themes.current() and themes.isDark() keep answering for the loaded stylesheet - a different question, still a valid one. Both are reported below.

- -

9. What does not follow a class

-

Icons whose colour is baked into a data-uri at build time (diagram, gantt, fileManager, list, timeView) and the theme marker keep the bundle's mode. Charts take their theme from scripts, so they follow the loaded file too. This is by construction, not a defect - a var() does not resolve inside a data-uri.

-
-
a data-uri icon inside a dark scope -
-
Stays as the bundle drew it
-
-
- -

Mechanical report

-

Every case above, checked against the values the browser resolved. Re-run after any switch in the bar.

-
running…
-
- - - - From fe8178bc294984fac82109374da00a641eb5ed53 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 9 Sep 2026 15:58:26 +0400 Subject: [PATCH 21/25] Publish themes.mode and themes.refreshMode as documented API They shipped on the same shelf as isFluent and isCompact - exported and typed, but reaching neither the docs nor dx.all.d.ts. Tech writing and PM want them documented, so they become static members of the themes namespace alongside current, ready and initialized. The docs system now needs entries for ui.themes.mode(element) and ui.themes.refreshMode(). --- packages/devextreme/js/ui/themes.d.ts | 28 +++++++++++++++------------ packages/devextreme/ts/dx.all.d.ts | 8 ++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/devextreme/js/ui/themes.d.ts b/packages/devextreme/js/ui/themes.d.ts index 3480db75310d..628ff7fe3c8c 100644 --- a/packages/devextreme/js/ui/themes.d.ts +++ b/packages/devextreme/js/ui/themes.d.ts @@ -35,6 +35,22 @@ export default class themes { * @public */ static initialized(callback: Function): void; + /** + * @docid ui.themes.mode + * @publicName mode(element) + * @param1 element:Element|jQuery + * @return String + * @static + * @public + */ + static mode(element: UserDefinedElement): 'light' | 'dark'; + /** + * @docid ui.themes.refreshMode + * @publicName refreshMode() + * @static + * @public + */ + static refreshMode(): void; } export function current(): string; @@ -44,17 +60,5 @@ export function isMaterial(theme: string): boolean; export function isGeneric(theme: string): boolean; export function isCompact(theme: string): boolean; -/** - * The colour mode an element is rendered in: 'light' or 'dark'. - * - * Unlike `current()` and `isDark()`, which answer for the loaded stylesheet, this answers for a - * place on the page - a theme may ship both modes in one bundle and let a class pick between them. - */ export function mode(element: UserDefinedElement): 'light' | 'dark'; - -/** - * Re-reads the colour mode for widgets that render outside the element they belong to, such as an - * open popup. Call it after changing what an element resolves to: moving a `dx-theme-mode-*` class, - * or switching the whole theme. - */ export function refreshMode(): void; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 72666c8a5343..f11b2354ec43 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -34103,6 +34103,14 @@ declare module DevExpress.ui { * [descr:ui.themes.initialized(callback)] */ static initialized(callback: Function): void; + /** + * [descr:ui.themes.mode(element)] + */ + static mode(element: DevExpress.core.UserDefinedElement): 'light' | 'dark'; + /** + * [descr:ui.themes.refreshMode()] + */ + static refreshMode(): void; } /** * [descr:Widget] From 9d5b4b2510e81dbc45214b1aabcc4529d1c407d8 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 10 Sep 2026 18:36:23 +0400 Subject: [PATCH 22/25] Put the inverted-order note back in a block comment Two consecutive // lines trip scss/double-slash-comment-empty-line-before on the second one, which is what turned the lint job red. --- .../scss/widgets/fluent-next/_design-system.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 19ebd5b0a257..4f1f3ae5a2bd 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -84,7 +84,9 @@ $accent: colors.$color !default; @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; } -// Inverted first: a named class on the SAME element has to win, and at equal weight source order -// is what decides. +/* + * Inverted first: a named class on the SAME element has to win, and at equal weight source order + * is what decides. + */ @include inverted-scope(); @include named-scopes(colors.$mode, if(colors.$mode == "light", "dark", "light")); From 83de3728d2160bf2cbf5b9a60e135aa9cb6d3751 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 10 Sep 2026 18:36:23 +0400 Subject: [PATCH 23/25] Drop the pointer to a file the repository does not carry --- packages/devextreme-scss/tests/data-uri-static-markers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme-scss/tests/data-uri-static-markers.test.ts b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts index fa048171b07c..5b7ac3857ee0 100644 --- a/packages/devextreme-scss/tests/data-uri-static-markers.test.ts +++ b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts @@ -71,7 +71,7 @@ const expand = (hex: string): string => { /* * A bundle declares each role more than once: the mode it was built for sits on `:root`, and the - * opposite mode sits on the `dx-theme-mode-*` classes (see THEME_MODES.html). A literal baked into + * opposite mode sits on the `dx-theme-mode-*` classes. A literal baked into * a data-uri is what a page with no mode class shows, so only the `:root` scope may answer here — * scanning the whole text would hand back whichever block happens to come first. */ From 5d9c73228f43becb9ab763b35d44da854cad8380 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 10 Sep 2026 18:36:23 +0400 Subject: [PATCH 24/25] Delete the refreshMode case that passes with refreshMode emptied It asserts that Callbacks.remove unsubscribes - core behaviour this PR does not touch, already covered by DevExpress.core/utils.callbacks.tests.js. --- .../js/__internal/ui/__tests__/themes.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts index 5fcb59e18ee0..e6cbf4630747 100644 --- a/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts +++ b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts @@ -89,15 +89,4 @@ describe('themes.refreshMode', () => { themeModeChangedCallback.remove(subscriber); } }); - - it('stops telling a subscriber that unsubscribed', () => { - const told: number[] = []; - const subscriber = (): void => { told.push(1); }; - - themeModeChangedCallback.add(subscriber); - themeModeChangedCallback.remove(subscriber); - refreshMode(); - - expect(told).toHaveLength(0); - }); }); From de75e2b2d44f041ca96ad52f716062c5744a3f6b Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Thu, 10 Sep 2026 18:58:57 +0400 Subject: [PATCH 25/25] Give the focus back after the overlay moves to its new mode Detaching the wrapper takes the focus out and the browser does not return it. Changing the container option has always done this, but that is one overlay the application named on purpose; refreshMode runs on every open one at once because a class moved somewhere, so the caret should survive it. The selection lives on the element, so restoring the focus is enough. Read through the wrapper's root, or the shadow-DOM run reports the host instead. --- .../js/__internal/ui/overlay/overlay.ts | 21 +++++++++++++-- .../DevExpress.ui.widgets/overlay.tests.js | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index cdbcc0bb46e2..c196a7d6cf76 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -662,9 +662,26 @@ class Overlay< // Move only if the scope named a different node: appending is not a no-op for a child already // in place, and the detach takes the focus out, restarts animations and reloads any iframe. const { $container } = this._positionController; + const wrapper = this._$wrapper?.get(0) as HTMLElement | undefined; - if ($container && $container.get(0) !== this._$wrapper?.parent().get(0)) { - this._moveToContainer(); + if (!$container || $container.get(0) === wrapper?.parentElement) { + return; + } + + /* + * The move detaches the wrapper, and a detached element loses the focus for good - the browser + * does not hand it back on re-insert. Unlike a container the application changed on one named + * overlay, this runs on every open overlay at once, because an application announced that a + * class moved somewhere; taking the caret out of whatever the user was typing in is not part + * of that. The selection survives on the element itself, so restoring the focus is enough. + */ + const focused = domAdapter.getActiveElement(wrapper) as HTMLElement | null; + const shouldRestoreFocus = !!wrapper && !!focused && domUtils.contains(wrapper, focused); + + this._moveToContainer(); + + if (shouldRestoreFocus) { + focused?.focus(); } } diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index efdaf0fa680b..61e7e14f9eee 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -407,6 +407,33 @@ testModule('render', moduleConfig, () => { $scope.remove(); }); + test('Theme mode - a move keeps the focus inside the overlay', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ + visible: true, + contentTemplate: () => $('') + }).dxOverlay('instance'); + + const input = overlay.$content().find('.probe-input').get(0); + // under ?shadowDom the overlay lives in a shadow root, and the document reports its host + const focused = () => input.getRootNode().activeElement; + + input.focus(); + input.setSelectionRange(4, 4); + + assert.strictEqual(focused(), input, 'the caret starts inside the overlay'); + + declareMode($scope, 'dark'); + themes.refreshMode(); + + assert.ok(overlay.$wrapper().parent().hasClass('dx-theme-mode-dark'), 'the overlay did move'); + assert.strictEqual(focused(), input, 'and the focus came back to where it was'); + assert.strictEqual(input.selectionStart, 4, 'with the caret still in place'); + + overlay.dispose(); + $scope.remove(); + }); + test('Theme mode - a disposed overlay stops listening', function(assert) { const $scope = modeScope('light'); const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance');