Skip to content

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

Open
EugeniyKiyashko wants to merge 6 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 6 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.

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.
@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
… 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.
Both fluent-next PNG pairs were byte-identical, so the $mode branch produced no
difference in any bundle. One copy each now lives at a path that does not claim a
colour scheme, and the duplicates go. All 49 bundles are unchanged byte for byte.
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.
@EugeniyKiyashko
EugeniyKiyashko requested a review from a team as a code owner September 2, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant