-
Notifications
You must be signed in to change notification settings - Fork 673
Fluent design tokens pipeline #34701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/26_2_new_fluent_theme_with_design_tokens
Are you sure you want to change the base?
Changes from all commits
6a4e2bb
496e29c
fb949a2
cd34ff2
09eb682
5a828f7
2df9aed
f32d1d1
b2cad90
5759aa4
65845e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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-/"] }, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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-]+", | ||
|
|
@@ -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." } | ||
| ] | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 | ||
|
|
@@ -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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| 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(); | ||
| 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, '-')), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
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.