Skip to content

Fluent-next: ship both colour modes in every bundle, selectable by class - #35011

Open
EugeniyKiyashko wants to merge 25 commits into
DevExpress:feature/26_2_new_fluent_theme_with_design_tokensfrom
EugeniyKiyashko:fluent-next/theme-modes
Open

Fluent-next: ship both colour modes in every bundle, selectable by class#35011
EugeniyKiyashko wants to merge 25 commits into
DevExpress:feature/26_2_new_fluent_theme_with_design_tokensfrom
EugeniyKiyashko:fluent-next/theme-modes

Conversation

@EugeniyKiyashko

Copy link
Copy Markdown
Contributor

No description provided.

@EugeniyKiyashko EugeniyKiyashko self-assigned this Sep 2, 2026
@EugeniyKiyashko EugeniyKiyashko changed the title Ship both colour modes in every fluent-next bundle, selectable by class Fluent-next: ship both colour modes in every bundle, selectable by class Sep 2, 2026
@EugeniyKiyashko
EugeniyKiyashko requested a review from a team as a code owner September 2, 2026 10:12
@EugeniyKiyashko
EugeniyKiyashko force-pushed the feature/26_2_new_fluent_theme_with_design_tokens branch from 1057a2b to 09855f4 Compare September 3, 2026 16:53
@EugeniyKiyashko
EugeniyKiyashko requested review from a team as code owners September 3, 2026 16:53
@pharret31
pharret31 force-pushed the feature/26_2_new_fluent_theme_with_design_tokens branch from 14b4d9e to 8ef9603 Compare September 4, 2026 19:01
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.
… 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.
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.
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.
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.
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.
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%).
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.
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.
… when it moved

Two of the three questions the mode classes left open; the third - mirroring the
mode onto <html> 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.
…e contract up

The playground page below is what found this. A theme switch reuses one <link>
and swaps its href, so the new stylesheet lands after the call returns. Both
points inside themes.current() that were tried - beside themeReadyCallback and
in initDeferred.done - fired while the old values were still live in at least
one direction, and left open overlays behind. Two of four in one direction,
three of four the other way, reproducible.

Rather than ship something that works by luck, the contract is now one call:
tell us with themes.refreshMode() after you change what an element resolves to,
whether that was a class or the whole theme. That path is reliable in every
direction, repeatedly, and is what the tests pin.

playground/theme-modes.html exercises every supported use on the real bundle
with real widgets - naming a mode, inverting at depth, widgets and overlays in a
scope, live switching, swatches, role overrides, themes.mode(), and the things
that deliberately do not follow - and checks each mechanically, so a broken case
is named rather than merely looking wrong.
EugeniyKiyashko added a commit to EugeniyKiyashko/DevExtreme-dxDemos that referenced this pull request Sep 9, 2026
…e itself

Both colour modes ship in one fluent-next bundle and a class picks between them.
The page walks the whole contract on the real bundle and real widgets: naming a
mode on any element, inverting against the nearest enclosing one at any depth,
widgets and overlays inside a scope, switching live, swatches, role overrides,
themes.mode(element) against themes.current(), and the icons and theme marker
that deliberately keep the bundle's mode.

Twelve checks run in the page and report expected against resolved, so a case
that breaks is named rather than merely looking wrong.

The bundles are built from the branch behind DevExpress/DevExtreme#35011 - the
page needs themes.mode() and themes.refreshMode(), which are not in a release
yet.
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.
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.
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.
…ass can reach

The gate asked whether EVERY selector of a rule is the document root, so a rule like :root, .dx-button declared on <html> 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.
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.
…s 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.
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.
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().
Comment thread packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss Outdated
Comment thread packages/devextreme-scss/tests/data-uri-static-markers.test.ts Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hazard is real beyond the case the guard covers: a popup owned by a dx-theme-mode-* scope
cannot be dragged at all.

PopupPositionController._getDragResizeContainer() returns _$markupContainer whenever a
viewport is defined (popup_position_controller.ts:165-169), so the drag boundary collapses onto
the spawned wrapper.

Not introduced here — I measured the same dead drag for a dx-swatch-custom island on this same
build. What changes is who reaches it: a swatch is niche, a mode scope is the ordinary way to use
this feature. The guard above skips the wrapper when the scope matches the viewport; the case the
feature exists for still gets one.

The wrapper carries custom properties, not geometry. Reading the box off the viewport rather than
off _$markupContainer would keep those apart. Resizing reads the same _$dragResizeContainer
worth checking, my own resize run was inconclusive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and I agree it is not new here. I measured it on this build, with a viewport that has a real size:

popup sits in area it may be dragged in drag by 100,100 resize by +60,+40
nothing in particular 600px tall moves 100,100 grows 60,40
a theme-mode scope 0px tall does not move width +60, height -60
dx-swatch-custom 0px tall does not move width +60, height -60

The swatch row and the mode row are identical, so this PR does not create the problem. What it changes is how many people meet it: putting a mode class on a block is the ordinary way to use this feature, while a swatch is a niche thing.

To answer the part you left open: resizing is affected too, and it shows up more plainly than dragging. The popup does not merely refuse to grow - its height collapses instead.

Why the suite never notices: the drag and resize block in popup.tests.js creates its popups with dragAndResizeArea: window, which sidesteps the element the popup was placed into. Those tests can't reach this path by construction.

I would rather not fix it inside this PR. The fix belongs where a popup decides which area it may be dragged in, it affects every popup whether or not any of this feature is used, and nothing else here goes near that code. Worth its own ticket - happy to raise it.

Comment thread packages/devextreme/js/__internal/ui/overlay/overlay.ts Outdated
Comment thread packages/devextreme/js/__internal/ui/__tests__/themes.test.ts Outdated
Two consecutive // lines trip scss/double-slash-comment-empty-line-before on the second one, which is what turned the lint job red.
It asserts that Callbacks.remove unsubscribes - core behaviour this PR does not touch, already covered by DevExpress.core/utils.callbacks.tests.js.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants