Fluent-next: ship both colour modes in every bundle, selectable by class - #35011
Open
EugeniyKiyashko wants to merge 39 commits into
Conversation
…e's numbered parameters (DevExpress#34742)
…ss#34744) Co-authored-by: Andrei Kharitonov <pharret31@users.noreply.github.com>
…, fix hardcoded colors in Widget Gallery (DevExpress#34698) Co-authored-by: Andrei Kharitonov <pharret31@users.noreply.github.com> Co-authored-by: EugeniyKiyashko <EugeniyKiyashko@users.noreply.github.com>
Rebase onto main brought the adduse/dead-import rules; fluent-next was written before them. Removes 257 adduse markers and 141 dead theme-root imports (27 files become empty placeholders), plus the formatting fixes the new @Stylistic rules require.
The rebase took main's lockfile to avoid a hand-merge; re-adding the branch's devextreme-scss devDependencies is what pnpm regenerates.
They were unrelated to the theme: 13 workflows had gained a `push: branches: [26_1]` trigger (default_workflow a `[0-9][0-9]_[0-9]` glob, wrapper_tests_e2e a `26_*` entry). The fluent-next additions to the testcafe and demo visual-test matrices stay.
…ublished derived sizes (DevExpress#34927)
The teal-dark scheme of the demo read --dxds-color-content-subtle-disabled, a role 262.15.0 does not define, so its disabled dates took the literal in the var() fallback instead of the theme's colour. It moves to --dxds-color-content-disabled, which the theme does define.
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.
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.
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%).
EugeniyKiyashko
force-pushed
the
feature/26_2_new_fluent_theme_with_design_tokens
branch
from
September 3, 2026 16:53
1057a2b to
09855f4
Compare
pharret31
force-pushed
the
feature/26_2_new_fluent_theme_with_design_tokens
branch
from
September 4, 2026 19:01
14b4d9e to
8ef9603
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.