diff --git a/.github/renovate.json b/.github/renovate.json index df3d3d21ff4a..df30143f72d8 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -123,6 +123,14 @@ "matchPackageNames": [ "*" ] + }, + { + "matchPackageNames": [ + "@devexpress/design-tokens-internal" + ], + "automerge": false, + "dependencyDashboardApproval": true, + "minimumReleaseAge": null } ], "lockFileMaintenance": { diff --git a/packages/devextreme-scss/.stylelintrc.json b/packages/devextreme-scss/.stylelintrc.json index e1af3a160c8a..af383c56a302 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." } + ], "font-family-name-quotes": "always-unless-keyword", "@stylistic/indentation": [2, { "ignore": ["inside-parens"] }], "keyframes-name-pattern": "dx-[a-z0-9-]+", @@ -69,6 +73,30 @@ "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-/", "/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." } + ] + } + }, + { + "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", + "scss/widgets/fluent-next/**/_variables.scss" + ], + "rules": { + "declaration-property-value-disallowed-list": [ + { "/.*/": ["/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-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index b2d6603ab75b..790ecbe8702a 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', @@ -173,10 +181,10 @@ 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 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({ name: 'scssToCss', @@ -291,21 +299,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', @@ -315,7 +312,6 @@ const createDsConfig = () => createConfig('ds', getComponentThemeFiles(), [ const configs = [ ...FLUENT_PALETTES.map(createPaletteConfig), ...FLUENT_MODES.map(createModeConfig), - createComponentThemeConfig(), createDsConfig(), ]; @@ -359,6 +355,58 @@ 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 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'), + ); + const availableNames = buildAvailableNames( + Object.keys(tokens), + new Set(getBridgeFiles()), + ); + + const referenced = new Map(); + + for (const file of await collectThemeStyleSheets()) { + const content = await readFile(file, 'utf-8'); + const source = path.relative(themePath, file); + const found = [ + ...collectTokenReferences(content, source).map((name) => [name, `ds.$${name}`]), + ...collectCustomPropertyReferences(content, source).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 +420,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..3f449c180278 --- /dev/null +++ b/packages/devextreme-scss/build/tokens/consumed-tokens.ts @@ -0,0 +1,41 @@ +// Pure half of the consumed-token check in build-tokens.mjs, so the tests can run it without a build. + +// 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 ? '/*' : '*/')); + + 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); + +// 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); + +// 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, +): Set => new Set( + [...flatTokenKeys] + .map((key) => key.split(':')) + .filter(([sourceFile]) => consumedSourceFiles.has(sourceFile)) + .map(([, tokenPath]) => tokenPath.replace(/\//g, '-')), +); 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 7c794e566a36..456bbc34f092 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": [ @@ -72,7 +73,9 @@ "options": { "mode": "all" }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -94,7 +97,9 @@ "options": { "mode": "ci" }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -173,7 +178,9 @@ "mode": "all", "watch": true }, - "dependsOn": ["build:tokens"], + "dependsOn": [ + "build:tokens" + ], "inputs": [ "{projectRoot}/build/**/*", "{projectRoot}/fonts/**/*", @@ -198,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/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/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..f61bc0749f1b 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/common/_mixins.scss @@ -3,15 +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-content-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( $global-content-rest, 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..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 "../../../_design-system/variables/ds" as ds; +@use "sizes" as mapSizes; @use "../../base/map" with ( - $map-marker-tooltip-margin: ds.$spacing-100, + $map-marker-tooltip-margin: mapSizes.$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..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; 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..56f00f106b20 --- /dev/null +++ b/packages/devextreme-scss/tests/consumed-tokens.test.ts @@ -0,0 +1,160 @@ +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;', + '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;', 'probe.scss')).toEqual([]); + }); + + it('ignores references parked in block comments', () => { + expect(collectTokenReferences('/* see ds.$spacing-40 */\n$a: ds.$spacing-80;', 'probe.scss')).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, 'probe.scss')).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, '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, '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;', '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;', '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); }', '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%); }', 'probe.scss')).toEqual([ + 'neutral-10', + ]); + }); + + it('tolerates whitespace after the opening parenthesis', () => { + 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); }', 'probe.scss')).toEqual([]); + }); + + it('ignores a reference parked in a comment', () => { + 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;', 'probe.scss')).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']); + }); +}); 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..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 }; @@ -634,19 +638,27 @@ 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); }); 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}`); }); @@ -730,6 +742,29 @@ test('migrated components follow the grammar strictly', () => { expect(offenders).toEqual([]); }); +test('design tokens are read only where variables are declared', () => { + // 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) => 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([]); +}); + 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. 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/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/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)); diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index 42aee0774318..e3bda97f02d7 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'); // --------------------------------------------------------------------------------------------- @@ -577,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'], @@ -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..ef2ec7109bd9 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 }, @@ -753,7 +753,8 @@ "message", "summary", "summary-item", - "overlay" + "overlay", + "content" ], "tooltip": [ "overlay", 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" + ] +}