Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@
"matchPackageNames": [
"*"
]
},
{
"matchPackageNames": [
"@devexpress/design-tokens-internal"
],
"automerge": false,
"dependencyDashboardApproval": true,
"minimumReleaseAge": null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

automerge: false is right, but minimumReleaseAge: null drops the 7-day soak from the * rule, so a PR is opened immediately on every publish of this exact-pinned internal package. That PR then waits for the designers' signal while occupying 1 of only 2 prConcurrentLimit slots, starving every other dependency update for as long as it sits.

For "update manually on a signal", dependencyDashboardApproval: true fits better: the update is visible on the dashboard, no PR until someone approves, no slot taken. minimumReleaseAge: null only makes sense paired with that.

The repo also already has an idiom for packages that must be bumped by hand — {"matchPackageNames": ["devexpress-gantt", "devexpress-diagram", "rrule", "sass-embedded", "systemjs"], "enabled": false} - worth following if no PR is wanted at all.

}
],
"lockFileMaintenance": {
Expand Down
28 changes: 28 additions & 0 deletions packages/devextreme-scss/.stylelintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-/"] },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rule, same intent as line 17, but the message drops the closing "The public --dx-* properties are unaffected." - that's the sentence that stops someone from thinking the rule bans all custom properties, so it's worth keeping in both. The override above (line 78) also carries a "comment" key while this one doesn't.

{ "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-]+",
Expand Down Expand Up @@ -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." }
]
}
}
]
}
90 changes: 70 additions & 20 deletions packages/devextreme-scss/build/tokens/build-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only .mjs in the repo that imports a .ts. It carries two undeclared constraints:

  • the file is now permanently restricted to erasable-syntax-only TS; an enum/namespace would break the build with a confusing error;
  • it sits outside: tconfig.json include: ["./*.ts"], so nothing type-checks it except ts-jest incidentally.

Cheapest fix: make it .mjs (there are three type annotations) or add it to a tsconfig so the constraint is enforced. At minimum a one-line comment saying why it's .ts and what it may not use.

Choose the better option as you decide

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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -315,7 +312,6 @@ const createDsConfig = () => createConfig('ds', getComponentThemeFiles(), [
const configs = [
...FLUENT_PALETTES.map(createPaletteConfig),
...FLUENT_MODES.map(createModeConfig),
createComponentThemeConfig(),
createDsConfig(),
];

Expand Down Expand Up @@ -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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check reads scss/widgets/fluent-next//*.scss, but build:tokens declares only {projectRoot}/build/tokens//* and pnpm-lock.yaml as inputs. So Nx replays the cache whenever only a stylesheet changed, and the check silently does not run.

Two options:

  • add {projectRoot}/scss/widgets/fluent-next/**/* to the build:tokens inputs, or
  • move the integration half into tests/consumed-tokens.test.ts - that target already has inputs: ["{projectRoot}/**/*"] and dependsOn: ["build:tokens"], and styles.yml runs it. The pure functions are already there, so the integration case fits naturally.

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 });

Expand All @@ -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();
41 changes: 41 additions & 0 deletions packages/devextreme-scss/build/tokens/consumed-tokens.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
consumedSourceFiles: ReadonlySet<string>,
): Set<string> => new Set(
[...flatTokenKeys]
.map((key) => key.split(':'))
.filter(([sourceFile]) => consumedSourceFiles.has(sourceFile))
.map(([, tokenPath]) => tokenPath.replace(/\//g, '-')),
);
1 change: 1 addition & 0 deletions packages/devextreme-scss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 26 additions & 4 deletions packages/devextreme-scss/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
},
"inputs": [
"{projectRoot}/build/tokens/**/*",
"{projectRoot}/scss/widgets/fluent-next/**/*",
"{workspaceRoot}/pnpm-lock.yaml"
],
"outputs": [
Expand Down Expand Up @@ -72,7 +73,9 @@
"options": {
"mode": "all"
},
"dependsOn": ["build:tokens"],
"dependsOn": [
"build:tokens"
],
"inputs": [
"{projectRoot}/build/**/*",
"{projectRoot}/fonts/**/*",
Expand All @@ -94,7 +97,9 @@
"options": {
"mode": "ci"
},
"dependsOn": ["build:tokens"],
"dependsOn": [
"build:tokens"
],
"inputs": [
"{projectRoot}/build/**/*",
"{projectRoot}/fonts/**/*",
Expand Down Expand Up @@ -173,7 +178,9 @@
"mode": "all",
"watch": true
},
"dependsOn": ["build:tokens"],
"dependsOn": [
"build:tokens"
],
"inputs": [
"{projectRoot}/build/**/*",
"{projectRoot}/fonts/**/*",
Expand All @@ -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": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
$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");
@include meta.load-css("../../_design-system/fluent/accents/#{$accent}");
@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");
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blank lines left over from the removed declaration.

$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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@use "../colors" as *;
@use "../../../_design-system/variables/ds" as ds;
@use "sass:color";
@use "colors" as *;
@use "sizes" as *;
Expand Down Expand Up @@ -32,7 +31,7 @@
$datagrid-focused-border-color: gridBaseColors.$grid-border-focused,
$header-filter-color: gridBaseColors.$grid-header-filter-icon-rest,
$header-filter-color-empty: gridBaseColors.$grid-header-filter-empty-icon-rest,
$base-focus-color: ds.$color-content-neutral-default-inverted-rest,
$base-focus-color: gridBaseColors.$grid-content-focused,
$datagrid-text-stub-background-image-path: gridBaseColors.$grid-text-stub-bg-rest,
$datagrid-group-row-border: $data-grid-group-row-border,
$datagrid-sticky-column-border: $data-grid-sticky-column-border,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading