From 6a4e2bb91fabf59c9801ee19d2e732eaf2189c9d Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 00:17:10 +0300 Subject: [PATCH 1/7] Disable automerge of @devexpress/design-tokens-internal --- .github/renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/renovate.json b/.github/renovate.json index df3d3d21ff4a..f35ffa2b949e 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -123,6 +123,13 @@ "matchPackageNames": [ "*" ] + }, + { + "matchPackageNames": [ + "@devexpress/design-tokens-internal" + ], + "automerge": false, + "minimumReleaseAge": null } ], "lockFileMaintenance": { From 496e29ce97c8e61a5b3683421bba208086c25c36 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:36:04 +0300 Subject: [PATCH 2/7] Validate consumed tokens by tokens.flat.json --- .../build/tokens/build-tokens.mjs | 68 +++++++++ .../build/tokens/consumed-tokens.ts | 52 +++++++ .../tests/consumed-tokens.test.ts | 132 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 packages/devextreme-scss/build/tokens/consumed-tokens.ts create mode 100644 packages/devextreme-scss/tests/consumed-tokens.test.ts diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index b2d6603ab75b..bf71ba7a0acc 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -4,6 +4,11 @@ import { createRequire } from 'node:module'; import { readdir, readFile, rm } from 'node:fs/promises'; import StyleDictionary from 'style-dictionary'; import { registerTransforms } from './transforms.mjs'; +import { + buildAvailableNames, + collectCustomPropertyReferences, + collectTokenReferences, +} from './consumed-tokens.ts'; // Suppress ONE known noisy sd-transforms warning about unresolvable // {font-weight…} references inside math expressions. Scoped to console.warn @@ -123,6 +128,9 @@ const tokensDir = path.dirname(require.resolve('@devexpress/design-tokens-intern const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; +const THEME_FOLDER = 'fluent-next'; + +const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); const FLUENT_PALETTES = [ 'blue', @@ -359,6 +367,64 @@ async function validateReferences() { return files.length; } +async function collectThemeStyleSheets() { + const entries = await readdir(themePath, { withFileTypes: true, recursive: true }); + + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.scss')) + .map((entry) => path.join(entry.parentPath, entry.name)); +} + +/* + * Every `ds.$…` a widget reads must still exist in the token package. validateReferences() above + * only checks the generated output against itself, so a release that deletes a token surfaces much + * later, as a Sass "Undefined variable" on the first bundle that touches it — one name per rebuild, + * with nothing pointing at the bump as the cause. + * + * The check reads the package's flat index instead of the generated bridge: the two carry the same + * 1578 names, but the index also carries the version for the message and needs no generated output. + * Reusing getComponentThemeFiles() is what keeps the scope from drifting away from the generator. + */ +async function validateConsumedTokens() { + const { version, tokens } = JSON.parse( + await readFile(path.join(tokensDir, 'tokens.flat.json'), 'utf-8'), + ); + const availableNames = buildAvailableNames( + Object.keys(tokens), + new Set(getComponentThemeFiles()), + ); + + const referenced = new Map(); + + for (const file of await collectThemeStyleSheets()) { + const content = await readFile(file, 'utf-8'); + const found = [ + ...collectTokenReferences(content).map((name) => [name, `ds.$${name}`]), + ...collectCustomPropertyReferences(content).map((name) => [name, `var(--dxds-${name})`]), + ]; + + for (const [name, reference] of found) { + if (!referenced.has(name)) { + referenced.set(name, { file, reference }); + } + } + } + + const missing = [...referenced].filter(([name]) => !availableNames.has(name)); + + if (missing.length > 0) { + const details = missing + .map(([, { file, reference }]) => ` ${reference} (first used in ${path.relative(themePath, file)})`) + .join('\n'); + + throw new Error( + `Tokens used by ${THEME_FOLDER} but absent from @devexpress/design-tokens-internal ${version}:\n${details}`, + ); + } + + return referenced.size; +} + async function build() { await rm(buildPath, { recursive: true, force: true }); @@ -372,8 +438,10 @@ async function build() { } const fileCount = await validateReferences(); + const consumedCount = await validateConsumedTokens(); console.log(`Design tokens generated: ${fileCount} files in ${buildPath}`); + console.log(`Design tokens consumed by ${THEME_FOLDER}: ${consumedCount} verified against the package`); } await build(); diff --git a/packages/devextreme-scss/build/tokens/consumed-tokens.ts b/packages/devextreme-scss/build/tokens/consumed-tokens.ts new file mode 100644 index 000000000000..6a279935af2f --- /dev/null +++ b/packages/devextreme-scss/build/tokens/consumed-tokens.ts @@ -0,0 +1,52 @@ +/* + * Pure half of the consumed-token check driven by build-tokens.mjs: everything here is a plain + * transformation, so tests/consumed-tokens.test.ts can exercise it without running a build. + */ + +/* + * Commented-out declarations still spell out token names (stepper/_colors.scss parks a few), so + * comments are stripped before scanning — a dead reference must not fail the build. + * + * Line comments go first, so a `/* … *\/` nested in one disappears with it. The cost is that a `//` + * inside a string or a url() swallows the rest of its line: a reference sharing that line would go + * uncounted. That under-reports rather than failing wrongly, no theme stylesheet does it today, and + * fluent-next-naming.test.ts strips comments the same way. + */ +export const stripScssComments = (content: string): string => content + .replace(/\/\/[^\n\r]*/g, '') + .split(/\/\*|\*\//) + .filter((_, index) => index % 2 === 0) + .join(''); + +/* + * The charset is wider than the kebab-case the generator emits, so a malformed name is captured + * whole and fails the check. Matching only [a-z0-9-] would truncate `ds.$spacing-40_typo` to the + * valid `spacing-40` and report the stylesheet as verified. + */ +export const collectTokenReferences = (content: string): string[] => [ + ...stripScssComments(content).matchAll(/\bds\.\$([\w-]+)/g), +].map(([, name]) => name); + +/* + * Nothing forces a stylesheet through the bridge — `var(--dxds-…)` written by hand compiles to + * whatever the browser resolves, so a dropped token would degrade silently. No theme stylesheet + * does it today; collecting the form keeps it that way. devextreme-vnext documents the same escape + * hatch as an open gap (VNEXT_DESIGN_TOKENS.md, "Known gaps"). + */ +export const collectCustomPropertyReferences = (content: string): string[] => [ + ...stripScssComments(content).matchAll(/var\(\s*--dxds-([\w-]+)/g), +].map(([, name]) => name); + +/* + * tokens.flat.json spans every design system, and 128 of the names fluent-next uses also exist + * under material — so the lookup is narrowed to the source files the bridge is generated from. + */ +export const buildAvailableNames = ( + flatTokenKeys: Iterable, + consumedSourceFiles: ReadonlySet, +): Set => new Set( + [...flatTokenKeys] + .map((key) => key.split(':')) + .filter(([sourceFile]) => consumedSourceFiles.has(sourceFile)) + .map(([, tokenPath]) => tokenPath.replace(/\//g, '-')), +); diff --git a/packages/devextreme-scss/tests/consumed-tokens.test.ts b/packages/devextreme-scss/tests/consumed-tokens.test.ts new file mode 100644 index 000000000000..b62c8a65056f --- /dev/null +++ b/packages/devextreme-scss/tests/consumed-tokens.test.ts @@ -0,0 +1,132 @@ +import { + buildAvailableNames, + collectCustomPropertyReferences, + collectTokenReferences, + stripScssComments, +} from '../build/tokens/consumed-tokens'; + +describe('collectTokenReferences', () => { + it('collects every distinct ds.$ reference a stylesheet makes', () => { + const references = collectTokenReferences( + '$a: ds.$spacing-40;\n$b: ds.$color-content-neutral-default-rest;', + ); + + expect(references).toEqual(['spacing-40', 'color-content-neutral-default-rest']); + }); + + it('ignores references parked in line comments', () => { + expect(collectTokenReferences('// $a: ds.$spacing-40 !default;')).toEqual([]); + }); + + it('ignores references parked in block comments', () => { + expect(collectTokenReferences('/* see ds.$spacing-40 */\n$a: ds.$spacing-80;')).toEqual([ + 'spacing-80', + ]); + }); + + it('ignores references spread across a multi-line block comment', () => { + const content = [ + '/*', + ' * The divergence marker names ds.$color-surface-primary-default-rest and', + ' * ds.$color-content-neutral-default-rest as the equivalents.', + ' */', + '$a: ds.$spacing-40;', + ].join('\n'); + + expect(collectTokenReferences(content)).toEqual(['spacing-40']); + }); + + it('keeps the declarations between several block comments', () => { + const content = [ + '/* ds.$dead-before */', + '$a: ds.$spacing-40;', + '/*\n * ds.$dead-between\n */', + '$b: ds.$spacing-80;', + ].join('\n'); + + expect(collectTokenReferences(content)).toEqual(['spacing-40', 'spacing-80']); + }); + + it('ignores a line comment nested inside a block comment', () => { + const content = '/*\n// $dead: ds.$color-surface-danger-default-rest !default;\n*/\n$a: ds.$spacing-40;'; + + expect(collectTokenReferences(content)).toEqual(['spacing-40']); + }); + + it('captures a malformed name whole instead of truncating it to a valid prefix', () => { + expect(collectTokenReferences('$a: ds.$spacing-40_typo;')).toEqual(['spacing-40_typo']); + expect(collectTokenReferences('$a: ds.$spacingTypo;')).toEqual(['spacingTypo']); + }); + + it('does not treat a variable that merely ends in ds as a namespace', () => { + expect(collectTokenReferences('$a: $borders.$spacing-40;')).toEqual([]); + }); +}); + +describe('collectCustomPropertyReferences', () => { + it('collects a custom property written without going through the bridge', () => { + expect(collectCustomPropertyReferences('.x { color: var(--dxds-color-content-neutral-default-rest); }')).toEqual([ + 'color-content-neutral-default-rest', + ]); + }); + + it('collects a reference nested in a relative colour', () => { + expect(collectCustomPropertyReferences('.x { color: rgb(from var(--dxds-neutral-10) r g b / 40%); }')).toEqual([ + 'neutral-10', + ]); + }); + + it('tolerates whitespace after the opening parenthesis', () => { + expect(collectCustomPropertyReferences('.x { color: var( --dxds-spacing-40 ); }')).toEqual([ + 'spacing-40', + ]); + }); + + it('ignores custom properties of other namespaces', () => { + expect(collectCustomPropertyReferences('.x { color: var(--dx-color-text); }')).toEqual([]); + }); + + it('ignores a reference parked in a comment', () => { + expect(collectCustomPropertyReferences('// color: var(--dxds-spacing-40);')).toEqual([]); + }); +}); + +describe('stripScssComments', () => { + it('keeps declarations that follow a closed block comment', () => { + expect(stripScssComments('/* note */ $a: 1;')).toBe(' $a: 1;'); + }); +}); + +describe('buildAvailableNames', () => { + const consumed = new Set(['components/core/theme/fluent']); + + it('turns a flat token key into the name the bridge declares', () => { + const names = buildAvailableNames( + ['components/core/theme/fluent:button/color/bg/rest'], + consumed, + ); + + expect([...names]).toEqual(['button-color-bg-rest']); + }); + + it('skips tokens sourced from files the build does not consume', () => { + const names = buildAvailableNames( + ['components/wpf/theme/fluent:button/color/bg/rest'], + consumed, + ); + + expect([...names]).toEqual([]); + }); + + it('keeps a name that another design system also defines, scoped to the consumed file', () => { + const names = buildAvailableNames( + [ + 'semantic/colors/material/light:color/surface/primary/default/rest', + 'components/core/theme/fluent:color/surface/primary/default/rest', + ], + consumed, + ); + + expect([...names]).toEqual(['color-surface-primary-default-rest']); + }); +}); From fb949a2fe2fda5e37b66c52b7eede529fd4d5ab4 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:37:18 +0300 Subject: [PATCH 3/7] Forbidden direct token using --- packages/devextreme-scss/.stylelintrc.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/devextreme-scss/.stylelintrc.json b/packages/devextreme-scss/.stylelintrc.json index e1af3a160c8a..923f83cd0062 100644 --- a/packages/devextreme-scss/.stylelintrc.json +++ b/packages/devextreme-scss/.stylelintrc.json @@ -12,6 +12,10 @@ "color-function-notation": "legacy", "declaration-block-no-redundant-longhand-properties": null, "declaration-no-important": true, + "declaration-property-value-disallowed-list": [ + { "/.*/": ["/var\\(\\s*--dxds-/"] }, + { "message": "Read a design token through the ds bridge (ds.$name), not var(--dxds-…): the bridge fails the build on an unknown name, a raw custom property compiles and degrades silently. The public --dx-* properties are unaffected." } + ], "font-family-name-quotes": "always-unless-keyword", "@stylistic/indentation": [2, { "ignore": ["inside-parens"] }], "keyframes-name-pattern": "dx-[a-z0-9-]+", From cd34ff2f776f5468302c7a96d29e13288fea0008 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:41:34 +0300 Subject: [PATCH 4/7] Generate design tokens before assembling npm scss --- packages/devextreme/project.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 19efa18de2d0..6cb2001a78fe 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1224,6 +1224,12 @@ }, "build:npm:scss": { "executor": "devextreme-nx-infra-plugin:scss-assemble", + "dependsOn": [ + { + "projects": ["devextreme-scss"], + "target": "build:tokens" + } + ], "options": { "scssPackagePath": "../devextreme-scss", "outputDir": "./artifacts/npm/devextreme/scss" From 09eb6822a615bf1429addac09e90b304bcbd237b Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 04:09:50 +0300 Subject: [PATCH 5/7] Read design tokens only where variables are declared --- .../scss/widgets/fluent-next/common/_colors.scss | 1 + .../scss/widgets/fluent-next/common/_mixins.scss | 7 +++---- .../scss/widgets/fluent-next/dataGrid/_index.scss | 3 +-- .../scss/widgets/fluent-next/gantt/_colors.scss | 2 ++ .../scss/widgets/fluent-next/gantt/_index.scss | 3 +-- .../scss/widgets/fluent-next/gridBase/_colors.scss | 2 ++ .../scss/widgets/fluent-next/map/_index.scss | 4 ++-- .../scss/widgets/fluent-next/map/_sizes.scss | 5 +++++ .../scss/widgets/fluent-next/treeList/_index.scss | 3 +-- .../scss/widgets/fluent-next/validation/_sizes.scss | 3 +++ 10 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/map/_sizes.scss diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss index aeba260f63c4..0b793321de2b 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/common/_colors.scss @@ -22,6 +22,7 @@ $palette-border: ds.$color-border-neutral-default-rest !default; // Non-color theme-level values (opacity/font family) — kept referencing the theme layer. $global-font-family: ds.$font-family-sans-serif !default; +$invalid-badge-bg-rest: ds.$color-content-danger-compound-rest !default; $invalid-badge-content-rest: ds.$color-content-neutral-default-static-dark-rest !default; $valid-badge-content-rest: ds.$color-surface-success-default-rest !default; $palette-text: ds.$color-content-neutral-default-rest !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss b/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss index f0bdce9ab922..6872ed99b2b6 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss @@ -3,14 +3,13 @@ @use "sizes" as *; @use "../sizes" as *; @use "../../base/mixins" as *; -@use "../../../_design-system/variables/ds" as ds; +@use "../validation/sizes" as validationSizes; @use "../../base/validation" as baseValidation with ( - $validation-summary-margin-top: ds.$spacing-200, - $validation-message-content-padding: ds.$spacing-100, + $validation-summary-margin-top: validationSizes.$validation-summary-margin-block-start, + $validation-message-content-padding: validationSizes.$validation-message-padding, ); @use "../list/sizes" as listSizes; -$invalid-badge-bg-rest: ds.$color-content-danger-compound-rest !default; @mixin dx-base-typography() { @include dx-base-typography-mixin( diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/dataGrid/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/dataGrid/_index.scss index e40af43cc0a3..2af71762387f 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/dataGrid/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/dataGrid/_index.scss @@ -1,5 +1,4 @@ @use "../colors" as *; -@use "../../../_design-system/variables/ds" as ds; @use "sass:color"; @use "colors" as *; @use "sizes" as *; @@ -32,7 +31,7 @@ $datagrid-focused-border-color: gridBaseColors.$grid-border-focused, $header-filter-color: gridBaseColors.$grid-header-filter-icon-rest, $header-filter-color-empty: gridBaseColors.$grid-header-filter-empty-icon-rest, - $base-focus-color: ds.$color-content-neutral-default-inverted-rest, + $base-focus-color: gridBaseColors.$grid-content-focused, $datagrid-text-stub-background-image-path: gridBaseColors.$grid-text-stub-bg-rest, $datagrid-group-row-border: $data-grid-group-row-border, $datagrid-sticky-column-border: $data-grid-sticky-column-border, diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_colors.scss index fef79bd883a7..c92b379b7eef 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_colors.scss @@ -46,3 +46,5 @@ $gantt-ti-bg-rest: ds.$color-surface-primary-alpha-hovered !default; * variables (NAMING.md, O7). */ $gantt-successor-background-color: ds.$color-surface-neutral-default-static-light-rest; + +$gantt-selection-bg-rest: ds.$color-surface-primary-deep-rest !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss index 18516d4abd19..47e6d193ce96 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss @@ -10,7 +10,6 @@ @use "../../base/gantt/mixins" as *; @use "../gridBase/colors" as gridBaseColors; @use "../form/sizes" as formSizes; -@use "../../../_design-system/variables/ds" as ds; // adduse @use "../splitterBar"; @@ -254,7 +253,7 @@ } .dx-gantt-sel { - background-color: ds.$color-surface-primary-deep-rest; + background-color: $gantt-selection-bg-rest; } .dx-gantt-conn-v { 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 444050e29e34..a4dbc82083b2 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -93,3 +93,5 @@ $grid-ai-chat-message-border-rest: ds.$color-border-neutral-default-rest !defaul $grid-ai-chat-message-error-content-rest: ds.$color-content-danger-default-rest !default; $grid-icon-rest: ds.$color-content-neutral-subdued-rest !default; $grid-ai-chat-message-success-content-rest: ds.$color-content-success-default-rest !default; + +$grid-content-focused: ds.$color-content-neutral-default-inverted-rest !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss index e2f6c920133d..90995de9b061 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss @@ -1,9 +1,9 @@ @use "sass:color"; @use "../colors" as *; @use "../sizes" as *; -@use "../../../_design-system/variables/ds" as ds; +@use "sizes" as *; @use "../../base/map" with ( - $map-marker-tooltip-margin: ds.$spacing-100, + $map-marker-tooltip-margin: $map-marker-tooltip-margin, ); // adduse diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/map/_sizes.scss b/packages/devextreme-scss/scss/widgets/fluent-next/map/_sizes.scss new file mode 100644 index 000000000000..2c030152f32f --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_sizes.scss @@ -0,0 +1,5 @@ +@use "../../../_design-system/variables/ds" as ds; + +// adduse + +$map-marker-tooltip-margin: ds.$spacing-100 !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/treeList/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/treeList/_index.scss index 1348497177d0..864bae3d8553 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/treeList/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/treeList/_index.scss @@ -1,5 +1,4 @@ @use "../colors" as *; -@use "../../../_design-system/variables/ds" as ds; @use "sass:math"; @use "sass:color"; @use "colors" as *; @@ -31,7 +30,7 @@ $datagrid-row-error-color: gridBaseColors.$grid-row-error-content-rest, $header-filter-color: gridBaseColors.$grid-header-filter-icon-rest, $header-filter-color-empty: gridBaseColors.$grid-header-filter-empty-icon-rest, - $base-focus-color: ds.$color-content-neutral-default-inverted-rest, + $base-focus-color: gridBaseColors.$grid-content-focused, ); @use 'layout/cell'; @include grid-base(treelist); diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss b/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss index f40b46541f64..d67f8a6f1fd7 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss @@ -24,3 +24,6 @@ $validation-message-font-size: ds.$font-size-caption-md !default; $validation-message-padding-inline: ds.$spacing-60 !default; $validation-message-line-height: ds.$line-height-120 !default; // dx-no-semantic-role: 120 is off the line-height-role scale } + +$validation-summary-margin-block-start: ds.$spacing-200 !default; +$validation-message-padding: ds.$spacing-100 !default; From 5a828f797865c8d645a3ba57b57e64e54cea9832 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 04:10:51 +0300 Subject: [PATCH 6/7] Forbidden variables outside declared files --- packages/devextreme-scss/.stylelintrc.json | 23 +++++++++++++++++++ .../tests/fluent-next-naming.baseline.json | 4 +--- .../tests/fluent-next-naming.test.ts | 16 +++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/devextreme-scss/.stylelintrc.json b/packages/devextreme-scss/.stylelintrc.json index 923f83cd0062..98e04043ea00 100644 --- a/packages/devextreme-scss/.stylelintrc.json +++ b/packages/devextreme-scss/.stylelintrc.json @@ -73,6 +73,29 @@ "rules": { "scss/dollar-variable-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$" } + }, + { + "comment": "A file that emits rules consumes the widget's own variables, so the token a value comes from is stated once, next to the other variables of that widget. Declaration files are exempted by the override below. `@use … with ()` arguments are at-rule parameters and stay invisible to stylelint; fluent-next-naming.test.ts covers that form.", + "files": ["scss/widgets/fluent-next/**/*.scss"], + "rules": { + "declaration-property-value-disallowed-list": [ + { "/.*/": ["/var\\(\\s*--dxds-/", "/\\bds\\.\\$/"] }, + { "message": "Resolve the design token into a variable in _colors.scss or _sizes.scss, then use that variable here" } + ] + } + }, + { + "files": [ + "scss/widgets/fluent-next/**/_colors.scss", + "scss/widgets/fluent-next/**/_sizes.scss", + "scss/widgets/fluent-next/**/_variables.scss" + ], + "rules": { + "declaration-property-value-disallowed-list": [ + { "/.*/": ["/var\\(\\s*--dxds-/"] }, + { "message": "Read a design token through the ds bridge (ds.$name), not var(--dxds-…): the bridge fails the build on an unknown name, a raw custom property compiles and degrades silently" } + ] + } } ] } diff --git a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json index 1dab80533342..241e442304d7 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json +++ b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json @@ -340,9 +340,7 @@ "scrollViewColors.$scroll-view-pull-down-bg-rest" ] }, - "declarationsOutsideVariableFiles": [ - "common/_mixins.scss: 1" - ], + "declarationsOutsideVariableFiles": [], "starImportsOfBase": [ "dataGrid/_sizes.scss: ../../base/dataGrid/variables", "treeList/_sizes.scss: ../../base/treeList/variables" diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 437410916abc..f9d4c514aaaf 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -660,6 +660,22 @@ test('migrated components follow the grammar strictly', () => { expect(offenders).toEqual([]); }); +test('design tokens are read only where variables are declared', () => { + /* + * A file that emits rules must consume the widget's own variables, so the token a value comes + * from is stated once, next to the other variables of that widget. Read straight from the file + * because a token can also arrive as an `@use … with ()` argument, which stylelint cannot see — + * it lints declarations, and those arguments are at-rule parameters. + */ + const offenders = walk(themeRoot, '.scss') + .filter((file) => !DECLARATION_FILES.some((name) => file.endsWith(name))) + .flatMap((file) => [...stripComments(readFileSync(file, 'utf8')).matchAll(/\bds\.\$([\w-]+)/g)] + .map(([, token]) => `${file.slice(themeRoot.length + 1)}: ds.$${token}`)) + .sort(); + + expect(offenders).toEqual([]); +}); + test('the rename mapping stays collision-free and fully applied', () => { // Mirrors `node tools/naming/rename.mjs --check --residue` so CI enforces it too: a batch that is // half-applied, or two batches mapping onto one name, must not survive a green test run. From 2df9aedbbedd4645c3ec5566b46ea2a7b6b63351 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 05:27:00 +0300 Subject: [PATCH 7/7] Exclude component tier from token generation --- .../build/tokens/build-tokens.mjs | 37 +++++++------------ .../widgets/fluent-next/_design-system.scss | 13 ++++--- .../tests/fluent-next-naming.test.ts | 18 ++++++--- .../tools/naming/derive-registries.mjs | 26 ++++++++----- .../tools/naming/registries.json | 4 +- 5 files changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index bf71ba7a0acc..c3710b9041f8 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -181,10 +181,13 @@ const getModeFiles = (mode) => [ `semantic/colors/${THEME_NAME}/${mode}`, ]; -const getComponentThemeFiles = () => [ - ...getModeFiles('light'), - `components/core/theme/${THEME_NAME}`, -]; +/* + * Source files behind the SCSS bridge. The component tier is deliberately absent: its 601 tokens + * are aliases onto the semantic roles, the theme reads the roles directly, and emitting the tier + * put 601 unreferenced custom properties into every theme stylesheet. Leaving it out of the bridge + * also turns `ds.$button-color-bg-rest` into a Sass error rather than a dangling var(). + */ +const getBridgeFiles = () => getModeFiles('light'); StyleDictionary.registerFormat({ name: 'scssToCss', @@ -299,21 +302,10 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ }, ]); -const createComponentThemeConfig = () => createConfig('components-theme', getComponentThemeFiles(), [ - { - destination: `${THEME_NAME}/components/theme.scss`, - format: 'css/variables', - filter: (token) => normalizeFilePath(token).includes(`components/core/theme/${THEME_NAME}.json`), - options: FILE_OPTIONS, - }, -]); - -// All token names for the SCSS bridge file: the common + light-mode + component -// *theme* (color) set. Component *size* tokens are intentionally excluded — fluent-next -// maps sizes onto the base scales (spacing/font-size/border-radius/…), so no widget -// references the component `*-layout-*` tokens and they are not emitted (see -// widgets/fluent-next/_design-system.scss). -const createDsConfig = () => createConfig('ds', getComponentThemeFiles(), [ +// Component *size* tokens are excluded for the same reason as the component theme: fluent-next +// maps sizes onto the base scales (spacing/font-size/border-radius/…), so no widget would read the +// `*-layout-*` names (see widgets/fluent-next/_design-system.scss). +const createDsConfig = () => createConfig('ds', getBridgeFiles(), [ { destination: 'variables/_ds.scss', format: 'scssToCss', @@ -323,7 +315,6 @@ const createDsConfig = () => createConfig('ds', getComponentThemeFiles(), [ const configs = [ ...FLUENT_PALETTES.map(createPaletteConfig), ...FLUENT_MODES.map(createModeConfig), - createComponentThemeConfig(), createDsConfig(), ]; @@ -382,8 +373,8 @@ async function collectThemeStyleSheets() { * with nothing pointing at the bump as the cause. * * The check reads the package's flat index instead of the generated bridge: the two carry the same - * 1578 names, but the index also carries the version for the message and needs no generated output. - * Reusing getComponentThemeFiles() is what keeps the scope from drifting away from the generator. + * names, but the index also carries the version for the message and needs no generated output. + * Reusing getBridgeFiles() is what keeps the scope from drifting away from the generator. */ async function validateConsumedTokens() { const { version, tokens } = JSON.parse( @@ -391,7 +382,7 @@ async function validateConsumedTokens() { ); const availableNames = buildAvailableNames( Object.keys(tokens), - new Set(getComponentThemeFiles()), + new Set(getBridgeFiles()), ); const referenced = new Map(); 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 ec52b08c39c1..e255d380ae46 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -4,11 +4,13 @@ $accent: colors.$color; /* - * Tier order mirrors the design-tokens package: base scales and palettes, then semantic roles, - * then components. The component size tokens (fluent components sizes) are intentionally NOT - * emitted: fluent-next maps sizes onto the base scales (spacing, font-size, border-radius, - * border-width), so the component layout custom properties would never be referenced by any - * widget. Only the component color theme is consumed. + * Tier order mirrors the design-tokens package: base scales and palettes, then semantic roles. + * + * The component tier is not emitted at all. Its tokens are aliases onto the semantic roles, the + * theme reads those roles directly, and emitting the tier only added unreferenced custom properties + * 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. */ @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @@ -16,4 +18,3 @@ $accent: colors.$color; @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}"); -@include meta.load-css("../../_design-system/fluent/components/theme"); diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index f9d4c514aaaf..112110250a41 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -574,13 +574,19 @@ test('registries: the grammar stays decidable', () => { }); }); -test('registries are in sync with the generated design tokens', () => { - // Guards against editing registries.json by hand or letting it drift from the token package. - const generatedComponentTokens = readFileSync( - join(packageRoot, 'scss', '_design-system', 'fluent', 'components', 'theme.scss'), +test('registries are in sync with the design token package', () => { + /* + * Guards against editing registries.json by hand or letting it drift from the package. Counted + * from the package's flat index, the same source derive-registries.mjs reads — the component tier + * is no longer emitted as SCSS, so there is no generated file left to count. + */ + const flatTokens = JSON.parse(readFileSync( + require.resolve('@devexpress/design-tokens-internal/tokens.flat.json'), 'utf8', - ); - const tokenCount = [...generatedComponentTokens.matchAll(/--dxds-[a-z0-9-]+:/g)].length; + )); + const tokenCount = Object.keys(flatTokens.tokens) + .filter((key) => key.startsWith('components/core/theme/fluent:')).length; + expect(tokenCount).toBe(registries.derivedFrom.componentTokenCount); }); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index 06430a4c5683..f18bdeeed5aa 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -6,19 +6,25 @@ * node tools/naming/derive-registries.mjs --check # fails if the committed file is stale * * Vocabularies that describe the design system (parts, states, sub-element anatomy) are DERIVED - * from the generated token package, so they cannot drift from it. Judgment calls (component - * exceptions, chassis dependents, rejected synonyms) live in OVERRIDES below and are reviewed as - * code. Run `pnpm nx build:tokens devextreme-scss` first — this script reads generated output. + * from the token package, so they cannot drift from it. Judgment calls (component exceptions, + * chassis dependents, rejected synonyms) live in OVERRIDES below and are reviewed as code. + * + * The component names come from the package's flat index rather than from generated output, so the + * vocabulary survives the component tier no longer being emitted (it is an alias layer the theme + * stopped reading) and the script needs no build to run. */ import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); const packageRoot = join(here, '..', '..'); const themeDir = join(packageRoot, 'scss', 'widgets', 'fluent-next'); -const componentTokens = join(packageRoot, 'scss', '_design-system', 'fluent', 'components', 'theme.scss'); +const flatTokens = require.resolve('@devexpress/design-tokens-internal/tokens.flat.json'); +const COMPONENT_TOKEN_SOURCE = 'components/core/theme/fluent'; const output = join(here, 'registries.json'); // --------------------------------------------------------------------------------------------- @@ -811,8 +817,10 @@ const stripState = (name, states) => { }; const deriveFromTokens = (states) => { - const names = [...readFileSync(componentTokens, 'utf8').matchAll(/--dxds-([a-z0-9-]+):/g)] - .map((match) => match[1]); + const { tokens } = JSON.parse(readFileSync(flatTokens, 'utf8')); + const names = Object.keys(tokens) + .filter((key) => key.startsWith(`${COMPONENT_TOKEN_SOURCE}:`)) + .map((key) => key.slice(key.indexOf(':') + 1).replace(/\//g, '-')); const parts = new Set(); const packageElementPaths = new Set(); @@ -907,14 +915,14 @@ const build = () => { return { $comment: 'GENERATED by tools/naming/derive-registries.mjs — do not edit by hand. ' - + 'Judgment calls live in OVERRIDES in that script; vocabularies are derived from ' - + 'scss/_design-system (regenerate with `pnpm nx build:tokens devextreme-scss` first).', + + 'Judgment calls live in OVERRIDES in that script; vocabularies are derived from the ' + + '@devexpress/design-tokens-internal package and from the theme folder layout.', parseRule: '$(-)*(-)*-(-) — parsed right-to-left ' + 'with longest match. Overlaps between vocabularies competing for DIFFERENT positions are ' + 'intentional and resolved positionally; see assertParseable() in the generator for the two ' + 'overlaps that are forbidden.', derivedFrom: { - componentTokens: 'scss/_design-system/fluent/components/theme.scss', + componentTokens: `@devexpress/design-tokens-internal → ${COMPONENT_TOKEN_SOURCE}`, componentTokenCount: derived.tokenCount, themeFolders: folders.length, }, diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index 59c50d424518..afff3fbbc5b3 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -1,8 +1,8 @@ { - "$comment": "GENERATED by tools/naming/derive-registries.mjs — do not edit by hand. Judgment calls live in OVERRIDES in that script; vocabularies are derived from scss/_design-system (regenerate with `pnpm nx build:tokens devextreme-scss` first).", + "$comment": "GENERATED by tools/naming/derive-registries.mjs — do not edit by hand. Judgment calls live in OVERRIDES in that script; vocabularies are derived from the @devexpress/design-tokens-internal package and from the theme folder layout.", "parseRule": "$(-)*(-)*-(-) — parsed right-to-left with longest match. Overlaps between vocabularies competing for DIFFERENT positions are intentional and resolved positionally; see assertParseable() in the generator for the two overlaps that are forbidden.", "derivedFrom": { - "componentTokens": "scss/_design-system/fluent/components/theme.scss", + "componentTokens": "@devexpress/design-tokens-internal → components/core/theme/fluent", "componentTokenCount": 601, "themeFolders": 86 },