From bb6fbaca7070b1d8edf09b9df4aef93cff2ad01b Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 00:17:10 +0300 Subject: [PATCH 01/14] 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 eed52d85e760d0b64c0362129eedc9334600ab1f Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:36:04 +0300 Subject: [PATCH 02/14] 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 c393cc359b42e66fa567d2bfe580184b67c27b4f Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:37:18 +0300 Subject: [PATCH 03/14] 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 cd15d28968f761da27e08c070f42e2b7a6fce12f Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 03:41:34 +0300 Subject: [PATCH 04/14] 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 10691f71f840fdf46acb49ce23c00fd3623bc464 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 04:09:50 +0300 Subject: [PATCH 05/14] 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 73df8859d1dd..307bebf1e95f 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 "colors" as *; @use "sizes" as *; @use "../sizes" as *; @@ -31,7 +30,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 ca2b6708cebb..7b31e95488ed 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gantt/_index.scss @@ -9,7 +9,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"; @@ -253,7 +252,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 9318916bb796..b64e1364a25d 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/map/_index.scss @@ -1,8 +1,8 @@ @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 24fe5a3f4fdb..15bc695d4f8f 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 "colors" as *; @use "sizes" as *; @@ -30,7 +29,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 8fadfdab9d80f96987987e968ecd96939de1ed5b Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 04:10:51 +0300 Subject: [PATCH 06/14] 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 1f422f263603..7ccd2db6b984 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json +++ b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json @@ -349,9 +349,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 c386e34256fc..b07039d98e98 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -730,6 +730,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 c8efba091802b3a2fa04254ee60462eda8d038a0 Mon Sep 17 00:00:00 2001 From: Raushen Date: Sat, 8 Aug 2026 05:27:00 +0300 Subject: [PATCH 07/14] 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 b07039d98e98..0cbbe878b42c 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -634,13 +634,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 42aee0774318..10048faf7a97 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'); // --------------------------------------------------------------------------------------------- @@ -816,8 +822,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(); @@ -912,14 +920,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 9689e0137dd9..04cf4ad47ccd 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 }, From 1709c73a43854890c61d502dc4e89c6598e7f91c Mon Sep 17 00:00:00 2001 From: Raushen Date: Tue, 11 Aug 2026 17:57:55 +0300 Subject: [PATCH 08/14] Apply comments --- .github/renovate.json | 1 + packages/devextreme-scss/.stylelintrc.json | 3 +- .../build/tokens/build-tokens.mjs | 27 +++----- .../build/tokens/consumed-tokens.ts | 63 ++++++++----------- packages/devextreme-scss/project.json | 1 + .../widgets/fluent-next/common/_mixins.scss | 3 +- .../scss/widgets/fluent-next/map/_index.scss | 7 +-- .../fluent-next/validation/_sizes.scss | 5 +- .../tests/consumed-tokens.test.ts | 56 ++++++++++++----- .../tests/fluent-next-naming.test.ts | 55 +++++++++------- .../tools/naming/derive-registries.mjs | 2 +- .../tools/naming/registries.json | 3 +- 12 files changed, 123 insertions(+), 103 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index f35ffa2b949e..df30143f72d8 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -129,6 +129,7 @@ "@devexpress/design-tokens-internal" ], "automerge": false, + "dependencyDashboardApproval": true, "minimumReleaseAge": null } ], diff --git a/packages/devextreme-scss/.stylelintrc.json b/packages/devextreme-scss/.stylelintrc.json index 98e04043ea00..236034d00ed8 100644 --- a/packages/devextreme-scss/.stylelintrc.json +++ b/packages/devextreme-scss/.stylelintrc.json @@ -85,6 +85,7 @@ } }, { + "comment": "Turning a token into a widget variable is what these files are for, so only the raw custom-property form stays banned here.", "files": [ "scss/widgets/fluent-next/**/_colors.scss", "scss/widgets/fluent-next/**/_sizes.scss", @@ -93,7 +94,7 @@ "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" } + { "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." } ] } } diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index c3710b9041f8..790ecbe8702a 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -181,12 +181,9 @@ const getModeFiles = (mode) => [ `semantic/colors/${THEME_NAME}/${mode}`, ]; -/* - * 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(). - */ +// Source files behind the SCSS bridge. The component tier is absent on purpose: its tokens only +// alias the semantic roles the theme already reads, so emitting them added unreferenced custom +// properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); StyleDictionary.registerFormat({ @@ -366,16 +363,9 @@ async function collectThemeStyleSheets() { .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 - * 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. - */ +// 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. async function validateConsumedTokens() { const { version, tokens } = JSON.parse( await readFile(path.join(tokensDir, 'tokens.flat.json'), 'utf-8'), @@ -389,9 +379,10 @@ async function validateConsumedTokens() { for (const file of await collectThemeStyleSheets()) { const content = await readFile(file, 'utf-8'); + const source = path.relative(themePath, file); const found = [ - ...collectTokenReferences(content).map((name) => [name, `ds.$${name}`]), - ...collectCustomPropertyReferences(content).map((name) => [name, `var(--dxds-${name})`]), + ...collectTokenReferences(content, source).map((name) => [name, `ds.$${name}`]), + ...collectCustomPropertyReferences(content, source).map((name) => [name, `var(--dxds-${name})`]), ]; for (const [name, reference] of found) { diff --git a/packages/devextreme-scss/build/tokens/consumed-tokens.ts b/packages/devextreme-scss/build/tokens/consumed-tokens.ts index 6a279935af2f..3f449c180278 100644 --- a/packages/devextreme-scss/build/tokens/consumed-tokens.ts +++ b/packages/devextreme-scss/build/tokens/consumed-tokens.ts @@ -1,46 +1,35 @@ -/* - * 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. - */ +// Pure half of the consumed-token check in build-tokens.mjs, so the tests can run it without 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(''); +// A commented-out declaration still names a token, and a dead reference must not fail the build. +// Throws on an unpaired delimiter: it would shift the alternation and hide the rest of the file. +export const stripScssComments = (content: string, source: string): string => { + const delimiters = content.replace(/\/\/[^\n\r]*/g, '').match(/\/\*|\*\//g) ?? []; + const paired = delimiters.length % 2 === 0 + && delimiters.every((delimiter, index) => delimiter === (index % 2 === 0 ? '/*' : '*/')); -/* - * 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), + if (!paired) { + throw new Error(`Unpaired block comment delimiter in ${source}: code cannot be told from comment`); + } + + return content + .replace(/\/\/[^\n\r]*/g, '') + .split(/\/\*|\*\//) + .filter((_, index) => index % 2 === 0) + .join(''); +}; + +// Wider than kebab-case on purpose: `[a-z0-9-]` would truncate `ds.$spacing-40_typo` to a valid name. +export const collectTokenReferences = (content: string, source: string): string[] => [ + ...stripScssComments(content, source).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), +// Bypassing the bridge compiles silently, so the raw form is collected too. Unused today. +export const collectCustomPropertyReferences = (content: string, source: string): string[] => [ + ...stripScssComments(content, source).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. - */ +// The index spans every design system and names repeat across them, so it is narrowed to the +// source files the bridge is generated from. export const buildAvailableNames = ( flatTokenKeys: Iterable, consumedSourceFiles: ReadonlySet, diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 7c794e566a36..5123bc105b7c 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -41,6 +41,7 @@ }, "inputs": [ "{projectRoot}/build/tokens/**/*", + "{projectRoot}/scss/widgets/fluent-next/**/*", "{workspaceRoot}/pnpm-lock.yaml" ], "outputs": [ 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 6872ed99b2b6..f61bc0749f1b 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss @@ -6,11 +6,10 @@ @use "../validation/sizes" as validationSizes; @use "../../base/validation" as baseValidation with ( $validation-summary-margin-top: validationSizes.$validation-summary-margin-block-start, - $validation-message-content-padding: validationSizes.$validation-message-padding, + $validation-message-content-padding: validationSizes.$validation-message-content-padding, ); @use "../list/sizes" as listSizes; - @mixin dx-base-typography() { @include dx-base-typography-mixin( $global-content-rest, 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 b64e1364a25d..3714e51930c0 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,6 @@ -@use "../colors" as *; -@use "../sizes" as *; -@use "sizes" as *; +@use "sizes" as mapSizes; @use "../../base/map" with ( - $map-marker-tooltip-margin: $map-marker-tooltip-margin, + $map-marker-tooltip-margin: mapSizes.$map-marker-tooltip-margin, ); // adduse - 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 d67f8a6f1fd7..806a7131e0a0 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/validation/_sizes.scss @@ -8,6 +8,8 @@ $validation-message-line-height: null !default; $validation-message-padding-inline: null !default; $validation-overlay-border-radius: ds.$border-radius-40 !default; +$validation-summary-margin-block-start: ds.$spacing-200 !default; +$validation-message-content-padding: ds.$spacing-100 !default; $validation-message-padding-block: ds.$spacing-40 !default; $validation-message-font-size: ds.$font-size-caption-md !default; @@ -24,6 +26,3 @@ $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; diff --git a/packages/devextreme-scss/tests/consumed-tokens.test.ts b/packages/devextreme-scss/tests/consumed-tokens.test.ts index b62c8a65056f..56f00f106b20 100644 --- a/packages/devextreme-scss/tests/consumed-tokens.test.ts +++ b/packages/devextreme-scss/tests/consumed-tokens.test.ts @@ -9,17 +9,18 @@ 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;', + 'probe.scss', ); 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([]); + expect(collectTokenReferences('// $a: ds.$spacing-40 !default;', 'probe.scss')).toEqual([]); }); it('ignores references parked in block comments', () => { - expect(collectTokenReferences('/* see ds.$spacing-40 */\n$a: ds.$spacing-80;')).toEqual([ + expect(collectTokenReferences('/* see ds.$spacing-40 */\n$a: ds.$spacing-80;', 'probe.scss')).toEqual([ 'spacing-80', ]); }); @@ -33,7 +34,7 @@ describe('collectTokenReferences', () => { '$a: ds.$spacing-40;', ].join('\n'); - expect(collectTokenReferences(content)).toEqual(['spacing-40']); + expect(collectTokenReferences(content, 'probe.scss')).toEqual(['spacing-40']); }); it('keeps the declarations between several block comments', () => { @@ -44,56 +45,83 @@ describe('collectTokenReferences', () => { '$b: ds.$spacing-80;', ].join('\n'); - expect(collectTokenReferences(content)).toEqual(['spacing-40', 'spacing-80']); + expect(collectTokenReferences(content, 'probe.scss')).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']); + expect(collectTokenReferences(content, 'probe.scss')).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']); + expect(collectTokenReferences('$a: ds.$spacing-40_typo;', 'probe.scss')).toEqual(['spacing-40_typo']); + expect(collectTokenReferences('$a: ds.$spacingTypo;', 'probe.scss')).toEqual(['spacingTypo']); }); it('does not treat a variable that merely ends in ds as a namespace', () => { - expect(collectTokenReferences('$a: $borders.$spacing-40;')).toEqual([]); + expect(collectTokenReferences('$a: $borders.$spacing-40;', 'probe.scss')).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([ + expect(collectCustomPropertyReferences('.x { color: var(--dxds-color-content-neutral-default-rest); }', 'probe.scss')).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([ + expect(collectCustomPropertyReferences('.x { color: rgb(from var(--dxds-neutral-10) r g b / 40%); }', 'probe.scss')).toEqual([ 'neutral-10', ]); }); it('tolerates whitespace after the opening parenthesis', () => { - expect(collectCustomPropertyReferences('.x { color: var( --dxds-spacing-40 ); }')).toEqual([ + expect(collectCustomPropertyReferences('.x { color: var( --dxds-spacing-40 ); }', 'probe.scss')).toEqual([ 'spacing-40', ]); }); it('ignores custom properties of other namespaces', () => { - expect(collectCustomPropertyReferences('.x { color: var(--dx-color-text); }')).toEqual([]); + expect(collectCustomPropertyReferences('.x { color: var(--dx-color-text); }', 'probe.scss')).toEqual([]); }); it('ignores a reference parked in a comment', () => { - expect(collectCustomPropertyReferences('// color: var(--dxds-spacing-40);')).toEqual([]); + expect(collectCustomPropertyReferences('// color: var(--dxds-spacing-40);', 'probe.scss')).toEqual([]); + }); +}); + +describe('stripScssComments delimiter check', () => { + it('accepts paired delimiters', () => { + expect(() => stripScssComments('/* note */\n$a: 1;\n/* another */', 'probe.scss')).not.toThrow(); + }); + + it('throws on a block comment that is never closed', () => { + expect(() => stripScssComments('/* note\n$a: ds.$spacing-40;', 'probe.scss')).toThrow('Unpaired block comment'); + }); + + it('throws on an unpaired closing delimiter', () => { + expect(() => stripScssComments('$a: 1;\n*/\n$b: 2;', 'probe.scss')).toThrow('Unpaired block comment'); + }); + + it('throws when delimiters pair up in the wrong order', () => { + // Even count, so only the ordering check can catch this one. + expect(() => stripScssComments('$a: 1;\n*/\n$b: 2;\n/* note', 'probe.scss')).toThrow('Unpaired block comment'); + }); + + it('names the stylesheet it was given', () => { + expect(() => stripScssComments('/* note', 'gantt/_colors.scss')).toThrow('gantt/_colors.scss'); + }); + + it('ignores delimiters that a line comment already removed', () => { + expect(() => stripScssComments('// /* not opened here\n$a: 1;', 'probe.scss')).not.toThrow(); }); }); describe('stripScssComments', () => { it('keeps declarations that follow a closed block comment', () => { - expect(stripScssComments('/* note */ $a: 1;')).toBe(' $a: 1;'); + expect(stripScssComments('/* note */ $a: 1;', 'probe.scss')).toBe(' $a: 1;'); }); }); diff --git a/packages/devextreme-scss/tests/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 0cbbe878b42c..1a84106cadff 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -20,8 +20,18 @@ import { } from 'fs'; import { join, resolve, sep } from 'path'; +import { + collectCustomPropertyReferences, + collectTokenReferences, + stripScssComments, +} from '../build/tokens/consumed-tokens'; + const packageRoot = process.cwd(); -const themeRoot = join(packageRoot, 'scss', 'widgets', 'fluent-next'); +const widgetsRoot = join(packageRoot, 'scss', 'widgets'); +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); const registries = JSON.parse( readFileSync(join(packageRoot, 'tools', 'naming', 'registries.json'), 'utf8'), ); @@ -62,12 +72,6 @@ const walk = (dir: string, extension: string): string[] => { return result; }; -const stripComments = (content: string): string => content - .replace(/\/\/[^\n\r]*/g, '') - .split(/\/\*|\*\//) - .filter((_, index) => index % 2 === 0) - .join(''); - /** * Ranges of `@use … with ( … )` argument lists. Their left-hand sides are the *base module's* * parameter names, not declarations of this file, and must never be treated as either a declaration @@ -152,7 +156,7 @@ const findIncludeRanges = (content: string): [number, number][] => { * variables from the 14 function locals in color.scss and button/_mixins.scss. */ const parseFile = (file: string): Parsed => { - const content = stripComments(readFileSync(file, 'utf8')); + const content = stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)); const withRanges = [ ...findWithRanges(content), ...findSignatureRanges(content), @@ -452,7 +456,7 @@ const findings = { * parameter's first segment happens to be a component name. */ const parameters = new Set(files.flatMap(({ file }) => { - const content = stripComments(readFileSync(join(themeRoot, file), 'utf8')); + const content = stripScssComments(readFileSync(join(themeRoot, file), 'utf8'), sourceLabel(join(themeRoot, file))); return findSignatureRanges(content) .flatMap(([from, to]) => [...content.slice(from, to).matchAll(/\$[a-z0-9_-]+/gi)] .map((match) => match[0])); @@ -571,7 +575,7 @@ const findings = { publicSurfaceUnused: (() => { const declared = new Set(); THEMES.forEach((theme) => walk(join(packageRoot, 'scss', 'widgets', theme), '.scss') - .forEach((file) => [...stripComments(readFileSync(file, 'utf8')) + .forEach((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => declared.add(match[1])))); const consumers = publicNameConsumers(); @@ -582,7 +586,7 @@ const findings = { publicSurfaceUndeclared: (() => { const declared = new Set(); THEMES.forEach((theme) => walk(join(packageRoot, 'scss', 'widgets', theme), '.scss') - .forEach((file) => [...stripComments(readFileSync(file, 'utf8')) + .forEach((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => declared.add(match[1])))); const consumers = publicNameConsumers(); @@ -596,7 +600,7 @@ const findings = { const perTheme = THEMES.map((theme) => { const names = new Set(); walk(join(packageRoot, 'scss', 'widgets', theme), '.scss').forEach((file) => { - [...stripComments(readFileSync(file, 'utf8')).matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] + [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)).matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .forEach((match) => names.add(match[1])); }); return { theme, names }; @@ -652,7 +656,9 @@ test('registries are in sync with the design token package', () => { test('no name carries the theme prefix', () => { const offenders = walk(themeRoot, '.scss').flatMap((file) => { - const found = [...stripComments(readFileSync(file, 'utf8')).matchAll(/\$fluent-[\w-]+/g)]; + const found = [ + ...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)).matchAll(/\$fluent-[\w-]+/g), + ]; return [...new Set(found.map((match) => match[0]))] .map((name) => `${resolve(file).slice(resolve(themeRoot).length + 1)}: ${name}`); }); @@ -737,16 +743,23 @@ test('migrated components follow the grammar strictly', () => { }); 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. - */ + // Covers `@use … with ()` arguments too, which stylelint cannot reach — it lints declarations. 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}`)) + .flatMap((file) => collectTokenReferences(readFileSync(file, 'utf8'), sourceLabel(file)) + .map((token) => `${sourceLabel(file)}: ds.$${token}`)) + .sort(); + + expect(offenders).toEqual([]); +}); + +test('design tokens are never read as a raw custom property', () => { + // Banned everywhere, declaration files included: `var(--dxds-…)` compiles even when the name is + // wrong, while the bridge fails the build. stylelint covers declaration values, not at-rule + // parameters, and that is where these would appear. + const offenders = walk(themeRoot, '.scss') + .flatMap((file) => collectCustomPropertyReferences(readFileSync(file, 'utf8'), sourceLabel(file)) + .map((token) => `${sourceLabel(file)}: var(--dxds-${token})`)) .sort(); expect(offenders).toEqual([]); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index 10048faf7a97..e3bda97f02d7 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -583,7 +583,7 @@ const OVERRIDES = { 'button-group': ['item'], 'load-panel': ['content'], 'tile-view': ['tile', 'wrapper'], - validation: ['message', 'summary', 'summary-item', 'overlay'], + validation: ['message', 'summary', 'summary-item', 'overlay', 'content'], tooltip: ['overlay', 'content', 'popup', 'arrow'], popover: ['popup', 'title'], splitter: ['resize-handle', 'icon'], diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index 04cf4ad47ccd..ef2ec7109bd9 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -753,7 +753,8 @@ "message", "summary", "summary-item", - "overlay" + "overlay", + "content" ], "tooltip": [ "overlay", From f364bbeee2bbd6a2b9efc0d29dd512d3966dfe4d Mon Sep 17 00:00:00 2001 From: Raushen Date: Tue, 11 Aug 2026 18:18:49 +0300 Subject: [PATCH 09/14] Apply ts check --- packages/devextreme-scss/package.json | 1 + packages/devextreme-scss/project.json | 29 ++++++++++++++++--- .../devextreme-scss/tests/opentype.js.d.ts | 2 +- .../tests/unused-elements.test.ts | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/devextreme-scss/package.json b/packages/devextreme-scss/package.json index 577c260ea0df..5071151f565f 100644 --- a/packages/devextreme-scss/package.json +++ b/packages/devextreme-scss/package.json @@ -29,6 +29,7 @@ "naming:residue": "node tools/naming/rename.mjs --residue", "review:evidence": "node tools/review/evidence.mjs", "test": "jest --no-coverage --runInBand --config=./tests/jest.config.json", + "typecheck": "tsc -p tsconfig.json", "watch": "pnpm --workspace-root nx run devextreme-scss --target=watch" }, "version": "26.2.0" diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 5123bc105b7c..456bbc34f092 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -73,7 +73,9 @@ "options": { "mode": "all" }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -95,7 +97,9 @@ "options": { "mode": "ci" }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -174,7 +178,9 @@ "mode": "all", "watch": true }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -199,10 +205,25 @@ "options": { "script": "test" }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens", + "typecheck" + ], "inputs": [ "{projectRoot}/**/*" ] + }, + "typecheck": { + "executor": "nx:run-script", + "options": { + "script": "typecheck" + }, + "inputs": [ + "{projectRoot}/build/tokens/**/*.ts", + "{projectRoot}/tests/**/*.ts", + "{projectRoot}/tsconfig.json" + ], + "cache": true } }, "tags": [] diff --git a/packages/devextreme-scss/tests/opentype.js.d.ts b/packages/devextreme-scss/tests/opentype.js.d.ts index 37bcc4bfeb97..dfd39f3290b2 100644 --- a/packages/devextreme-scss/tests/opentype.js.d.ts +++ b/packages/devextreme-scss/tests/opentype.js.d.ts @@ -9,7 +9,7 @@ declare module 'opentype.js' { length: number; get(index: Number): Glyph; - push(index: Number, loader: (font: Font, index: Number) => Glyph); + push(index: Number, loader: (font: Font, index: Number) => Glyph): void; } interface Font { diff --git a/packages/devextreme-scss/tests/unused-elements.test.ts b/packages/devextreme-scss/tests/unused-elements.test.ts index 96f0aae9a6e3..59d3d310ac1c 100644 --- a/packages/devextreme-scss/tests/unused-elements.test.ts +++ b/packages/devextreme-scss/tests/unused-elements.test.ts @@ -87,7 +87,7 @@ test('There are no unused images in repository', () => { expect(fullImagesFileList).toEqual(usedImagesFileList); }); -['generic', 'material', 'fluent', 'fluent-next'].forEach((themeName) => { +(['generic', 'material', 'fluent', 'fluent-next'] as const).forEach((themeName) => { test(`There are no unused variables in ${themeName} SCSS files`, () => { const baseScssFiles = getFilesFromDirectory(join('scss', 'widgets', 'base'), ['.scss']) .map((fileName) => resolve(fileName)); From 54dfba0b8c38436d2d297bafd08a2ce465e329dc Mon Sep 17 00:00:00 2001 From: Raushen Date: Tue, 11 Aug 2026 21:19:51 +0300 Subject: [PATCH 10/14] Apply comments --- packages/devextreme-scss/.stylelintrc.json | 10 +++++----- packages/devextreme/project.json | 6 ------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/devextreme-scss/.stylelintrc.json b/packages/devextreme-scss/.stylelintrc.json index 236034d00ed8..af383c56a302 100644 --- a/packages/devextreme-scss/.stylelintrc.json +++ b/packages/devextreme-scss/.stylelintrc.json @@ -14,7 +14,7 @@ "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." } + { "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." } ], "font-family-name-quotes": "always-unless-keyword", "@stylistic/indentation": [2, { "ignore": ["inside-parens"] }], @@ -79,8 +79,8 @@ "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" } + { "/.*/": ["/var\\(\\s*--dxds-/", "/var\\(\\s*--dx-/", "/\\bds\\.\\$/"] }, + { "message": "Use the widget's own variable here: resolve the design token in _colors.scss or _sizes.scss. A custom property compiles even when its name is wrong." } ] } }, @@ -93,8 +93,8 @@ ], "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. The public --dx-* properties are unaffected." } + { "/.*/": ["/var\\(\\s*--dxds-/", "/var\\(\\s*--dx-/"] }, + { "message": "Read a design token through the ds bridge (ds.$name), not as a custom property: the bridge fails the build on an unknown name, a custom property compiles and degrades silently." } ] } } diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 6cb2001a78fe..19efa18de2d0 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1224,12 +1224,6 @@ }, "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 b6c79cbad88bba0aa1526452b431a8dc849ec8e2 Mon Sep 17 00:00:00 2001 From: Raushen Date: Tue, 11 Aug 2026 21:25:49 +0300 Subject: [PATCH 11/14] Update tsconfig --- packages/devextreme-scss/tests/tsconfig.json | 16 -------------- packages/devextreme-scss/tsconfig.json | 23 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 16 deletions(-) delete mode 100644 packages/devextreme-scss/tests/tsconfig.json create mode 100644 packages/devextreme-scss/tsconfig.json diff --git a/packages/devextreme-scss/tests/tsconfig.json b/packages/devextreme-scss/tests/tsconfig.json deleted file mode 100644 index 3c5dc9495a43..000000000000 --- a/packages/devextreme-scss/tests/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "compilerOptions": { - "baseUrl": ".", - "lib": [ - "es2019" - ], - "types": [ - "node", - "jest" - ] - }, - "include": [ - "./*.ts" - ] -} diff --git a/packages/devextreme-scss/tsconfig.json b/packages/devextreme-scss/tsconfig.json new file mode 100644 index 000000000000..d775499ed315 --- /dev/null +++ b/packages/devextreme-scss/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "target": "es2022", + "module": "preserve", + "moduleResolution": "bundler", + "lib": [ + "es2022" + ], + "types": [ + "node", + "jest" + ], + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": [ + "build/tokens/**/*.ts", + "tests/**/*.ts" + ] +} From 631bf3c08dd7f55bd0135402e0ff42b6cbd15d6c Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 13 Aug 2026 17:37:59 +0300 Subject: [PATCH 12/14] Fluent-next: ship accents as CSS, keep the sources out of the package --- packages/devextreme-scss/project.json | 8 ++- .../scss/widgets/fluent-next/_colors.scss | 6 +- packages/devextreme/project.json | 13 +++- .../scss-assemble/executor.e2e.spec.ts | 25 +++++++ .../src/executors/scss-assemble/schema.json | 7 ++ .../src/executors/scss-assemble/schema.ts | 1 + .../scss-assemble/scss-assemble.impl.ts | 10 +-- .../executors/scss-build/executor.e2e.spec.ts | 71 ++++++++++++++++++- .../src/executors/scss-build/executor.ts | 1 + .../executors/scss-build/scss-build.impl.ts | 52 ++++++++++++++ 10 files changed, 180 insertions(+), 14 deletions(-) diff --git a/packages/devextreme-scss/project.json b/packages/devextreme-scss/project.json index 456bbc34f092..32b5f91d250d 100644 --- a/packages/devextreme-scss/project.json +++ b/packages/devextreme-scss/project.json @@ -88,7 +88,8 @@ ], "outputs": [ "{projectRoot}/scss/bundles", - "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents" ], "cache": true }, @@ -113,7 +114,8 @@ ], "outputs": [ "{projectRoot}/scss/bundles", - "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css" + "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents" ], "cache": true }, @@ -139,6 +141,7 @@ "outputs": [ "{projectRoot}/scss/bundles", "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents", "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", "{workspaceRoot}/packages/devextreme/artifacts/css/icons" ], @@ -167,6 +170,7 @@ "outputs": [ "{projectRoot}/scss/bundles", "{workspaceRoot}/packages/devextreme/artifacts/css/dx.*.css", + "{workspaceRoot}/packages/devextreme/artifacts/css/accents", "{workspaceRoot}/packages/devextreme/artifacts/css/fonts", "{workspaceRoot}/packages/devextreme/artifacts/css/icons" ], diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss index 3186b5f06bef..71ff73285689 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss @@ -5,13 +5,9 @@ $color: null !default; $mode: null !default; -$theme-marker-color: null !default; +$theme-marker-color: $color !default; $theme-marker-mode: null !default; -@if $color == "blue" { - $theme-marker-color: "blue" !default; -} - @if $mode == "light" { $theme-marker-mode: "light" !default; } diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 19efa18de2d0..6cc1354d93d4 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1226,13 +1226,24 @@ "executor": "devextreme-nx-infra-plugin:scss-assemble", "options": { "scssPackagePath": "../devextreme-scss", - "outputDir": "./artifacts/npm/devextreme/scss" + "outputDir": "./artifacts/npm/devextreme/scss", + "exclude": [ + "widgets/fluent-next/**", + "_design-system/**", + "bundles/dx.fluent-next.*.scss" + ] }, "configurations": { "internal": { "outputDir": "./artifacts/npm/devextreme-internal/scss" } }, + "dependsOn": [ + { + "projects": ["devextreme-scss"], + "target": "build:tokens" + } + ], "inputs": [ "{workspaceRoot}/packages/devextreme-scss/scss/**/*", "{workspaceRoot}/packages/devextreme-scss/fonts/**/*", diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts index bebf5fbea714..cb2b69e7bb26 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/executor.e2e.spec.ts @@ -83,4 +83,29 @@ describe('ScssAssembleExecutor E2E', () => { expect(content).toContain(expectedSvg); expect(content).toContain(expectedPng); }); + + it('should keep excluded sources out of the package', async () => { + await writeFileText( + path.join(scssPackageDir, 'scss', 'widgets', 'kept', '_index.scss'), + '.a {}', + ); + await writeFileText( + path.join(scssPackageDir, 'scss', 'widgets', 'dropped', '_index.scss'), + '.b {}', + ); + await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.kept.scss'), '.c {}'); + await writeFileText(path.join(scssPackageDir, 'scss', 'bundles', 'dx.dropped.scss'), '.d {}'); + + const context = createMockContext({ root: tempDir }); + const result = await executor( + { ...OPTIONS, exclude: ['widgets/dropped/**', 'bundles/dx.dropped.scss'] }, + context, + ); + + expect(result.success).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'widgets', 'kept', '_index.scss'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.kept.scss'))).toBe(true); + expect(fs.existsSync(path.join(outputDir, 'widgets', 'dropped'))).toBe(false); + expect(fs.existsSync(path.join(outputDir, 'bundles', 'dx.dropped.scss'))).toBe(false); + }); }); diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json index 3efa0803ccff..042520c8ebd4 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.json @@ -11,6 +11,13 @@ "outputDir": { "type": "string", "description": "Output directory for assembled SCSS files (relative to project root)." + }, + "exclude": { + "type": "array", + "description": "Glob patterns, relative to the scss directory, kept out of the package.", + "items": { + "type": "string" + } } }, "required": ["scssPackagePath", "outputDir"] diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts index 196986ac5966..859d836d2d4c 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/schema.ts @@ -1,4 +1,5 @@ export interface ScssAssembleExecutorSchema { scssPackagePath: string; outputDir: string; + exclude?: string[]; } diff --git a/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts index b49778435343..175d296951b7 100644 --- a/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts +++ b/packages/nx-infra-plugin/src/executors/scss-assemble/scss-assemble.impl.ts @@ -37,10 +37,11 @@ async function inlineDataUri(content: string, scssRoot: string): Promise async function copyScssWithInlineDataUri( scssPackagePath: string, outputDir: string, + exclude: string[], ): Promise { const scssSourceDir = path.join(scssPackagePath, 'scss'); const cwd = toPosixPath(scssSourceDir); - const relPaths = await glob('**/*', { cwd, nodir: true }); + const relPaths = await glob('**/*', { cwd, nodir: true, ignore: exclude }); await Promise.all( relPaths.map(async (relPath) => { @@ -77,6 +78,7 @@ async function copyIcons(scssPackagePath: string, outputDir: string): Promise({ @@ -84,11 +86,11 @@ export default createExecutor( resolve: (options, { projectRoot }) => { const scssPackagePath = path.resolve(projectRoot, options.scssPackagePath); const outputDir = path.resolve(projectRoot, options.outputDir); - return { scssPackagePath, outputDir }; + return { scssPackagePath, outputDir, exclude: options.exclude ?? [] }; }, - run: async ({ scssPackagePath, outputDir }) => { + run: async ({ scssPackagePath, outputDir, exclude }) => { await Promise.all([ - copyScssWithInlineDataUri(scssPackagePath, outputDir), + copyScssWithInlineDataUri(scssPackagePath, outputDir, exclude), copyFonts(scssPackagePath, outputDir), copyIcons(scssPackagePath, outputDir), ]); diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index 10c6d1e9a037..84f7f0969c7a 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import executor from './executor'; +import executor, { findMissingThemeCss } from './executor'; import { ScssBuildExecutorSchema } from './schema'; import { createMockContext, createTempDir, cleanupTempDir } from '../../utils/test-utils'; import { writeFileText, writeJson, readFileText } from '../../utils'; @@ -29,7 +29,7 @@ function createMockModules(projectRoot: string): void { '}', 'module.exports = {', ' SassString,', - ' compile: () => ({ css: \'@charset "UTF-8"; .a{display:flex}\' })', + ' compile: () => ({ css: \'/**\\n * Do not edit directly, this file was auto-generated.\\n */\\n@charset "UTF-8"; .a{display:flex}\' })', '};', '', ].join('\n'), @@ -144,6 +144,11 @@ async function setupProjectStructure(workspaceRoot: string): Promise { '.generic-$COLOR { color: red; }', ); + await writeFileText( + path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'blue.scss'), + ':root { --dxds-primary-100: #0f6cbd; }', + ); + createMockModules(projectRoot); return projectRoot; } @@ -189,6 +194,33 @@ describe('ScssBuildExecutor E2E', () => { expect(commonCss).toContain('DevExtreme (dx.common.css)'); }); + it('compiles design-system accent sources into the accents subfolder without minification', async () => { + const projectRoot = await setupProjectStructure(tempDir); + await writeFileText( + path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'storm.scss'), + ':root { --dxds-primary-100: #6d6a68; }', + ); + + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + const options: ScssBuildExecutorSchema = { mode: 'all', cssOutputDir: './artifacts/css' }; + const result = await executor(options, context); + + expect(result.success).toBe(true); + + const stormCss = await readFileText( + path.join(projectRoot, 'artifacts', 'css', 'accents', 'storm.css'), + ); + expect(stormCss).toContain('DevExtreme (storm.css)'); + expect(stormCss).not.toContain('/*min:'); + expect(stormCss).not.toContain('/*prefixed*/'); + expect(stormCss).not.toContain('auto-generated'); + }); + it('builds ci mode only for selected dev bundles and uses ci profile', async () => { const projectRoot = await setupProjectStructure(tempDir); const context = createMockContext({ @@ -219,6 +251,41 @@ describe('ScssBuildExecutor E2E', () => { expect(fs.existsSync(path.join(projectRoot, 'scss', 'bundles', 'dx.common.scss'))).toBe(true); }); + it('fails when the design-system produced no accent palettes', async () => { + const projectRoot = await setupProjectStructure(tempDir); + fs.rmSync(path.join(projectRoot, 'scss', '_design-system'), { recursive: true }); + + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + const options: ScssBuildExecutorSchema = { mode: 'all', cssOutputDir: './artifacts/css' }; + const result = await executor(options, context); + + expect(result.success).toBe(false); + }); + + it('reports declared themes that left no CSS behind', async () => { + const projectRoot = await setupProjectStructure(tempDir); + const context = createMockContext({ + root: tempDir, + projectName: 'devextreme-scss', + projectRoot: 'packages/devextreme-scss', + }); + + await executor({ mode: 'all', cssOutputDir: './artifacts/css' }, context); + + const cssDir = path.join(projectRoot, 'artifacts', 'css'); + const deps = { themeOptions: { getThemes: () => [['generic', 'default', 'light']] } }; + + expect(findMissingThemeCss(cssDir, deps as never)).toEqual([]); + + fs.rmSync(path.join(cssDir, 'dx.light.css')); + expect(findMissingThemeCss(cssDir, deps as never)).toEqual(['dx.light.css']); + }); + it('fails in ci mode when a configured bundle source is missing', async () => { await setupProjectStructure(tempDir); const context = createMockContext({ diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts index 8c0bdaed7166..84dd0943568a 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.ts @@ -1 +1,2 @@ export { default } from './scss-build.impl'; +export { findMissingThemeCss } from './scss-build.impl'; diff --git a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts index cb7979db0d1a..2ee59affd76d 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts @@ -13,6 +13,9 @@ import { ScssBuildExecutorSchema } from './schema'; const DEFAULT_BUNDLES_DIR = './scss/bundles'; const DEFAULT_CSS_OUTPUT_DIR = '../devextreme/artifacts/css'; +const ACCENT_SOURCES_DIR = './scss/_design-system/fluent/accents'; +const ACCENT_OUTPUT_DIR_NAME = 'accents'; +const GENERATOR_BANNER_REGEX = /^\s*\/\*[\s\S]*?auto-generated[\s\S]*?\*\/\s*/; const DEFAULT_DEV_BUNDLE_NAMES = [ 'light', 'light.compact', @@ -112,6 +115,19 @@ async function generateScssBundles( await writeFileText(path.join(resolvedBundlesDir, 'dx.common.scss'), commonTemplate); } +export function findMissingThemeCss(cssOutputDir: string, deps: BuildDependencies): string[] { + const declaredCssNames = [ + ...deps.themeOptions + .getThemes() + .map(([theme, size, color, mode]) => + generateBundleName(theme, size, color, mode).replace(/\.scss$/, '.css'), + ), + 'dx.common.css', + ]; + + return declaredCssNames.filter((name) => !fs.existsSync(path.join(cssOutputDir, name))); +} + function loadDependencies(projectRoot: string): BuildDependencies { const projectRequire = createRequire(path.join(projectRoot, 'package.json')); @@ -207,6 +223,32 @@ async function compileFile( await writeFileText(path.join(outputDir, outFileName), withHeader); } +async function compileAccentOverrides( + projectRoot: string, + cssOutputDir: string, + deps: BuildDependencies, +): Promise { + const accentSourcesDir = path.resolve(projectRoot, ACCENT_SOURCES_DIR); + const pattern = normalizeGlobPathForWindows(path.join(accentSourcesDir, '*.scss')); + const accentSources = await glob(pattern, { nodir: true }); + + if (accentSources.length === 0) { + throw new Error(`No accent palettes to compile in ${accentSourcesDir}`); + } + + const accentOutputDir = path.join(cssOutputDir, ACCENT_OUTPUT_DIR_NAME); + + for (const source of accentSources) { + logger.verbose(`Compiling accent ${source}`); + const compiled = deps.sass.compile(source); + const outFileName = `${path.basename(source, '.scss')}.css`; + const license = createStarLicenseHeader(outFileName, deps.devextremeVersion); + const css = compiled.css.replace(GENERATOR_BANNER_REGEX, ''); + const withHeader = prependLicenseAndMoveCharsetFirst(css, license); + await writeFileText(path.join(accentOutputDir, outFileName), withHeader); + } +} + async function copyThemeAssets(projectRoot: string, cssOutputDir: string): Promise { const fontsFrom = path.resolve(projectRoot, 'fonts'); const iconsFrom = path.resolve(projectRoot, 'icons'); @@ -280,6 +322,15 @@ async function runSingleBuild( logger.verbose(`Compiling ${source}`); await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + + await compileAccentOverrides(projectRoot, cssOutputDir, deps); + + if (options.mode !== 'ci') { + const missingThemeCss = findMissingThemeCss(cssOutputDir, deps); + if (missingThemeCss.length > 0) { + throw new Error(`Declared themes produced no CSS: ${missingThemeCss.join(', ')}`); + } + } } function loadChokidar(projectRoot: string): { @@ -319,6 +370,7 @@ async function runWatchBuild( await compileFile(source, cssOutputDir, minifyProfile, deps, projectRoot); } + await compileAccentOverrides(projectRoot, cssOutputDir, deps); await copyThemeAssets(projectRoot, cssOutputDir); }; From 28439daecdda32d6c09148b94e1233f6fdad41e2 Mon Sep 17 00:00:00 2001 From: Raushen Date: Thu, 13 Aug 2026 23:11:42 +0300 Subject: [PATCH 13/14] Custom accent color --- .../widgets/fluent-next/accents/custom.css | 32 ++++++++++++++++ .../tests/accent-custom.test.ts | 37 +++++++++++++++++++ .../tools/naming/derive-registries.mjs | 6 ++- .../tools/naming/registries.json | 3 +- .../executors/scss-build/executor.e2e.spec.ts | 9 +++++ .../executors/scss-build/scss-build.impl.ts | 20 ++++++---- 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css create mode 100644 packages/devextreme-scss/tests/accent-custom.test.ts diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css new file mode 100644 index 000000000000..8c2d8ad7c807 --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css @@ -0,0 +1,32 @@ +/* + * Accent scale derived from a single brand color: set --dx-accent-color and include this file + * instead of a palette from the same folder. The given color becomes step 100 as is; lighter + * steps move toward --dx-accent-lightness-max, darker ones toward --dx-accent-lightness-min, + * both fading chroma to --dx-accent-chroma-min. The hue never changes. + * + * The scale is an approximation, not a replacement for the designed palettes: on the shipped + * eleven it differs by up to 0.06 dE in oklab, mostly on the lightest steps. + */ +:root { + --dx-accent-lightness-max: 0.95; + --dx-accent-lightness-min: 0.15; + --dx-accent-chroma-min: 0.04; + --dxds-primary-10: oklch(from var(--dx-accent-color) calc(l + 9 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 9 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-20: oklch(from var(--dx-accent-color) calc(l + 8 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-30: oklch(from var(--dx-accent-color) calc(l + 7 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-40: oklch(from var(--dx-accent-color) calc(l + 6 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-50: oklch(from var(--dx-accent-color) calc(l + 5 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-60: oklch(from var(--dx-accent-color) calc(l + 4 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-70: oklch(from var(--dx-accent-color) calc(l + 3 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-80: oklch(from var(--dx-accent-color) calc(l + 2 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-90: oklch(from var(--dx-accent-color) calc(l + 1 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-100: oklch(from var(--dx-accent-color) l c h); + --dxds-primary-110: oklch(from var(--dx-accent-color) calc(l - 1 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-120: oklch(from var(--dx-accent-color) calc(l - 2 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-130: oklch(from var(--dx-accent-color) calc(l - 3 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-140: oklch(from var(--dx-accent-color) calc(l - 4 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-150: oklch(from var(--dx-accent-color) calc(l - 5 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-160: oklch(from var(--dx-accent-color) calc(l - 6 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-170: oklch(from var(--dx-accent-color) calc(l - 7 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-180: oklch(from var(--dx-accent-color) calc(l - 8 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); +} diff --git a/packages/devextreme-scss/tests/accent-custom.test.ts b/packages/devextreme-scss/tests/accent-custom.test.ts new file mode 100644 index 000000000000..961d6e05fe64 --- /dev/null +++ b/packages/devextreme-scss/tests/accent-custom.test.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const scssRoot = path.resolve(__dirname, '..', 'scss'); +const designedAccentsDir = path.join(scssRoot, '_design-system', 'fluent', 'accents'); +const customAccentPath = path.join(scssRoot, 'widgets', 'fluent-next', 'accents', 'custom.css'); + +const readSteps = (filePath: string): string[] => { + const content = fs.readFileSync(filePath, 'utf8'); + return [...content.matchAll(/--dxds-primary-(\d+)\s*:/g)].map((match) => match[1]); +}; + +const designedPalettes = fs.readdirSync(designedAccentsDir) + .filter((name) => name.endsWith('.scss')) + .map((name) => ({ name, steps: readSteps(path.join(designedAccentsDir, name)) })); + +describe('accents/custom.css', () => { + it('covers exactly the steps the designed palettes declare', () => { + const customAccentSteps = readSteps(customAccentPath); + + expect(designedPalettes.length).toBeGreaterThan(0); + designedPalettes.forEach(({ name, steps }) => { + expect([name, customAccentSteps]).toEqual([name, steps]); + }); + }); + + it('derives every step from --dx-accent-color instead of a literal color', () => { + const declarations = fs.readFileSync(customAccentPath, 'utf8') + .match(/--dxds-primary-\d+\s*:[^;]+;/g) ?? []; + + expect(declarations).toHaveLength(readSteps(customAccentPath).length); + declarations.forEach((declaration) => { + expect(declaration).toContain('oklch(from var(--dx-accent-color)'); + expect(declaration).not.toMatch(/#[0-9a-f]{3,8}\b/i); + }); + }); +}); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index e3bda97f02d7..65c8a03347c8 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -48,8 +48,12 @@ const OVERRIDES = { * `typography` is a registered concern — the common/ criterion, satisfied without moving anything. * Moving the declarations into common/ was tried and reverted: it pulled common/ earlier in the * load order and shifted `--dx-line-height` inside its :root block, i.e. it changed the emitted CSS. + * + * `accents/` holds no SCSS at all: it is the hand-written companion of the generated palettes in + * `_design-system/fluent/accents/`, shipped as a standalone stylesheet next to them in + * `dist/css/accents/`. It declares design-system custom properties, never theme variables. */ - systemFolders: ['common', 'typography'], + systemFolders: ['accents', 'common', 'typography'], // component -> folder that is allowed to declare it (O2: exactly one declaration home). // Only needed where more than one folder currently declares the component's variables. diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index ef2ec7109bd9..aaddfc821791 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -4,7 +4,7 @@ "derivedFrom": { "componentTokens": "@devexpress/design-tokens-internal → components/core/theme/fluent", "componentTokenCount": 601, - "themeFolders": 86 + "themeFolders": 87 }, "components": { "accordion": "accordion", @@ -178,6 +178,7 @@ "widget": "widget" }, "systemFolders": [ + "accents", "common", "typography" ], diff --git a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts index 84f7f0969c7a..e1c16507d5c7 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/executor.e2e.spec.ts @@ -200,6 +200,10 @@ describe('ScssBuildExecutor E2E', () => { path.join(projectRoot, 'scss', '_design-system', 'fluent', 'accents', 'storm.scss'), ':root { --dxds-primary-100: #6d6a68; }', ); + await writeFileText( + path.join(projectRoot, 'scss', 'widgets', 'fluent-next', 'accents', 'custom.css'), + ':root { --dxds-primary-100: oklch(from var(--dx-accent-color) l c h); }', + ); const context = createMockContext({ root: tempDir, @@ -219,6 +223,11 @@ describe('ScssBuildExecutor E2E', () => { expect(stormCss).not.toContain('/*min:'); expect(stormCss).not.toContain('/*prefixed*/'); expect(stormCss).not.toContain('auto-generated'); + + const customAccentCss = await readFileText( + path.join(projectRoot, 'artifacts', 'css', 'accents', 'custom.css'), + ); + expect(customAccentCss).toContain('DevExtreme (custom.css)'); }); it('builds ci mode only for selected dev bundles and uses ci profile', async () => { diff --git a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts index 2ee59affd76d..468638dfd9d2 100644 --- a/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts +++ b/packages/nx-infra-plugin/src/executors/scss-build/scss-build.impl.ts @@ -13,7 +13,8 @@ import { ScssBuildExecutorSchema } from './schema'; const DEFAULT_BUNDLES_DIR = './scss/bundles'; const DEFAULT_CSS_OUTPUT_DIR = '../devextreme/artifacts/css'; -const ACCENT_SOURCES_DIR = './scss/_design-system/fluent/accents'; +const GENERATED_ACCENT_PALETTES_DIR = './scss/_design-system/fluent/accents'; +const AUTHORED_ACCENT_STYLES_DIR = './scss/widgets/fluent-next/accents'; const ACCENT_OUTPUT_DIR_NAME = 'accents'; const GENERATOR_BANNER_REGEX = /^\s*\/\*[\s\S]*?auto-generated[\s\S]*?\*\/\s*/; const DEFAULT_DEV_BUNDLE_NAMES = [ @@ -223,25 +224,30 @@ async function compileFile( await writeFileText(path.join(outputDir, outFileName), withHeader); } +function globAccentSources(sourcesDir: string, extension: string): Promise { + return glob(normalizeGlobPathForWindows(path.join(sourcesDir, extension)), { nodir: true }); +} + async function compileAccentOverrides( projectRoot: string, cssOutputDir: string, deps: BuildDependencies, ): Promise { - const accentSourcesDir = path.resolve(projectRoot, ACCENT_SOURCES_DIR); - const pattern = normalizeGlobPathForWindows(path.join(accentSourcesDir, '*.scss')); - const accentSources = await glob(pattern, { nodir: true }); + const palettesDir = path.resolve(projectRoot, GENERATED_ACCENT_PALETTES_DIR); + const palettes = await globAccentSources(palettesDir, '*.scss'); - if (accentSources.length === 0) { - throw new Error(`No accent palettes to compile in ${accentSourcesDir}`); + if (palettes.length === 0) { + throw new Error(`No accent palettes to compile in ${palettesDir}`); } + const authoredDir = path.resolve(projectRoot, AUTHORED_ACCENT_STYLES_DIR); + const accentSources = [...palettes, ...(await globAccentSources(authoredDir, '*.css'))]; const accentOutputDir = path.join(cssOutputDir, ACCENT_OUTPUT_DIR_NAME); for (const source of accentSources) { logger.verbose(`Compiling accent ${source}`); const compiled = deps.sass.compile(source); - const outFileName = `${path.basename(source, '.scss')}.css`; + const outFileName = `${path.basename(source, path.extname(source))}.css`; const license = createStarLicenseHeader(outFileName, deps.devextremeVersion); const css = compiled.css.replace(GENERATOR_BANNER_REGEX, ''); const withHeader = prependLicenseAndMoveCharsetFirst(css, license); From e64c4b34813a11292bde868ab0fe49fa9b989b91 Mon Sep 17 00:00:00 2001 From: Raushen Date: Fri, 14 Aug 2026 15:55:29 +0300 Subject: [PATCH 14/14] Fix comments --- .../widgets/fluent-next/accents/custom.css | 47 ++++++++----------- .../tests/accent-custom.test.ts | 32 +++++++++++-- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css index 8c2d8ad7c807..985fe02b8088 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css +++ b/packages/devextreme-scss/scss/widgets/fluent-next/accents/custom.css @@ -1,32 +1,25 @@ -/* - * Accent scale derived from a single brand color: set --dx-accent-color and include this file - * instead of a palette from the same folder. The given color becomes step 100 as is; lighter - * steps move toward --dx-accent-lightness-max, darker ones toward --dx-accent-lightness-min, - * both fading chroma to --dx-accent-chroma-min. The hue never changes. - * - * The scale is an approximation, not a replacement for the designed palettes: on the shipped - * eleven it differs by up to 0.06 dE in oklab, mostly on the lightest steps. - */ +/* Set --dx-accent-color to a brand color; load after the theme stylesheet. */ :root { + --dx-accent-color-source: var(--dx-accent-color, #0f6cbd); --dx-accent-lightness-max: 0.95; --dx-accent-lightness-min: 0.15; --dx-accent-chroma-min: 0.04; - --dxds-primary-10: oklch(from var(--dx-accent-color) calc(l + 9 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 9 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-20: oklch(from var(--dx-accent-color) calc(l + 8 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-30: oklch(from var(--dx-accent-color) calc(l + 7 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-40: oklch(from var(--dx-accent-color) calc(l + 6 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-50: oklch(from var(--dx-accent-color) calc(l + 5 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-60: oklch(from var(--dx-accent-color) calc(l + 4 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-70: oklch(from var(--dx-accent-color) calc(l + 3 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-80: oklch(from var(--dx-accent-color) calc(l + 2 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-90: oklch(from var(--dx-accent-color) calc(l + 1 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); - --dxds-primary-100: oklch(from var(--dx-accent-color) l c h); - --dxds-primary-110: oklch(from var(--dx-accent-color) calc(l - 1 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-120: oklch(from var(--dx-accent-color) calc(l - 2 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-130: oklch(from var(--dx-accent-color) calc(l - 3 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-140: oklch(from var(--dx-accent-color) calc(l - 4 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-150: oklch(from var(--dx-accent-color) calc(l - 5 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-160: oklch(from var(--dx-accent-color) calc(l - 6 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-170: oklch(from var(--dx-accent-color) calc(l - 7 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); - --dxds-primary-180: oklch(from var(--dx-accent-color) calc(l - 8 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-10: oklch(from var(--dx-accent-color-source) calc(l + 9 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 9 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-20: oklch(from var(--dx-accent-color-source) calc(l + 8 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-30: oklch(from var(--dx-accent-color-source) calc(l + 7 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-40: oklch(from var(--dx-accent-color-source) calc(l + 6 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-50: oklch(from var(--dx-accent-color-source) calc(l + 5 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-60: oklch(from var(--dx-accent-color-source) calc(l + 4 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-70: oklch(from var(--dx-accent-color-source) calc(l + 3 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-80: oklch(from var(--dx-accent-color-source) calc(l + 2 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-90: oklch(from var(--dx-accent-color-source) calc(l + 1 * (var(--dx-accent-lightness-max) - min(l, var(--dx-accent-lightness-max))) / 9) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 9) h); + --dxds-primary-100: oklch(from var(--dx-accent-color-source) l c h); + --dxds-primary-110: oklch(from var(--dx-accent-color-source) calc(l - 1 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 1 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-120: oklch(from var(--dx-accent-color-source) calc(l - 2 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 2 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-130: oklch(from var(--dx-accent-color-source) calc(l - 3 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 3 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-140: oklch(from var(--dx-accent-color-source) calc(l - 4 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 4 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-150: oklch(from var(--dx-accent-color-source) calc(l - 5 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 5 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-160: oklch(from var(--dx-accent-color-source) calc(l - 6 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 6 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-170: oklch(from var(--dx-accent-color-source) calc(l - 7 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 7 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); + --dxds-primary-180: oklch(from var(--dx-accent-color-source) calc(l - 8 * (max(l, var(--dx-accent-lightness-min)) - var(--dx-accent-lightness-min)) / 8) calc(c - 8 * (max(c, var(--dx-accent-chroma-min)) - var(--dx-accent-chroma-min)) / 8) h); } diff --git a/packages/devextreme-scss/tests/accent-custom.test.ts b/packages/devextreme-scss/tests/accent-custom.test.ts index 961d6e05fe64..822e657de5cf 100644 --- a/packages/devextreme-scss/tests/accent-custom.test.ts +++ b/packages/devextreme-scss/tests/accent-custom.test.ts @@ -10,6 +10,22 @@ const readSteps = (filePath: string): string[] => { return [...content.matchAll(/--dxds-primary-(\d+)\s*:/g)].map((match) => match[1]); }; +const readPrimaryDeclarations = (filePath: string): string[] => + fs.readFileSync(filePath, 'utf8').match(/--dxds-primary-\d+\s*:[^;]+;/g) ?? []; + +const readStepColor = (filePath: string, step: string): string => { + const content = fs.readFileSync(filePath, 'utf8'); + const match = new RegExp(`--dxds-primary-${step}\\s*:\\s*(#[0-9a-f]{3,8})\\b`, 'i').exec(content); + + if (!match) { + throw new Error(`No literal color for step ${step} in ${filePath}`); + } + + return match[1]; +}; + +const defaultPalettePath = path.join(designedAccentsDir, 'blue.scss'); + const designedPalettes = fs.readdirSync(designedAccentsDir) .filter((name) => name.endsWith('.scss')) .map((name) => ({ name, steps: readSteps(path.join(designedAccentsDir, name)) })); @@ -24,14 +40,20 @@ describe('accents/custom.css', () => { }); }); - it('derives every step from --dx-accent-color instead of a literal color', () => { - const declarations = fs.readFileSync(customAccentPath, 'utf8') - .match(/--dxds-primary-\d+\s*:[^;]+;/g) ?? []; + it('derives every step from --dx-accent-color-source instead of a literal color', () => { + const declarations = readPrimaryDeclarations(customAccentPath); - expect(declarations).toHaveLength(readSteps(customAccentPath).length); + expect(declarations).toHaveLength(readSteps(defaultPalettePath).length); declarations.forEach((declaration) => { - expect(declaration).toContain('oklch(from var(--dx-accent-color)'); + expect(declaration).toContain('oklch(from var(--dx-accent-color-source)'); expect(declaration).not.toMatch(/#[0-9a-f]{3,8}\b/i); }); }); + + it('resolves --dx-accent-color-source to the default blue accent when --dx-accent-color is unset', () => { + const defaultAccentColor = readStepColor(defaultPalettePath, '100'); + + expect(fs.readFileSync(customAccentPath, 'utf8')) + .toContain(`--dx-accent-color-source: var(--dx-accent-color, ${defaultAccentColor});`); + }); });